arcgraph 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 (165) hide show
  1. arcgraph/__init__.py +41 -0
  2. arcgraph/_build_provenance.json +10 -0
  3. arcgraph/adapters/__init__.py +6 -0
  4. arcgraph/adapters/arq_adapter.py +369 -0
  5. arcgraph/adapters/celery_adapter.py +440 -0
  6. arcgraph/adapters/common.py +436 -0
  7. arcgraph/adapters/django_adapter.py +765 -0
  8. arcgraph/adapters/fastapi_adapter.py +415 -0
  9. arcgraph/adapters/logging_adapter.py +335 -0
  10. arcgraph/adapters/mcp_adapter.py +100 -0
  11. arcgraph/adapters/pydantic_adapter.py +290 -0
  12. arcgraph/adapters/pytest_adapter.py +221 -0
  13. arcgraph/adapters/pytest_collection.py +96 -0
  14. arcgraph/adapters/pytest_fixtures.py +371 -0
  15. arcgraph/adapters/pytest_types.py +251 -0
  16. arcgraph/adapters/registry.py +34 -0
  17. arcgraph/adapters/service_container_adapter.py +548 -0
  18. arcgraph/adapters/sqlalchemy_adapter.py +446 -0
  19. arcgraph/adapters/typer_click_adapter.py +292 -0
  20. arcgraph/analyzers/__init__.py +1 -0
  21. arcgraph/analyzers/bindings.py +782 -0
  22. arcgraph/analyzers/calls/__init__.py +6 -0
  23. arcgraph/analyzers/calls/analyzer.py +2921 -0
  24. arcgraph/analyzers/calls/constants.py +702 -0
  25. arcgraph/analyzers/calls/context.py +298 -0
  26. arcgraph/analyzers/calls/lexical.py +237 -0
  27. arcgraph/analyzers/coverage.py +826 -0
  28. arcgraph/analyzers/exports.py +46 -0
  29. arcgraph/analyzers/har_trace.py +323 -0
  30. arcgraph/analyzers/imports.py +190 -0
  31. arcgraph/analyzers/otel_trace.py +352 -0
  32. arcgraph/analyzers/precise_references.py +1199 -0
  33. arcgraph/analyzers/python_scopes.py +56 -0
  34. arcgraph/analyzers/runtime_trace.py +516 -0
  35. arcgraph/analyzers/similarity.py +430 -0
  36. arcgraph/analyzers/symbols.py +363 -0
  37. arcgraph/analyzers/type_unions.py +89 -0
  38. arcgraph/analyzers/types.py +2293 -0
  39. arcgraph/assets/__init__.py +1 -0
  40. arcgraph/assets/workbench/__init__.py +1 -0
  41. arcgraph/assets/workbench/index.html +2398 -0
  42. arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
  43. arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
  44. arcgraph/change/__init__.py +10 -0
  45. arcgraph/change/baseline.py +145 -0
  46. arcgraph/change/changed_regions.py +728 -0
  47. arcgraph/change/contracts.py +927 -0
  48. arcgraph/change/errors.py +88 -0
  49. arcgraph/change/evidence.py +289 -0
  50. arcgraph/change/graph_delta.py +1422 -0
  51. arcgraph/change/identities.py +1277 -0
  52. arcgraph/change/paths.py +234 -0
  53. arcgraph/change/pins.py +426 -0
  54. arcgraph/change/planner.py +504 -0
  55. arcgraph/change/protected_surfaces.py +328 -0
  56. arcgraph/change/provider.py +46 -0
  57. arcgraph/change/service.py +1030 -0
  58. arcgraph/change/source_provider.py +311 -0
  59. arcgraph/change/store.py +1138 -0
  60. arcgraph/change/trusted_runners.py +154 -0
  61. arcgraph/change/verdicts.py +141 -0
  62. arcgraph/change/verification_plan.py +113 -0
  63. arcgraph/change/verifier.py +579 -0
  64. arcgraph/core/__init__.py +1 -0
  65. arcgraph/core/assurance.py +343 -0
  66. arcgraph/core/binding_metrics.py +75 -0
  67. arcgraph/core/cleanup.py +701 -0
  68. arcgraph/core/confidence_profiles.py +108 -0
  69. arcgraph/core/evidence_manifest.py +862 -0
  70. arcgraph/core/expression_kinds.py +11 -0
  71. arcgraph/core/force_graph_export.py +1125 -0
  72. arcgraph/core/freshness.py +94 -0
  73. arcgraph/core/graph_store.py +1099 -0
  74. arcgraph/core/ids.py +206 -0
  75. arcgraph/core/language_tiers.py +150 -0
  76. arcgraph/core/merge.py +649 -0
  77. arcgraph/core/metadata.py +30 -0
  78. arcgraph/core/operation_lock.py +218 -0
  79. arcgraph/core/passes.py +142 -0
  80. arcgraph/core/payload_policy.py +708 -0
  81. arcgraph/core/precise_references.py +124 -0
  82. arcgraph/core/query_engine.py +3078 -0
  83. arcgraph/core/recovery.py +18 -0
  84. arcgraph/core/scanner.py +1454 -0
  85. arcgraph/core/schemas.py +700 -0
  86. arcgraph/core/semantic.py +263 -0
  87. arcgraph/core/semantic_metrics.py +619 -0
  88. arcgraph/core/sharing_retry.py +84 -0
  89. arcgraph/core/structural.py +260 -0
  90. arcgraph/core/target_resolver.py +486 -0
  91. arcgraph/core/type_metrics.py +67 -0
  92. arcgraph/core/unresolved_classification.py +471 -0
  93. arcgraph/core/utils.py +100 -0
  94. arcgraph/core/visual_contract.py +240 -0
  95. arcgraph/core/visual_slice_engine.py +933 -0
  96. arcgraph/interfaces/__init__.py +1 -0
  97. arcgraph/interfaces/agent_capabilities.py +493 -0
  98. arcgraph/interfaces/agent_help.py +313 -0
  99. arcgraph/interfaces/benchmark.py +548 -0
  100. arcgraph/interfaces/ci.py +1992 -0
  101. arcgraph/interfaces/cli.py +2799 -0
  102. arcgraph/interfaces/cli_change.py +690 -0
  103. arcgraph/interfaces/cli_feedback.py +176 -0
  104. arcgraph/interfaces/cli_ops.py +69 -0
  105. arcgraph/interfaces/cli_support.py +113 -0
  106. arcgraph/interfaces/cli_trial.py +42 -0
  107. arcgraph/interfaces/cli_visual.py +601 -0
  108. arcgraph/interfaces/cli_workspace.py +300 -0
  109. arcgraph/interfaces/client_setup.py +481 -0
  110. arcgraph/interfaces/docs.py +1255 -0
  111. arcgraph/interfaces/human_output.py +409 -0
  112. arcgraph/interfaces/local_state.py +577 -0
  113. arcgraph/interfaces/mcp_server.py +457 -0
  114. arcgraph/interfaces/mcp_tools.py +1209 -0
  115. arcgraph/interfaces/metrics.py +571 -0
  116. arcgraph/interfaces/ops.py +330 -0
  117. arcgraph/interfaces/precision.py +1670 -0
  118. arcgraph/interfaces/project_probe.py +75 -0
  119. arcgraph/interfaces/reports.py +1121 -0
  120. arcgraph/interfaces/stdio_encoding.py +29 -0
  121. arcgraph/interfaces/trace.py +592 -0
  122. arcgraph/interfaces/trial_feedback.py +519 -0
  123. arcgraph/interfaces/trial_setup.py +535 -0
  124. arcgraph/interfaces/visual_server.py +354 -0
  125. arcgraph/interfaces/visual_slice.py +168 -0
  126. arcgraph/interfaces/visual_smoke.py +666 -0
  127. arcgraph/interfaces/workbench.py +576 -0
  128. arcgraph/interfaces/workspace.py +1513 -0
  129. arcgraph/pipeline/__init__.py +1 -0
  130. arcgraph/pipeline/contracts.py +122 -0
  131. arcgraph/pipeline/external_frontend.py +451 -0
  132. arcgraph/pipeline/external_l3_frontends.py +368 -0
  133. arcgraph/pipeline/external_protocol.py +358 -0
  134. arcgraph/pipeline/frontends.py +437 -0
  135. arcgraph/pipeline/indexer.py +661 -0
  136. arcgraph/pipeline/openapi_frontend.py +708 -0
  137. arcgraph/pipeline/python_frontend.py +298 -0
  138. arcgraph/pipeline/reindexer.py +1333 -0
  139. arcgraph/pipeline/scip_frontend.py +726 -0
  140. arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
  141. arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
  142. arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
  143. arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
  144. arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
  145. arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
  146. arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
  147. arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
  148. arcgraph/pipeline/typescript_extractor.mjs +177 -0
  149. arcgraph/pipeline/typescript_frameworks.py +947 -0
  150. arcgraph/pipeline/typescript_frontend.py +656 -0
  151. arcgraph/pipeline/typescript_references.mjs +167 -0
  152. arcgraph/providers/__init__.py +27 -0
  153. arcgraph/providers/context_provider.py +1258 -0
  154. arcgraph/providers/flow_payload.py +117 -0
  155. arcgraph/providers/memory_connector.py +216 -0
  156. arcgraph/providers/orchestrator.py +475 -0
  157. arcgraph/providers/relationship_payload.py +102 -0
  158. arcgraph/providers/risk_provider.py +843 -0
  159. arcgraph/providers/why_provider.py +349 -0
  160. arcgraph/version_info.py +246 -0
  161. arcgraph-0.1.0.dist-info/METADATA +676 -0
  162. arcgraph-0.1.0.dist-info/RECORD +165 -0
  163. arcgraph-0.1.0.dist-info/WHEEL +4 -0
  164. arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
  165. arcgraph-0.1.0.dist-info/licenses/LICENSE +203 -0
