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,244 @@
|
|
|
1
|
+
"""Offline async-body call evidence; no Async/Blocking Usage score or 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 async_bindings import Bindings, COMPREHENSIONS, DEFINITIONS, dotted
|
|
13
|
+
from duplication import digest, read_verified
|
|
14
|
+
import source_facts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
POLICY = "python/async-owned-calls-review-v1"
|
|
18
|
+
MAX_OWNERS = 100000
|
|
19
|
+
MAX_CALLS = 100000
|
|
20
|
+
BLOCKING = {"time.sleep", "subprocess.run", "subprocess.call", "subprocess.check_call",
|
|
21
|
+
"subprocess.check_output", "os.system", "urllib.request.urlopen"}
|
|
22
|
+
OFFLOAD = {"asyncio.to_thread", "anyio.to_thread.run_sync", "starlette.concurrency.run_in_threadpool"}
|
|
23
|
+
WRAPPER = "asgiref.sync.sync_to_async"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fingerprint(value):
|
|
27
|
+
return digest(json.dumps(value, sort_keys=True, separators=(",", ":")))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extent(node):
|
|
31
|
+
return {"line": node.lineno, "column": node.col_offset, "endLine": node.end_lineno, "endColumn": node.end_col_offset}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Calls:
|
|
35
|
+
def __init__(self, bindings, node, owner):
|
|
36
|
+
self.bindings, self.scope, self.owner = bindings, bindings.scopes[node], owner
|
|
37
|
+
self.rows = []
|
|
38
|
+
self.awaits = Counter()
|
|
39
|
+
self.yields = 0
|
|
40
|
+
|
|
41
|
+
def resolve(self, node, shadowed):
|
|
42
|
+
target, reason = self.bindings.resolve(node, self.scope, shadowed=shadowed)
|
|
43
|
+
return {"syntax": dotted(node), "target": target, "status": "import-matched" if target else "unresolved", "reason": reason}
|
|
44
|
+
|
|
45
|
+
def callback(self, call, shadowed):
|
|
46
|
+
value = call.args[0] if call.args and not isinstance(call.args[0], ast.Starred) else None
|
|
47
|
+
if value is None:
|
|
48
|
+
return {"kind": "unresolved-argument-layout", "binding": None}
|
|
49
|
+
return {"kind": type(value).__name__, "binding": self.resolve(value, shadowed),
|
|
50
|
+
"evaluation": "eager-call-expression" if isinstance(value, ast.Call) else "callback-expression-only"}
|
|
51
|
+
|
|
52
|
+
def record(self, node, context, shadowed, parent):
|
|
53
|
+
binding = self.resolve(node.func, shadowed)
|
|
54
|
+
target = binding["target"]
|
|
55
|
+
category = "other-import-call" if target else "unresolved-call"
|
|
56
|
+
callback = None
|
|
57
|
+
if target in BLOCKING:
|
|
58
|
+
category = "blocking-call-candidate"
|
|
59
|
+
elif target in OFFLOAD or target == WRAPPER:
|
|
60
|
+
category = "offload-call" if target in OFFLOAD else "offload-wrapper-construction"
|
|
61
|
+
callback = self.callback(node, shadowed)
|
|
62
|
+
elif isinstance(node.func, ast.Call):
|
|
63
|
+
wrapped = self.resolve(node.func.func, shadowed)
|
|
64
|
+
if wrapped["target"] == WRAPPER and node.func.args and not isinstance(node.func.args[0], ast.Starred):
|
|
65
|
+
category = "offload-wrapped-invocation"
|
|
66
|
+
binding = {**wrapped, "reason": "immediate-import-matched-wrapper-syntax"}
|
|
67
|
+
callback = self.callback(node.func, shadowed)
|
|
68
|
+
location = extent(node)
|
|
69
|
+
self.rows.append({"id": fingerprint([self.owner["id"], location]), **location,
|
|
70
|
+
"executionContext": context, "category": category, "binding": binding,
|
|
71
|
+
"directlyAwaited": isinstance(parent, ast.Await), "callback": callback,
|
|
72
|
+
"disposition": "review-only"})
|
|
73
|
+
if len(self.rows) > MAX_CALLS:
|
|
74
|
+
raise ValueError("Async call population exceeds the call budget.")
|
|
75
|
+
|
|
76
|
+
def comprehension(self, node, context, shadowed):
|
|
77
|
+
# Only the first iterable is evaluated when a generator is constructed.
|
|
78
|
+
first, *rest = node.generators
|
|
79
|
+
self.visit(first.iter, context, shadowed, node)
|
|
80
|
+
inner_context = "deferred-generator-body" if isinstance(node, ast.GeneratorExp) else context
|
|
81
|
+
names = {child.id for generator in node.generators for child in ast.walk(generator.target) if isinstance(child, ast.Name)}
|
|
82
|
+
inner_shadowed = shadowed | names
|
|
83
|
+
for condition in first.ifs:
|
|
84
|
+
self.visit(condition, inner_context, inner_shadowed, node)
|
|
85
|
+
for generator in rest:
|
|
86
|
+
self.visit(generator.iter, inner_context, inner_shadowed, node)
|
|
87
|
+
for condition in generator.ifs:
|
|
88
|
+
self.visit(condition, inner_context, inner_shadowed, node)
|
|
89
|
+
for field in ("elt", "key", "value"):
|
|
90
|
+
if hasattr(node, field):
|
|
91
|
+
self.visit(getattr(node, field), inner_context, inner_shadowed, node)
|
|
92
|
+
|
|
93
|
+
def visit(self, node, context="async-body", shadowed=frozenset(), parent=None):
|
|
94
|
+
if isinstance(node, DEFINITIONS):
|
|
95
|
+
return
|
|
96
|
+
if isinstance(node, COMPREHENSIONS):
|
|
97
|
+
self.comprehension(node, context, shadowed)
|
|
98
|
+
return
|
|
99
|
+
if isinstance(node, ast.Call):
|
|
100
|
+
self.record(node, context, shadowed, parent)
|
|
101
|
+
elif isinstance(node, ast.Await):
|
|
102
|
+
self.awaits[context] += 1
|
|
103
|
+
elif isinstance(node, (ast.Yield, ast.YieldFrom)):
|
|
104
|
+
self.yields += 1
|
|
105
|
+
# Type aliases and annotations are declaration syntax, not body calls.
|
|
106
|
+
if isinstance(node, ast.TypeAlias):
|
|
107
|
+
return
|
|
108
|
+
if isinstance(node, ast.AnnAssign):
|
|
109
|
+
self.visit(node.target, context, shadowed, node)
|
|
110
|
+
if node.value:
|
|
111
|
+
self.visit(node.value, context, shadowed, node)
|
|
112
|
+
return
|
|
113
|
+
for child in ast.iter_child_nodes(node):
|
|
114
|
+
self.visit(child, context, shadowed, node)
|
|
115
|
+
|
|
116
|
+
def measure(self, node):
|
|
117
|
+
for statement in node.body:
|
|
118
|
+
self.visit(statement)
|
|
119
|
+
self.rows.sort(key=lambda row: (row["line"], row["column"], row["endLine"], row["endColumn"]))
|
|
120
|
+
return {"kind": "async-generator" if self.yields else "coroutine",
|
|
121
|
+
"awaitExpressions": dict(sorted(self.awaits.items())), "calls": self.rows}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def population_summary(rows):
|
|
125
|
+
eligible = [row for row in rows if row["status"] == "observed"]
|
|
126
|
+
calls = [call for row in eligible for call in row["measurement"]["calls"]]
|
|
127
|
+
direct = [call for call in calls if call["executionContext"] == "async-body"]
|
|
128
|
+
return {"selectedFunctions": len(rows), "asyncFunctions": len(eligible), "excludedSynchronousFunctions": len(rows) - len(eligible),
|
|
129
|
+
"availability": "available" if eligible else "unavailable",
|
|
130
|
+
"asyncBodyCallExpressions": len(direct) if eligible else None,
|
|
131
|
+
"asyncBodyCallCategories": dict(sorted(Counter(call["category"] for call in direct).items())) if eligible else None,
|
|
132
|
+
"deferredGeneratorCallExpressions": len(calls) - len(direct) if eligible else None,
|
|
133
|
+
"functionsWithBlockingCandidates": sum(any(call["category"] == "blocking-call-candidate" and
|
|
134
|
+
call["executionContext"] == "async-body" for call in row["measurement"]["calls"]) for row in eligible) if eligible else None}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def collect(root, facts):
|
|
138
|
+
if len(facts["functions"]) > MAX_OWNERS:
|
|
139
|
+
raise ValueError("Selected function population exceeds the async owner budget.")
|
|
140
|
+
by_file = defaultdict(dict)
|
|
141
|
+
for function in facts["functions"]:
|
|
142
|
+
by_file[function["file"]][function["id"]] = function
|
|
143
|
+
rows, call_count = [], 0
|
|
144
|
+
for record in facts["selection"]["files"]:
|
|
145
|
+
if record["disposition"] != "analyzed":
|
|
146
|
+
continue
|
|
147
|
+
tree = ast.parse(read_verified(root, record), filename=record["file"])
|
|
148
|
+
bindings = Bindings(tree)
|
|
149
|
+
for node in bindings.scopes:
|
|
150
|
+
if not isinstance(node, source_facts.OWNERS):
|
|
151
|
+
continue
|
|
152
|
+
key = digest(f"{record['file']}|{node.lineno}|{node.col_offset}|{type(node).__name__}")
|
|
153
|
+
owner = by_file[record["file"]].get(key)
|
|
154
|
+
if owner is None:
|
|
155
|
+
continue
|
|
156
|
+
row = {name: owner[name] for name in ("id", "file", "member", "line", "population")}
|
|
157
|
+
row.update(sourceSha256=record["contentSha256"], endLine=node.end_lineno, functionKind=type(node).__name__,
|
|
158
|
+
disposition="review-only", status="excluded", reason="synchronous-owner", measurement=None)
|
|
159
|
+
if isinstance(node, ast.AsyncFunctionDef):
|
|
160
|
+
row.update(status="observed", reason=None, measurement=Calls(bindings, node, owner).measure(node))
|
|
161
|
+
call_count += len(row["measurement"]["calls"])
|
|
162
|
+
if call_count > MAX_CALLS:
|
|
163
|
+
raise ValueError("Async call population exceeds the call budget.")
|
|
164
|
+
rows.append(row)
|
|
165
|
+
if {row["id"] for row in rows} != {row["id"] for row in facts["functions"]} or len(rows) != len(facts["functions"]):
|
|
166
|
+
raise ValueError("Selected function population changed during async review.")
|
|
167
|
+
rows.sort(key=lambda row: (row["file"], row["line"], row["id"]))
|
|
168
|
+
return rows, {**population_summary(rows), "populations": {
|
|
169
|
+
population: population_summary([row for row in rows if row["population"] == population])
|
|
170
|
+
for population in sorted({row["population"] for row in rows})}}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def verify_facts(target, facts):
|
|
174
|
+
options = facts["selection"]["options"]
|
|
175
|
+
current = source_facts.analyze(target, include=options["include"], exclude=options["exclude"],
|
|
176
|
+
test_paths=options["testPaths"], production_paths=options["productionPaths"], include_tests=options["includeTests"],
|
|
177
|
+
include_generated=options["includeGenerated"], include_support=options["includeSupport"])
|
|
178
|
+
if current != facts:
|
|
179
|
+
raise ValueError("Source facts or selection changed; collect a fresh source snapshot.")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def review(target, facts, *, run_id=None, audit_id=None):
|
|
183
|
+
target = Path(target).absolute()
|
|
184
|
+
root = target if target.is_dir() else target.parent
|
|
185
|
+
configuration = {"policy": POLICY, "selectionFingerprint": facts["selection"]["configurationFingerprint"],
|
|
186
|
+
"parserVersion": facts["producer"]["parserVersion"], "maxOwners": MAX_OWNERS, "maxCalls": MAX_CALLS,
|
|
187
|
+
"blockingCatalogue": sorted(BLOCKING), "offloadCatalogue": sorted(OFFLOAD), "wrapperCatalogue": [WRAPPER]}
|
|
188
|
+
report = {"schemaVersion": 1, "artifactKind": "experimental-python-async-usage-review",
|
|
189
|
+
"producer": {"name": "codemetrics-ai/python-async-usage", "version": "0.1.0-experimental"},
|
|
190
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
|
191
|
+
"runId": str(uuid.UUID(run_id)) if run_id else str(uuid.uuid4()),
|
|
192
|
+
"auditId": str(uuid.UUID(audit_id)) if audit_id else str(uuid.uuid4()),
|
|
193
|
+
"policy": POLICY, "configuration": configuration, "configurationFingerprint": fingerprint(configuration),
|
|
194
|
+
"subject": {"root": str(root), "entryPoint": str(target)},
|
|
195
|
+
"sourceFactsProducer": facts["producer"], "sourceFactsPolicy": facts["measurementPolicy"],
|
|
196
|
+
"selection": facts["selection"], "status": facts["status"], "diagnostics": list(facts["diagnostics"]),
|
|
197
|
+
"scope": "Selected async bodies, exclusive of nested definitions and declaration expressions. Import spelling only; no runtime identity, reachability, duration, offload-success, safety, defect, or score claim. Synchronous owners excluded; deferred generator calls separate. Unmatched calls unresolved.",
|
|
198
|
+
"functions": [], "summary": None}
|
|
199
|
+
if facts["status"] != "complete":
|
|
200
|
+
return report
|
|
201
|
+
try:
|
|
202
|
+
verify_facts(target, facts)
|
|
203
|
+
report["functions"], report["summary"] = collect(root, facts)
|
|
204
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError, KeyError, TypeError) as error:
|
|
205
|
+
report["status"] = "incomplete"
|
|
206
|
+
report["diagnostics"].append({"code": "async-usage-analysis", "message": str(error)})
|
|
207
|
+
return report
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def analyze(target, *, run_id=None, audit_id=None, **selection):
|
|
211
|
+
return review(target, source_facts.analyze(target, **selection), run_id=run_id, audit_id=audit_id)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main():
|
|
215
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
216
|
+
parser.add_argument("target")
|
|
217
|
+
parser.add_argument("--output", type=Path, help="New .json file with an existing parent")
|
|
218
|
+
parser.add_argument("--run-id")
|
|
219
|
+
parser.add_argument("--audit-id")
|
|
220
|
+
for name in ("include", "exclude", "test-path", "production-path"):
|
|
221
|
+
parser.add_argument(f"--{name}", action="append", default=[])
|
|
222
|
+
for name in ("include-tests", "include-generated", "include-support"):
|
|
223
|
+
parser.add_argument(f"--{name}", action="store_true")
|
|
224
|
+
args = parser.parse_args()
|
|
225
|
+
try:
|
|
226
|
+
report = analyze(args.target, run_id=args.run_id, audit_id=args.audit_id, include=args.include, exclude=args.exclude,
|
|
227
|
+
test_paths=args.test_path, production_paths=args.production_path, include_tests=args.include_tests,
|
|
228
|
+
include_generated=args.include_generated, include_support=args.include_support)
|
|
229
|
+
payload = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
|
|
230
|
+
if args.output:
|
|
231
|
+
if args.output.suffix != ".json":
|
|
232
|
+
raise ValueError("Output must be a new .json file.")
|
|
233
|
+
with args.output.open("x", encoding="utf-8") as stream:
|
|
234
|
+
stream.write(payload)
|
|
235
|
+
else:
|
|
236
|
+
sys.stdout.write(payload)
|
|
237
|
+
return 0 if report["status"] == "complete" else 2
|
|
238
|
+
except (OSError, ValueError) as error:
|
|
239
|
+
print(f"Cannot produce async review: {error}", file=sys.stderr)
|
|
240
|
+
return 2
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Explicitly execute a test module with new coverage data and source snapshots."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from coverage_provenance import POLICY, snapshot_digest, source_snapshot
|
|
15
|
+
import coverage_review
|
|
16
|
+
import source_facts
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def now():
|
|
20
|
+
return datetime.now(timezone.utc).isoformat()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_command(command, root, output, timeout):
|
|
24
|
+
environment = {key: value for key, value in os.environ.items()
|
|
25
|
+
if not key.upper().startswith(("COVERAGE_", "PYTHON")) and key.upper() != "PYTEST_ADDOPTS"}
|
|
26
|
+
# Third-party pytest auto-plugins can start their own competing coverage session.
|
|
27
|
+
environment["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1"
|
|
28
|
+
options = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {"start_new_session": True}
|
|
29
|
+
with (output / "tests.log").open("w", encoding="utf-8") as log:
|
|
30
|
+
with subprocess.Popen(command, cwd=root, env=environment, stdout=log, stderr=subprocess.STDOUT, **options) as child:
|
|
31
|
+
try:
|
|
32
|
+
return child.wait(timeout=timeout)
|
|
33
|
+
except subprocess.TimeoutExpired:
|
|
34
|
+
if os.name == "nt":
|
|
35
|
+
subprocess.run(["taskkill", "/PID", str(child.pid), "/T", "/F"], capture_output=True, check=False)
|
|
36
|
+
else:
|
|
37
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
38
|
+
child.kill()
|
|
39
|
+
child.wait()
|
|
40
|
+
raise ValueError("Coverage test command timed out; collection is incomplete.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def collect(target, output, *, python=sys.executable, module="pytest", arguments=(), timeout=1800, **selection):
|
|
44
|
+
if not math.isfinite(timeout) or timeout <= 0 or not module or module.startswith("-"):
|
|
45
|
+
raise ValueError("Provide a module name and a positive timeout.")
|
|
46
|
+
root = Path(target).resolve()
|
|
47
|
+
output = Path(output).resolve()
|
|
48
|
+
executable = Path(python).resolve()
|
|
49
|
+
if not root.is_dir() or not executable.is_file():
|
|
50
|
+
raise ValueError("Collection requires a project directory and a Python executable.")
|
|
51
|
+
if output.is_relative_to(root):
|
|
52
|
+
raise ValueError("Collection output must be outside the target root.")
|
|
53
|
+
output.mkdir(parents=False, exist_ok=False)
|
|
54
|
+
manifest = {"schemaVersion": 1, "policy": POLICY, "collectionId": str(uuid.uuid4()),
|
|
55
|
+
"startedAtUtc": now(), "finishedAtUtc": None, "root": str(root), "status": "incomplete",
|
|
56
|
+
"configuration": {"branch": True, "configFile": False, "processScope": "single-process", "freshBytecodeCache": True},
|
|
57
|
+
"sourceSelection": None, "beforeSourceSha256": None, "afterSourceSha256": None,
|
|
58
|
+
"sourceFactsPolicy": None, "sourceFactsProducer": None, "reportSha256": None,
|
|
59
|
+
"command": [], "runtime": None, "testExitCode": None, "diagnostics": []}
|
|
60
|
+
try:
|
|
61
|
+
before = source_facts.analyze(root, **selection)
|
|
62
|
+
if before["status"] != "complete":
|
|
63
|
+
raise ValueError(f"Source snapshot failed: {before['diagnostics']}")
|
|
64
|
+
manifest.update(sourceSelection=source_snapshot(before["selection"]), beforeSourceSha256=snapshot_digest(before["selection"]),
|
|
65
|
+
sourceFactsPolicy=before["measurementPolicy"], sourceFactsProducer=before["producer"])
|
|
66
|
+
command = [str(executable), "-I", "-X", f"pycache_prefix={output / 'bytecode'}",
|
|
67
|
+
str(Path(__file__).with_name("coverage_worker.py").resolve()), str(root), str(output), module, *arguments]
|
|
68
|
+
manifest["command"] = command
|
|
69
|
+
manifest["testExitCode"] = run_command(command, root, output, timeout)
|
|
70
|
+
after = source_facts.analyze(root, **selection)
|
|
71
|
+
manifest["afterSourceSha256"] = snapshot_digest(after["selection"])
|
|
72
|
+
if after["status"] != "complete" or manifest["beforeSourceSha256"] != manifest["afterSourceSha256"]:
|
|
73
|
+
raise ValueError("Source inventory changed during collection.")
|
|
74
|
+
if manifest["testExitCode"] != 0:
|
|
75
|
+
raise ValueError(f"Test command exited {manifest['testExitCode']}; see tests.log.")
|
|
76
|
+
runtime = json.loads((output / "runtime.json").read_text(encoding="utf-8"))
|
|
77
|
+
manifest["runtime"] = runtime
|
|
78
|
+
if type(runtime.get("testExitCode")) is not int or runtime["testExitCode"] != 0:
|
|
79
|
+
raise ValueError("Worker did not record successful test completion.")
|
|
80
|
+
_, provenance = coverage_review.load_coverage(output / "coverage.json")
|
|
81
|
+
manifest["reportSha256"] = provenance["sha256"]
|
|
82
|
+
manifest["status"] = "complete"
|
|
83
|
+
except (OSError, ValueError, RecursionError) as error:
|
|
84
|
+
manifest["diagnostics"].append({"code": "coverage-collection", "message": str(error)})
|
|
85
|
+
manifest["finishedAtUtc"] = now()
|
|
86
|
+
with (output / "provenance.json").open("x", encoding="utf-8") as handle:
|
|
87
|
+
json.dump(manifest, handle, indent=2)
|
|
88
|
+
handle.write("\n")
|
|
89
|
+
return manifest
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main():
|
|
93
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
94
|
+
parser.add_argument("target", type=Path)
|
|
95
|
+
parser.add_argument("--output-directory", type=Path, required=True)
|
|
96
|
+
parser.add_argument("--python", default=sys.executable, help="Python 3.12 with coverage and test dependencies installed")
|
|
97
|
+
parser.add_argument("--module", default="pytest", help="Explicit test module to execute")
|
|
98
|
+
parser.add_argument("--timeout", type=float, default=1800)
|
|
99
|
+
for option in ("include", "exclude", "test-path", "production-path"):
|
|
100
|
+
parser.add_argument(f"--{option}", action="append", default=[])
|
|
101
|
+
for option in ("include-tests", "include-generated", "include-support"):
|
|
102
|
+
parser.add_argument(f"--{option}", action="store_true")
|
|
103
|
+
# Split explicitly: argparse REMAINDER would consume collector options after target.
|
|
104
|
+
argv = sys.argv[1:]
|
|
105
|
+
separator = argv.index("--") if "--" in argv else len(argv)
|
|
106
|
+
args = parser.parse_args(argv[:separator])
|
|
107
|
+
try:
|
|
108
|
+
result = collect(args.target, args.output_directory, python=args.python, module=args.module,
|
|
109
|
+
arguments=argv[separator + 1:], timeout=args.timeout,
|
|
110
|
+
include=args.include, exclude=args.exclude, test_paths=args.test_path, production_paths=args.production_path,
|
|
111
|
+
include_tests=args.include_tests, include_generated=args.include_generated, include_support=args.include_support)
|
|
112
|
+
print(json.dumps({"status": result["status"], "collectionId": result["collectionId"],
|
|
113
|
+
"directory": str(args.output_directory), "diagnostics": result["diagnostics"]}))
|
|
114
|
+
return 0 if result["status"] == "complete" else 2
|
|
115
|
+
except (OSError, ValueError) as error:
|
|
116
|
+
print(f"Cannot collect coverage: {error}", file=sys.stderr)
|
|
117
|
+
return 2
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Local collection receipts bind reports to source observations, not signatures."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
from duplication import digest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
POLICY = "python/coverage-collection-v1"
|
|
13
|
+
MAX_MANIFEST_BYTES = 16 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def source_snapshot(selection):
|
|
17
|
+
"""Bind selected source, not incidental creation of ignored cache directories."""
|
|
18
|
+
return {"policy": selection["policy"], "configurationFingerprint": selection["configurationFingerprint"],
|
|
19
|
+
"options": selection["options"], "files": [row for row in selection["files"] if row["disposition"] == "analyzed"],
|
|
20
|
+
"excludedFunctions": selection["excludedFunctions"]}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def snapshot_digest(selection):
|
|
24
|
+
return digest(json.dumps(source_snapshot(selection), sort_keys=True, separators=(",", ":")))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate(path, root, facts, coverage_input, load_json):
|
|
28
|
+
"""Fail closed when the caller explicitly requests collection verification."""
|
|
29
|
+
if coverage_input is None:
|
|
30
|
+
raise ValueError("Collection provenance requires a coverage JSON report.")
|
|
31
|
+
with Path(path).open("rb") as handle:
|
|
32
|
+
raw = handle.read(MAX_MANIFEST_BYTES + 1)
|
|
33
|
+
if len(raw) > MAX_MANIFEST_BYTES:
|
|
34
|
+
raise ValueError("Collection provenance exceeds the manifest byte budget.")
|
|
35
|
+
manifest = load_json(raw)
|
|
36
|
+
if not isinstance(manifest, dict) or type(manifest.get("schemaVersion")) is not int or manifest["schemaVersion"] != 1:
|
|
37
|
+
raise ValueError("Unsupported collection provenance schema.")
|
|
38
|
+
if manifest.get("policy") != POLICY or manifest.get("status") != "complete":
|
|
39
|
+
raise ValueError("Collection did not complete under the supported provenance policy.")
|
|
40
|
+
try:
|
|
41
|
+
collection_id = str(uuid.UUID(manifest["collectionId"]))
|
|
42
|
+
started = datetime.fromisoformat(manifest["startedAtUtc"])
|
|
43
|
+
finished = datetime.fromisoformat(manifest["finishedAtUtc"])
|
|
44
|
+
if started.tzinfo is None or finished.tzinfo is None or finished < started:
|
|
45
|
+
raise ValueError("Invalid collection timestamps.")
|
|
46
|
+
subject = manifest["root"]
|
|
47
|
+
if not isinstance(subject, str) or os.path.normcase(str(Path(subject).resolve())) != os.path.normcase(str(root.resolve())):
|
|
48
|
+
raise ValueError("Collection root does not match the reviewed root.")
|
|
49
|
+
if type(manifest["testExitCode"]) is not int or manifest["testExitCode"] != 0:
|
|
50
|
+
raise ValueError("Collection test command did not succeed.")
|
|
51
|
+
if manifest["reportSha256"] != coverage_input["sha256"]:
|
|
52
|
+
raise ValueError("Coverage report differs from the collected report.")
|
|
53
|
+
if coverage_input["branchDataPresent"] is not True:
|
|
54
|
+
raise ValueError("Collected report is missing the configured branch data.")
|
|
55
|
+
expected = snapshot_digest(facts["selection"])
|
|
56
|
+
if manifest["beforeSourceSha256"] != expected or manifest["afterSourceSha256"] != expected:
|
|
57
|
+
raise ValueError("Current source selection differs from collection snapshots.")
|
|
58
|
+
if manifest["sourceSelection"] != source_snapshot(facts["selection"]):
|
|
59
|
+
raise ValueError("Collection source inventory does not match current source.")
|
|
60
|
+
if manifest["sourceFactsPolicy"] != facts["measurementPolicy"] or manifest["sourceFactsProducer"] != facts["producer"]:
|
|
61
|
+
raise ValueError("Collection source measurement policy or parser differs.")
|
|
62
|
+
runtime = manifest["runtime"]
|
|
63
|
+
if (type(runtime["testExitCode"]) is not int or runtime["testExitCode"] != 0 or
|
|
64
|
+
runtime["coverageVersion"] != coverage_input["version"] or not runtime["pythonVersion"].startswith("3.12.")):
|
|
65
|
+
raise ValueError("Collection runtime does not match coverage metadata or supported Python.")
|
|
66
|
+
if manifest["configuration"] != {"branch": True, "configFile": False, "processScope": "single-process", "freshBytecodeCache": True}:
|
|
67
|
+
raise ValueError("Unsupported coverage collection configuration.")
|
|
68
|
+
command = manifest["command"]
|
|
69
|
+
if not isinstance(command, list) or not command or any(not isinstance(value, str) for value in command):
|
|
70
|
+
raise ValueError("Collection command is missing or malformed.")
|
|
71
|
+
except (KeyError, TypeError, AttributeError) as error:
|
|
72
|
+
raise ValueError("Malformed collection provenance.") from error
|
|
73
|
+
return {"path": str(Path(path).absolute()), "sha256": digest(raw.decode("utf-8")),
|
|
74
|
+
"collectionId": collection_id, "policy": POLICY, "startedAtUtc": manifest["startedAtUtc"],
|
|
75
|
+
"finishedAtUtc": manifest["finishedAtUtc"], "sourceBinding": "matched-pre-post-snapshots",
|
|
76
|
+
"freshness": "source-matched", "command": command, "receipt": manifest,
|
|
77
|
+
"limitations": "Local unsigned receipt; pre/post observations cannot detect edits reverted during execution, prove assertion quality, or cover subprocess execution."}
|