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.
Files changed (106) hide show
  1. package/README.md +51 -5
  2. package/dist/analyzer.d.ts +1 -0
  3. package/dist/analyzer.js +11 -5
  4. package/dist/cli.js +7 -3
  5. package/dist/dependency-evidence.d.ts +5 -0
  6. package/dist/dependency-evidence.js +30 -0
  7. package/dist/dependency-inventory.d.ts +67 -0
  8. package/dist/dependency-inventory.js +203 -0
  9. package/dist/dependency-metadata.d.ts +65 -0
  10. package/dist/dependency-metadata.js +125 -0
  11. package/dist/dependency-pnpm.d.ts +4 -0
  12. package/dist/dependency-pnpm.js +237 -0
  13. package/dist/dependency-spec.d.ts +13 -0
  14. package/dist/dependency-spec.js +30 -0
  15. package/dist/error-handling-scoring.d.ts +1 -0
  16. package/dist/error-handling-scoring.js +15 -0
  17. package/dist/error-handling.d.ts +4 -1
  18. package/dist/error-handling.js +50 -17
  19. package/dist/evidence-reader.js +3 -1
  20. package/dist/evidence-tools.js +4 -0
  21. package/dist/failure-contracts.d.ts +19 -0
  22. package/dist/failure-contracts.js +261 -0
  23. package/dist/function-scoring.d.ts +2 -1
  24. package/dist/function-scoring.js +13 -24
  25. package/dist/handler-disposition.d.ts +9 -0
  26. package/dist/handler-disposition.js +156 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +1 -0
  29. package/dist/python/ASYNC_ADAPTERS.md +38 -0
  30. package/dist/python/ASYNC_CONTEXT.md +58 -0
  31. package/dist/python/ASYNC_SCORING.md +174 -0
  32. package/dist/python/ASYNC_USAGE.md +62 -0
  33. package/dist/python/BEHAVIOR_REVIEW.md +84 -0
  34. package/dist/python/COVERAGE.md +96 -0
  35. package/dist/python/DECOMPOSITION.md +46 -0
  36. package/dist/python/DUPLICATION.md +36 -0
  37. package/dist/python/ERROR_HANDLING.md +45 -0
  38. package/dist/python/ERROR_HANDLING_CONTEXT.md +61 -0
  39. package/dist/python/ERROR_HANDLING_OPERATIONS.md +54 -0
  40. package/dist/python/ERROR_HANDLING_TRANSFERS.md +64 -0
  41. package/dist/python/METHOD_COMPLEXITY.md +46 -0
  42. package/dist/python/README.md +162 -0
  43. package/dist/python/analysis.py +54 -0
  44. package/dist/python/async_adapters.py +243 -0
  45. package/dist/python/async_bindings.py +180 -0
  46. package/dist/python/async_context.py +143 -0
  47. package/dist/python/async_helper_paths.py +186 -0
  48. package/dist/python/async_provenance.py +129 -0
  49. package/dist/python/async_scoring.py +203 -0
  50. package/dist/python/async_usage.py +244 -0
  51. package/dist/python/coverage_collect.py +121 -0
  52. package/dist/python/coverage_provenance.py +77 -0
  53. package/dist/python/coverage_review.py +273 -0
  54. package/dist/python/coverage_worker.py +46 -0
  55. package/dist/python/decomposition.py +199 -0
  56. package/dist/python/duplication.py +177 -0
  57. package/dist/python/error_handling.py +249 -0
  58. package/dist/python/error_handling_context.py +294 -0
  59. package/dist/python/error_handling_operations.py +228 -0
  60. package/dist/python/error_handling_transfers.py +211 -0
  61. package/dist/python/review_cli.py +36 -0
  62. package/dist/python/source_facts.py +293 -0
  63. package/dist/python/source_selection.py +157 -0
  64. package/dist/python-analyzer.d.ts +21 -0
  65. package/dist/python-analyzer.js +123 -0
  66. package/dist/python-async-adapters.d.ts +67 -0
  67. package/dist/python-async-adapters.js +197 -0
  68. package/dist/python-async-context.d.ts +73 -0
  69. package/dist/python-async-context.js +181 -0
  70. package/dist/python-async-scoring.d.ts +89 -0
  71. package/dist/python-async-scoring.js +249 -0
  72. package/dist/python-async-usage.d.ts +56 -0
  73. package/dist/python-async-usage.js +143 -0
  74. package/dist/python-cli.d.ts +2 -0
  75. package/dist/python-cli.js +53 -0
  76. package/dist/python-collect-cli.d.ts +2 -0
  77. package/dist/python-collect-cli.js +28 -0
  78. package/dist/python-complexity.d.ts +18 -0
  79. package/dist/python-complexity.js +36 -0
  80. package/dist/python-coverage.d.ts +63 -0
  81. package/dist/python-coverage.js +173 -0
  82. package/dist/python-decomposition.d.ts +56 -0
  83. package/dist/python-decomposition.js +102 -0
  84. package/dist/python-duplication.d.ts +65 -0
  85. package/dist/python-duplication.js +89 -0
  86. package/dist/python-error-context.d.ts +84 -0
  87. package/dist/python-error-context.js +165 -0
  88. package/dist/python-error-handling.d.ts +56 -0
  89. package/dist/python-error-handling.js +120 -0
  90. package/dist/python-error-operations.d.ts +44 -0
  91. package/dist/python-error-operations.js +155 -0
  92. package/dist/python-error-transfers.d.ts +38 -0
  93. package/dist/python-error-transfers.js +125 -0
  94. package/dist/python-evidence.d.ts +23 -0
  95. package/dist/python-evidence.js +255 -0
  96. package/dist/score-arithmetic.d.ts +1 -12
  97. package/dist/score-arithmetic.js +1 -40
  98. package/dist/shared-source-facts/arithmetic.d.mts +9 -0
  99. package/dist/shared-source-facts/arithmetic.mjs +32 -0
  100. package/dist/shared-source-facts/contract.d.mts +35 -0
  101. package/dist/shared-source-facts/contract.mjs +95 -0
  102. package/dist/shared-source-facts/population.d.mts +12 -0
  103. package/dist/shared-source-facts/population.mjs +41 -0
  104. package/dist/source-facts.d.ts +4 -0
  105. package/dist/source-facts.js +16 -0
  106. 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."}