quality-graph-core 0.1.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ coverage.json
9
+ coverage.xml
10
+ htmlcov/
11
+ coverage-report/
12
+ dist/
13
+ build/
14
+ *.egg-info/
15
+ .mutmut-cache/
16
+ mutants/
17
+ reports/
18
+ .tools/
19
+ node_modules/
20
+ .DS_Store
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: quality-graph-core
3
+ Version: 0.1.0
4
+ Summary: Platform-independent Quality Graph contracts and decision logic
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: defusedxml>=0.7.1
8
+ Requires-Dist: pyyaml>=6.0.2
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Quality Graph Core
12
+
13
+ Platform-independent graph, result protocol, policy, and provider contracts for Quality Graph.
14
+
15
+ ```python
16
+ from quality_graph_core import Graph, Result
17
+
18
+ graph = Graph.from_yaml(source)
19
+ result = Result.from_json(payload)
20
+ ```
21
+
22
+ Providers implement the runtime-checkable `Provider` interface and return a deterministic
23
+ `GeneratedProject`. Core never imports a provider or the CLI.
@@ -0,0 +1,13 @@
1
+ # Quality Graph Core
2
+
3
+ Platform-independent graph, result protocol, policy, and provider contracts for Quality Graph.
4
+
5
+ ```python
6
+ from quality_graph_core import Graph, Result
7
+
8
+ graph = Graph.from_yaml(source)
9
+ result = Result.from_json(payload)
10
+ ```
11
+
12
+ Providers implement the runtime-checkable `Provider` interface and return a deterministic
13
+ `GeneratedProject`. Core never imports a provider or the CLI.
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.27.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "quality-graph-core"
7
+ version = "0.1.0"
8
+ description = "Platform-independent Quality Graph contracts and decision logic"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ dependencies = ["defusedxml>=0.7.1", "pyyaml>=6.0.2"]
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["src/quality_graph_core"]
@@ -0,0 +1,11 @@
1
+ """Public package metadata for Quality Graph Core."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from quality_graph_core.graph import Graph
6
+ from quality_graph_core.provider import GeneratedFile, GeneratedProject, Provider
7
+ from quality_graph_core.result import Result
8
+
9
+ __version__ = version("quality-graph-core")
10
+
11
+ __all__ = ["GeneratedFile", "GeneratedProject", "Graph", "Provider", "Result", "__version__"]
@@ -0,0 +1,372 @@
1
+ """Translate command and report formats into the shared result protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass, replace
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from defusedxml import ElementTree
11
+
12
+ from quality_graph_core.result import (
13
+ Annotation,
14
+ Diagnostic,
15
+ DiagnosticKind,
16
+ FailureKind,
17
+ Finding,
18
+ JsonValue,
19
+ Metric,
20
+ Provenance,
21
+ Result,
22
+ ResultStatus,
23
+ Severity,
24
+ SourceLocation,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Iterator
29
+ from pathlib import Path
30
+ from typing import Protocol
31
+
32
+ class XmlElement(Protocol):
33
+ """Describe the defused XML element operations used by the adapter."""
34
+
35
+ tag: str
36
+ text: str | None
37
+
38
+ def iter(self, tag: str) -> Iterator[XmlElement]:
39
+ """Iterate descendants with the requested tag."""
40
+ ...
41
+
42
+ def find(self, path: str) -> XmlElement | None:
43
+ """Find the first matching descendant."""
44
+ ...
45
+
46
+ def get(self, key: str, _default: str = "") -> str:
47
+ """Return one XML attribute or its default."""
48
+ ...
49
+
50
+
51
+ MAX_REPORT_BYTES = 10 * 1024 * 1024
52
+ MAX_SUMMARY_CHARACTERS = 60_000
53
+
54
+
55
+ class AdapterError(ValueError):
56
+ """Represent deterministic failure to read or translate a report."""
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class AdapterContext:
61
+ """Provide trusted node and workflow metadata to an adapter."""
62
+
63
+ node_id: str
64
+ title: str
65
+ command_succeeded: bool
66
+ provenance: Provenance
67
+
68
+
69
+ def adapt_exit(context: AdapterContext, output: str = "") -> Result:
70
+ """Map one command exit outcome to a portable result."""
71
+ status = ResultStatus.PASSED if context.command_succeeded else ResultStatus.FAILED
72
+ failure = None if context.command_succeeded else FailureKind.COMMAND
73
+ summary = _bounded_summary(output.strip())
74
+ diagnostics = (
75
+ ()
76
+ if context.command_succeeded
77
+ else (
78
+ Diagnostic(
79
+ DiagnosticKind.COMMAND,
80
+ "The declared command failed.",
81
+ summary[:20_000],
82
+ ),
83
+ )
84
+ )
85
+ return Result(
86
+ context.node_id,
87
+ context.title,
88
+ status,
89
+ context.provenance,
90
+ failure,
91
+ summary,
92
+ diagnostics=diagnostics,
93
+ )
94
+
95
+
96
+ def adapt_native(context: AdapterContext, report: bytes) -> Result:
97
+ """Validate a native result and bind it to trusted execution metadata."""
98
+ try:
99
+ result = Result.from_json(report)
100
+ except (TypeError, ValueError, json.JSONDecodeError) as error:
101
+ message = f"Native result is invalid: {error}"
102
+ raise AdapterError(message) from error
103
+ if result.node_id != context.node_id or result.title != context.title:
104
+ message = "Native result identity does not match the declared node"
105
+ raise AdapterError(message)
106
+ if result.provenance != context.provenance:
107
+ message = "Native result provenance does not match the current workflow attempt"
108
+ raise AdapterError(message)
109
+ return _reconcile_command(context, result)
110
+
111
+
112
+ def adapt_sarif(context: AdapterContext, report: bytes) -> Result:
113
+ """Translate SARIF findings and locations into the shared protocol."""
114
+ data = _decode_json(report, "SARIF")
115
+ root = _object(data, "SARIF report")
116
+ runs = _array(root.get("runs"), "SARIF runs")
117
+ findings: list[Finding] = []
118
+ annotations: list[Annotation] = []
119
+ for run_value in runs:
120
+ run = _object(run_value, "SARIF run")
121
+ for result_value in _array(run.get("results", []), "SARIF results"):
122
+ finding, annotation = _sarif_finding(_object(result_value, "SARIF result"))
123
+ findings.append(finding)
124
+ if annotation is not None:
125
+ annotations.append(annotation)
126
+ errors = sum(finding.severity is Severity.ERROR for finding in findings)
127
+ status = ResultStatus.FAILED if errors or not context.command_succeeded else ResultStatus.PASSED
128
+ result = Result(
129
+ context.node_id,
130
+ context.title,
131
+ status,
132
+ context.provenance,
133
+ FailureKind.QUALITY if status is ResultStatus.FAILED else None,
134
+ f"Found {len(findings)} SARIF findings ({errors} errors).",
135
+ (Metric("Findings", str(len(findings))), Metric("Errors", str(errors))),
136
+ tuple(findings),
137
+ tuple(annotations),
138
+ )
139
+ return _reconcile_command(context, result)
140
+
141
+
142
+ def adapt_junit(context: AdapterContext, report: bytes) -> Result:
143
+ """Translate JUnit XML test failures into stable findings."""
144
+ try:
145
+ root = ElementTree.fromstring(report)
146
+ except ElementTree.ParseError as error:
147
+ message = f"JUnit report is invalid XML: {error}"
148
+ raise AdapterError(message) from error
149
+ if root.tag not in {"testsuite", "testsuites"}:
150
+ message = "JUnit report root must be testsuite or testsuites"
151
+ raise AdapterError(message)
152
+ cases = tuple(root.iter("testcase"))
153
+ findings = tuple(
154
+ finding
155
+ for case in cases
156
+ for finding in (_junit_finding(cast("XmlElement", case)),)
157
+ if finding is not None
158
+ )
159
+ skipped = sum(case.find("skipped") is not None for case in cases)
160
+ status = (
161
+ ResultStatus.FAILED if findings or not context.command_succeeded else ResultStatus.PASSED
162
+ )
163
+ result = Result(
164
+ context.node_id,
165
+ context.title,
166
+ status,
167
+ context.provenance,
168
+ FailureKind.QUALITY if status is ResultStatus.FAILED else None,
169
+ f"Ran {len(cases)} tests: {len(findings)} failed, {skipped} skipped.",
170
+ (
171
+ Metric("Tests", str(len(cases))),
172
+ Metric("Failures", str(len(findings))),
173
+ Metric("Skipped", str(skipped)),
174
+ ),
175
+ findings,
176
+ )
177
+ return _reconcile_command(context, result)
178
+
179
+
180
+ def read_report(workspace: Path, relative_path: str) -> bytes:
181
+ """Read one bounded report without escaping the repository workspace."""
182
+ root = workspace.resolve()
183
+ path = (root / relative_path).resolve()
184
+ if path != root and root not in path.parents:
185
+ message = f"Report path escapes the workspace: {relative_path}"
186
+ raise AdapterError(message)
187
+ if not path.is_file():
188
+ message = f"Report file does not exist: {relative_path}"
189
+ raise AdapterError(message)
190
+ size = path.stat().st_size
191
+ if size > MAX_REPORT_BYTES:
192
+ message = f"Report exceeds the {MAX_REPORT_BYTES}-byte limit: {relative_path}"
193
+ raise AdapterError(message)
194
+ return path.read_bytes()
195
+
196
+
197
+ def adapter_failure(context: AdapterContext, error: AdapterError) -> Result:
198
+ """Represent adapter failure distinctly from a check failure."""
199
+ return Result(
200
+ context.node_id,
201
+ context.title,
202
+ ResultStatus.FAILED,
203
+ context.provenance,
204
+ FailureKind.ADAPTER,
205
+ str(error),
206
+ diagnostics=(Diagnostic(DiagnosticKind.ADAPTER, "Result adapter failed.", str(error)),),
207
+ )
208
+
209
+
210
+ def _reconcile_command(context: AdapterContext, result: Result) -> Result:
211
+ if context.command_succeeded or result.status in {ResultStatus.FAILED, ResultStatus.CANCELLED}:
212
+ return result
213
+ diagnostic = Diagnostic(
214
+ DiagnosticKind.COMMAND,
215
+ "The declared command failed despite a passing report.",
216
+ )
217
+ return replace(
218
+ result,
219
+ status=ResultStatus.FAILED,
220
+ failure_kind=FailureKind.COMMAND,
221
+ diagnostics=(*result.diagnostics, diagnostic),
222
+ )
223
+
224
+
225
+ def _bounded_summary(value: str) -> str:
226
+ if len(value) <= MAX_SUMMARY_CHARACTERS:
227
+ return value
228
+ omitted = len(value) - MAX_SUMMARY_CHARACTERS
229
+ while True:
230
+ notice = f"\n\n_Output truncated; {omitted} characters omitted._"
231
+ prefix_length = MAX_SUMMARY_CHARACTERS - len(notice)
232
+ updated = len(value) - prefix_length
233
+ if updated == omitted:
234
+ return value[:prefix_length] + notice
235
+ omitted = updated
236
+
237
+
238
+ def _decode_json(value: bytes, context: str) -> JsonValue:
239
+ try:
240
+ return cast("JsonValue", json.loads(value))
241
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
242
+ message = f"{context} is invalid JSON: {error}"
243
+ raise AdapterError(message) from error
244
+
245
+
246
+ def _sarif_finding(data: dict[str, JsonValue]) -> tuple[Finding, Annotation | None]:
247
+ rule_id = _optional_string(data.get("ruleId"), "SARIF rule id")
248
+ message = _sarif_message(_object(data.get("message"), "SARIF message"))
249
+ severity = _sarif_severity(_optional_string(data.get("level"), "SARIF level"))
250
+ location = _sarif_location(data.get("locations"))
251
+ partial = _optional_object(data.get("partialFingerprints"), "SARIF partial fingerprints")
252
+ fingerprint = _sarif_fingerprint(rule_id, message, location, partial)
253
+ finding_id = f"sarif-{fingerprint[:24]}"
254
+ finding = Finding(
255
+ finding_id,
256
+ severity,
257
+ message,
258
+ rule_id,
259
+ fingerprint=fingerprint,
260
+ location=location,
261
+ )
262
+ annotation = Annotation(severity, message, location, rule_id) if location is not None else None
263
+ return finding, annotation
264
+
265
+
266
+ def _sarif_message(data: dict[str, JsonValue]) -> str:
267
+ text = data.get("text", data.get("markdown"))
268
+ return _string(text, "SARIF message text")
269
+
270
+
271
+ def _sarif_severity(value: str | None) -> Severity:
272
+ if value == "error":
273
+ return Severity.ERROR
274
+ if value == "warning":
275
+ return Severity.WARNING
276
+ return Severity.NOTICE
277
+
278
+
279
+ def _sarif_location(value: JsonValue) -> SourceLocation | None:
280
+ locations = _array(value if value is not None else [], "SARIF locations")
281
+ if not locations:
282
+ return None
283
+ location = _object(locations[0], "SARIF location")
284
+ physical = _object(location.get("physicalLocation"), "SARIF physical location")
285
+ artifact = _object(physical.get("artifactLocation"), "SARIF artifact location")
286
+ region = _object(physical.get("region"), "SARIF region")
287
+ start_line = _integer(region.get("startLine"), "SARIF start line")
288
+ end_line = _optional_integer(region.get("endLine"), "SARIF end line") or start_line
289
+ return SourceLocation(
290
+ _string(artifact.get("uri"), "SARIF artifact URI"),
291
+ start_line,
292
+ end_line,
293
+ _optional_integer(region.get("startColumn"), "SARIF start column"),
294
+ _optional_integer(region.get("endColumn"), "SARIF end column"),
295
+ )
296
+
297
+
298
+ def _sarif_fingerprint(
299
+ rule_id: str | None,
300
+ message: str,
301
+ location: SourceLocation | None,
302
+ partial: dict[str, JsonValue] | None,
303
+ ) -> str:
304
+ if partial:
305
+ semantic = "\n".join(
306
+ f"{key}={_string(value, 'SARIF fingerprint')}" for key, value in sorted(partial.items())
307
+ )
308
+ else:
309
+ semantic = "\n".join((rule_id or "", message, location.path if location else ""))
310
+ return hashlib.sha256(semantic.encode()).hexdigest()
311
+
312
+
313
+ def _junit_finding(case: XmlElement) -> Finding | None:
314
+ failure = case.find("failure")
315
+ if failure is None:
316
+ failure = case.find("error")
317
+ if failure is None:
318
+ return None
319
+ class_name = case.get("classname", "")
320
+ test_name = case.get("name", "unnamed test")
321
+ failure_type = failure.get("type", "failure")
322
+ message = failure.get("message") or (failure.text or "Test failed").strip()
323
+ semantic = f"{class_name}\n{test_name}\n{failure_type}\n{message}"
324
+ fingerprint = hashlib.sha256(semantic.encode()).hexdigest()
325
+ return Finding(
326
+ f"junit-{fingerprint[:24]}",
327
+ Severity.ERROR,
328
+ message,
329
+ failure_type,
330
+ fingerprint=fingerprint,
331
+ group=class_name or None,
332
+ )
333
+
334
+
335
+ def _object(value: JsonValue, context: str) -> dict[str, JsonValue]:
336
+ if not isinstance(value, dict):
337
+ message = f"{context} must be an object"
338
+ raise AdapterError(message)
339
+ return value
340
+
341
+
342
+ def _optional_object(value: JsonValue, context: str) -> dict[str, JsonValue] | None:
343
+ return None if value is None else _object(value, context)
344
+
345
+
346
+ def _array(value: JsonValue, context: str) -> list[JsonValue]:
347
+ if not isinstance(value, list):
348
+ message = f"{context} must be an array"
349
+ raise AdapterError(message)
350
+ return value
351
+
352
+
353
+ def _string(value: JsonValue, context: str) -> str:
354
+ if not isinstance(value, str):
355
+ message = f"{context} must be a string"
356
+ raise AdapterError(message)
357
+ return value
358
+
359
+
360
+ def _optional_string(value: JsonValue, context: str) -> str | None:
361
+ return None if value is None else _string(value, context)
362
+
363
+
364
+ def _integer(value: JsonValue, context: str) -> int:
365
+ if not isinstance(value, int) or isinstance(value, bool):
366
+ message = f"{context} must be an integer"
367
+ raise AdapterError(message)
368
+ return value
369
+
370
+
371
+ def _optional_integer(value: JsonValue, context: str) -> int | None:
372
+ return None if value is None else _integer(value, context)