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