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,243 @@
|
|
|
1
|
+
"""Review partial construction and sync_to_async decorator syntax without scoring."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import async_usage as usage
|
|
11
|
+
from async_provenance import Provenance, before, shape, unresolved
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
POLICY = "python/async-callback-adapters-review-v1"
|
|
15
|
+
PARTIAL = "functools.partial"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def expression(node):
|
|
19
|
+
return {"kind": type(node).__name__, "shape": shape(node), **usage.extent(node)}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Adapters(Provenance):
|
|
23
|
+
def partial(self, node, scope, shadowed):
|
|
24
|
+
if not isinstance(node, ast.Call):
|
|
25
|
+
return None
|
|
26
|
+
target, _ = self.bindings.resolve(node.func, scope, shadowed=shadowed)
|
|
27
|
+
if target != PARTIAL:
|
|
28
|
+
return None
|
|
29
|
+
callback = node.args[0] if node.args and not isinstance(node.args[0], ast.Starred) else None
|
|
30
|
+
return {"status": "partial-construction-syntax", "importTarget": target,
|
|
31
|
+
"construction": usage.extent(node),
|
|
32
|
+
"argumentLayout": "first-positional-callback" if callback else "unresolved",
|
|
33
|
+
"callback": None if callback is None else {
|
|
34
|
+
**expression(callback), "reference": self.reference(callback, scope, shadowed)},
|
|
35
|
+
"boundPositionalExpressions": [expression(arg) for arg in node.args[1:]] if callback else None,
|
|
36
|
+
"keywordExpressions": [{"name": kw.arg, "unpacking": kw.arg is None,
|
|
37
|
+
"value": expression(kw.value)} for kw in node.keywords]}
|
|
38
|
+
|
|
39
|
+
def partial_reference(self, node, scope, shadowed, execution, owner_id):
|
|
40
|
+
inline = self.partial(node, scope, shadowed)
|
|
41
|
+
if inline:
|
|
42
|
+
return {"status": "inline-partial-syntax", "constructionCallId":
|
|
43
|
+
usage.fingerprint([owner_id, inline["construction"]]), "partial": inline}
|
|
44
|
+
if not isinstance(node, ast.Name):
|
|
45
|
+
return unresolved("not-a-name-or-matched-partial")
|
|
46
|
+
veto = self.veto(node.id, shadowed)
|
|
47
|
+
if veto:
|
|
48
|
+
return unresolved(veto)
|
|
49
|
+
if execution != "async-body":
|
|
50
|
+
return unresolved("deferred-execution-context")
|
|
51
|
+
if len(scope.bindings.get(node.id, [])) != 1:
|
|
52
|
+
return unresolved("not-a-unique-same-owner-binding")
|
|
53
|
+
assignment = self.assignments[scope].get(node.id)
|
|
54
|
+
if assignment is None or not before(assignment, node):
|
|
55
|
+
return unresolved("no-direct-assignment-before-use")
|
|
56
|
+
partial = self.partial(assignment.value, scope, frozenset())
|
|
57
|
+
if partial is None:
|
|
58
|
+
return unresolved("not-a-matched-partial-assignment")
|
|
59
|
+
if partial["argumentLayout"] != "first-positional-callback":
|
|
60
|
+
return unresolved("unresolved-argument-layout")
|
|
61
|
+
return {"status": "stored-partial-syntax", "name": node.id,
|
|
62
|
+
"assignment": usage.extent(assignment), "constructionCallId":
|
|
63
|
+
usage.fingerprint([owner_id, partial["construction"]]), "partial": partial}
|
|
64
|
+
|
|
65
|
+
def decorated(self, node, scope, shadowed):
|
|
66
|
+
if not isinstance(node, ast.Name):
|
|
67
|
+
return unresolved("non-name-reference")
|
|
68
|
+
veto = self.veto(node.id, shadowed)
|
|
69
|
+
if veto:
|
|
70
|
+
return unresolved(veto)
|
|
71
|
+
while scope:
|
|
72
|
+
if node.id not in scope.bindings:
|
|
73
|
+
scope = scope.parent
|
|
74
|
+
continue
|
|
75
|
+
if len(scope.bindings[node.id]) != 1:
|
|
76
|
+
return unresolved("rebound-or-ambiguous-name")
|
|
77
|
+
definition = self.definitions[scope].get(node.id)
|
|
78
|
+
if definition is None or not before(definition, node):
|
|
79
|
+
return unresolved("no-direct-definition-before-reference")
|
|
80
|
+
if not isinstance(definition, ast.FunctionDef) or len(definition.decorator_list) != 1:
|
|
81
|
+
return unresolved("unsupported-decorated-definition")
|
|
82
|
+
decorator = definition.decorator_list[0]
|
|
83
|
+
constructor = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
84
|
+
target, _ = self.bindings.resolve(constructor, scope)
|
|
85
|
+
if target != usage.WRAPPER:
|
|
86
|
+
return unresolved("unmatched-decorator-import")
|
|
87
|
+
options = []
|
|
88
|
+
if isinstance(decorator, ast.Call):
|
|
89
|
+
if decorator.args or any(kw.arg != "thread_sensitive" or
|
|
90
|
+
not isinstance(kw.value, ast.Constant) or type(kw.value.value) is not bool
|
|
91
|
+
for kw in decorator.keywords) or len(decorator.keywords) > 1:
|
|
92
|
+
return unresolved("unsupported-decorator-arguments")
|
|
93
|
+
options = [{"name": kw.arg, "value": kw.value.value} for kw in decorator.keywords]
|
|
94
|
+
key = usage.digest(f"{self.file}|{definition.lineno}|{definition.col_offset}|FunctionDef")
|
|
95
|
+
owner = self.owners.get(key)
|
|
96
|
+
if owner is None:
|
|
97
|
+
return unresolved("definition-outside-selected-owners")
|
|
98
|
+
return {"status": "decorated-wrapper-syntax", "importTarget": target,
|
|
99
|
+
"definition": {"ownerId": key, "file": self.file, "member": owner["member"],
|
|
100
|
+
"functionKind": "FunctionDef", **usage.extent(definition)},
|
|
101
|
+
"decorator": {**usage.extent(decorator), "form": "factory" if isinstance(decorator, ast.Call)
|
|
102
|
+
else "bare", "options": options}}
|
|
103
|
+
return unresolved("no-local-definition")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AdapterCalls(usage.Calls):
|
|
107
|
+
def __init__(self, bindings, node, owner, adapters):
|
|
108
|
+
super().__init__(bindings, node, owner)
|
|
109
|
+
self.adapters, self.observations = adapters, {}
|
|
110
|
+
|
|
111
|
+
def record(self, node, execution, shadowed, parent):
|
|
112
|
+
super().record(node, execution, shadowed, parent)
|
|
113
|
+
call = self.rows[-1]
|
|
114
|
+
callback = None
|
|
115
|
+
if call["category"] in {"offload-call", "offload-wrapper-construction", "offload-wrapped-invocation"}:
|
|
116
|
+
helper = node.func if call["category"] == "offload-wrapped-invocation" else node
|
|
117
|
+
if helper.args and not isinstance(helper.args[0], ast.Starred):
|
|
118
|
+
callback = helper.args[0]
|
|
119
|
+
# Existing stored wrapper support supplies the construction-time callback.
|
|
120
|
+
callback_shadowed = shadowed
|
|
121
|
+
if isinstance(node.func, ast.Name):
|
|
122
|
+
wrapper, stored = self.adapters.wrapper(node.func, self.scope, shadowed, execution, self.owner["id"])
|
|
123
|
+
if wrapper["status"] == "stored-wrapper-syntax":
|
|
124
|
+
callback, callback_shadowed = stored, frozenset()
|
|
125
|
+
partial_reference = lambda value, names: self.adapters.partial_reference(
|
|
126
|
+
value, self.scope, names, execution, self.owner["id"])
|
|
127
|
+
self.observations[call["id"]] = {
|
|
128
|
+
"callId": call["id"], "executionContext": execution,
|
|
129
|
+
"partialConstruction": self.adapters.partial(node, self.scope, shadowed),
|
|
130
|
+
"partialCallee": partial_reference(node.func, shadowed),
|
|
131
|
+
"decoratedCallee": self.adapters.decorated(node.func, self.scope, shadowed),
|
|
132
|
+
"callbackPartial": None if callback is None else partial_reference(callback, callback_shadowed),
|
|
133
|
+
"callbackDecorator": None if callback is None else self.adapters.decorated(callback, self.scope, callback_shadowed)}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def summary(rows):
|
|
137
|
+
eligible = [row for row in rows if row["status"] == "observed"]
|
|
138
|
+
contexts = {}
|
|
139
|
+
for execution in ("async-body", "deferred-generator-body"):
|
|
140
|
+
calls = [call for row in eligible for call in row["calls"] if call["executionContext"] == execution]
|
|
141
|
+
linked = lambda field, statuses: sum(bool(call[field] and call[field]["status"] in statuses) for call in calls)
|
|
142
|
+
contexts[execution] = None if not eligible else {
|
|
143
|
+
"callExpressions": len(calls),
|
|
144
|
+
"partialConstructions": linked("partialConstruction", {"partial-construction-syntax"}),
|
|
145
|
+
"partialCalleeReferences": linked("partialCallee", {"inline-partial-syntax", "stored-partial-syntax"}),
|
|
146
|
+
"partialCallbackReferences": linked("callbackPartial", {"inline-partial-syntax", "stored-partial-syntax"}),
|
|
147
|
+
"decoratedCalleeReferences": linked("decoratedCallee", {"decorated-wrapper-syntax"}),
|
|
148
|
+
"decoratedCallbackReferences": linked("callbackDecorator", {"decorated-wrapper-syntax"})}
|
|
149
|
+
return {"selectedFunctions": len(rows), "asyncFunctions": len(eligible),
|
|
150
|
+
"excludedSynchronousFunctions": len(rows) - len(eligible),
|
|
151
|
+
"availability": "available" if eligible else "unavailable", "contexts": contexts}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def collect(root, facts, base):
|
|
155
|
+
owners = {row["id"]: row for row in facts["functions"]}
|
|
156
|
+
originals = {row["id"]: row for row in base["functions"]}
|
|
157
|
+
rows = []
|
|
158
|
+
for record in facts["selection"]["files"]:
|
|
159
|
+
if record["disposition"] != "analyzed":
|
|
160
|
+
continue
|
|
161
|
+
bindings = usage.Bindings(ast.parse(usage.read_verified(root, record), filename=record["file"]))
|
|
162
|
+
adapters = Adapters(bindings, owners, record["file"])
|
|
163
|
+
for node in bindings.scopes:
|
|
164
|
+
key = usage.digest(f"{record['file']}|{getattr(node, 'lineno', 0)}|{getattr(node, 'col_offset', 0)}|{type(node).__name__}")
|
|
165
|
+
if key not in owners:
|
|
166
|
+
continue
|
|
167
|
+
original = originals[key]
|
|
168
|
+
row = {name: original[name] for name in ("id", "file", "member", "line", "endLine", "population", "sourceSha256", "status", "reason")}
|
|
169
|
+
row.update(disposition="review-only", calls=[])
|
|
170
|
+
if isinstance(node, ast.AsyncFunctionDef):
|
|
171
|
+
visitor = AdapterCalls(bindings, node, owners[key], adapters)
|
|
172
|
+
measurement = visitor.measure(node)
|
|
173
|
+
if measurement != original["measurement"]:
|
|
174
|
+
raise ValueError("Async call evidence changed during adapter review.")
|
|
175
|
+
row["calls"] = [visitor.observations[call["id"]] for call in measurement["calls"]]
|
|
176
|
+
rows.append(row)
|
|
177
|
+
if len(rows) != len(owners) or {row["id"] for row in rows} != set(owners):
|
|
178
|
+
raise ValueError("Selected owners changed during adapter review.")
|
|
179
|
+
rows.sort(key=lambda row: (row["file"], row["line"], row["id"]))
|
|
180
|
+
return rows, {**summary(rows), "populations": {population: summary([row for row in rows if row["population"] == population])
|
|
181
|
+
for population in sorted({row["population"] for row in rows})}}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def review(target, facts, base):
|
|
185
|
+
configuration = {"policy": POLICY, "asyncConfigurationFingerprint": base["configurationFingerprint"]}
|
|
186
|
+
report = {"schemaVersion": 1, "artifactKind": "experimental-python-async-callback-adapters-review",
|
|
187
|
+
"producer": {"name": "codemetrics-ai/python-async-callback-adapters", "version": "0.1.0-experimental"},
|
|
188
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(), "runId": base["runId"], "auditId": base["auditId"],
|
|
189
|
+
"policy": POLICY, "configuration": configuration, "configurationFingerprint": usage.fingerprint(configuration),
|
|
190
|
+
"subject": base["subject"], "selection": facts["selection"], "sourceFactsProducer": facts["producer"],
|
|
191
|
+
"sourceFactsPolicy": facts["measurementPolicy"], "asyncRowsSha256": usage.fingerprint(base["functions"]),
|
|
192
|
+
"status": "incomplete", "diagnostics": [], "functions": [], "summary": None,
|
|
193
|
+
"scope": "Partial construction and decorator syntax only. No runtime identity, execution, callback-body traversal, successful offload, safety, performance or score claim. Async/Blocking Usage and Error Handling remain unscored."}
|
|
194
|
+
try:
|
|
195
|
+
fresh = usage.review(target, facts, run_id=base["runId"], audit_id=base["auditId"])
|
|
196
|
+
stable = lambda value: {key: item for key, item in value.items() if key != "generatedAtUtc"}
|
|
197
|
+
if fresh["status"] != "complete" or stable(fresh) != stable(base):
|
|
198
|
+
raise ValueError("Async input is incomplete, stale, or inconsistent; collect fresh evidence.")
|
|
199
|
+
target = Path(target).absolute()
|
|
200
|
+
rows, counts = collect(target if target.is_dir() else target.parent, facts, base)
|
|
201
|
+
report.update(status="complete", functions=rows, summary=counts)
|
|
202
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError, KeyError, TypeError) as error:
|
|
203
|
+
report["diagnostics"].append({"code": "async-adapter-analysis", "message": str(error)})
|
|
204
|
+
return report
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def analyze(target, *, run_id=None, audit_id=None, **selection):
|
|
208
|
+
facts = usage.source_facts.analyze(target, **selection)
|
|
209
|
+
base = usage.review(target, facts, run_id=run_id, audit_id=audit_id)
|
|
210
|
+
return review(target, facts, base)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main():
|
|
214
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
215
|
+
parser.add_argument("target")
|
|
216
|
+
parser.add_argument("--output", type=Path, help="New .json file with an existing parent")
|
|
217
|
+
parser.add_argument("--run-id")
|
|
218
|
+
parser.add_argument("--audit-id")
|
|
219
|
+
for name in ("include", "exclude", "test-path", "production-path"):
|
|
220
|
+
parser.add_argument(f"--{name}", action="append", default=[])
|
|
221
|
+
for name in ("include-tests", "include-generated", "include-support"):
|
|
222
|
+
parser.add_argument(f"--{name}", action="store_true")
|
|
223
|
+
args = parser.parse_args()
|
|
224
|
+
try:
|
|
225
|
+
report = analyze(args.target, run_id=args.run_id, audit_id=args.audit_id, include=args.include, exclude=args.exclude,
|
|
226
|
+
test_paths=args.test_path, production_paths=args.production_path, include_tests=args.include_tests,
|
|
227
|
+
include_generated=args.include_generated, include_support=args.include_support)
|
|
228
|
+
payload = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
|
|
229
|
+
if args.output:
|
|
230
|
+
if args.output.suffix != ".json":
|
|
231
|
+
raise ValueError("Output must be a new .json file.")
|
|
232
|
+
with args.output.open("x", encoding="utf-8") as stream:
|
|
233
|
+
stream.write(payload)
|
|
234
|
+
else:
|
|
235
|
+
sys.stdout.write(payload)
|
|
236
|
+
return 0 if report["status"] == "complete" else 2
|
|
237
|
+
except (OSError, ValueError) as error:
|
|
238
|
+
print(f"Cannot produce async adapter review: {error}", file=sys.stderr)
|
|
239
|
+
return 2
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Conservative lexical import matching, not runtime identity or type inference."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DEFINITIONS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
|
|
8
|
+
COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def dotted(node):
|
|
12
|
+
if isinstance(node, ast.Name):
|
|
13
|
+
return node.id
|
|
14
|
+
if isinstance(node, ast.Attribute):
|
|
15
|
+
prefix = dotted(node.value)
|
|
16
|
+
return f"{prefix}.{node.attr}" if prefix else None
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Scope(ast.NodeVisitor):
|
|
21
|
+
def __init__(self, node, parent):
|
|
22
|
+
self.node, self.parent = node, parent
|
|
23
|
+
self.bindings = defaultdict(list)
|
|
24
|
+
self.mutations = []
|
|
25
|
+
self.redirected = set()
|
|
26
|
+
self.dynamic = False
|
|
27
|
+
body = node.body if not isinstance(node, ast.Lambda) else [node.body]
|
|
28
|
+
self.direct = set(body)
|
|
29
|
+
if hasattr(node, "args"):
|
|
30
|
+
args = node.args
|
|
31
|
+
for child in [*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg]:
|
|
32
|
+
if child is not None:
|
|
33
|
+
self.bindings[child.arg].append(None)
|
|
34
|
+
for child in body:
|
|
35
|
+
self.visit(child)
|
|
36
|
+
for parameter in getattr(node, "type_params", []):
|
|
37
|
+
self.bindings[parameter.name].append(None)
|
|
38
|
+
|
|
39
|
+
def visit_Name(self, node):
|
|
40
|
+
if isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
41
|
+
self.bindings[node.id].append(None)
|
|
42
|
+
|
|
43
|
+
def visit_Attribute(self, node):
|
|
44
|
+
if isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
45
|
+
self.mutations.append(node)
|
|
46
|
+
self.generic_visit(node)
|
|
47
|
+
|
|
48
|
+
def visit_Import(self, node):
|
|
49
|
+
for alias in node.names:
|
|
50
|
+
name = alias.asname or alias.name.split(".")[0]
|
|
51
|
+
target = alias.name if alias.asname else name
|
|
52
|
+
self.bindings[name].append((target, node.lineno) if node in self.direct else None)
|
|
53
|
+
|
|
54
|
+
def visit_ImportFrom(self, node):
|
|
55
|
+
for alias in node.names:
|
|
56
|
+
if alias.name == "*":
|
|
57
|
+
self.dynamic = True
|
|
58
|
+
continue
|
|
59
|
+
target = f"{node.module}.{alias.name}"
|
|
60
|
+
self.bindings[alias.asname or alias.name].append(
|
|
61
|
+
(target, node.lineno) if node in self.direct and not node.level else None)
|
|
62
|
+
|
|
63
|
+
def visit_FunctionDef(self, node):
|
|
64
|
+
self.bindings[node.name].append(None)
|
|
65
|
+
# Headers execute in this scope, but are not owned-body call evidence.
|
|
66
|
+
for child in [*node.decorator_list, *node.args.defaults, *node.args.kw_defaults]:
|
|
67
|
+
if child is not None:
|
|
68
|
+
self.visit(child)
|
|
69
|
+
|
|
70
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
71
|
+
|
|
72
|
+
def visit_ClassDef(self, node):
|
|
73
|
+
self.bindings[node.name].append(None)
|
|
74
|
+
for child in [*node.decorator_list, *node.bases, *node.keywords]:
|
|
75
|
+
self.visit(child)
|
|
76
|
+
|
|
77
|
+
def visit_Lambda(self, node):
|
|
78
|
+
for child in [*node.args.defaults, *node.args.kw_defaults]:
|
|
79
|
+
if child is not None:
|
|
80
|
+
self.visit(child)
|
|
81
|
+
|
|
82
|
+
def visit_ExceptHandler(self, node):
|
|
83
|
+
if node.name:
|
|
84
|
+
self.bindings[node.name].append(None)
|
|
85
|
+
self.generic_visit(node)
|
|
86
|
+
|
|
87
|
+
def visit_MatchAs(self, node):
|
|
88
|
+
if node.name:
|
|
89
|
+
self.bindings[node.name].append(None)
|
|
90
|
+
self.generic_visit(node)
|
|
91
|
+
|
|
92
|
+
visit_MatchStar = visit_MatchAs
|
|
93
|
+
|
|
94
|
+
def visit_MatchMapping(self, node):
|
|
95
|
+
if node.rest:
|
|
96
|
+
self.bindings[node.rest].append(None)
|
|
97
|
+
self.generic_visit(node)
|
|
98
|
+
|
|
99
|
+
def visit_Global(self, node):
|
|
100
|
+
self.redirected.update(node.names)
|
|
101
|
+
|
|
102
|
+
visit_Nonlocal = visit_Global
|
|
103
|
+
|
|
104
|
+
def visit_Call(self, node):
|
|
105
|
+
if isinstance(node.func, ast.Name) and node.func.id in {"exec", "eval", "globals", "locals", "setattr", "delattr"}:
|
|
106
|
+
self.dynamic = True
|
|
107
|
+
self.generic_visit(node)
|
|
108
|
+
|
|
109
|
+
def visit_ListComp(self, node):
|
|
110
|
+
# Iteration variables are comprehension-local; walrus bindings are not.
|
|
111
|
+
for generator in node.generators:
|
|
112
|
+
self.visit(generator.iter)
|
|
113
|
+
for condition in generator.ifs:
|
|
114
|
+
self.visit(condition)
|
|
115
|
+
for field in ("elt", "key", "value"):
|
|
116
|
+
if hasattr(node, field):
|
|
117
|
+
self.visit(getattr(node, field))
|
|
118
|
+
|
|
119
|
+
visit_SetComp = visit_ListComp
|
|
120
|
+
visit_DictComp = visit_ListComp
|
|
121
|
+
visit_GeneratorExp = visit_ListComp
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Bindings(ast.NodeVisitor):
|
|
125
|
+
def __init__(self, tree):
|
|
126
|
+
self.scopes = {}
|
|
127
|
+
self.current = None
|
|
128
|
+
self.visit(tree)
|
|
129
|
+
# Cross-scope writes and reflective syntax veto matching conservatively.
|
|
130
|
+
self.redirected = set().union(*(scope.redirected for scope in self.scopes.values()))
|
|
131
|
+
self.dynamic = any(scope.dynamic for scope in self.scopes.values())
|
|
132
|
+
self.mutated_modules = set()
|
|
133
|
+
for scope in self.scopes.values():
|
|
134
|
+
for node in scope.mutations:
|
|
135
|
+
target, _ = self.resolve(node, scope, mutations=False)
|
|
136
|
+
if target:
|
|
137
|
+
self.mutated_modules.add(target.split(".")[0])
|
|
138
|
+
|
|
139
|
+
def visit_Module(self, node):
|
|
140
|
+
previous = self.current
|
|
141
|
+
parent = previous
|
|
142
|
+
if not isinstance(node, ast.ClassDef):
|
|
143
|
+
while parent and isinstance(parent.node, ast.ClassDef):
|
|
144
|
+
parent = parent.parent
|
|
145
|
+
self.current = Scope(node, parent)
|
|
146
|
+
self.scopes[node] = self.current
|
|
147
|
+
# Only body scopes are measured; definition headers aren't new owners.
|
|
148
|
+
body = [node.body] if isinstance(node, ast.Lambda) else node.body
|
|
149
|
+
for child in body:
|
|
150
|
+
self.visit(child)
|
|
151
|
+
self.current = previous
|
|
152
|
+
|
|
153
|
+
visit_FunctionDef = visit_Module
|
|
154
|
+
visit_AsyncFunctionDef = visit_Module
|
|
155
|
+
visit_ClassDef = visit_Module
|
|
156
|
+
visit_Lambda = visit_Module
|
|
157
|
+
|
|
158
|
+
def resolve(self, node, scope, *, shadowed=frozenset(), mutations=True):
|
|
159
|
+
path = dotted(node)
|
|
160
|
+
if path is None:
|
|
161
|
+
return None, "dynamic-callee-expression"
|
|
162
|
+
root, *suffix = path.split(".")
|
|
163
|
+
if root in shadowed:
|
|
164
|
+
return None, "comprehension-local-binding"
|
|
165
|
+
if self.dynamic or root in self.redirected:
|
|
166
|
+
return None, "reflective-or-cross-scope-binding"
|
|
167
|
+
while scope:
|
|
168
|
+
if root in scope.bindings:
|
|
169
|
+
entries = scope.bindings[root]
|
|
170
|
+
if len(entries) != 1 or entries[0] is None:
|
|
171
|
+
return None, "non-import-or-ambiguous-binding"
|
|
172
|
+
target, line = entries[0]
|
|
173
|
+
if node.lineno <= line:
|
|
174
|
+
return None, "import-not-before-use"
|
|
175
|
+
target = ".".join([target, *suffix])
|
|
176
|
+
if mutations and target.split(".")[0] in self.mutated_modules:
|
|
177
|
+
return None, "imported-module-attribute-write"
|
|
178
|
+
return target, "import-spelling-only"
|
|
179
|
+
scope = scope.parent
|
|
180
|
+
return None, "no-lexical-import"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Review unresolved async-call shapes and bounded source links, without scores."""
|
|
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
|
+
|
|
11
|
+
import async_usage as usage
|
|
12
|
+
from async_provenance import ContextCalls, Provenance
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
POLICY = "python/async-call-context-review-v1"
|
|
16
|
+
MAX_EXAMPLES = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def inventory(rows, context):
|
|
20
|
+
groups = defaultdict(list)
|
|
21
|
+
for row in rows:
|
|
22
|
+
for call in row["calls"]:
|
|
23
|
+
if call["executionContext"] == context and call["unresolvedReason"] is not None:
|
|
24
|
+
groups[(call["unresolvedReason"], call["shape"])].append({"ownerId": row["id"], "callId": call["callId"]})
|
|
25
|
+
return [{"reason": reason, "shape": shape, "callExpressions": len(items), "examples": items[:MAX_EXAMPLES]}
|
|
26
|
+
for (reason, shape), items in sorted(groups.items())]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def population_summary(rows):
|
|
30
|
+
eligible = [row for row in rows if row["status"] == "observed"]
|
|
31
|
+
calls = [call for row in eligible for call in row["calls"]]
|
|
32
|
+
contexts = {}
|
|
33
|
+
for context in ("async-body", "deferred-generator-body"):
|
|
34
|
+
selected = [call for call in calls if call["executionContext"] == context]
|
|
35
|
+
unmatched = [call for call in selected if call["unresolvedReason"] is not None]
|
|
36
|
+
contexts[context] = None if not eligible else {
|
|
37
|
+
"callExpressions": len(selected), "unresolvedImportCallExpressions": len(unmatched),
|
|
38
|
+
"unresolvedReasons": dict(sorted(Counter(call["unresolvedReason"] for call in unmatched).items())),
|
|
39
|
+
"unresolvedShapes": dict(sorted(Counter(call["shape"] for call in unmatched).items())),
|
|
40
|
+
"unresolvedInventory": inventory(eligible, context),
|
|
41
|
+
"localCalleeLinks": sum(bool(call["localCallee"] and call["localCallee"]["status"] == "local-definition") for call in selected),
|
|
42
|
+
"storedWrapperInvocationLinks": sum(bool(call["storedWrapper"] and call["storedWrapper"]["status"] == "stored-wrapper-syntax") for call in selected),
|
|
43
|
+
"localCallbackReferenceLinks": sum(bool(call["callback"] and call["callback"]["reference"]["status"] == "local-definition") for call in selected)}
|
|
44
|
+
return {"selectedFunctions": len(rows), "asyncFunctions": len(eligible),
|
|
45
|
+
"excludedSynchronousFunctions": len(rows) - len(eligible),
|
|
46
|
+
"availability": "available" if eligible else "unavailable", "contexts": contexts}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def collect(root, facts, base):
|
|
50
|
+
owners = {row["id"]: row for row in facts["functions"]}
|
|
51
|
+
prior = {row["id"]: row for row in base["functions"]}
|
|
52
|
+
rows = []
|
|
53
|
+
for record in facts["selection"]["files"]:
|
|
54
|
+
if record["disposition"] != "analyzed":
|
|
55
|
+
continue
|
|
56
|
+
tree = ast.parse(usage.read_verified(root, record), filename=record["file"])
|
|
57
|
+
bindings = usage.Bindings(tree)
|
|
58
|
+
provenance = Provenance(bindings, owners, record["file"])
|
|
59
|
+
for node in bindings.scopes:
|
|
60
|
+
if not isinstance(node, usage.source_facts.OWNERS):
|
|
61
|
+
continue
|
|
62
|
+
key = usage.digest(f"{record['file']}|{node.lineno}|{node.col_offset}|{type(node).__name__}")
|
|
63
|
+
owner = owners.get(key)
|
|
64
|
+
if owner is None:
|
|
65
|
+
continue
|
|
66
|
+
original = prior[key]
|
|
67
|
+
row = {name: original[name] for name in ("id", "file", "member", "line", "endLine", "population", "sourceSha256", "status", "reason")}
|
|
68
|
+
row.update(disposition="review-only", calls=[])
|
|
69
|
+
if isinstance(node, ast.AsyncFunctionDef):
|
|
70
|
+
measurement, row["calls"] = ContextCalls(bindings, node, owner, provenance).measure(node)
|
|
71
|
+
if measurement != original["measurement"]:
|
|
72
|
+
raise ValueError("Async ownership/call evidence changed during context review.")
|
|
73
|
+
rows.append(row)
|
|
74
|
+
if len(rows) != len(owners) or {row["id"] for row in rows} != set(owners):
|
|
75
|
+
raise ValueError("Selected owners changed during async context review.")
|
|
76
|
+
rows.sort(key=lambda row: (row["file"], row["line"], row["id"]))
|
|
77
|
+
return rows, {**population_summary(rows), "populations": {
|
|
78
|
+
population: population_summary([row for row in rows if row["population"] == population])
|
|
79
|
+
for population in sorted({row["population"] for row in rows})}}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def review(target, facts, base):
|
|
83
|
+
configuration = {"policy": POLICY, "asyncConfigurationFingerprint": base["configurationFingerprint"],
|
|
84
|
+
"maxExamplesPerGroup": MAX_EXAMPLES}
|
|
85
|
+
report = {"schemaVersion": 1, "artifactKind": "experimental-python-async-call-context-review",
|
|
86
|
+
"producer": {"name": "codemetrics-ai/python-async-call-context", "version": "0.1.0-experimental"},
|
|
87
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(), "runId": base["runId"], "auditId": base["auditId"],
|
|
88
|
+
"policy": POLICY, "configuration": configuration, "configurationFingerprint": usage.fingerprint(configuration),
|
|
89
|
+
"subject": base["subject"], "selection": facts["selection"], "sourceFactsProducer": facts["producer"],
|
|
90
|
+
"sourceFactsPolicy": facts["measurementPolicy"], "asyncRowsSha256": usage.fingerprint(base["functions"]),
|
|
91
|
+
"status": "incomplete", "diagnostics": [], "functions": [], "summary": None,
|
|
92
|
+
"scope": "Unresolved import-call inventory and bounded lexical source links. No execution, runtime identity, callback-body traversal, offload-success, safety, defect, or score claim. Both Async/Blocking Usage and Error Handling remain unscored."}
|
|
93
|
+
try:
|
|
94
|
+
fresh = usage.review(target, facts, run_id=base["runId"], audit_id=base["auditId"])
|
|
95
|
+
stable = lambda value: {key: item for key, item in value.items() if key != "generatedAtUtc"}
|
|
96
|
+
if fresh["status"] != "complete" or stable(fresh) != stable(base):
|
|
97
|
+
raise ValueError("Async input is incomplete, stale, or inconsistent; collect fresh evidence.")
|
|
98
|
+
target = Path(target).absolute()
|
|
99
|
+
root = target if target.is_dir() else target.parent
|
|
100
|
+
functions, summary = collect(root, facts, base)
|
|
101
|
+
report.update(status="complete", functions=functions, summary=summary)
|
|
102
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError, KeyError, TypeError) as error:
|
|
103
|
+
report["diagnostics"].append({"code": "async-context-analysis", "message": str(error)})
|
|
104
|
+
return report
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def analyze(target, *, run_id=None, audit_id=None, **selection):
|
|
108
|
+
facts = usage.source_facts.analyze(target, **selection)
|
|
109
|
+
base = usage.review(target, facts, run_id=run_id, audit_id=audit_id)
|
|
110
|
+
return review(target, facts, base)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main():
|
|
114
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
115
|
+
parser.add_argument("target")
|
|
116
|
+
parser.add_argument("--output", type=Path, help="New .json file with an existing parent")
|
|
117
|
+
parser.add_argument("--run-id")
|
|
118
|
+
parser.add_argument("--audit-id")
|
|
119
|
+
for name in ("include", "exclude", "test-path", "production-path"):
|
|
120
|
+
parser.add_argument(f"--{name}", action="append", default=[])
|
|
121
|
+
for name in ("include-tests", "include-generated", "include-support"):
|
|
122
|
+
parser.add_argument(f"--{name}", action="store_true")
|
|
123
|
+
args = parser.parse_args()
|
|
124
|
+
try:
|
|
125
|
+
report = analyze(args.target, run_id=args.run_id, audit_id=args.audit_id, include=args.include, exclude=args.exclude,
|
|
126
|
+
test_paths=args.test_path, production_paths=args.production_path, include_tests=args.include_tests,
|
|
127
|
+
include_generated=args.include_generated, include_support=args.include_support)
|
|
128
|
+
payload = json.dumps(report, indent=2, ensure_ascii=True) + "\n"
|
|
129
|
+
if args.output:
|
|
130
|
+
if args.output.suffix != ".json":
|
|
131
|
+
raise ValueError("Output must be a new .json file.")
|
|
132
|
+
with args.output.open("x", encoding="utf-8") as stream:
|
|
133
|
+
stream.write(payload)
|
|
134
|
+
else:
|
|
135
|
+
sys.stdout.write(payload)
|
|
136
|
+
return 0 if report["status"] == "complete" else 2
|
|
137
|
+
except (OSError, ValueError) as error:
|
|
138
|
+
print(f"Cannot produce async context review: {error}", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
raise SystemExit(main())
|