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,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())