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.
- arcgraph/__init__.py +41 -0
- arcgraph/_build_provenance.json +10 -0
- arcgraph/adapters/__init__.py +6 -0
- arcgraph/adapters/arq_adapter.py +369 -0
- arcgraph/adapters/celery_adapter.py +440 -0
- arcgraph/adapters/common.py +436 -0
- arcgraph/adapters/django_adapter.py +765 -0
- arcgraph/adapters/fastapi_adapter.py +415 -0
- arcgraph/adapters/logging_adapter.py +335 -0
- arcgraph/adapters/mcp_adapter.py +100 -0
- arcgraph/adapters/pydantic_adapter.py +290 -0
- arcgraph/adapters/pytest_adapter.py +221 -0
- arcgraph/adapters/pytest_collection.py +96 -0
- arcgraph/adapters/pytest_fixtures.py +371 -0
- arcgraph/adapters/pytest_types.py +251 -0
- arcgraph/adapters/registry.py +34 -0
- arcgraph/adapters/service_container_adapter.py +548 -0
- arcgraph/adapters/sqlalchemy_adapter.py +446 -0
- arcgraph/adapters/typer_click_adapter.py +292 -0
- arcgraph/analyzers/__init__.py +1 -0
- arcgraph/analyzers/bindings.py +782 -0
- arcgraph/analyzers/calls/__init__.py +6 -0
- arcgraph/analyzers/calls/analyzer.py +2921 -0
- arcgraph/analyzers/calls/constants.py +702 -0
- arcgraph/analyzers/calls/context.py +298 -0
- arcgraph/analyzers/calls/lexical.py +237 -0
- arcgraph/analyzers/coverage.py +826 -0
- arcgraph/analyzers/exports.py +46 -0
- arcgraph/analyzers/har_trace.py +323 -0
- arcgraph/analyzers/imports.py +190 -0
- arcgraph/analyzers/otel_trace.py +352 -0
- arcgraph/analyzers/precise_references.py +1199 -0
- arcgraph/analyzers/python_scopes.py +56 -0
- arcgraph/analyzers/runtime_trace.py +516 -0
- arcgraph/analyzers/similarity.py +430 -0
- arcgraph/analyzers/symbols.py +363 -0
- arcgraph/analyzers/type_unions.py +89 -0
- arcgraph/analyzers/types.py +2293 -0
- arcgraph/assets/__init__.py +1 -0
- arcgraph/assets/workbench/__init__.py +1 -0
- arcgraph/assets/workbench/index.html +2398 -0
- arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
- arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
- arcgraph/change/__init__.py +10 -0
- arcgraph/change/baseline.py +145 -0
- arcgraph/change/changed_regions.py +728 -0
- arcgraph/change/contracts.py +927 -0
- arcgraph/change/errors.py +88 -0
- arcgraph/change/evidence.py +289 -0
- arcgraph/change/graph_delta.py +1422 -0
- arcgraph/change/identities.py +1277 -0
- arcgraph/change/paths.py +234 -0
- arcgraph/change/pins.py +426 -0
- arcgraph/change/planner.py +504 -0
- arcgraph/change/protected_surfaces.py +328 -0
- arcgraph/change/provider.py +46 -0
- arcgraph/change/service.py +1030 -0
- arcgraph/change/source_provider.py +311 -0
- arcgraph/change/store.py +1138 -0
- arcgraph/change/trusted_runners.py +154 -0
- arcgraph/change/verdicts.py +141 -0
- arcgraph/change/verification_plan.py +113 -0
- arcgraph/change/verifier.py +579 -0
- arcgraph/core/__init__.py +1 -0
- arcgraph/core/assurance.py +343 -0
- arcgraph/core/binding_metrics.py +75 -0
- arcgraph/core/cleanup.py +701 -0
- arcgraph/core/confidence_profiles.py +108 -0
- arcgraph/core/evidence_manifest.py +862 -0
- arcgraph/core/expression_kinds.py +11 -0
- arcgraph/core/force_graph_export.py +1125 -0
- arcgraph/core/freshness.py +94 -0
- arcgraph/core/graph_store.py +1099 -0
- arcgraph/core/ids.py +206 -0
- arcgraph/core/language_tiers.py +150 -0
- arcgraph/core/merge.py +649 -0
- arcgraph/core/metadata.py +30 -0
- arcgraph/core/operation_lock.py +218 -0
- arcgraph/core/passes.py +142 -0
- arcgraph/core/payload_policy.py +708 -0
- arcgraph/core/precise_references.py +124 -0
- arcgraph/core/query_engine.py +3078 -0
- arcgraph/core/recovery.py +18 -0
- arcgraph/core/scanner.py +1454 -0
- arcgraph/core/schemas.py +700 -0
- arcgraph/core/semantic.py +263 -0
- arcgraph/core/semantic_metrics.py +619 -0
- arcgraph/core/sharing_retry.py +84 -0
- arcgraph/core/structural.py +260 -0
- arcgraph/core/target_resolver.py +486 -0
- arcgraph/core/type_metrics.py +67 -0
- arcgraph/core/unresolved_classification.py +471 -0
- arcgraph/core/utils.py +100 -0
- arcgraph/core/visual_contract.py +240 -0
- arcgraph/core/visual_slice_engine.py +933 -0
- arcgraph/interfaces/__init__.py +1 -0
- arcgraph/interfaces/agent_capabilities.py +493 -0
- arcgraph/interfaces/agent_help.py +313 -0
- arcgraph/interfaces/benchmark.py +548 -0
- arcgraph/interfaces/ci.py +1992 -0
- arcgraph/interfaces/cli.py +2799 -0
- arcgraph/interfaces/cli_change.py +690 -0
- arcgraph/interfaces/cli_feedback.py +176 -0
- arcgraph/interfaces/cli_ops.py +69 -0
- arcgraph/interfaces/cli_support.py +113 -0
- arcgraph/interfaces/cli_trial.py +42 -0
- arcgraph/interfaces/cli_visual.py +601 -0
- arcgraph/interfaces/cli_workspace.py +300 -0
- arcgraph/interfaces/client_setup.py +481 -0
- arcgraph/interfaces/docs.py +1255 -0
- arcgraph/interfaces/human_output.py +409 -0
- arcgraph/interfaces/local_state.py +577 -0
- arcgraph/interfaces/mcp_server.py +457 -0
- arcgraph/interfaces/mcp_tools.py +1209 -0
- arcgraph/interfaces/metrics.py +571 -0
- arcgraph/interfaces/ops.py +330 -0
- arcgraph/interfaces/precision.py +1670 -0
- arcgraph/interfaces/project_probe.py +75 -0
- arcgraph/interfaces/reports.py +1121 -0
- arcgraph/interfaces/stdio_encoding.py +29 -0
- arcgraph/interfaces/trace.py +592 -0
- arcgraph/interfaces/trial_feedback.py +519 -0
- arcgraph/interfaces/trial_setup.py +535 -0
- arcgraph/interfaces/visual_server.py +354 -0
- arcgraph/interfaces/visual_slice.py +168 -0
- arcgraph/interfaces/visual_smoke.py +666 -0
- arcgraph/interfaces/workbench.py +576 -0
- arcgraph/interfaces/workspace.py +1513 -0
- arcgraph/pipeline/__init__.py +1 -0
- arcgraph/pipeline/contracts.py +122 -0
- arcgraph/pipeline/external_frontend.py +451 -0
- arcgraph/pipeline/external_l3_frontends.py +368 -0
- arcgraph/pipeline/external_protocol.py +358 -0
- arcgraph/pipeline/frontends.py +437 -0
- arcgraph/pipeline/indexer.py +661 -0
- arcgraph/pipeline/openapi_frontend.py +708 -0
- arcgraph/pipeline/python_frontend.py +298 -0
- arcgraph/pipeline/reindexer.py +1333 -0
- arcgraph/pipeline/scip_frontend.py +726 -0
- arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
- arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
- arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
- arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
- arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
- arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
- arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
- arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
- arcgraph/pipeline/typescript_extractor.mjs +177 -0
- arcgraph/pipeline/typescript_frameworks.py +947 -0
- arcgraph/pipeline/typescript_frontend.py +656 -0
- arcgraph/pipeline/typescript_references.mjs +167 -0
- arcgraph/providers/__init__.py +27 -0
- arcgraph/providers/context_provider.py +1258 -0
- arcgraph/providers/flow_payload.py +117 -0
- arcgraph/providers/memory_connector.py +216 -0
- arcgraph/providers/orchestrator.py +475 -0
- arcgraph/providers/relationship_payload.py +102 -0
- arcgraph/providers/risk_provider.py +843 -0
- arcgraph/providers/why_provider.py +349 -0
- arcgraph/version_info.py +246 -0
- arcgraph-0.1.0.dist-info/METADATA +676 -0
- arcgraph-0.1.0.dist-info/RECORD +165 -0
- arcgraph-0.1.0.dist-info/WHEEL +4 -0
- arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
arcgraph/version_info.py
ADDED
|
@@ -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]
|