@@ -0,0 +1,349 @@
1
+ """Structural why provider with optional external memory context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from arcgraph.core.payload_policy import (
8
+ apply_target_payload_contract,
9
+ normalize_targets,
10
+ unique_values,
11
+ )
12
+ from arcgraph.core.query_engine import QueryEngine
13
+ from arcgraph.core.recovery import stale_recovery_action
14
+ from arcgraph.core.schemas import SCHEMA_VERSION, WhyReport
15
+ from arcgraph.providers.memory_connector import (
16
+ MemoryConnectorError,
17
+ ExternalMemoryConnector,
18
+ MemoryWhyQuery,
19
+ UnavailableExternalMemoryConnector,
20
+ build_memory_candidate,
21
+ )
22
+
23
+
24
+ class WhyProvider:
25
+ def __init__(
26
+ self,
27
+ query_engine: QueryEngine,
28
+ external_memory: ExternalMemoryConnector | None = None,
29
+ ) -> None:
30
+ self.query_engine = query_engine
31
+ self.external_memory = external_memory or UnavailableExternalMemoryConnector()
32
+
33
+ def get_why(
34
+ self,
35
+ target: str,
36
+ max_results: int = 10,
37
+ *,
38
+ repo_id: str = "default",
39
+ task: str | None = None,
40
+ ) -> dict[str, Any]:
41
+ target = normalize_targets([target], label="target", max_targets=1)[0]
42
+ context = self._structural_context(target, max_results=max_results)
43
+ current = context["current"]
44
+ impact = context["impact"]
45
+ resolved_targets = context["resolved_targets"]
46
+ structural_why = context["structural_why"]
47
+ max_results = context["max_results"]
48
+ external_memory = self._external_memory_context(
49
+ repo_id=repo_id,
50
+ target=target,
51
+ task=task,
52
+ resolved_targets=resolved_targets,
53
+ paths=context["paths"],
54
+ structural_why=structural_why,
55
+ max_results=max_results,
56
+ )
57
+
58
+ payload = {
59
+ "schema_version": SCHEMA_VERSION,
60
+ "index_version": current.get("index_version"),
61
+ "status": "available" if impact.get("resolved_targets") else "partial",
62
+ "target": target,
63
+ "task": task,
64
+ "resolved_targets": resolved_targets,
65
+ "freshness": current["freshness"],
66
+ "recovery_action": stale_recovery_action(current["freshness"]),
67
+ "capabilities": current.get("capabilities", {}),
68
+ "structural_why": structural_why,
69
+ "historical_decisions": external_memory.get("historical_decisions", []),
70
+ "lessons_learned": external_memory.get("lessons_learned", []),
71
+ "best_practices": external_memory.get("best_practices", []),
72
+ "external_memory": external_memory,
73
+ "warnings": unique_values(
74
+ [
75
+ *impact.get("warnings", []),
76
+ *external_memory.get("warnings", []),
77
+ ]
78
+ ),
79
+ }
80
+ payload = apply_target_payload_contract(payload)
81
+ return WhyReport.model_validate(payload).model_dump(
82
+ mode="json", exclude_none=True
83
+ )
84
+
85
+ def propose_learning(
86
+ self,
87
+ *,
88
+ repo_id: str,
89
+ target: str,
90
+ observation: str,
91
+ memory_type: str = "lesson_learned",
92
+ title: str | None = None,
93
+ ) -> dict[str, Any]:
94
+ target = normalize_targets([target], label="target", max_targets=1)[0]
95
+ context = self._structural_context(target, max_results=5)
96
+ current = context["current"]
97
+ proposal_title = title or f"ArcGraph lesson for {target}"
98
+ resolved_targets = context["resolved_targets"]
99
+ paths = context["paths"]
100
+ content = observation.strip() or self._default_learning_content(
101
+ target=target,
102
+ structural_why=context["structural_why"],
103
+ paths=paths,
104
+ )
105
+ candidate = build_memory_candidate(
106
+ title=proposal_title,
107
+ content=content,
108
+ memory_type=memory_type,
109
+ repo_id=repo_id,
110
+ target=target,
111
+ resolved_targets=resolved_targets,
112
+ paths=paths,
113
+ index_version=current.get("index_version"),
114
+ commit_sha=current.get("commit_sha"),
115
+ )
116
+ candidate_payload = candidate.model_dump(mode="json")
117
+ return apply_target_payload_contract(
118
+ {
119
+ "schema_version": SCHEMA_VERSION,
120
+ "index_version": current.get("index_version"),
121
+ "status": "available",
122
+ "mode": "proposal-only",
123
+ "read_only": True,
124
+ "freshness": current["freshness"],
125
+ "capabilities": current.get("capabilities", {}),
126
+ "memory_candidate": candidate_payload,
127
+ "proposal": candidate_payload,
128
+ "external_memory": {
129
+ "status": "candidate_only",
130
+ "reason": "ArcGraph generated an external memory candidate but did not persist it.",
131
+ },
132
+ "warnings": [
133
+ "This is a read-only proposal. Persist it through external memory proposal tools after review."
134
+ ],
135
+ }
136
+ )
137
+
138
+ def _structural_context(self, target: str, *, max_results: int) -> dict[str, Any]:
139
+ current = self.query_engine.current()
140
+ max_results = max(1, min(max_results, 50))
141
+ impact = self.query_engine.impact(target, max_depth=2)
142
+ callers = self.query_engine.callers(target)
143
+ callees = self.query_engine.callees(target)
144
+ architecture = self.query_engine.architecture()
145
+ resolved_targets = impact.get("resolved_targets", [])[:max_results]
146
+ paths = self._paths_for_targets(resolved_targets, max_results=max_results)
147
+ structural_why = self._structural_reasons(
148
+ target=target,
149
+ current=current,
150
+ impact=impact,
151
+ callers=callers,
152
+ callees=callees,
153
+ test_candidates=impact.get("test_candidates", []),
154
+ architecture=architecture,
155
+ max_results=max_results,
156
+ )
157
+ return {
158
+ "current": current,
159
+ "max_results": max_results,
160
+ "impact": impact,
161
+ "resolved_targets": resolved_targets,
162
+ "paths": paths,
163
+ "structural_why": structural_why,
164
+ }
165
+
166
+ def _external_memory_context(
167
+ self,
168
+ *,
169
+ repo_id: str,
170
+ target: str,
171
+ task: str | None,
172
+ resolved_targets: list[str],
173
+ paths: list[str],
174
+ structural_why: list[dict[str, Any]],
175
+ max_results: int,
176
+ ) -> dict[str, Any]:
177
+ query = MemoryWhyQuery(
178
+ repo_id=repo_id,
179
+ target=target,
180
+ task=task,
181
+ resolved_targets=resolved_targets,
182
+ paths=paths,
183
+ structural_why=structural_why,
184
+ max_results=max_results,
185
+ )
186
+ try:
187
+ context = self.external_memory.get_why_context(query)
188
+ payload = context.model_dump(mode="json")
189
+ except MemoryConnectorError as exc:
190
+ context = UnavailableExternalMemoryConnector(str(exc)).get_why_context(
191
+ query
192
+ )
193
+ payload = context.model_dump(mode="json")
194
+ except Exception as exc:
195
+ context = UnavailableExternalMemoryConnector(
196
+ f"External memory connector failed: {exc}"
197
+ ).get_why_context(query)
198
+ payload = context.model_dump(mode="json")
199
+ payload["memory_count"] = len(payload.get("memories", []))
200
+ return payload
201
+
202
+ def _paths_for_targets(
203
+ self, target_ids: list[str], *, max_results: int
204
+ ) -> list[str]:
205
+ paths: dict[str, None] = {}
206
+ for node in self.query_engine.nodes_by_ids(target_ids[:max_results]):
207
+ path = node.get("path")
208
+ if path:
209
+ paths.setdefault(path, None)
210
+ return list(paths)[:max_results]
211
+
212
+ @staticmethod
213
+ def _default_learning_content(
214
+ *, target: str, structural_why: list[dict[str, Any]], paths: list[str]
215
+ ) -> str:
216
+ entrypoints = [
217
+ item
218
+ for reason in structural_why
219
+ if reason.get("kind") == "entrypoint_reachability"
220
+ for item in reason.get("evidence", [])
221
+ ]
222
+ resources = [
223
+ item
224
+ for reason in structural_why
225
+ if reason.get("kind") == "resource_flow"
226
+ for item in reason.get("evidence", [])
227
+ ]
228
+ parts = [
229
+ f"When changing {target}, inspect its ArcGraph impact before editing.",
230
+ ]
231
+ if entrypoints:
232
+ parts.append(
233
+ "Relevant entrypoints include "
234
+ + ", ".join(item.get("id", "") for item in entrypoints[:5] if item)
235
+ + "."
236
+ )
237
+ if resources:
238
+ parts.append(
239
+ "Check resource effects on "
240
+ + ", ".join(item.get("id", "") for item in resources[:5] if item)
241
+ + "."
242
+ )
243
+ if paths:
244
+ parts.append("Start with " + ", ".join(paths[:5]) + ".")
245
+ return " ".join(parts)
246
+
247
+ @staticmethod
248
+ def _structural_reasons(
249
+ *,
250
+ target: str,
251
+ current: dict[str, Any],
252
+ impact: dict[str, Any],
253
+ callers: dict[str, Any],
254
+ callees: dict[str, Any],
255
+ test_candidates: list[dict[str, Any]],
256
+ architecture: dict[str, Any],
257
+ max_results: int,
258
+ ) -> list[dict[str, Any]]:
259
+ reasons: list[dict[str, Any]] = []
260
+ freshness = current["freshness"]
261
+ if freshness.get("stale"):
262
+ reasons.append(
263
+ {
264
+ "kind": "freshness",
265
+ "summary": "The ArcGraph index is stale for this repository.",
266
+ "evidence": {
267
+ "stale_files": freshness.get("stale_files", [])[:max_results]
268
+ },
269
+ }
270
+ )
271
+
272
+ entrypoints = impact.get("entrypoint_impact", {}).get("entrypoints", [])
273
+ if entrypoints:
274
+ reasons.append(
275
+ {
276
+ "kind": "entrypoint_reachability",
277
+ "summary": f"{target} is reachable from framework entrypoints.",
278
+ "evidence": entrypoints[:max_results],
279
+ }
280
+ )
281
+
282
+ resources = impact.get("resource_impact", {}).get("resources", [])
283
+ if resources:
284
+ reasons.append(
285
+ {
286
+ "kind": "resource_flow",
287
+ "summary": f"{target} touches indexed tables, queues, or resources.",
288
+ "evidence": resources[:max_results],
289
+ }
290
+ )
291
+
292
+ caller_nodes = callers.get("callers", [])
293
+ if caller_nodes:
294
+ reasons.append(
295
+ {
296
+ "kind": "fan_in",
297
+ "summary": f"{target} has direct callers in the static call graph.",
298
+ "evidence": caller_nodes[:max_results],
299
+ }
300
+ )
301
+
302
+ callee_nodes = callees.get("callees", [])
303
+ if callee_nodes:
304
+ reasons.append(
305
+ {
306
+ "kind": "fan_out",
307
+ "summary": f"{target} calls other indexed symbols.",
308
+ "evidence": callee_nodes[:max_results],
309
+ }
310
+ )
311
+
312
+ if test_candidates:
313
+ reasons.append(
314
+ {
315
+ "kind": "test_surface",
316
+ "summary": "Heuristic test candidates are available, but coverage evidence is not.",
317
+ "evidence": test_candidates[:max_results],
318
+ }
319
+ )
320
+
321
+ fan_in = [
322
+ item
323
+ for item in architecture.get("top_fan_in", [])
324
+ if item.get("target") in impact.get("resolved_targets", [])
325
+ ]
326
+ fan_out = [
327
+ item
328
+ for item in architecture.get("top_fan_out", [])
329
+ if item.get("source") in impact.get("resolved_targets", [])
330
+ ]
331
+ if fan_in or fan_out:
332
+ reasons.append(
333
+ {
334
+ "kind": "architecture_hotspot",
335
+ "summary": f"{target} appears in top fan-in/fan-out architecture statistics.",
336
+ "evidence": {"fan_in": fan_in, "fan_out": fan_out},
337
+ }
338
+ )
339
+
340
+ if not impact.get("resolved_targets"):
341
+ reasons.append(
342
+ {
343
+ "kind": "unresolved_target",
344
+ "summary": f"{target} did not resolve to a graph node.",
345
+ "evidence": {},
346
+ }
347
+ )
348
+
349
+ return reasons
@@ -0,0 +1,246 @@
1
+ """Runtime package identity and provenance reporting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import hashlib
7
+ import importlib.metadata
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from arcgraph import READ_SCHEMA_VERSION, SCHEMA_VERSION, __version__
16
+
17
+ # Identity of the running code cannot change inside one process; only the
18
+ # working-tree flags can. A short reuse window keeps repeated index_status
19
+ # calls cheap while an interactive edit still shows up promptly.
20
+ _VERSION_INFO_TTL_SECONDS = 5.0
21
+ _VERSION_INFO_CACHE: tuple[float, dict[str, Any]] | None = None
22
+
23
+
24
+ def version_info(*, refresh: bool = False) -> dict[str, Any]:
25
+ """Return an auditable identity for the exact ArcGraph code being executed.
26
+
27
+ Hashing every runtime file and shelling out to git costs seconds on large
28
+ trees, and `index_status` — the cheapest documented check — reports this
29
+ on every call. Results are therefore reused for
30
+ _VERSION_INFO_TTL_SECONDS; callers that must observe live working-tree
31
+ state (the `version` command) pass ``refresh=True``.
32
+ """
33
+
34
+ global _VERSION_INFO_CACHE
35
+ if not refresh:
36
+ cached = _VERSION_INFO_CACHE
37
+ if cached is not None:
38
+ captured_at, payload = cached
39
+ if time.monotonic() - captured_at < _VERSION_INFO_TTL_SECONDS:
40
+ return copy.deepcopy(payload)
41
+
42
+ payload = _build_version_info()
43
+ _VERSION_INFO_CACHE = (time.monotonic(), copy.deepcopy(payload))
44
+ return payload
45
+
46
+
47
+ def _build_version_info() -> dict[str, Any]:
48
+ package_dir = Path(__file__).resolve().parent
49
+ source_root = package_dir.parent
50
+ source_checkout = (source_root / "pyproject.toml").is_file() and (
51
+ source_root / ".git"
52
+ ).exists()
53
+ runtime_sha256 = _runtime_fingerprint(package_dir)
54
+ source = _source_provenance(source_root) if source_checkout else None
55
+ artifact = None if source_checkout else _installed_artifact_provenance(package_dir)
56
+ build = None if source_checkout else _embedded_build_provenance(package_dir)
57
+
58
+ if source is not None:
59
+ suffix = f"g{source['commit_sha'][:12]}"
60
+ if not source["working_tree_clean"]:
61
+ suffix += f".dirty.{runtime_sha256[:12]}"
62
+ display_version = f"{__version__}+{suffix}"
63
+ provenance_status = "source_checkout"
64
+ elif artifact and _mapping_value(artifact, "sha256"):
65
+ source_label = _build_source_label(build)
66
+ display_version = f"{__version__}+{source_label}wheel.{artifact['sha256'][:12]}"
67
+ provenance_status = "verified_artifact_hash"
68
+ else:
69
+ display_version = f"{__version__}+runtime.{runtime_sha256[:12]}"
70
+ provenance_status = "runtime_fingerprint_only"
71
+
72
+ return {
73
+ "schema_version": "1.0.0",
74
+ "status": "available",
75
+ "product_version": __version__,
76
+ "display_version": display_version,
77
+ "read_schema_version": READ_SCHEMA_VERSION,
78
+ "index_schema_version": SCHEMA_VERSION,
79
+ "execution_mode": (
80
+ "source_checkout" if source_checkout else "installed_distribution"
81
+ ),
82
+ "provenance_status": provenance_status,
83
+ "source_provenance": source,
84
+ "build_provenance": build,
85
+ "artifact_provenance": artifact,
86
+ "runtime_fingerprint": {
87
+ "algorithm": "sha256",
88
+ "sha256": runtime_sha256,
89
+ "scope": "installed_arcgraph_runtime_files",
90
+ },
91
+ "non_claims": _identity_non_claims(source=source, build=build),
92
+ }
93
+
94
+
95
+ def _build_source_label(build: dict[str, Any] | None) -> str:
96
+ source = _mapping_value(build, "source", {})
97
+ commit = _mapping_value(source, "commit_sha")
98
+ if not isinstance(commit, str):
99
+ return ""
100
+ label = f"g{commit[:12]}"
101
+ if _mapping_value(source, "working_tree_clean") is False:
102
+ status_hash = _mapping_value(source, "working_tree_status_sha256")
103
+ if isinstance(status_hash, str):
104
+ label += f".dirty.{status_hash[:12]}"
105
+ return f"{label}."
106
+
107
+
108
+ def _identity_non_claims(
109
+ *, source: dict[str, Any] | None, build: dict[str, Any] | None
110
+ ) -> list[str]:
111
+ if source is not None:
112
+ return []
113
+ build_source = _mapping_value(build, "source", {})
114
+ if not _mapping_value(build_source, "commit_sha"):
115
+ return [
116
+ "The installed wheel does not itself claim a Git commit; bind its artifact SHA-256 to the release provenance manifest."
117
+ ]
118
+ if _mapping_value(build_source, "working_tree_clean") is False:
119
+ return [
120
+ "The wheel was built from a dirty working tree; its commit alone cannot reproduce the artifact. Use the artifact SHA-256 and dirty-state fingerprint."
121
+ ]
122
+ return []
123
+
124
+
125
+ def _embedded_build_provenance(package_dir: Path) -> dict[str, Any] | None:
126
+ path = package_dir / "_build_provenance.json"
127
+ try:
128
+ value = json.loads(path.read_text(encoding="utf-8"))
129
+ except (OSError, json.JSONDecodeError):
130
+ return None
131
+ if (
132
+ not isinstance(value, dict)
133
+ or _mapping_value(value, "schema_version") != "1.0.0"
134
+ ):
135
+ return None
136
+ source = _mapping_value(value, "source")
137
+ if not isinstance(source, dict):
138
+ return None
139
+ commit = _mapping_value(source, "commit_sha")
140
+ tree = _mapping_value(source, "tree_sha")
141
+ status_hash = _mapping_value(source, "working_tree_status_sha256")
142
+ clean = _mapping_value(source, "working_tree_clean")
143
+ if not _is_hex_digest(commit, 40) or not _is_hex_digest(tree, 40):
144
+ return None
145
+ if not isinstance(clean, bool) or not _is_hex_digest(status_hash, 64):
146
+ return None
147
+ return value
148
+
149
+
150
+ def _installed_artifact_provenance(package_dir: Path) -> dict[str, Any] | None:
151
+ try:
152
+ distribution = importlib.metadata.distribution("arcgraph")
153
+ except importlib.metadata.PackageNotFoundError:
154
+ return None
155
+ try:
156
+ installed_package = Path(distribution.locate_file("arcgraph")).resolve()
157
+ if installed_package != package_dir:
158
+ return None
159
+ direct_url_text = distribution.read_text("direct_url.json")
160
+ direct_url = json.loads(direct_url_text) if direct_url_text else {}
161
+ except (OSError, TypeError, json.JSONDecodeError):
162
+ direct_url = {}
163
+
164
+ archive_info = _mapping_value(direct_url, "archive_info", {})
165
+ hashes = _mapping_value(archive_info, "hashes", {})
166
+ sha256 = _mapping_value(hashes, "sha256")
167
+ if sha256 is None and isinstance(archive_info, dict):
168
+ legacy_hash = _mapping_value(archive_info, "hash")
169
+ if isinstance(legacy_hash, str) and legacy_hash.startswith("sha256="):
170
+ sha256 = legacy_hash.split("=", 1)[1]
171
+ if not _is_hex_digest(sha256, 64):
172
+ sha256 = None
173
+ return {
174
+ "sha256": sha256,
175
+ "identity_source": (
176
+ "pep610_direct_url_archive_hash" if sha256 else "unavailable"
177
+ ),
178
+ }
179
+
180
+
181
+ def _source_provenance(source_root: Path) -> dict[str, Any] | None:
182
+ try:
183
+ commit = _git(source_root, ["rev-parse", "HEAD"]).decode("ascii").strip()
184
+ tree = _git(source_root, ["rev-parse", "HEAD^{tree}"]).decode("ascii").strip()
185
+ status = _git(
186
+ source_root,
187
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
188
+ )
189
+ except (OSError, RuntimeError, subprocess.TimeoutExpired, UnicodeDecodeError):
190
+ return None
191
+ return {
192
+ "commit_sha": commit,
193
+ "tree_sha": tree,
194
+ "working_tree_clean": not status,
195
+ "working_tree_status_sha256": hashlib.sha256(status).hexdigest(),
196
+ }
197
+
198
+
199
+ def _git(source_root: Path, args: list[str]) -> bytes:
200
+ completed = subprocess.run(
201
+ ["git", "-C", os.fspath(source_root), *args],
202
+ check=False,
203
+ capture_output=True,
204
+ timeout=30,
205
+ )
206
+ if completed.returncode != 0:
207
+ raise RuntimeError("git provenance query failed")
208
+ return completed.stdout
209
+
210
+
211
+ def _runtime_fingerprint(package_dir: Path) -> str:
212
+ digest = hashlib.sha256()
213
+ for path in sorted(package_dir.rglob("*")):
214
+ relative = path.relative_to(package_dir)
215
+ if "__pycache__" in relative.parts or "tests" in relative.parts:
216
+ continue
217
+ if path.is_symlink():
218
+ digest.update(relative.as_posix().encode("utf-8"))
219
+ digest.update(b"\0symlink\0")
220
+ digest.update(os.readlink(path).encode("utf-8", errors="surrogateescape"))
221
+ continue
222
+ if not path.is_file() or path.suffix in {".pyc", ".pyo"}:
223
+ continue
224
+ digest.update(relative.as_posix().encode("utf-8"))
225
+ digest.update(b"\0")
226
+ with path.open("rb") as handle:
227
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
228
+ digest.update(chunk)
229
+ digest.update(b"\0")
230
+ return digest.hexdigest()
231
+
232
+
233
+ def _is_hex_digest(value: Any, length: int) -> bool:
234
+ if not isinstance(value, str) or len(value) != length:
235
+ return False
236
+ try:
237
+ int(value, 16)
238
+ except ValueError:
239
+ return False
240
+ return True
241
+
242
+
243
+ def _mapping_value(mapping: Any, key: str, default: Any = None) -> Any:
244
+ if not isinstance(mapping, dict) or key not in mapping:
245
+ return default
246
+ return mapping[key]