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.
- package/README.md +31 -1
- package/dist/analyzer.d.ts +1 -0
- package/dist/analyzer.js +8 -4
- 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/evidence-reader.js +3 -1
- package/dist/evidence-tools.js +4 -0
- package/dist/function-scoring.d.ts +2 -1
- package/dist/function-scoring.js +13 -24
- 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,177 @@
|
|
|
1
|
+
"""Offline exact-body duplication review. Parse target source; never execute it."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sys
|
|
11
|
+
import tokenize
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
import source_facts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
POLICY = "python/exact-function-body-v1"
|
|
18
|
+
MAX_NORMALIZED_BYTES = 64 * 1024 * 1024
|
|
19
|
+
MAX_OWNERS = 100000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def digest(value):
|
|
23
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def body_signature(node):
|
|
27
|
+
"""Ignore only the owner's docstring and AST locations, not names/literals."""
|
|
28
|
+
if isinstance(node, ast.Lambda):
|
|
29
|
+
return None, None, "lambda"
|
|
30
|
+
body = node.body
|
|
31
|
+
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str):
|
|
32
|
+
body = body[1:]
|
|
33
|
+
tree = ast.Module(body=body, type_ignores=[])
|
|
34
|
+
if any(isinstance(child, source_facts.OWNERS + (ast.ClassDef,)) for child in ast.walk(tree)):
|
|
35
|
+
return None, None, "nested-definition"
|
|
36
|
+
statements = sum(isinstance(child, ast.stmt) and not isinstance(child, (ast.Pass, ast.Global, ast.Nonlocal))
|
|
37
|
+
and not (isinstance(child, ast.AnnAssign) and child.value is None) for child in ast.walk(tree))
|
|
38
|
+
return ast.dump(tree, annotate_fields=True, include_attributes=False), statements, None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_verified(root, record):
|
|
42
|
+
path = root / record["file"]
|
|
43
|
+
if source_facts.linked(path) or not path.resolve().is_relative_to(root.resolve()):
|
|
44
|
+
raise ValueError("Selected source became linked or left the root.")
|
|
45
|
+
if path.stat().st_size > source_facts.MAX_FILE_BYTES:
|
|
46
|
+
raise ValueError("Selected source exceeds the analyzer file budget.")
|
|
47
|
+
with tokenize.open(path) as handle:
|
|
48
|
+
source = handle.read()
|
|
49
|
+
if digest(source) != record["contentSha256"]:
|
|
50
|
+
raise ValueError("Selected source changed after population measurement.")
|
|
51
|
+
return source
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def collect(root, facts, minimum_statements):
|
|
55
|
+
by_file = defaultdict(dict)
|
|
56
|
+
for function in facts["functions"]:
|
|
57
|
+
by_file[function["file"]][function["id"]] = function
|
|
58
|
+
groups, exclusions = defaultdict(list), []
|
|
59
|
+
eligible = 0
|
|
60
|
+
normalized_bytes = 0
|
|
61
|
+
if len(facts["functions"]) > MAX_OWNERS:
|
|
62
|
+
raise ValueError("Selected function population exceeds the duplication owner budget.")
|
|
63
|
+
for record in facts["selection"]["files"]:
|
|
64
|
+
if record["disposition"] != "analyzed":
|
|
65
|
+
continue
|
|
66
|
+
source = read_verified(root, record)
|
|
67
|
+
seen = set()
|
|
68
|
+
for node in ast.walk(ast.parse(source, filename=record["file"])):
|
|
69
|
+
if not isinstance(node, source_facts.OWNERS):
|
|
70
|
+
continue
|
|
71
|
+
key = digest(f"{record['file']}|{node.lineno}|{node.col_offset}|{type(node).__name__}")
|
|
72
|
+
function = by_file[record["file"]].get(key)
|
|
73
|
+
if function is None:
|
|
74
|
+
continue
|
|
75
|
+
seen.add(key)
|
|
76
|
+
signature, statements, reason = body_signature(node)
|
|
77
|
+
location = {key: function[key] for key in ("id", "file", "member", "line", "population")}
|
|
78
|
+
location.update(endLine=node.end_lineno, statementCount=statements,
|
|
79
|
+
sourceSha256=record["contentSha256"])
|
|
80
|
+
if reason or statements < minimum_statements:
|
|
81
|
+
exclusions.append({**location, "reason": reason or "below-statement-minimum"})
|
|
82
|
+
continue
|
|
83
|
+
normalized_bytes += len(signature.encode("utf-8"))
|
|
84
|
+
if normalized_bytes > MAX_NORMALIZED_BYTES:
|
|
85
|
+
raise ValueError("Normalized bodies exceed the duplication byte budget.")
|
|
86
|
+
eligible += 1
|
|
87
|
+
# Full structural text is the equality key; a hash collision cannot
|
|
88
|
+
# group different bodies. Async/sync and populations stay separate.
|
|
89
|
+
groups[(function["population"], type(node).__name__, signature)].append(location)
|
|
90
|
+
if seen != set(by_file[record["file"]]):
|
|
91
|
+
raise ValueError("Selected function identities changed during duplication analysis.")
|
|
92
|
+
findings = []
|
|
93
|
+
for (population, kind, signature), occurrences in groups.items():
|
|
94
|
+
if len(occurrences) < 2:
|
|
95
|
+
continue
|
|
96
|
+
occurrences.sort(key=lambda item: (item["file"], item["line"], item["id"]))
|
|
97
|
+
findings.append({"id": digest(f"{POLICY}|{population}|{kind}|{signature}"),
|
|
98
|
+
"population": population, "functionKind": kind, "bodySha256": digest(signature),
|
|
99
|
+
"statementCount": occurrences[0]["statementCount"], "occurrences": occurrences,
|
|
100
|
+
"disposition": "review-only"})
|
|
101
|
+
findings.sort(key=lambda item: (-item["statementCount"], -len(item["occurrences"]), item["id"]))
|
|
102
|
+
exclusions.sort(key=lambda item: (item["file"], item["line"], item["id"]))
|
|
103
|
+
summary = {"selectedFunctions": len(facts["functions"]), "eligibleFunctions": eligible,
|
|
104
|
+
"excludedFunctions": len(exclusions), "exclusionReasons": dict(sorted(Counter(item["reason"] for item in exclusions).items())),
|
|
105
|
+
"duplicateGroups": len(findings), "matchedFunctions": sum(len(item["occurrences"]) for item in findings)}
|
|
106
|
+
return findings, exclusions, summary
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def analyze(target, *, minimum_statements=5, run_id=None, audit_id=None, **selection):
|
|
110
|
+
facts = source_facts.analyze(target, **selection)
|
|
111
|
+
return review(target, facts, minimum_statements=minimum_statements, run_id=run_id, audit_id=audit_id)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def review(target, facts, *, minimum_statements=5, run_id=None, audit_id=None):
|
|
115
|
+
"""Use the same measured population as packaged complexity evidence."""
|
|
116
|
+
if type(minimum_statements) is not int or minimum_statements < 2:
|
|
117
|
+
raise ValueError("Minimum statements must be an integer of at least two.")
|
|
118
|
+
run_id = str(uuid.UUID(run_id)) if run_id else str(uuid.uuid4())
|
|
119
|
+
audit_id = str(uuid.UUID(audit_id)) if audit_id else str(uuid.uuid4())
|
|
120
|
+
target = Path(target).absolute()
|
|
121
|
+
root = target if target.is_dir() else target.parent
|
|
122
|
+
configuration = {"policy": POLICY, "minimumStatements": minimum_statements,
|
|
123
|
+
"selectionFingerprint": facts["selection"]["configurationFingerprint"],
|
|
124
|
+
"parserVersion": facts["producer"]["parserVersion"],
|
|
125
|
+
"maxOwners": MAX_OWNERS, "maxNormalizedBytes": MAX_NORMALIZED_BYTES}
|
|
126
|
+
report = {"schemaVersion": 1, "artifactKind": "experimental-python-duplication-review",
|
|
127
|
+
"producer": {"name": "codemetrics-ai/python-duplication", "version": "0.1.0-experimental"},
|
|
128
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(), "runId": run_id, "auditId": audit_id,
|
|
129
|
+
"status": facts["status"], "policy": POLICY, "configuration": configuration,
|
|
130
|
+
"configurationFingerprint": digest(json.dumps(configuration, sort_keys=True, separators=(",", ":"))),
|
|
131
|
+
"subject": {"root": str(root), "entryPoint": str(target)},
|
|
132
|
+
"sourceFactsProducer": facts["producer"], "sourceFactsPolicy": facts["measurementPolicy"],
|
|
133
|
+
"selection": facts["selection"], "diagnostics": list(facts["diagnostics"]),
|
|
134
|
+
"scope": "Exact whole-function AST bodies; names/literals retained; signatures, decorators, leading docstrings and locations excluded. Populations and async/sync kinds compared separately. Not semantic equivalence, partial-block detection, a duplication percentage, or a score.",
|
|
135
|
+
"groups": [], "excludedFunctions": [], "summary": None}
|
|
136
|
+
if facts["status"] != "complete":
|
|
137
|
+
return report
|
|
138
|
+
try:
|
|
139
|
+
report["groups"], report["excludedFunctions"], report["summary"] = collect(root, facts, minimum_statements)
|
|
140
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError) as error:
|
|
141
|
+
report["status"] = "incomplete"
|
|
142
|
+
report["diagnostics"].append({"code": "duplication-analysis", "message": str(error)})
|
|
143
|
+
return report
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main():
|
|
147
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
148
|
+
parser.add_argument("target", help="Python file or directory; same source selection as source_facts.py")
|
|
149
|
+
parser.add_argument("--minimum-statements", type=int, default=5)
|
|
150
|
+
parser.add_argument("--run-id")
|
|
151
|
+
parser.add_argument("--audit-id")
|
|
152
|
+
parser.add_argument("--output", type=Path, help="New .json file; never overwrite prior output")
|
|
153
|
+
for option in ("include", "exclude", "test-path", "production-path"):
|
|
154
|
+
parser.add_argument(f"--{option}", action="append", default=[])
|
|
155
|
+
for option in ("include-tests", "include-generated", "include-support"):
|
|
156
|
+
parser.add_argument(f"--{option}", action="store_true")
|
|
157
|
+
args = parser.parse_args()
|
|
158
|
+
try:
|
|
159
|
+
report = analyze(args.target, minimum_statements=args.minimum_statements, run_id=args.run_id, audit_id=args.audit_id,
|
|
160
|
+
include=args.include, exclude=args.exclude, test_paths=args.test_path, production_paths=args.production_path,
|
|
161
|
+
include_tests=args.include_tests, include_generated=args.include_generated, include_support=args.include_support)
|
|
162
|
+
payload = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
|
|
163
|
+
if args.output:
|
|
164
|
+
if args.output.suffix != ".json":
|
|
165
|
+
raise ValueError("Output must be a new .json file.")
|
|
166
|
+
with args.output.open("x", encoding="utf-8") as handle:
|
|
167
|
+
handle.write(payload)
|
|
168
|
+
else:
|
|
169
|
+
sys.stdout.write(payload)
|
|
170
|
+
return 0 if report["status"] == "complete" else 2
|
|
171
|
+
except (OSError, ValueError) as error:
|
|
172
|
+
print(f"Cannot produce duplication review: {error}", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Source-only exception-handler observations; no handling score or target execution."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import tokenize
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from duplication import digest, read_verified
|
|
16
|
+
import source_facts
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
POLICY = "python/exception-handler-observations-v1"
|
|
20
|
+
MAX_HANDLERS = 100000
|
|
21
|
+
DOCUMENTATION_POLICY = "suite-comments-or-string-statements/tool-directives-excluded/content-not-adjudicated-v1"
|
|
22
|
+
DIRECTIVE = re.compile(r"^(?:noqa\b|type:\s*ignore\b|pragma:\s*no cover\b|fmt:|pylint:|ruff:)", re.I)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def inert(statement):
|
|
26
|
+
return isinstance(statement, ast.Pass) or (
|
|
27
|
+
isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant) and
|
|
28
|
+
(statement.value.value is Ellipsis or isinstance(statement.value.value, str)))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def observations(body):
|
|
32
|
+
"""Owned syntax only; nested definitions and handlers cannot credit the enclosing handler."""
|
|
33
|
+
result = {"raises": [], "returns": [], "loopTransfers": [], "calls": 0, "nestedHandlersExcluded": 0}
|
|
34
|
+
|
|
35
|
+
def visit(node):
|
|
36
|
+
if isinstance(node, source_facts.OWNERS + (ast.ClassDef,)):
|
|
37
|
+
return
|
|
38
|
+
if isinstance(node, ast.ExceptHandler):
|
|
39
|
+
result["nestedHandlersExcluded"] += 1
|
|
40
|
+
return
|
|
41
|
+
if isinstance(node, ast.Raise):
|
|
42
|
+
result["raises"].append({"line": node.lineno, "bare": node.exc is None, "explicitCause": node.cause is not None})
|
|
43
|
+
elif isinstance(node, ast.Return):
|
|
44
|
+
result["returns"].append({"line": node.lineno, "hasValue": node.value is not None})
|
|
45
|
+
elif isinstance(node, (ast.Break, ast.Continue)):
|
|
46
|
+
result["loopTransfers"].append({"line": node.lineno, "kind": type(node).__name__.lower()})
|
|
47
|
+
elif isinstance(node, ast.Call):
|
|
48
|
+
result["calls"] += 1
|
|
49
|
+
for child in ast.iter_child_nodes(node):
|
|
50
|
+
visit(child)
|
|
51
|
+
|
|
52
|
+
for statement in body:
|
|
53
|
+
visit(statement)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HandlerVisitor(ast.NodeVisitor):
|
|
58
|
+
def __init__(self, source, record, owners):
|
|
59
|
+
self.lines = source.splitlines()
|
|
60
|
+
self.tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
|
|
61
|
+
self.record, self.owners = record, owners
|
|
62
|
+
self.rows = []
|
|
63
|
+
self.scope = {"kind": "module", "member": "<module>", "functionId": None}
|
|
64
|
+
self.finally_lines = []
|
|
65
|
+
|
|
66
|
+
def scoped_body(self, scope, body, *, deferred=True):
|
|
67
|
+
previous = self.scope, self.finally_lines
|
|
68
|
+
self.scope, self.finally_lines = scope, [] if deferred else self.finally_lines
|
|
69
|
+
for statement in body:
|
|
70
|
+
self.visit(statement)
|
|
71
|
+
self.scope, self.finally_lines = previous
|
|
72
|
+
|
|
73
|
+
def visit_FunctionDef(self, node):
|
|
74
|
+
key = digest(f"{self.record['file']}|{node.lineno}|{node.col_offset}|{type(node).__name__}")
|
|
75
|
+
owner = self.owners.get(key)
|
|
76
|
+
if owner:
|
|
77
|
+
self.scoped_body({"kind": "function", "member": owner["member"], "functionId": key}, node.body)
|
|
78
|
+
|
|
79
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
80
|
+
|
|
81
|
+
def visit_ClassDef(self, node):
|
|
82
|
+
prefix = "" if self.scope["kind"] == "module" else self.scope["member"] + "."
|
|
83
|
+
self.scoped_body({"kind": "class-body", "member": prefix + node.name, "functionId": None}, node.body, deferred=False)
|
|
84
|
+
|
|
85
|
+
def visit_Try(self, node):
|
|
86
|
+
previous = self.finally_lines
|
|
87
|
+
self.finally_lines = previous + ([node.lineno] if node.finalbody else [])
|
|
88
|
+
for statement in node.body + node.orelse:
|
|
89
|
+
self.visit(statement)
|
|
90
|
+
for handler in node.handlers:
|
|
91
|
+
self.handler(handler, isinstance(node, ast.TryStar))
|
|
92
|
+
# A try's own finally does not run after a handler located in that finally.
|
|
93
|
+
self.finally_lines = previous
|
|
94
|
+
for statement in node.finalbody:
|
|
95
|
+
self.visit(statement)
|
|
96
|
+
|
|
97
|
+
visit_TryStar = visit_Try
|
|
98
|
+
|
|
99
|
+
def documentation(self, node):
|
|
100
|
+
first = node.body[0]
|
|
101
|
+
# AST columns are UTF-8 byte offsets; tokenize columns are character offsets.
|
|
102
|
+
column = len(self.lines[first.lineno - 1].encode("utf-8")[:first.col_offset].decode("utf-8"))
|
|
103
|
+
header = [token.start for token in self.tokens if token.string == ":" and
|
|
104
|
+
(node.lineno, node.col_offset) <= token.start < (first.lineno, column)]
|
|
105
|
+
start = header[-1]
|
|
106
|
+
end = node.end_lineno
|
|
107
|
+
for index in range(end, len(self.lines)):
|
|
108
|
+
line = self.lines[index]
|
|
109
|
+
if not line.strip():
|
|
110
|
+
continue
|
|
111
|
+
if line.lstrip().startswith("#") and len(line) - len(line.lstrip()) > node.col_offset:
|
|
112
|
+
end = index + 1
|
|
113
|
+
else:
|
|
114
|
+
break
|
|
115
|
+
documents, directives = [], []
|
|
116
|
+
for token in self.tokens:
|
|
117
|
+
if token.type != tokenize.COMMENT or not (start < token.start and token.start[0] <= end):
|
|
118
|
+
continue
|
|
119
|
+
text = token.string[1:].strip()
|
|
120
|
+
if not any(character.isalnum() for character in text):
|
|
121
|
+
continue
|
|
122
|
+
item = {"kind": "comment", "line": token.start[0], "text": text}
|
|
123
|
+
(directives if DIRECTIVE.match(text) else documents).append(item)
|
|
124
|
+
for statement in node.body:
|
|
125
|
+
if isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant) and isinstance(statement.value.value, str):
|
|
126
|
+
text = statement.value.value.strip()
|
|
127
|
+
if any(character.isalnum() for character in text):
|
|
128
|
+
documents.append({"kind": "string-statement", "line": statement.lineno, "text": text})
|
|
129
|
+
return documents, directives
|
|
130
|
+
|
|
131
|
+
def handler(self, node, group):
|
|
132
|
+
if len(self.rows) >= MAX_HANDLERS:
|
|
133
|
+
raise ValueError("Exception-handler population exceeds the handler budget.")
|
|
134
|
+
documents, directives = self.documentation(node) if all(inert(item) for item in node.body) else ([], [])
|
|
135
|
+
terminal = type(node.body[-1]).__name__
|
|
136
|
+
if all(inert(item) for item in node.body):
|
|
137
|
+
classification = "documented-no-op" if documents else "undocumented-no-op"
|
|
138
|
+
else:
|
|
139
|
+
classification = {"Raise": "terminal-raise", "Return": "terminal-return",
|
|
140
|
+
"Break": "terminal-loop-control", "Continue": "terminal-loop-control"}.get(terminal, "other-body")
|
|
141
|
+
exception = ast.unparse(node.type) if node.type else None
|
|
142
|
+
self.rows.append({"id": digest(f"{self.record['file']}|{node.lineno}|{node.col_offset}|ExceptHandler"),
|
|
143
|
+
"file": self.record["file"], "line": node.lineno, "endLine": node.end_lineno,
|
|
144
|
+
"sourceSha256": self.record["contentSha256"], "population": self.record["category"],
|
|
145
|
+
"owner": dict(self.scope), "exceptionTypeSyntax": exception, "bareExcept": node.type is None,
|
|
146
|
+
"exceptionGroup": group, "enclosingFinallyLines": list(self.finally_lines),
|
|
147
|
+
"classification": classification, "documentation": documents, "directiveComments": directives,
|
|
148
|
+
"bodyObservations": observations(node.body), "disposition": "review-only"})
|
|
149
|
+
for statement in node.body:
|
|
150
|
+
self.visit(statement)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def summarize(rows):
|
|
154
|
+
def counts(items):
|
|
155
|
+
return {"handlers": len(items), "byClassification": dict(sorted(Counter(item["classification"] for item in items).items()))}
|
|
156
|
+
return {**counts(rows), "functionsWithHandlers": len({row["owner"]["functionId"] for row in rows if row["owner"]["functionId"]}),
|
|
157
|
+
"byOwnerKind": dict(sorted(Counter(row["owner"]["kind"] for row in rows).items())),
|
|
158
|
+
"populations": {kind: counts([row for row in rows if row["population"] == kind]) for kind in sorted({row["population"] for row in rows})}}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def collect(root, facts):
|
|
162
|
+
owners = {item["id"]: item for item in facts["functions"]}
|
|
163
|
+
if len(owners) != len(facts["functions"]):
|
|
164
|
+
raise ValueError("Repeated selected function identity.")
|
|
165
|
+
rows, seen = [], set()
|
|
166
|
+
for record in facts["selection"]["files"]:
|
|
167
|
+
if record["disposition"] != "analyzed":
|
|
168
|
+
continue
|
|
169
|
+
source = read_verified(root, record)
|
|
170
|
+
tree = ast.parse(source, filename=record["file"])
|
|
171
|
+
for node in ast.walk(tree):
|
|
172
|
+
if isinstance(node, source_facts.OWNERS):
|
|
173
|
+
key = digest(f"{record['file']}|{node.lineno}|{node.col_offset}|{type(node).__name__}")
|
|
174
|
+
if key in owners:
|
|
175
|
+
seen.add(key)
|
|
176
|
+
visitor = HandlerVisitor(source, record, owners)
|
|
177
|
+
visitor.visit(tree)
|
|
178
|
+
rows.extend(visitor.rows)
|
|
179
|
+
if len(rows) > MAX_HANDLERS:
|
|
180
|
+
raise ValueError("Exception-handler population exceeds the handler budget.")
|
|
181
|
+
if seen != set(owners):
|
|
182
|
+
raise ValueError("Selected function identities changed during error-handling analysis.")
|
|
183
|
+
rows.sort(key=lambda row: (row["file"], row["line"], row["id"]))
|
|
184
|
+
return rows, summarize(rows)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def analyze(target, *, run_id=None, audit_id=None, **selection):
|
|
188
|
+
return review(target, source_facts.analyze(target, **selection), run_id=run_id, audit_id=audit_id)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def review(target, facts, *, run_id=None, audit_id=None):
|
|
192
|
+
target = Path(target).absolute()
|
|
193
|
+
root = target if target.is_dir() else target.parent
|
|
194
|
+
configuration = {"policy": POLICY, "selectionFingerprint": facts["selection"]["configurationFingerprint"],
|
|
195
|
+
"parserVersion": facts["producer"]["parserVersion"], "maxHandlers": MAX_HANDLERS,
|
|
196
|
+
"documentationPolicy": DOCUMENTATION_POLICY}
|
|
197
|
+
report = {"schemaVersion": 1, "artifactKind": "experimental-python-error-handling-review",
|
|
198
|
+
"producer": {"name": "codemetrics-ai/python-error-handling", "version": "0.1.0-experimental"},
|
|
199
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
|
200
|
+
"runId": str(uuid.UUID(run_id)) if run_id else str(uuid.uuid4()),
|
|
201
|
+
"auditId": str(uuid.UUID(audit_id)) if audit_id else str(uuid.uuid4()),
|
|
202
|
+
"policy": POLICY, "configuration": configuration,
|
|
203
|
+
"configurationFingerprint": digest(json.dumps(configuration, sort_keys=True, separators=(",", ":"))),
|
|
204
|
+
"subject": {"root": str(root), "entryPoint": str(target)},
|
|
205
|
+
"sourceFactsProducer": facts["producer"], "sourceFactsPolicy": facts["measurementPolicy"],
|
|
206
|
+
"selection": facts["selection"], "status": facts["status"], "diagnostics": list(facts["diagnostics"]),
|
|
207
|
+
"scope": "Selected-source exception suites, including module/class bodies and except*. Syntactic observations only; no score, handling-quality judgment, resolved exception types, path completeness or propagation guarantee. Context-manager suppression is outside scope.",
|
|
208
|
+
"handlers": [], "summary": None}
|
|
209
|
+
if facts["status"] != "complete":
|
|
210
|
+
return report
|
|
211
|
+
try:
|
|
212
|
+
report["handlers"], report["summary"] = collect(root, facts)
|
|
213
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError, tokenize.TokenError) as error:
|
|
214
|
+
report["status"] = "incomplete"
|
|
215
|
+
report["diagnostics"].append({"code": "error-handling-analysis", "message": str(error)})
|
|
216
|
+
return report
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main():
|
|
220
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
221
|
+
parser.add_argument("target", help="Python source file or directory")
|
|
222
|
+
parser.add_argument("--output", type=Path, help="New .json file with an existing parent")
|
|
223
|
+
parser.add_argument("--run-id")
|
|
224
|
+
parser.add_argument("--audit-id")
|
|
225
|
+
for name in ("include", "exclude", "test-path", "production-path"):
|
|
226
|
+
parser.add_argument(f"--{name}", action="append", default=[])
|
|
227
|
+
for name in ("include-tests", "include-generated", "include-support"):
|
|
228
|
+
parser.add_argument(f"--{name}", action="store_true")
|
|
229
|
+
args = parser.parse_args()
|
|
230
|
+
try:
|
|
231
|
+
report = analyze(args.target, run_id=args.run_id, audit_id=args.audit_id, include=args.include, exclude=args.exclude,
|
|
232
|
+
test_paths=args.test_path, production_paths=args.production_path, include_tests=args.include_tests,
|
|
233
|
+
include_generated=args.include_generated, include_support=args.include_support)
|
|
234
|
+
payload = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
|
|
235
|
+
if args.output:
|
|
236
|
+
if args.output.suffix != ".json":
|
|
237
|
+
raise ValueError("Output must be a new .json file.")
|
|
238
|
+
with args.output.open("x", encoding="utf-8") as handle:
|
|
239
|
+
handle.write(payload)
|
|
240
|
+
else:
|
|
241
|
+
sys.stdout.write(payload)
|
|
242
|
+
return 0 if report["status"] == "complete" else 2
|
|
243
|
+
except (OSError, ValueError) as error:
|
|
244
|
+
print(f"Cannot produce error-handling review: {error}", file=sys.stderr)
|
|
245
|
+
return 2
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
raise SystemExit(main())
|