codemetrics-ai 0.4.0 → 0.6.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -5
- package/dist/analyzer.d.ts +1 -0
- package/dist/analyzer.js +11 -5
- package/dist/cli.js +7 -3
- package/dist/dependency-evidence.d.ts +5 -0
- package/dist/dependency-evidence.js +30 -0
- package/dist/dependency-inventory.d.ts +67 -0
- package/dist/dependency-inventory.js +203 -0
- package/dist/dependency-metadata.d.ts +65 -0
- package/dist/dependency-metadata.js +125 -0
- package/dist/dependency-pnpm.d.ts +4 -0
- package/dist/dependency-pnpm.js +237 -0
- package/dist/dependency-spec.d.ts +13 -0
- package/dist/dependency-spec.js +30 -0
- package/dist/error-handling-scoring.d.ts +1 -0
- package/dist/error-handling-scoring.js +15 -0
- package/dist/error-handling.d.ts +4 -1
- package/dist/error-handling.js +50 -17
- package/dist/evidence-reader.js +3 -1
- package/dist/evidence-tools.js +4 -0
- package/dist/failure-contracts.d.ts +19 -0
- package/dist/failure-contracts.js +261 -0
- package/dist/function-scoring.d.ts +2 -1
- package/dist/function-scoring.js +13 -24
- package/dist/handler-disposition.d.ts +9 -0
- package/dist/handler-disposition.js +156 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/python/ASYNC_ADAPTERS.md +38 -0
- package/dist/python/ASYNC_CONTEXT.md +58 -0
- package/dist/python/ASYNC_SCORING.md +174 -0
- package/dist/python/ASYNC_USAGE.md +62 -0
- package/dist/python/BEHAVIOR_REVIEW.md +84 -0
- package/dist/python/COVERAGE.md +96 -0
- package/dist/python/DECOMPOSITION.md +46 -0
- package/dist/python/DUPLICATION.md +36 -0
- package/dist/python/ERROR_HANDLING.md +45 -0
- package/dist/python/ERROR_HANDLING_CONTEXT.md +61 -0
- package/dist/python/ERROR_HANDLING_OPERATIONS.md +54 -0
- package/dist/python/ERROR_HANDLING_TRANSFERS.md +64 -0
- package/dist/python/METHOD_COMPLEXITY.md +46 -0
- package/dist/python/README.md +162 -0
- package/dist/python/analysis.py +54 -0
- package/dist/python/async_adapters.py +243 -0
- package/dist/python/async_bindings.py +180 -0
- package/dist/python/async_context.py +143 -0
- package/dist/python/async_helper_paths.py +186 -0
- package/dist/python/async_provenance.py +129 -0
- package/dist/python/async_scoring.py +203 -0
- package/dist/python/async_usage.py +244 -0
- package/dist/python/coverage_collect.py +121 -0
- package/dist/python/coverage_provenance.py +77 -0
- package/dist/python/coverage_review.py +273 -0
- package/dist/python/coverage_worker.py +46 -0
- package/dist/python/decomposition.py +199 -0
- package/dist/python/duplication.py +177 -0
- package/dist/python/error_handling.py +249 -0
- package/dist/python/error_handling_context.py +294 -0
- package/dist/python/error_handling_operations.py +228 -0
- package/dist/python/error_handling_transfers.py +211 -0
- package/dist/python/review_cli.py +36 -0
- package/dist/python/source_facts.py +293 -0
- package/dist/python/source_selection.py +157 -0
- package/dist/python-analyzer.d.ts +21 -0
- package/dist/python-analyzer.js +123 -0
- package/dist/python-async-adapters.d.ts +67 -0
- package/dist/python-async-adapters.js +197 -0
- package/dist/python-async-context.d.ts +73 -0
- package/dist/python-async-context.js +181 -0
- package/dist/python-async-scoring.d.ts +89 -0
- package/dist/python-async-scoring.js +249 -0
- package/dist/python-async-usage.d.ts +56 -0
- package/dist/python-async-usage.js +143 -0
- package/dist/python-cli.d.ts +2 -0
- package/dist/python-cli.js +53 -0
- package/dist/python-collect-cli.d.ts +2 -0
- package/dist/python-collect-cli.js +28 -0
- package/dist/python-complexity.d.ts +18 -0
- package/dist/python-complexity.js +36 -0
- package/dist/python-coverage.d.ts +63 -0
- package/dist/python-coverage.js +173 -0
- package/dist/python-decomposition.d.ts +56 -0
- package/dist/python-decomposition.js +102 -0
- package/dist/python-duplication.d.ts +65 -0
- package/dist/python-duplication.js +89 -0
- package/dist/python-error-context.d.ts +84 -0
- package/dist/python-error-context.js +165 -0
- package/dist/python-error-handling.d.ts +56 -0
- package/dist/python-error-handling.js +120 -0
- package/dist/python-error-operations.d.ts +44 -0
- package/dist/python-error-operations.js +155 -0
- package/dist/python-error-transfers.d.ts +38 -0
- package/dist/python-error-transfers.js +125 -0
- package/dist/python-evidence.d.ts +23 -0
- package/dist/python-evidence.js +255 -0
- package/dist/score-arithmetic.d.ts +1 -12
- package/dist/score-arithmetic.js +1 -40
- package/dist/shared-source-facts/arithmetic.d.mts +9 -0
- package/dist/shared-source-facts/arithmetic.mjs +32 -0
- package/dist/shared-source-facts/contract.d.mts +35 -0
- package/dist/shared-source-facts/contract.mjs +95 -0
- package/dist/shared-source-facts/population.d.mts +12 -0
- package/dist/shared-source-facts/population.mjs +41 -0
- package/dist/source-facts.d.ts +4 -0
- package/dist/source-facts.js +16 -0
- package/package.json +15 -6
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Exception raise/chaining syntax and conservative local patterns; no score."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sys
|
|
10
|
+
import tokenize
|
|
11
|
+
|
|
12
|
+
import error_handling_context as context
|
|
13
|
+
from duplication import digest, read_verified
|
|
14
|
+
|
|
15
|
+
POLICY = "python/exception-raise-chaining-v1"
|
|
16
|
+
MAX_RAISES = 100000
|
|
17
|
+
MAX_PATH_CHARACTERS = 200
|
|
18
|
+
MAX_CONTAINERS = 32
|
|
19
|
+
MAX_SYMBOL_ATTRIBUTES = 8
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def symbolic_path(node):
|
|
23
|
+
"""Bounded identifier path; calls/subscripts in a receiver are not simple symbols."""
|
|
24
|
+
parts = []
|
|
25
|
+
while isinstance(node, ast.Attribute) and len(parts) < MAX_SYMBOL_ATTRIBUTES:
|
|
26
|
+
parts.append(node.attr)
|
|
27
|
+
node = node.value
|
|
28
|
+
if not isinstance(node, ast.Name):
|
|
29
|
+
return None
|
|
30
|
+
value = ".".join([node.id, *reversed(parts)])
|
|
31
|
+
return value if len(value) <= MAX_PATH_CHARACTERS else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def expression(node, handler_name):
|
|
35
|
+
if node is None:
|
|
36
|
+
return {"kind": "absent", "astKind": None, "symbol": None, "matchesHandlerNameSyntax": False}
|
|
37
|
+
symbol = symbolic_path(node.func if isinstance(node, ast.Call) else node)
|
|
38
|
+
kind = "none" if isinstance(node, ast.Constant) and node.value is None else (
|
|
39
|
+
"name" if isinstance(node, ast.Name) else "attribute" if isinstance(node, ast.Attribute) else
|
|
40
|
+
"call" if isinstance(node, ast.Call) else "other")
|
|
41
|
+
return {"kind": kind, "astKind": type(node).__name__, "symbol": symbol,
|
|
42
|
+
"matchesHandlerNameSyntax": isinstance(node, ast.Name) and node.id == handler_name}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def owned_raises(handler, parents):
|
|
46
|
+
rows = []
|
|
47
|
+
|
|
48
|
+
def visit(node):
|
|
49
|
+
if isinstance(node, context.handlers.source_facts.OWNERS + (ast.ClassDef, ast.ExceptHandler)):
|
|
50
|
+
return
|
|
51
|
+
if isinstance(node, ast.Raise):
|
|
52
|
+
containers, parent = [], parents[node]
|
|
53
|
+
while parent is not handler:
|
|
54
|
+
if isinstance(parent, ast.stmt):
|
|
55
|
+
containers.append({"kind": type(parent).__name__, "line": parent.lineno})
|
|
56
|
+
parent = parents[parent]
|
|
57
|
+
rows.append({"line": node.lineno, "column": node.col_offset, "endLine": node.end_lineno,
|
|
58
|
+
"direct": node in handler.body, "exception": expression(node.exc, handler.name),
|
|
59
|
+
"cause": expression(node.cause, handler.name),
|
|
60
|
+
"containsNamedExpression": any(isinstance(item, ast.NamedExpr) for item in ast.walk(node)),
|
|
61
|
+
"containers": containers[:MAX_CONTAINERS], "containersTruncated": len(containers) > MAX_CONTAINERS})
|
|
62
|
+
if len(rows) > MAX_RAISES:
|
|
63
|
+
raise ValueError("Raise observation budget exceeded.")
|
|
64
|
+
for child in ast.iter_child_nodes(node):
|
|
65
|
+
visit(child)
|
|
66
|
+
|
|
67
|
+
for statement in handler.body:
|
|
68
|
+
visit(statement)
|
|
69
|
+
return sorted(rows, key=lambda row: (row["line"], row["column"]))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def classify(handler, original, raises):
|
|
73
|
+
# Retain v1 recognized patterns and conservative execution blockers exactly.
|
|
74
|
+
if original["status"] == "recognized-shape" or original["unresolvedReasons"] != ["no-supported-local-shape"]:
|
|
75
|
+
return dict(original)
|
|
76
|
+
if len(handler.body) != 1 or not isinstance(handler.body[0], ast.Raise):
|
|
77
|
+
return dict(original)
|
|
78
|
+
observation = raises[0]
|
|
79
|
+
exc, cause = observation["exception"], observation["cause"]
|
|
80
|
+
pattern = None
|
|
81
|
+
if not observation["containsNamedExpression"] and exc["symbol"] is not None:
|
|
82
|
+
if cause["kind"] == "none":
|
|
83
|
+
pattern = "direct-raise-from-none"
|
|
84
|
+
elif cause["matchesHandlerNameSyntax"]:
|
|
85
|
+
pattern = "direct-raise-from-handler-name"
|
|
86
|
+
elif cause["kind"] == "absent":
|
|
87
|
+
pattern = "direct-handler-name-raise" if exc["matchesHandlerNameSyntax"] else "direct-symbol-or-call-raise"
|
|
88
|
+
if pattern:
|
|
89
|
+
return {"status": "recognized-shape", "pattern": pattern,
|
|
90
|
+
"evidenceLines": sorted({handler.lineno, observation["line"]}), "unresolvedReasons": []}
|
|
91
|
+
return {"status": "unresolved", "pattern": None, "evidenceLines": [handler.lineno],
|
|
92
|
+
"unresolvedReasons": ["raise-expression-or-cause-unresolved"]}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TransferVisitor(context.ContextVisitor):
|
|
96
|
+
def __init__(self, source, record, owners, tree):
|
|
97
|
+
super().__init__(source, record, owners, tree)
|
|
98
|
+
self.transfers = []
|
|
99
|
+
|
|
100
|
+
def handler(self, node, group):
|
|
101
|
+
super().handler(node, group)
|
|
102
|
+
# ContextVisitor appends the parent after visiting nested handlers.
|
|
103
|
+
original = self.contexts[-1]
|
|
104
|
+
raises = owned_raises(node, self.parents)
|
|
105
|
+
for row in raises:
|
|
106
|
+
row["id"] = digest(f"{original['file']}|{row['line']}|{row['column']}|Raise")
|
|
107
|
+
self.transfers.append({key: original[key] for key in ("handlerId", "file", "line", "sourceSha256", "population", "owner")} |
|
|
108
|
+
{"handlerNameSyntax": node.name, "raises": raises, "originalClassification": original["classification"],
|
|
109
|
+
"classification": classify(node, original["classification"], raises), "disposition": "review-only"})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def summarize(rows):
|
|
113
|
+
def counts(items):
|
|
114
|
+
raises = [value for row in items for value in row["raises"]]
|
|
115
|
+
return {**{key: value for key, value in context.summarize(items).items() if key != "populations"},
|
|
116
|
+
"raiseStatements": len(raises), "handlersWithRaises": sum(bool(row["raises"]) for row in items),
|
|
117
|
+
"newlyRecognizedShapes": sum(row["originalClassification"]["status"] == "unresolved" and row["classification"]["status"] == "recognized-shape" for row in items),
|
|
118
|
+
"byExceptionSyntax": dict(sorted(Counter(row["exception"]["kind"] for row in raises).items())),
|
|
119
|
+
"handlerNameRaiseSyntax": sum(row["exception"]["matchesHandlerNameSyntax"] for row in raises),
|
|
120
|
+
"handlerNameCauseSyntax": sum(row["cause"]["matchesHandlerNameSyntax"] for row in raises),
|
|
121
|
+
"byCauseSyntax": dict(sorted(Counter(row["cause"]["kind"] for row in raises).items()))}
|
|
122
|
+
return {**counts(rows), "populations": {kind: counts([row for row in rows if row["population"] == kind]) for kind in sorted({row["population"] for row in rows})}}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def review(target, facts, base):
|
|
126
|
+
configuration = {"policy": POLICY, "contextPolicy": context.POLICY, "contextConfigurationFingerprint": base["configurationFingerprint"],
|
|
127
|
+
"maxRaises": MAX_RAISES, "maxSymbolCharacters": MAX_PATH_CHARACTERS, "maxContainers": MAX_CONTAINERS, "maxSymbolAttributes": MAX_SYMBOL_ATTRIBUTES}
|
|
128
|
+
report = {"schemaVersion": 1, "artifactKind": "experimental-python-exception-transfer-review",
|
|
129
|
+
"producer": {"name": "codemetrics-ai/python-exception-transfers", "version": "0.1.0-experimental"},
|
|
130
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(), "runId": base["runId"], "auditId": base["auditId"],
|
|
131
|
+
"policy": POLICY, "configuration": configuration, "configurationFingerprint": context.fingerprint(configuration),
|
|
132
|
+
"contextReview": base, "contextRowsSha256": context.fingerprint(base["contexts"]),
|
|
133
|
+
"status": base["status"], "diagnostics": list(base["diagnostics"]), "handlers": [], "summary": None,
|
|
134
|
+
"scope": "Owned raise and cause syntax with conservative local shapes; not resolved exception identity, propagation, handling quality, defects, or scores."}
|
|
135
|
+
if facts["status"] != "complete" or base["status"] != "complete":
|
|
136
|
+
report["status"] = "incomplete"
|
|
137
|
+
report["diagnostics"] = list(base["diagnostics"] or facts["diagnostics"])
|
|
138
|
+
return report
|
|
139
|
+
target = Path(target).absolute()
|
|
140
|
+
root = target if target.is_dir() else target.parent
|
|
141
|
+
try:
|
|
142
|
+
regenerated = context.review(target, facts, base["handlerReview"])
|
|
143
|
+
stable = lambda value: {key: item for key, item in value.items() if key != "generatedAtUtc"}
|
|
144
|
+
if regenerated["status"] != "complete" or stable(regenerated) != stable(base):
|
|
145
|
+
raise ValueError("Context evidence changed or has inconsistent provenance.")
|
|
146
|
+
owners = {item["id"]: item for item in facts["functions"]}
|
|
147
|
+
rows, contexts, handlers, raise_count = [], [], [], 0
|
|
148
|
+
for record in facts["selection"]["files"]:
|
|
149
|
+
if record["disposition"] != "analyzed":
|
|
150
|
+
continue
|
|
151
|
+
source = read_verified(root, record)
|
|
152
|
+
tree = ast.parse(source, filename=record["file"])
|
|
153
|
+
visitor = TransferVisitor(source, record, owners, tree)
|
|
154
|
+
visitor.visit(tree)
|
|
155
|
+
rows.extend(visitor.transfers)
|
|
156
|
+
contexts.extend(visitor.contexts)
|
|
157
|
+
handlers.extend(visitor.rows)
|
|
158
|
+
raise_count += sum(len(row["raises"]) for row in visitor.transfers)
|
|
159
|
+
if raise_count > MAX_RAISES or len(rows) > context.handlers.MAX_HANDLERS:
|
|
160
|
+
raise ValueError("Transfer observation population exceeds its budget.")
|
|
161
|
+
order = lambda row: (row["file"], row["line"], row["handlerId"])
|
|
162
|
+
rows.sort(key=order)
|
|
163
|
+
contexts.sort(key=order)
|
|
164
|
+
handlers.sort(key=lambda row: (row["file"], row["line"], row["id"]))
|
|
165
|
+
if contexts != base["contexts"] or handlers != base["handlerReview"]["handlers"]:
|
|
166
|
+
raise ValueError("Handler/context observations changed during transfer analysis.")
|
|
167
|
+
report["handlers"], report["summary"] = rows, summarize(rows)
|
|
168
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError, tokenize.TokenError) as error:
|
|
169
|
+
report["status"] = "incomplete"
|
|
170
|
+
report["diagnostics"].append({"code": "exception-transfer-analysis", "message": str(error)})
|
|
171
|
+
return report
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def analyze(target, *, run_id=None, audit_id=None, **selection):
|
|
175
|
+
facts = context.handlers.source_facts.analyze(target, **selection)
|
|
176
|
+
handlers = context.handlers.review(target, facts, run_id=run_id, audit_id=audit_id)
|
|
177
|
+
base = context.review(target, facts, handlers)
|
|
178
|
+
return review(target, facts, base)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main():
|
|
182
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
183
|
+
parser.add_argument("target", help="Python source file or directory")
|
|
184
|
+
parser.add_argument("--output", type=Path, help="New .json file with an existing parent")
|
|
185
|
+
parser.add_argument("--run-id")
|
|
186
|
+
parser.add_argument("--audit-id")
|
|
187
|
+
for name in ("include", "exclude", "test-path", "production-path"):
|
|
188
|
+
parser.add_argument(f"--{name}", action="append", default=[])
|
|
189
|
+
for name in ("include-tests", "include-generated", "include-support"):
|
|
190
|
+
parser.add_argument(f"--{name}", action="store_true")
|
|
191
|
+
args = parser.parse_args()
|
|
192
|
+
try:
|
|
193
|
+
report = analyze(args.target, run_id=args.run_id, audit_id=args.audit_id, include=args.include, exclude=args.exclude,
|
|
194
|
+
test_paths=args.test_path, production_paths=args.production_path, include_tests=args.include_tests,
|
|
195
|
+
include_generated=args.include_generated, include_support=args.include_support)
|
|
196
|
+
payload = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
|
|
197
|
+
if args.output:
|
|
198
|
+
if args.output.suffix != ".json":
|
|
199
|
+
raise ValueError("Output must be a new .json file.")
|
|
200
|
+
with args.output.open("x", encoding="utf-8") as handle:
|
|
201
|
+
handle.write(payload)
|
|
202
|
+
else:
|
|
203
|
+
sys.stdout.write(payload)
|
|
204
|
+
return 0 if report["status"] == "complete" else 2
|
|
205
|
+
except (OSError, ValueError) as error:
|
|
206
|
+
print(f"Cannot produce exception transfer review: {error}", file=sys.stderr)
|
|
207
|
+
return 2
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Shared CLI for standalone, unscored source reviewers."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run(analyze, description):
|
|
10
|
+
parser = argparse.ArgumentParser(description=description)
|
|
11
|
+
parser.add_argument("target")
|
|
12
|
+
parser.add_argument("--output", type=Path)
|
|
13
|
+
parser.add_argument("--run-id")
|
|
14
|
+
parser.add_argument("--audit-id")
|
|
15
|
+
for name in ("include", "exclude", "test-path", "production-path"):
|
|
16
|
+
parser.add_argument(f"--{name}", action="append", default=[])
|
|
17
|
+
for name in ("include-tests", "include-generated", "include-support"):
|
|
18
|
+
parser.add_argument(f"--{name}", action="store_true")
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
try:
|
|
21
|
+
report = analyze(args.target, run_id=args.run_id, audit_id=args.audit_id,
|
|
22
|
+
include=args.include, exclude=args.exclude, test_paths=args.test_path,
|
|
23
|
+
production_paths=args.production_path, include_tests=args.include_tests,
|
|
24
|
+
include_generated=args.include_generated, include_support=args.include_support)
|
|
25
|
+
payload = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
|
|
26
|
+
if args.output:
|
|
27
|
+
if args.output.suffix != ".json":
|
|
28
|
+
raise ValueError("Output must be a new .json file with an existing parent.")
|
|
29
|
+
with args.output.open("x", encoding="utf-8") as stream:
|
|
30
|
+
stream.write(payload)
|
|
31
|
+
else:
|
|
32
|
+
sys.stdout.write(payload)
|
|
33
|
+
return 0 if report["status"] == "complete" else 2
|
|
34
|
+
except (OSError, ValueError) as error:
|
|
35
|
+
print(f"Cannot produce review: {error}", file=sys.stderr)
|
|
36
|
+
return 2
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Experimental Python function facts. Parse source only; never import target code."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import platform
|
|
10
|
+
import sys
|
|
11
|
+
import tokenize
|
|
12
|
+
from source_selection import (declaration_reason, file_record, generated_source, import_bindings,
|
|
13
|
+
inventory, resolve, selection_options)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
POLICY = "python/owned-function-complexity-v2"
|
|
17
|
+
EXCLUDED_DIRECTORIES = {".git", ".venv", "venv", "env", "__pycache__", "node_modules", "build", "dist", ".tox", ".mypy_cache", ".pytest_cache"}
|
|
18
|
+
OWNERS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)
|
|
19
|
+
MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
20
|
+
MAX_FILES = 10000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Decisions(ast.NodeVisitor):
|
|
24
|
+
"""Count decisions in one body, excluding nested executable owners."""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self.counts = {key: 0 for key in (
|
|
28
|
+
"ifStatements", "loops", "exceptHandlers", "conditionalExpressions",
|
|
29
|
+
"booleanOperators", "chainedComparisons", "comprehensionLoops",
|
|
30
|
+
"comprehensionFilters", "matchCases", "matchGuards", "matchAlternatives")}
|
|
31
|
+
|
|
32
|
+
def visit_AnnAssign(self, node):
|
|
33
|
+
self.visit(node.target)
|
|
34
|
+
if node.value is not None:
|
|
35
|
+
self.visit(node.value)
|
|
36
|
+
|
|
37
|
+
def visit(self, node):
|
|
38
|
+
if isinstance(node, OWNERS + (ast.ClassDef,)):
|
|
39
|
+
return
|
|
40
|
+
if isinstance(node, ast.If):
|
|
41
|
+
self.counts["ifStatements"] += 1
|
|
42
|
+
elif isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
43
|
+
self.counts["loops"] += 1
|
|
44
|
+
elif isinstance(node, ast.ExceptHandler):
|
|
45
|
+
self.counts["exceptHandlers"] += 1
|
|
46
|
+
elif isinstance(node, ast.IfExp):
|
|
47
|
+
self.counts["conditionalExpressions"] += 1
|
|
48
|
+
elif isinstance(node, ast.BoolOp):
|
|
49
|
+
self.counts["booleanOperators"] += len(node.values) - 1
|
|
50
|
+
elif isinstance(node, ast.Compare):
|
|
51
|
+
self.counts["chainedComparisons"] += len(node.ops) - 1
|
|
52
|
+
elif isinstance(node, ast.comprehension):
|
|
53
|
+
self.counts["comprehensionLoops"] += 1
|
|
54
|
+
self.counts["comprehensionFilters"] += len(node.ifs)
|
|
55
|
+
elif isinstance(node, ast.match_case):
|
|
56
|
+
if not irrefutable(node.pattern):
|
|
57
|
+
self.counts["matchCases"] += 1
|
|
58
|
+
self.counts["matchGuards"] += int(node.guard is not None)
|
|
59
|
+
membership = membership_pattern(node.pattern)
|
|
60
|
+
if membership is not None:
|
|
61
|
+
# Only the whole-case OR is one membership decision. Keep all
|
|
62
|
+
# guard/body decisions and nested-owner boundaries unchanged.
|
|
63
|
+
self.generic_visit(membership)
|
|
64
|
+
if node.guard is not None:
|
|
65
|
+
self.visit(node.guard)
|
|
66
|
+
for statement in node.body:
|
|
67
|
+
self.visit(statement)
|
|
68
|
+
return
|
|
69
|
+
elif isinstance(node, ast.MatchOr):
|
|
70
|
+
self.counts["matchAlternatives"] += len(node.patterns) - 1
|
|
71
|
+
super().visit(node)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def membership_pattern(pattern):
|
|
75
|
+
"""Recognize a whole-case non-destructuring OR, optionally as-bound."""
|
|
76
|
+
while isinstance(pattern, ast.MatchAs) and pattern.pattern is not None:
|
|
77
|
+
pattern = pattern.pattern
|
|
78
|
+
if isinstance(pattern, ast.MatchOr) and all(
|
|
79
|
+
isinstance(child, (ast.MatchValue, ast.MatchSingleton)) or (
|
|
80
|
+
isinstance(child, ast.MatchClass) and not child.patterns and not child.kwd_patterns)
|
|
81
|
+
for child in pattern.patterns
|
|
82
|
+
):
|
|
83
|
+
return pattern
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def irrefutable(pattern):
|
|
88
|
+
"""A capture or wildcard (possibly as-bound) is the default match branch."""
|
|
89
|
+
if isinstance(pattern, ast.MatchAs):
|
|
90
|
+
return pattern.pattern is None or irrefutable(pattern.pattern)
|
|
91
|
+
if isinstance(pattern, ast.MatchOr):
|
|
92
|
+
return any(irrefutable(child) for child in pattern.patterns)
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Functions(ast.NodeVisitor):
|
|
97
|
+
def __init__(self, file, tree=None):
|
|
98
|
+
self.file = file
|
|
99
|
+
self.parents = []
|
|
100
|
+
self.functions = []
|
|
101
|
+
self.excluded_functions = []
|
|
102
|
+
self.bindings = import_bindings(tree) if tree is not None else {}
|
|
103
|
+
self.contexts = []
|
|
104
|
+
|
|
105
|
+
def visit_AnnAssign(self, node):
|
|
106
|
+
self.visit(node.target)
|
|
107
|
+
if node.value is not None:
|
|
108
|
+
self.visit(node.value)
|
|
109
|
+
|
|
110
|
+
def visit_ClassDef(self, node):
|
|
111
|
+
self.parents.append(node.name)
|
|
112
|
+
protocol = any(resolve(base, self.bindings) in {"typing.Protocol", "typing_extensions.Protocol"} for base in node.bases)
|
|
113
|
+
self.contexts.append(("class", protocol))
|
|
114
|
+
for child in node.body:
|
|
115
|
+
self.visit(child)
|
|
116
|
+
self.contexts.pop()
|
|
117
|
+
self.parents.pop()
|
|
118
|
+
|
|
119
|
+
def visit_FunctionDef(self, node):
|
|
120
|
+
name = getattr(node, "name", f"<lambda@{node.lineno}:{node.col_offset}>")
|
|
121
|
+
member = ".".join(self.parents + [name])
|
|
122
|
+
identity = f"{self.file}|{node.lineno}|{node.col_offset}|{type(node).__name__}"
|
|
123
|
+
location = {"id": hashlib.sha256(identity.encode("utf-8")).hexdigest(),
|
|
124
|
+
"file": self.file, "member": member, "line": node.lineno}
|
|
125
|
+
protocol_method = bool(self.contexts and self.contexts[-1] == ("class", True))
|
|
126
|
+
reason = declaration_reason(node, self.bindings, protocol_method)
|
|
127
|
+
if reason:
|
|
128
|
+
self.excluded_functions.append({**location, "reason": reason})
|
|
129
|
+
return
|
|
130
|
+
decisions = Decisions()
|
|
131
|
+
body = [node.body] if isinstance(node, ast.Lambda) else node.body
|
|
132
|
+
for child in body:
|
|
133
|
+
decisions.visit(child)
|
|
134
|
+
self.functions.append({
|
|
135
|
+
**location,
|
|
136
|
+
"ownership": "exclusive-function-body",
|
|
137
|
+
"complexity": {"value": 1 + sum(decisions.counts.values()), "baseline": 1, "decisions": decisions.counts},
|
|
138
|
+
})
|
|
139
|
+
self.parents.append(name)
|
|
140
|
+
self.contexts.append(("function", False))
|
|
141
|
+
for child in body:
|
|
142
|
+
self.visit(child)
|
|
143
|
+
self.parents.pop()
|
|
144
|
+
self.contexts.pop()
|
|
145
|
+
|
|
146
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
147
|
+
visit_Lambda = visit_FunctionDef
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def measure_source(source, file):
|
|
151
|
+
tree = ast.parse(source, filename=file)
|
|
152
|
+
collector = Functions(file, tree)
|
|
153
|
+
collector.visit(tree)
|
|
154
|
+
return collector.functions
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def new_document(options=None):
|
|
158
|
+
return {
|
|
159
|
+
"schemaVersion": 2, "language": "python",
|
|
160
|
+
"producer": {"name": "codemetrics-ai/python-source-facts", "version": "0.4.0-experimental",
|
|
161
|
+
"parser": "python-ast", "parserVersion": platform.python_version()},
|
|
162
|
+
"measurementPolicy": POLICY, "status": "complete",
|
|
163
|
+
"capabilities": {"functionComplexity": "available", "coverage": "unsupported", "maintainability": "unsupported"},
|
|
164
|
+
"scope": {
|
|
165
|
+
"description": "Selected Python implementation bodies. File roles are explicit scope conventions, not runtime reachability or defect judgments. See selection inventory.",
|
|
166
|
+
"excludes": ["nested-function-and-class-bodies-from-parent", "decorators", "class-headers", "annotations", "parameter-initializers",
|
|
167
|
+
"module-and-class-initializer-decisions", "symlinks", "directory-junctions", *sorted(EXCLUDED_DIRECTORIES)],
|
|
168
|
+
},
|
|
169
|
+
"diagnostics": [], "functions": [], "selection": inventory(options or selection_options()),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def diagnostic(document, code, message, file=None):
|
|
174
|
+
document["status"] = "incomplete"
|
|
175
|
+
entry = {"code": code, "message": message}
|
|
176
|
+
if file:
|
|
177
|
+
entry["file"] = str(file)
|
|
178
|
+
document["diagnostics"].append(entry)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def linked(path):
|
|
182
|
+
return path.is_symlink() or path.is_junction()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def discover(target, document):
|
|
186
|
+
if linked(target):
|
|
187
|
+
diagnostic(document, "linked-target", "An explicit symlink/junction target is not analyzed.")
|
|
188
|
+
return []
|
|
189
|
+
if target.is_file():
|
|
190
|
+
if target.suffix not in {".py", ".pyi"}:
|
|
191
|
+
diagnostic(document, "unsupported-file", "The input file must have a .py or .pyi extension.")
|
|
192
|
+
return []
|
|
193
|
+
return [target]
|
|
194
|
+
if not target.is_dir():
|
|
195
|
+
diagnostic(document, "missing-target", "The input path does not exist.")
|
|
196
|
+
return []
|
|
197
|
+
files = []
|
|
198
|
+
def on_error(error):
|
|
199
|
+
diagnostic(document, "discovery-error", str(error))
|
|
200
|
+
for directory, directories, names in os.walk(target, followlinks=False, onerror=on_error):
|
|
201
|
+
retained = []
|
|
202
|
+
for name in sorted(directories):
|
|
203
|
+
path = Path(directory) / name
|
|
204
|
+
if name in EXCLUDED_DIRECTORIES or linked(path):
|
|
205
|
+
document["selection"]["prunedDirectories"].append({"path": path.relative_to(target).as_posix(), "reason": "linked-directory" if linked(path) else "dependency-cache-or-build-directory"})
|
|
206
|
+
else:
|
|
207
|
+
retained.append(name)
|
|
208
|
+
directories[:] = retained
|
|
209
|
+
for name in sorted(names):
|
|
210
|
+
path = Path(directory) / name
|
|
211
|
+
if path.suffix in {".py", ".pyi"}:
|
|
212
|
+
files.append(path)
|
|
213
|
+
if len(files) > MAX_FILES:
|
|
214
|
+
diagnostic(document, "file-limit", f"More than {MAX_FILES} Python files; select a smaller root.")
|
|
215
|
+
return []
|
|
216
|
+
return files
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def analyze(target, **configuration):
|
|
220
|
+
try:
|
|
221
|
+
options = selection_options(**configuration)
|
|
222
|
+
except ValueError as error:
|
|
223
|
+
document = new_document()
|
|
224
|
+
diagnostic(document, "invalid-selection", str(error))
|
|
225
|
+
return document
|
|
226
|
+
document = new_document(options)
|
|
227
|
+
if sys.version_info[:2] != (3, 12):
|
|
228
|
+
diagnostic(document, "unsupported-parser", "This experimental policy is tested with Python 3.12 only.")
|
|
229
|
+
return document
|
|
230
|
+
target = Path(os.path.abspath(target))
|
|
231
|
+
root = target if target.is_dir() else target.parent
|
|
232
|
+
for path in discover(target, document):
|
|
233
|
+
file = path.relative_to(root).as_posix()
|
|
234
|
+
record = file_record(file, options)
|
|
235
|
+
document["selection"]["files"].append(record)
|
|
236
|
+
if linked(path):
|
|
237
|
+
record.update(disposition="excluded", reason="linked-file")
|
|
238
|
+
if record["disposition"] == "excluded":
|
|
239
|
+
continue
|
|
240
|
+
try:
|
|
241
|
+
if not path.resolve().is_relative_to(root.resolve()):
|
|
242
|
+
raise ValueError("File resolves outside the selected root.")
|
|
243
|
+
if path.stat().st_size > MAX_FILE_BYTES:
|
|
244
|
+
raise ValueError(f"File exceeds {MAX_FILE_BYTES} bytes.")
|
|
245
|
+
with tokenize.open(path) as source:
|
|
246
|
+
text = source.read()
|
|
247
|
+
record["contentSha256"] = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
248
|
+
if generated_source(text):
|
|
249
|
+
record.update(category="generated", reason="generated-header-marker")
|
|
250
|
+
if not options["includeGenerated"]:
|
|
251
|
+
record["disposition"] = "excluded"
|
|
252
|
+
continue
|
|
253
|
+
tree = ast.parse(text, filename=file)
|
|
254
|
+
collector = Functions(file, tree)
|
|
255
|
+
collector.visit(tree)
|
|
256
|
+
document["functions"].extend({**item, "population": record["category"]} for item in collector.functions)
|
|
257
|
+
document["selection"]["excludedFunctions"].extend(collector.excluded_functions)
|
|
258
|
+
except (SyntaxError, UnicodeError, OSError, ValueError, RecursionError) as error:
|
|
259
|
+
record.update(disposition="failed", reason="parse-error")
|
|
260
|
+
diagnostic(document, "parse-error", str(error), file)
|
|
261
|
+
document["functions"].sort(key=lambda item: (item["file"], item["line"], item["id"]))
|
|
262
|
+
return document
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main():
|
|
266
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
267
|
+
parser.add_argument("target", help="Python file or directory (production candidates by default)")
|
|
268
|
+
for option in ("include", "exclude", "test-path", "production-path"):
|
|
269
|
+
parser.add_argument(f"--{option}", action="append", default=[], help="Root-relative POSIX pattern; repeatable")
|
|
270
|
+
for option in ("include-tests", "include-generated", "include-support"):
|
|
271
|
+
parser.add_argument(f"--{option}", action="store_true")
|
|
272
|
+
parser.add_argument("--output", type=Path, help="New .json file; existing files are never overwritten")
|
|
273
|
+
args = parser.parse_args()
|
|
274
|
+
document = analyze(args.target, include=args.include, exclude=args.exclude, test_paths=args.test_path,
|
|
275
|
+
production_paths=args.production_path, include_tests=args.include_tests,
|
|
276
|
+
include_generated=args.include_generated, include_support=args.include_support)
|
|
277
|
+
payload = json.dumps(document, indent=2, ensure_ascii=True) + "\n"
|
|
278
|
+
try:
|
|
279
|
+
if args.output:
|
|
280
|
+
if args.output.suffix != ".json":
|
|
281
|
+
raise ValueError("Output must be a new .json file.")
|
|
282
|
+
with args.output.open("x", encoding="utf-8") as output:
|
|
283
|
+
output.write(payload)
|
|
284
|
+
else:
|
|
285
|
+
sys.stdout.write(payload)
|
|
286
|
+
except (OSError, ValueError) as error:
|
|
287
|
+
print(f"Cannot write source facts: {error}", file=sys.stderr)
|
|
288
|
+
return 2
|
|
289
|
+
return 0 if document["status"] == "complete" else 2
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
raise SystemExit(main())
|