codemetrics-ai 0.4.0 → 0.6.0-preview.1
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.
- package/README.md +51 -5
- package/dist/analyzer.d.ts +1 -0
- package/dist/analyzer.js +11 -5
- package/dist/cli.js +7 -3
- package/dist/dependency-evidence.d.ts +5 -0
- package/dist/dependency-evidence.js +30 -0
- package/dist/dependency-inventory.d.ts +67 -0
- package/dist/dependency-inventory.js +203 -0
- package/dist/dependency-metadata.d.ts +65 -0
- package/dist/dependency-metadata.js +125 -0
- package/dist/dependency-pnpm.d.ts +4 -0
- package/dist/dependency-pnpm.js +237 -0
- package/dist/dependency-spec.d.ts +13 -0
- package/dist/dependency-spec.js +30 -0
- package/dist/error-handling-scoring.d.ts +1 -0
- package/dist/error-handling-scoring.js +15 -0
- package/dist/error-handling.d.ts +4 -1
- package/dist/error-handling.js +50 -17
- package/dist/evidence-reader.js +3 -1
- package/dist/evidence-tools.js +4 -0
- package/dist/failure-contracts.d.ts +19 -0
- package/dist/failure-contracts.js +261 -0
- package/dist/function-scoring.d.ts +2 -1
- package/dist/function-scoring.js +13 -24
- package/dist/handler-disposition.d.ts +9 -0
- package/dist/handler-disposition.js +156 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/python/ASYNC_ADAPTERS.md +38 -0
- package/dist/python/ASYNC_CONTEXT.md +58 -0
- package/dist/python/ASYNC_SCORING.md +174 -0
- package/dist/python/ASYNC_USAGE.md +62 -0
- package/dist/python/BEHAVIOR_REVIEW.md +84 -0
- package/dist/python/COVERAGE.md +96 -0
- package/dist/python/DECOMPOSITION.md +46 -0
- package/dist/python/DUPLICATION.md +36 -0
- package/dist/python/ERROR_HANDLING.md +45 -0
- package/dist/python/ERROR_HANDLING_CONTEXT.md +61 -0
- package/dist/python/ERROR_HANDLING_OPERATIONS.md +54 -0
- package/dist/python/ERROR_HANDLING_TRANSFERS.md +64 -0
- package/dist/python/METHOD_COMPLEXITY.md +46 -0
- package/dist/python/README.md +162 -0
- package/dist/python/analysis.py +54 -0
- package/dist/python/async_adapters.py +243 -0
- package/dist/python/async_bindings.py +180 -0
- package/dist/python/async_context.py +143 -0
- package/dist/python/async_helper_paths.py +186 -0
- package/dist/python/async_provenance.py +129 -0
- package/dist/python/async_scoring.py +203 -0
- package/dist/python/async_usage.py +244 -0
- package/dist/python/coverage_collect.py +121 -0
- package/dist/python/coverage_provenance.py +77 -0
- package/dist/python/coverage_review.py +273 -0
- package/dist/python/coverage_worker.py +46 -0
- package/dist/python/decomposition.py +199 -0
- package/dist/python/duplication.py +177 -0
- package/dist/python/error_handling.py +249 -0
- package/dist/python/error_handling_context.py +294 -0
- package/dist/python/error_handling_operations.py +228 -0
- package/dist/python/error_handling_transfers.py +211 -0
- package/dist/python/review_cli.py +36 -0
- package/dist/python/source_facts.py +293 -0
- package/dist/python/source_selection.py +157 -0
- package/dist/python-analyzer.d.ts +21 -0
- package/dist/python-analyzer.js +123 -0
- package/dist/python-async-adapters.d.ts +67 -0
- package/dist/python-async-adapters.js +197 -0
- package/dist/python-async-context.d.ts +73 -0
- package/dist/python-async-context.js +181 -0
- package/dist/python-async-scoring.d.ts +89 -0
- package/dist/python-async-scoring.js +249 -0
- package/dist/python-async-usage.d.ts +56 -0
- package/dist/python-async-usage.js +143 -0
- package/dist/python-cli.d.ts +2 -0
- package/dist/python-cli.js +53 -0
- package/dist/python-collect-cli.d.ts +2 -0
- package/dist/python-collect-cli.js +28 -0
- package/dist/python-complexity.d.ts +18 -0
- package/dist/python-complexity.js +36 -0
- package/dist/python-coverage.d.ts +63 -0
- package/dist/python-coverage.js +173 -0
- package/dist/python-decomposition.d.ts +56 -0
- package/dist/python-decomposition.js +102 -0
- package/dist/python-duplication.d.ts +65 -0
- package/dist/python-duplication.js +89 -0
- package/dist/python-error-context.d.ts +84 -0
- package/dist/python-error-context.js +165 -0
- package/dist/python-error-handling.d.ts +56 -0
- package/dist/python-error-handling.js +120 -0
- package/dist/python-error-operations.d.ts +44 -0
- package/dist/python-error-operations.js +155 -0
- package/dist/python-error-transfers.d.ts +38 -0
- package/dist/python-error-transfers.js +125 -0
- package/dist/python-evidence.d.ts +23 -0
- package/dist/python-evidence.js +255 -0
- package/dist/score-arithmetic.d.ts +1 -12
- package/dist/score-arithmetic.js +1 -40
- package/dist/shared-source-facts/arithmetic.d.mts +9 -0
- package/dist/shared-source-facts/arithmetic.mjs +32 -0
- package/dist/shared-source-facts/contract.d.mts +35 -0
- package/dist/shared-source-facts/contract.mjs +95 -0
- package/dist/shared-source-facts/population.d.mts +12 -0
- package/dist/shared-source-facts/population.mjs +41 -0
- package/dist/source-facts.d.ts +4 -0
- package/dist/source-facts.js +16 -0
- package/package.json +15 -6
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Offline coverage.py line-coverage/CRAP review; never execute target code."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
from bisect import bisect_left, bisect_right
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from fractions import Fraction
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
import sys
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from duplication import digest, read_verified
|
|
16
|
+
import coverage_provenance
|
|
17
|
+
import source_facts
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
POLICY = "python/coveragepy-line-crap-review-v1"
|
|
21
|
+
MAX_REPORT_BYTES = 32 * 1024 * 1024
|
|
22
|
+
MAX_OWNERS = 100000
|
|
23
|
+
FORMULA = "CC^2 * (1 - coveredLines / executableLines)^3 + CC"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def unique_object(pairs):
|
|
27
|
+
result = {}
|
|
28
|
+
for key, value in pairs:
|
|
29
|
+
if key in result:
|
|
30
|
+
raise ValueError(f"Duplicate JSON key: {key}")
|
|
31
|
+
result[key] = value
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def invalid_constant(value):
|
|
36
|
+
raise ValueError(f"Non-finite JSON number: {value}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def report_path(value):
|
|
40
|
+
"""Coverage paths are relative to the selected source root, on either OS."""
|
|
41
|
+
path = value.replace("\\", "/")
|
|
42
|
+
if not path or path.startswith("/") or ":" in path or ".." in path.split("/"):
|
|
43
|
+
raise ValueError(f"Coverage path must be root-relative without traversal: {value}")
|
|
44
|
+
normalized = PurePosixPath(path).as_posix()
|
|
45
|
+
if normalized == ".":
|
|
46
|
+
raise ValueError("Coverage path must identify a file.")
|
|
47
|
+
return normalized
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def line_set(record, key):
|
|
51
|
+
lines = record.get(key)
|
|
52
|
+
if not isinstance(lines, list) or any(type(line) is not int or line < 1 for line in lines):
|
|
53
|
+
raise ValueError(f"{key} must contain positive integer line numbers.")
|
|
54
|
+
result = set(lines)
|
|
55
|
+
if len(result) != len(lines):
|
|
56
|
+
raise ValueError(f"{key} contains duplicate lines.")
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_coverage(path):
|
|
61
|
+
if path is None:
|
|
62
|
+
return {}, None
|
|
63
|
+
with Path(path).open("rb") as handle:
|
|
64
|
+
raw = handle.read(MAX_REPORT_BYTES + 1)
|
|
65
|
+
if len(raw) > MAX_REPORT_BYTES:
|
|
66
|
+
raise ValueError("Coverage JSON exceeds the report byte budget.")
|
|
67
|
+
data = json.loads(raw, object_pairs_hook=unique_object, parse_constant=invalid_constant)
|
|
68
|
+
if not isinstance(data, dict) or not isinstance(data.get("meta"), dict):
|
|
69
|
+
raise ValueError("Expected a coverage.py JSON report with metadata.")
|
|
70
|
+
meta = data["meta"]
|
|
71
|
+
if type(meta.get("format")) is not int or meta["format"] not in (2, 3):
|
|
72
|
+
raise ValueError("Only coverage.py JSON formats 2 and 3 are supported.")
|
|
73
|
+
if not isinstance(meta.get("version"), str) or not meta["version"]:
|
|
74
|
+
raise ValueError("Coverage producer version is required.")
|
|
75
|
+
if not isinstance(meta.get("timestamp"), str) or type(meta.get("branch_coverage")) is not bool:
|
|
76
|
+
raise ValueError("Coverage timestamp and branch_coverage metadata are required.")
|
|
77
|
+
files = data.get("files")
|
|
78
|
+
if not isinstance(files, dict) or len(files) > source_facts.MAX_FILES:
|
|
79
|
+
raise ValueError("Coverage files must be an object within the file-count budget.")
|
|
80
|
+
normalized = {}
|
|
81
|
+
for file, record in files.items():
|
|
82
|
+
name = report_path(file)
|
|
83
|
+
if name in normalized or not isinstance(record, dict):
|
|
84
|
+
raise ValueError("Duplicate normalized coverage path or invalid file record.")
|
|
85
|
+
executed = line_set(record, "executed_lines")
|
|
86
|
+
missing = line_set(record, "missing_lines")
|
|
87
|
+
excluded = line_set(record, "excluded_lines")
|
|
88
|
+
if missing & (executed | excluded):
|
|
89
|
+
raise ValueError(f"Conflicting executed/missing/excluded lines: {file}")
|
|
90
|
+
normalized[name] = {"executed": sorted(executed - excluded), "missing": sorted(missing), "excluded": sorted(excluded)}
|
|
91
|
+
provenance = {"path": str(Path(path).absolute()), "sha256": hashlib.sha256(raw).hexdigest(),
|
|
92
|
+
"producer": "coverage.py", "version": meta["version"], "format": meta["format"],
|
|
93
|
+
"reportedAt": meta["timestamp"], "branchDataPresent": meta["branch_coverage"],
|
|
94
|
+
"sourceBinding": "unverified", "freshness": "unverified"}
|
|
95
|
+
return normalized, provenance
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def body_region(node, lines):
|
|
99
|
+
if isinstance(node, ast.Lambda):
|
|
100
|
+
return None, "lambda-line-attribution"
|
|
101
|
+
first = node.body[0]
|
|
102
|
+
# Definition execution cannot prove body execution on a shared header line,
|
|
103
|
+
# including a multi-line signature whose closing line also starts the body.
|
|
104
|
+
if lines[first.lineno - 1].encode("utf-8")[:first.col_offset].strip():
|
|
105
|
+
return None, "body-shares-definition-line"
|
|
106
|
+
body = node.body
|
|
107
|
+
if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant) and isinstance(first.value.value, str):
|
|
108
|
+
body = body[1:]
|
|
109
|
+
if not body:
|
|
110
|
+
return None, "no-reportable-body-lines"
|
|
111
|
+
tree = ast.Module(body=body, type_ignores=[])
|
|
112
|
+
if any(isinstance(child, source_facts.OWNERS + (ast.ClassDef,)) for child in ast.walk(tree)):
|
|
113
|
+
return None, "nested-definition"
|
|
114
|
+
if body[0].lineno <= first.end_lineno and body[0] is not first:
|
|
115
|
+
return None, "body-shares-docstring-line"
|
|
116
|
+
return (body[0].lineno, node.end_lineno), None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def measure_function(function, node, lines, coverage, supplied):
|
|
120
|
+
result = {key: function[key] for key in ("id", "file", "member", "line", "population")}
|
|
121
|
+
result.update(endLine=node.end_lineno, complexity=function["complexity"]["value"],
|
|
122
|
+
coverage={"status": "unavailable"}, crapEstimate=None)
|
|
123
|
+
region, reason = body_region(node, lines)
|
|
124
|
+
if not supplied:
|
|
125
|
+
reason = "coverage-not-supplied"
|
|
126
|
+
elif coverage is None:
|
|
127
|
+
reason = "file-not-in-report"
|
|
128
|
+
if reason:
|
|
129
|
+
result["coverage"]["reason"] = reason
|
|
130
|
+
return result
|
|
131
|
+
start, end = region
|
|
132
|
+
owned = {key: values[bisect_left(values, start):bisect_right(values, end)] for key, values in coverage.items()}
|
|
133
|
+
covered = len(owned["executed"])
|
|
134
|
+
total = covered + len(owned["missing"])
|
|
135
|
+
if total == 0:
|
|
136
|
+
result["coverage"]["reason"] = "no-reportable-body-lines"
|
|
137
|
+
result["coverage"]["excludedLines"] = owned["excluded"]
|
|
138
|
+
return result
|
|
139
|
+
result["coverage"] = {"status": "reported-unverified", "basis": "executable-line",
|
|
140
|
+
"coveredLines": covered, "executableLines": total,
|
|
141
|
+
"executedLines": owned["executed"], "missingLines": owned["missing"],
|
|
142
|
+
"excludedLines": owned["excluded"], "fraction": covered / total}
|
|
143
|
+
cc = result["complexity"]
|
|
144
|
+
value = Fraction(cc * cc) * Fraction(total - covered, total) ** 3 + cc
|
|
145
|
+
result["crapEstimate"] = {"value": float(value), "disposition": "conditional-review-only",
|
|
146
|
+
"basis": "line-coverage-proxy", "formula": FORMULA}
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def collect(root, facts, coverage, supplied):
|
|
151
|
+
if len(facts["functions"]) > MAX_OWNERS:
|
|
152
|
+
raise ValueError("Selected functions exceed the coverage owner budget.")
|
|
153
|
+
by_file = defaultdict(dict)
|
|
154
|
+
for function in facts["functions"]:
|
|
155
|
+
by_file[function["file"]][function["id"]] = function
|
|
156
|
+
rows = []
|
|
157
|
+
selected_files = set()
|
|
158
|
+
for record in facts["selection"]["files"]:
|
|
159
|
+
if record["disposition"] != "analyzed":
|
|
160
|
+
continue
|
|
161
|
+
file = record["file"]
|
|
162
|
+
selected_files.add(file)
|
|
163
|
+
source = read_verified(root, record)
|
|
164
|
+
# AST line numbers use newlines, not every separator str.splitlines accepts.
|
|
165
|
+
lines = source.split("\n")
|
|
166
|
+
if lines[-1] == "":
|
|
167
|
+
lines.pop()
|
|
168
|
+
observed = coverage.get(file)
|
|
169
|
+
if observed and any(values and values[-1] > len(lines) for values in observed.values()):
|
|
170
|
+
raise ValueError(f"Coverage line exceeds current source length: {file}")
|
|
171
|
+
seen = set()
|
|
172
|
+
for node in ast.walk(ast.parse(source, filename=file)):
|
|
173
|
+
if not isinstance(node, source_facts.OWNERS):
|
|
174
|
+
continue
|
|
175
|
+
key = digest(f"{file}|{node.lineno}|{node.col_offset}|{type(node).__name__}")
|
|
176
|
+
function = by_file[file].get(key)
|
|
177
|
+
if function is not None:
|
|
178
|
+
seen.add(key)
|
|
179
|
+
row = measure_function(function, node, lines, observed, supplied)
|
|
180
|
+
row["sourceSha256"] = record["contentSha256"]
|
|
181
|
+
rows.append(row)
|
|
182
|
+
if seen != set(by_file[file]):
|
|
183
|
+
raise ValueError("Selected function identities changed during coverage analysis.")
|
|
184
|
+
rows.sort(key=lambda item: (item["file"], item["line"], item["id"]))
|
|
185
|
+
reasons = Counter(row["coverage"]["reason"] for row in rows if row["crapEstimate"] is None)
|
|
186
|
+
summary = {"selectedFunctions": len(rows), "conditionalEstimates": sum(row["crapEstimate"] is not None for row in rows),
|
|
187
|
+
"unavailableFunctions": sum(reasons.values()), "unavailableReasons": dict(sorted(reasons.items())),
|
|
188
|
+
"matchedFiles": len(selected_files & coverage.keys()), "unmatchedReportFiles": sorted(coverage.keys() - selected_files)}
|
|
189
|
+
return rows, summary
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def analyze(target, *, coverage_json=None, collection_manifest=None, run_id=None, audit_id=None, **selection):
|
|
193
|
+
facts = source_facts.analyze(target, **selection)
|
|
194
|
+
return review(target, facts, coverage_json=coverage_json, collection_manifest=collection_manifest, run_id=run_id, audit_id=audit_id)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def review(target, facts, *, coverage_json=None, collection_manifest=None, run_id=None, audit_id=None):
|
|
198
|
+
"""Share the exact source population and invocation IDs with packaged evidence."""
|
|
199
|
+
target = Path(target).absolute()
|
|
200
|
+
root = target if target.is_dir() else target.parent
|
|
201
|
+
configuration = {"policy": POLICY, "selectionFingerprint": facts["selection"]["configurationFingerprint"],
|
|
202
|
+
"complexityPolicy": facts["measurementPolicy"], "parserVersion": facts["producer"]["parserVersion"],
|
|
203
|
+
"maxReportBytes": MAX_REPORT_BYTES, "maxOwners": MAX_OWNERS}
|
|
204
|
+
report = {"schemaVersion": 2, "artifactKind": "experimental-python-coverage-review",
|
|
205
|
+
"producer": {"name": "codemetrics-ai/python-coverage-review", "version": "0.3.0-experimental"},
|
|
206
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
|
207
|
+
"runId": str(uuid.UUID(run_id)) if run_id else str(uuid.uuid4()),
|
|
208
|
+
"auditId": str(uuid.UUID(audit_id)) if audit_id else str(uuid.uuid4()),
|
|
209
|
+
"policy": POLICY, "configuration": configuration,
|
|
210
|
+
"configurationFingerprint": digest(json.dumps(configuration, sort_keys=True, separators=(",", ":"))),
|
|
211
|
+
"status": facts["status"], "subject": {"root": str(root), "entryPoint": str(target)},
|
|
212
|
+
"sourceFactsProducer": facts["producer"], "selection": facts["selection"],
|
|
213
|
+
"coverageInput": None, "collectionProvenance": None, "diagnostics": list(facts["diagnostics"]),
|
|
214
|
+
"scope": "Line-coverage CRAP review estimates, not scores or defect predictions. Imports without collection provenance have unverified source binding and freshness. New review IDs never authenticate historical coverage. Line coverage is not basis-path coverage or test effectiveness.",
|
|
215
|
+
"functions": [], "summary": None}
|
|
216
|
+
if facts["status"] != "complete":
|
|
217
|
+
return report
|
|
218
|
+
try:
|
|
219
|
+
coverage, report["coverageInput"] = load_coverage(coverage_json)
|
|
220
|
+
if collection_manifest is not None:
|
|
221
|
+
report["collectionProvenance"] = coverage_provenance.validate(
|
|
222
|
+
collection_manifest, root, facts, report["coverageInput"],
|
|
223
|
+
lambda raw: json.loads(raw, object_pairs_hook=unique_object, parse_constant=invalid_constant))
|
|
224
|
+
rows, summary = collect(root, facts, coverage, coverage_json is not None)
|
|
225
|
+
summary["sourceBoundEstimates"] = 0
|
|
226
|
+
if report["collectionProvenance"] is not None:
|
|
227
|
+
report["coverageInput"].update(sourceBinding="matched-pre-post-snapshots", freshness="source-matched")
|
|
228
|
+
for row in rows:
|
|
229
|
+
if row["crapEstimate"] is not None:
|
|
230
|
+
row["coverage"]["status"] = "reported-source-matched"
|
|
231
|
+
row["crapEstimate"]["disposition"] = "review-only"
|
|
232
|
+
summary["sourceBoundEstimates"] = summary["conditionalEstimates"]
|
|
233
|
+
summary["conditionalEstimates"] = 0
|
|
234
|
+
report["functions"], report["summary"] = rows, summary
|
|
235
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError) as error:
|
|
236
|
+
report["status"] = "incomplete"
|
|
237
|
+
report["diagnostics"].append({"code": "coverage-review", "message": str(error)})
|
|
238
|
+
return report
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def main():
|
|
242
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
243
|
+
parser.add_argument("target", help="Python file or directory; same selection as source_facts.py")
|
|
244
|
+
parser.add_argument("--coverage-json", type=Path, help="coverage.py JSON with paths relative to the selected root")
|
|
245
|
+
parser.add_argument("--collection-manifest", type=Path, help="Validate collection-time report/source binding; mismatch fails closed")
|
|
246
|
+
parser.add_argument("--run-id")
|
|
247
|
+
parser.add_argument("--audit-id")
|
|
248
|
+
parser.add_argument("--output", type=Path, help="New .json file; never overwrite previous results")
|
|
249
|
+
for option in ("include", "exclude", "test-path", "production-path"):
|
|
250
|
+
parser.add_argument(f"--{option}", action="append", default=[])
|
|
251
|
+
for option in ("include-tests", "include-generated", "include-support"):
|
|
252
|
+
parser.add_argument(f"--{option}", action="store_true")
|
|
253
|
+
args = parser.parse_args()
|
|
254
|
+
try:
|
|
255
|
+
report = analyze(args.target, coverage_json=args.coverage_json, collection_manifest=args.collection_manifest, run_id=args.run_id, audit_id=args.audit_id,
|
|
256
|
+
include=args.include, exclude=args.exclude, test_paths=args.test_path, production_paths=args.production_path,
|
|
257
|
+
include_tests=args.include_tests, include_generated=args.include_generated, include_support=args.include_support)
|
|
258
|
+
payload = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
|
|
259
|
+
if args.output:
|
|
260
|
+
if args.output.suffix != ".json":
|
|
261
|
+
raise ValueError("Output must be a new .json file.")
|
|
262
|
+
with args.output.open("x", encoding="utf-8") as handle:
|
|
263
|
+
handle.write(payload)
|
|
264
|
+
else:
|
|
265
|
+
sys.stdout.write(payload)
|
|
266
|
+
return 0 if report["status"] == "complete" else 2
|
|
267
|
+
except (OSError, ValueError) as error:
|
|
268
|
+
print(f"Cannot produce coverage review: {error}", file=sys.stderr)
|
|
269
|
+
return 2
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Private child process for explicit coverage collection; executes target tests."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import platform
|
|
7
|
+
import runpy
|
|
8
|
+
import sys
|
|
9
|
+
import traceback
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
if sys.version_info[:2] != (3, 12):
|
|
14
|
+
raise ValueError("Coverage collection requires Python 3.12.")
|
|
15
|
+
# Import the tooling before adding the target root to module lookup.
|
|
16
|
+
import coverage
|
|
17
|
+
|
|
18
|
+
root, output, module, *arguments = sys.argv[1:]
|
|
19
|
+
output = Path(output)
|
|
20
|
+
os.chdir(root)
|
|
21
|
+
sys.path.insert(0, root)
|
|
22
|
+
sys.argv = [module, *arguments]
|
|
23
|
+
measurement = coverage.Coverage(data_file=str(output / "raw.coverage"), source=[root],
|
|
24
|
+
branch=True, config_file=False)
|
|
25
|
+
measurement.start()
|
|
26
|
+
exit_code = 0
|
|
27
|
+
try:
|
|
28
|
+
runpy.run_module(module, run_name="__main__", alter_sys=True)
|
|
29
|
+
except SystemExit as error:
|
|
30
|
+
# pytest uses an IntEnum; SystemExit also permits bool as an integer code.
|
|
31
|
+
exit_code = int(error.code) if isinstance(error.code, int) else (0 if error.code is None else 1)
|
|
32
|
+
except BaseException:
|
|
33
|
+
traceback.print_exc()
|
|
34
|
+
exit_code = 1
|
|
35
|
+
finally:
|
|
36
|
+
measurement.stop()
|
|
37
|
+
measurement.save()
|
|
38
|
+
measurement.json_report(outfile=str(output / "coverage.json"))
|
|
39
|
+
receipt = {"coverageVersion": coverage.__version__, "pythonVersion": platform.python_version(),
|
|
40
|
+
"testExitCode": exit_code}
|
|
41
|
+
(output / "runtime.json").write_text(json.dumps(receipt), encoding="utf-8")
|
|
42
|
+
return exit_code
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Offline owned-statement and block-nesting evidence; no scores or target execution."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from duplication import digest, read_verified
|
|
13
|
+
import source_facts
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
POLICY = "python/owned-statements-block-nesting-v1"
|
|
17
|
+
MAX_OWNERS = 100000
|
|
18
|
+
BLOCKS = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith, ast.Try, ast.TryStar, ast.Match)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_docstring(node):
|
|
22
|
+
return isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BodyMeasure:
|
|
26
|
+
"""A function owns its statement tree, stopping at every nested definition."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.kinds = Counter()
|
|
30
|
+
self.excluded = Counter()
|
|
31
|
+
self.max_nesting = 0
|
|
32
|
+
|
|
33
|
+
def visit(self, node, depth=0):
|
|
34
|
+
if isinstance(node, source_facts.OWNERS + (ast.ClassDef,)):
|
|
35
|
+
self.excluded[type(node).__name__] += 1
|
|
36
|
+
return
|
|
37
|
+
if isinstance(node, (ast.Pass, ast.Global, ast.Nonlocal, ast.TypeAlias)) or (
|
|
38
|
+
isinstance(node, ast.AnnAssign) and node.value is None
|
|
39
|
+
) or (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and node.value.value is Ellipsis):
|
|
40
|
+
self.excluded[type(node).__name__] += 1
|
|
41
|
+
return
|
|
42
|
+
if isinstance(node, ast.stmt):
|
|
43
|
+
self.kinds[type(node).__name__] += 1
|
|
44
|
+
if isinstance(node, BLOCKS):
|
|
45
|
+
# Sibling arms (else/except/finally/case) share their construct's depth.
|
|
46
|
+
branch_depth = depth + 1
|
|
47
|
+
self.max_nesting = max(self.max_nesting, branch_depth)
|
|
48
|
+
for field, value in ast.iter_fields(node):
|
|
49
|
+
children = value if isinstance(value, list) else [value]
|
|
50
|
+
for child in children:
|
|
51
|
+
if not isinstance(child, ast.AST):
|
|
52
|
+
continue
|
|
53
|
+
# CPython represents elif as an If in orelse. A real nested
|
|
54
|
+
# if in an else suite has greater indentation, even though
|
|
55
|
+
# the unpositioned AST shape can otherwise be identical.
|
|
56
|
+
is_elif = (isinstance(node, ast.If) and field == "orelse" and
|
|
57
|
+
isinstance(child, ast.If) and child.col_offset == node.col_offset)
|
|
58
|
+
self.visit(child, depth if is_elif else branch_depth)
|
|
59
|
+
return
|
|
60
|
+
for child in ast.iter_child_nodes(node):
|
|
61
|
+
self.visit(child, depth)
|
|
62
|
+
|
|
63
|
+
def measure(self, node):
|
|
64
|
+
body = node.body
|
|
65
|
+
if body and is_docstring(body[0]):
|
|
66
|
+
self.excluded["docstring"] += 1
|
|
67
|
+
body = body[1:]
|
|
68
|
+
for statement in body:
|
|
69
|
+
self.visit(statement)
|
|
70
|
+
return {"ownedStatements": sum(self.kinds.values()), "maxBlockNesting": self.max_nesting,
|
|
71
|
+
"statementKinds": dict(sorted(self.kinds.items())), "excludedSyntax": dict(sorted(self.excluded.items()))}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def distribution(values):
|
|
75
|
+
"""Nearest-rank quantiles; empty populations are unavailable, never zero."""
|
|
76
|
+
values = sorted(values)
|
|
77
|
+
if not values:
|
|
78
|
+
return None
|
|
79
|
+
return {"count": len(values), "p50": values[(50 * len(values) + 99) // 100 - 1],
|
|
80
|
+
"p90": values[(90 * len(values) + 99) // 100 - 1],
|
|
81
|
+
"p95": values[(95 * len(values) + 99) // 100 - 1], "max": values[-1]}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def summarize(rows):
|
|
85
|
+
measured = [row for row in rows if row["status"] == "measured"]
|
|
86
|
+
populations = {}
|
|
87
|
+
for population in sorted({row["population"] for row in rows}):
|
|
88
|
+
selected = [row for row in rows if row["population"] == population]
|
|
89
|
+
eligible = [row for row in measured if row["population"] == population]
|
|
90
|
+
populations[population] = {"selectedFunctions": len(selected), "measuredFunctions": len(eligible),
|
|
91
|
+
"unavailableFunctions": len(selected) - len(eligible),
|
|
92
|
+
**{key: distribution(row["measurement"][key] for row in eligible)
|
|
93
|
+
for key in ("ownedStatements", "maxBlockNesting")}}
|
|
94
|
+
return {"selectedFunctions": len(rows), "measuredFunctions": len(measured), "unavailableFunctions": len(rows) - len(measured),
|
|
95
|
+
"unavailableReasons": dict(sorted(Counter(row["reason"] for row in rows if row["status"] == "unavailable").items())),
|
|
96
|
+
"populations": populations}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def collect(root, facts):
|
|
100
|
+
if len(facts["functions"]) > MAX_OWNERS:
|
|
101
|
+
raise ValueError("Selected function population exceeds the decomposition owner budget.")
|
|
102
|
+
by_file = defaultdict(dict)
|
|
103
|
+
for function in facts["functions"]:
|
|
104
|
+
if function["id"] in by_file[function["file"]]:
|
|
105
|
+
raise ValueError("Repeated selected function identity.")
|
|
106
|
+
by_file[function["file"]][function["id"]] = function
|
|
107
|
+
rows = []
|
|
108
|
+
for record in facts["selection"]["files"]:
|
|
109
|
+
if record["disposition"] != "analyzed":
|
|
110
|
+
continue
|
|
111
|
+
source = read_verified(root, record)
|
|
112
|
+
seen = set()
|
|
113
|
+
for node in ast.walk(ast.parse(source, filename=record["file"])):
|
|
114
|
+
if not isinstance(node, source_facts.OWNERS):
|
|
115
|
+
continue
|
|
116
|
+
key = digest(f"{record['file']}|{node.lineno}|{node.col_offset}|{type(node).__name__}")
|
|
117
|
+
function = by_file[record["file"]].get(key)
|
|
118
|
+
if function is None:
|
|
119
|
+
continue
|
|
120
|
+
seen.add(key)
|
|
121
|
+
row = {name: function[name] for name in ("id", "file", "member", "line", "population")}
|
|
122
|
+
row.update(endLine=node.end_lineno, sourceSha256=record["contentSha256"], functionKind=type(node).__name__,
|
|
123
|
+
complexity=function["complexity"]["value"], disposition="review-only")
|
|
124
|
+
if isinstance(node, ast.Lambda):
|
|
125
|
+
row.update(status="unavailable", reason="lambda-expression-body", measurement=None)
|
|
126
|
+
else:
|
|
127
|
+
row.update(status="measured", measurement=BodyMeasure().measure(node))
|
|
128
|
+
rows.append(row)
|
|
129
|
+
if seen != set(by_file[record["file"]]):
|
|
130
|
+
raise ValueError("Selected function identities changed during decomposition analysis.")
|
|
131
|
+
if len(rows) != len(facts["functions"]):
|
|
132
|
+
raise ValueError("Selected function population changed during decomposition analysis.")
|
|
133
|
+
rows.sort(key=lambda row: (row["file"], row["line"], row["id"]))
|
|
134
|
+
return rows, summarize(rows)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def analyze(target, *, run_id=None, audit_id=None, **selection):
|
|
138
|
+
return review(target, source_facts.analyze(target, **selection), run_id=run_id, audit_id=audit_id)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def review(target, facts, *, run_id=None, audit_id=None):
|
|
142
|
+
target = Path(target).absolute()
|
|
143
|
+
root = target if target.is_dir() else target.parent
|
|
144
|
+
configuration = {"policy": POLICY, "selectionFingerprint": facts["selection"]["configurationFingerprint"],
|
|
145
|
+
"parserVersion": facts["producer"]["parserVersion"], "maxOwners": MAX_OWNERS,
|
|
146
|
+
"quantiles": "nearest-rank", "complexityPolicy": facts["measurementPolicy"]}
|
|
147
|
+
report = {"schemaVersion": 1, "artifactKind": "experimental-python-decomposition-review",
|
|
148
|
+
"producer": {"name": "codemetrics-ai/python-decomposition", "version": "0.1.0-experimental"},
|
|
149
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
|
150
|
+
"runId": str(uuid.UUID(run_id)) if run_id else str(uuid.uuid4()),
|
|
151
|
+
"auditId": str(uuid.UUID(audit_id)) if audit_id else str(uuid.uuid4()),
|
|
152
|
+
"policy": POLICY, "configuration": configuration,
|
|
153
|
+
"configurationFingerprint": digest(json.dumps(configuration, sort_keys=True, separators=(",", ":"))),
|
|
154
|
+
"subject": {"root": str(root), "entryPoint": str(target)},
|
|
155
|
+
"sourceFactsProducer": facts["producer"], "sourceFactsPolicy": facts["measurementPolicy"],
|
|
156
|
+
"selection": facts["selection"], "status": facts["status"], "diagnostics": list(facts["diagnostics"]),
|
|
157
|
+
"scope": "Owned AST statements and block nesting, separately measured. Nested definitions and declaration syntax excluded. Lambdas unavailable. No expression-size measurement, score, threshold, or safe-extraction claim.",
|
|
158
|
+
"functions": [], "summary": None}
|
|
159
|
+
if facts["status"] != "complete":
|
|
160
|
+
return report
|
|
161
|
+
try:
|
|
162
|
+
report["functions"], report["summary"] = collect(root, facts)
|
|
163
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError) as error:
|
|
164
|
+
report["status"] = "incomplete"
|
|
165
|
+
report["diagnostics"].append({"code": "decomposition-analysis", "message": str(error)})
|
|
166
|
+
return report
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main():
|
|
170
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
171
|
+
parser.add_argument("target", help="Python source file or directory")
|
|
172
|
+
parser.add_argument("--output", type=Path, help="New .json file with an existing parent")
|
|
173
|
+
parser.add_argument("--run-id")
|
|
174
|
+
parser.add_argument("--audit-id")
|
|
175
|
+
for name in ("include", "exclude", "test-path", "production-path"):
|
|
176
|
+
parser.add_argument(f"--{name}", action="append", default=[])
|
|
177
|
+
for name in ("include-tests", "include-generated", "include-support"):
|
|
178
|
+
parser.add_argument(f"--{name}", action="store_true")
|
|
179
|
+
args = parser.parse_args()
|
|
180
|
+
try:
|
|
181
|
+
report = analyze(args.target, run_id=args.run_id, audit_id=args.audit_id, include=args.include, exclude=args.exclude,
|
|
182
|
+
test_paths=args.test_path, production_paths=args.production_path, include_tests=args.include_tests,
|
|
183
|
+
include_generated=args.include_generated, include_support=args.include_support)
|
|
184
|
+
payload = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
|
|
185
|
+
if args.output:
|
|
186
|
+
if args.output.suffix != ".json":
|
|
187
|
+
raise ValueError("Output must be a new .json file.")
|
|
188
|
+
with args.output.open("x", encoding="utf-8") as handle:
|
|
189
|
+
handle.write(payload)
|
|
190
|
+
else:
|
|
191
|
+
sys.stdout.write(payload)
|
|
192
|
+
return 0 if report["status"] == "complete" else 2
|
|
193
|
+
except (OSError, ValueError) as error:
|
|
194
|
+
print(f"Cannot produce decomposition review: {error}", file=sys.stderr)
|
|
195
|
+
return 2
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
raise SystemExit(main())
|