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,186 @@
|
|
|
1
|
+
"""Trace bounded local synchronous helpers with explicit callback execution context."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from async_adapters import Adapters, AdapterCalls
|
|
9
|
+
from async_provenance import ContextCalls
|
|
10
|
+
import async_usage as usage
|
|
11
|
+
from review_cli import run
|
|
12
|
+
|
|
13
|
+
POLICY = "python/async-local-helper-paths-review-v1"
|
|
14
|
+
MAX_DEPTH = 3
|
|
15
|
+
MAX_STEPS = 100000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def partial_callback(value):
|
|
19
|
+
if value and value["status"] in {"inline-partial-syntax", "stored-partial-syntax"}:
|
|
20
|
+
callback = value["partial"]["callback"]
|
|
21
|
+
return callback["reference"] if callback else None
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FilePaths:
|
|
26
|
+
def __init__(self, tree, owners, file, budget):
|
|
27
|
+
self.bindings = usage.Bindings(tree)
|
|
28
|
+
self.owners, self.file, self.budget = owners, file, budget
|
|
29
|
+
self.provenance = Adapters(self.bindings, owners, file)
|
|
30
|
+
self.nodes, self.cache = {}, {}
|
|
31
|
+
for node in self.bindings.scopes:
|
|
32
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
33
|
+
key = usage.digest(f"{file}|{node.lineno}|{node.col_offset}|{type(node).__name__}")
|
|
34
|
+
if key in owners:
|
|
35
|
+
self.nodes[key] = node
|
|
36
|
+
|
|
37
|
+
def spend(self, amount=1):
|
|
38
|
+
self.budget[0] += amount
|
|
39
|
+
if self.budget[0] > MAX_STEPS:
|
|
40
|
+
raise ValueError("Local helper measurement/traversal budget exceeded.")
|
|
41
|
+
|
|
42
|
+
def measure(self, key):
|
|
43
|
+
if key not in self.cache:
|
|
44
|
+
node, owner = self.nodes[key], self.owners[key]
|
|
45
|
+
contextual = ContextCalls(self.bindings, node, owner, self.provenance)
|
|
46
|
+
measurement, contexts = contextual.measure(node)
|
|
47
|
+
adapter = AdapterCalls(self.bindings, node, owner, self.provenance)
|
|
48
|
+
if adapter.measure(node) != measurement:
|
|
49
|
+
raise ValueError("Local helper call measurements disagree.")
|
|
50
|
+
self.spend(len(measurement["calls"]))
|
|
51
|
+
self.cache[key] = measurement, {row["callId"]: row for row in contexts}, adapter.observations
|
|
52
|
+
return self.cache[key]
|
|
53
|
+
|
|
54
|
+
def follow(self, reference, path, execution, visited, traces):
|
|
55
|
+
if reference and reference["status"] == "import-spelling" and reference.get("target") in usage.BLOCKING:
|
|
56
|
+
traces.append({"path": path, "executionContext": execution, "status": "blocking-candidate",
|
|
57
|
+
"reason": "catalogue-callback-import-spelling", "target": reference["target"]})
|
|
58
|
+
return
|
|
59
|
+
if not reference or reference["status"] not in {"local-definition", "decorated-wrapper-syntax"}:
|
|
60
|
+
traces.append({"path": path, "executionContext": execution, "status": "unresolved",
|
|
61
|
+
"reason": (reference or {}).get("reason", "unsupported-callback"), "target": None})
|
|
62
|
+
return
|
|
63
|
+
key = reference["definition"]["ownerId"]
|
|
64
|
+
reason = None
|
|
65
|
+
if key in visited:
|
|
66
|
+
reason = "recursive-or-repeated-owner"
|
|
67
|
+
elif len(visited) - 1 >= MAX_DEPTH:
|
|
68
|
+
reason = "helper-depth-limit"
|
|
69
|
+
elif not isinstance(self.nodes.get(key), ast.FunctionDef):
|
|
70
|
+
reason = "async-or-unselected-callee"
|
|
71
|
+
elif self.measure(key)[0]["kind"] == "async-generator":
|
|
72
|
+
# Calls.measure also notices yields in synchronous functions. Calling
|
|
73
|
+
# such a function constructs a generator; it does not run its body.
|
|
74
|
+
reason = "generator-body-deferred"
|
|
75
|
+
if reason:
|
|
76
|
+
traces.append({"path": path, "executionContext": execution, "status": "unresolved", "reason": reason, "target": None})
|
|
77
|
+
return
|
|
78
|
+
traces.append({"path": path, "executionContext": execution, "status": "local-helper-link",
|
|
79
|
+
"reason": "bounded-lexical-definition", "target": None, "targetOwnerId": key})
|
|
80
|
+
self.walk(key, path, execution, visited | {key}, traces)
|
|
81
|
+
|
|
82
|
+
def walk(self, key, prefix, execution, visited, traces):
|
|
83
|
+
measurement, contexts, adapters = self.measure(key)
|
|
84
|
+
for call in measurement["calls"]:
|
|
85
|
+
self.spend()
|
|
86
|
+
lexical, adapter = contexts[call["id"]], adapters[call["id"]]
|
|
87
|
+
mode = "deferred-generator-body" if call["executionContext"] == "deferred-generator-body" else execution
|
|
88
|
+
owner = self.owners[key]
|
|
89
|
+
path = prefix + [{"ownerId": key, "callId": call["id"], "file": self.file,
|
|
90
|
+
"member": owner["member"], **{name: call[name] for name in ("line", "column", "endLine", "endColumn")}}]
|
|
91
|
+
if call["category"] == "blocking-call-candidate":
|
|
92
|
+
traces.append({"path": path, "executionContext": mode, "status": "blocking-candidate",
|
|
93
|
+
"reason": "catalogue-import-spelling", "target": call["binding"]["target"]})
|
|
94
|
+
continue
|
|
95
|
+
decorated = adapter["decoratedCallee"]
|
|
96
|
+
if decorated["status"] == "decorated-wrapper-syntax":
|
|
97
|
+
self.follow(decorated, path, self.callback_mode(mode), visited, traces)
|
|
98
|
+
continue
|
|
99
|
+
local = lexical["localCallee"]
|
|
100
|
+
partial = partial_callback(adapter["partialCallee"])
|
|
101
|
+
if local and local["status"] == "local-definition":
|
|
102
|
+
self.follow(local, path, mode, visited, traces)
|
|
103
|
+
elif partial:
|
|
104
|
+
self.follow(partial, path, mode, visited, traces)
|
|
105
|
+
elif call["category"] == "unresolved-call" and not (lexical["storedWrapper"] and
|
|
106
|
+
lexical["storedWrapper"]["status"] == "stored-wrapper-syntax"):
|
|
107
|
+
traces.append({"path": path, "executionContext": mode, "status": "unresolved",
|
|
108
|
+
"reason": (local or {}).get("reason", lexical["unresolvedReason"]), "target": None})
|
|
109
|
+
if lexical["callback"]:
|
|
110
|
+
callback = partial_callback(adapter["callbackPartial"]) or lexical["callback"]["reference"]
|
|
111
|
+
# Constructor callbacks are only references, never evidence of an invocation.
|
|
112
|
+
callback_mode = "wrapper-callback-reference" if call["category"] == "offload-wrapper-construction" else self.callback_mode(mode)
|
|
113
|
+
self.follow(callback, path, callback_mode, visited, traces)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def callback_mode(mode):
|
|
117
|
+
return "thread-helper-callback" if mode == "async-body" else mode
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def summarize(rows):
|
|
121
|
+
def counts(items):
|
|
122
|
+
traces = [trace for row in items for trace in row["traces"]]
|
|
123
|
+
blocking = [trace for trace in traces if trace["status"] == "blocking-candidate"]
|
|
124
|
+
return {"asyncFunctions": len(items), "availability": "available" if items else "unavailable",
|
|
125
|
+
"functionsWithDirectOrHelperCandidates": sum(any(trace["status"] == "blocking-candidate" and
|
|
126
|
+
trace["executionContext"] == "async-body" for trace in row["traces"]) for row in items) if items else None,
|
|
127
|
+
"candidatePathsByContext": dict(sorted(Counter(trace["executionContext"] for trace in blocking).items())),
|
|
128
|
+
"unresolvedPathsByReason": dict(sorted(Counter(trace["reason"] for trace in traces if trace["status"] == "unresolved").items())),
|
|
129
|
+
"candidatePaths": len(blocking),
|
|
130
|
+
"localHelperLinks": sum(trace["status"] == "local-helper-link" for trace in traces),
|
|
131
|
+
"distinctBlockingSourceCalls": len({trace["path"][-1]["callId"] for trace in blocking})}
|
|
132
|
+
return {**counts(rows), "populations": {population: counts([row for row in rows if row["population"] == population])
|
|
133
|
+
for population in sorted({row["population"] for row in rows})}}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def review(target, facts, base):
|
|
137
|
+
configuration = {"policy": POLICY, "asyncConfigurationFingerprint": base["configurationFingerprint"],
|
|
138
|
+
"maxHelperDepth": MAX_DEPTH, "maxSteps": MAX_STEPS}
|
|
139
|
+
report = {"schemaVersion": 1, "artifactKind": "experimental-python-async-helper-paths",
|
|
140
|
+
"producer": {"name": "codemetrics-ai/python-async-helper-paths", "version": "0.1.0-experimental"},
|
|
141
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(), "runId": base["runId"], "auditId": base["auditId"],
|
|
142
|
+
"policy": POLICY, "configuration": configuration, "configurationFingerprint": usage.fingerprint(configuration),
|
|
143
|
+
"asyncRowsSha256": usage.fingerprint(base["functions"]), "subject": base["subject"], "selection": facts["selection"],
|
|
144
|
+
"sourceFactsProducer": facts["producer"], "sourceFactsPolicy": facts["measurementPolicy"],
|
|
145
|
+
"status": "incomplete", "diagnostics": [], "functions": [], "summary": None,
|
|
146
|
+
"scope": "Bounded same-file lexical synchronous helper paths and callback context. Import spelling, not runtime identity, reachability, successful offloading or performance. Both dimensions remain unscored."}
|
|
147
|
+
try:
|
|
148
|
+
fresh = usage.review(target, facts, run_id=base["runId"], audit_id=base["auditId"])
|
|
149
|
+
stable = lambda value: {key: item for key, item in value.items() if key != "generatedAtUtc"}
|
|
150
|
+
if fresh["status"] != "complete" or stable(fresh) != stable(base):
|
|
151
|
+
raise ValueError("Async input is incomplete, stale, or inconsistent.")
|
|
152
|
+
target = Path(target).absolute()
|
|
153
|
+
root = target if target.is_dir() else target.parent
|
|
154
|
+
owners = {row["id"]: row for row in facts["functions"]}
|
|
155
|
+
originals = {row["id"]: row for row in base["functions"] if row["status"] == "observed"}
|
|
156
|
+
rows, budget = [], [0]
|
|
157
|
+
for record in facts["selection"]["files"]:
|
|
158
|
+
if record["disposition"] != "analyzed":
|
|
159
|
+
continue
|
|
160
|
+
paths = FilePaths(ast.parse(usage.read_verified(root, record)), owners, record["file"], budget)
|
|
161
|
+
for key, node in paths.nodes.items():
|
|
162
|
+
if not isinstance(node, ast.AsyncFunctionDef):
|
|
163
|
+
continue
|
|
164
|
+
if paths.measure(key)[0] != originals[key]["measurement"]:
|
|
165
|
+
raise ValueError("Original async calls changed during helper analysis.")
|
|
166
|
+
traces = []
|
|
167
|
+
paths.walk(key, [], "async-body", {key}, traces)
|
|
168
|
+
row = {name: originals[key][name] for name in ("id", "file", "member", "line", "population", "sourceSha256")}
|
|
169
|
+
rows.append({**row, "traces": traces, "disposition": "review-only"})
|
|
170
|
+
if {row["id"] for row in rows} != set(originals):
|
|
171
|
+
raise ValueError("Async owners changed during helper analysis.")
|
|
172
|
+
rows.sort(key=lambda row: (row["file"], row["line"], row["id"]))
|
|
173
|
+
report.update(status="complete", functions=rows, summary=summarize(rows))
|
|
174
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError, KeyError, TypeError) as error:
|
|
175
|
+
report["diagnostics"].append({"code": "async-helper-analysis", "message": str(error)})
|
|
176
|
+
return report
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def analyze(target, *, run_id=None, audit_id=None, **selection):
|
|
180
|
+
facts = usage.source_facts.analyze(target, **selection)
|
|
181
|
+
base = usage.review(target, facts, run_id=run_id, audit_id=audit_id)
|
|
182
|
+
return review(target, facts, base)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
raise SystemExit(run(analyze, __doc__))
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Bounded source links for async calls; never follow or execute callback bodies."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
from async_bindings import dotted
|
|
6
|
+
import async_usage as usage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def shape(node):
|
|
10
|
+
if isinstance(node, ast.Name):
|
|
11
|
+
return "name"
|
|
12
|
+
if isinstance(node, ast.Attribute):
|
|
13
|
+
return "attribute-path" if dotted(node) else "attribute-on-expression"
|
|
14
|
+
return {ast.Call: "call-result", ast.Subscript: "subscript", ast.Lambda: "lambda"}.get(type(node), "other-expression")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def unresolved(reason):
|
|
18
|
+
return {"status": "unresolved", "reason": reason, "definition": None}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def before(definition, use):
|
|
22
|
+
return (definition.end_lineno, definition.end_col_offset) < (use.lineno, use.col_offset)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Provenance:
|
|
26
|
+
def __init__(self, bindings, owners, file):
|
|
27
|
+
self.bindings, self.owners, self.file = bindings, owners, file
|
|
28
|
+
self.definitions, self.assignments = {}, {}
|
|
29
|
+
for scope in bindings.scopes.values():
|
|
30
|
+
self.definitions[scope] = {node.name: node for node in scope.direct
|
|
31
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))}
|
|
32
|
+
self.assignments[scope] = {node.targets[0].id: node for node in scope.direct
|
|
33
|
+
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name)}
|
|
34
|
+
|
|
35
|
+
def veto(self, name, shadowed):
|
|
36
|
+
if name in shadowed:
|
|
37
|
+
return "comprehension-local-binding"
|
|
38
|
+
if self.bindings.dynamic or name in self.bindings.redirected:
|
|
39
|
+
return "reflective-or-cross-scope-binding"
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def reference(self, node, scope, shadowed):
|
|
43
|
+
# An import match is still only spelling; a local link is only a declaration.
|
|
44
|
+
target, reason = self.bindings.resolve(node, scope, shadowed=shadowed)
|
|
45
|
+
if target:
|
|
46
|
+
return {"status": "import-spelling", "reason": reason, "target": target, "definition": None}
|
|
47
|
+
if not isinstance(node, ast.Name):
|
|
48
|
+
return unresolved("non-name-reference")
|
|
49
|
+
veto = self.veto(node.id, shadowed)
|
|
50
|
+
if veto:
|
|
51
|
+
return unresolved(veto)
|
|
52
|
+
while scope:
|
|
53
|
+
if node.id in scope.bindings:
|
|
54
|
+
if len(scope.bindings[node.id]) != 1:
|
|
55
|
+
return unresolved("rebound-or-ambiguous-name")
|
|
56
|
+
definition = self.definitions[scope].get(node.id)
|
|
57
|
+
if definition is None:
|
|
58
|
+
return unresolved("not-a-direct-function-definition")
|
|
59
|
+
if definition.decorator_list:
|
|
60
|
+
return unresolved("decorated-definition")
|
|
61
|
+
if not before(definition, node):
|
|
62
|
+
return unresolved("definition-not-before-reference")
|
|
63
|
+
key = usage.digest(f"{self.file}|{definition.lineno}|{definition.col_offset}|{type(definition).__name__}")
|
|
64
|
+
owner = self.owners.get(key)
|
|
65
|
+
if owner is None:
|
|
66
|
+
return unresolved("definition-outside-selected-owners")
|
|
67
|
+
return {"status": "local-definition", "reason": "unique-direct-lexical-definition",
|
|
68
|
+
"definition": {"ownerId": key, "file": self.file, "member": owner["member"],
|
|
69
|
+
"functionKind": type(definition).__name__, **usage.extent(definition)}}
|
|
70
|
+
scope = scope.parent
|
|
71
|
+
return unresolved("no-local-definition")
|
|
72
|
+
|
|
73
|
+
def wrapper(self, node, scope, shadowed, context, owner_id):
|
|
74
|
+
veto = self.veto(node.id, shadowed)
|
|
75
|
+
if veto:
|
|
76
|
+
return unresolved(veto), None
|
|
77
|
+
if context != "async-body":
|
|
78
|
+
return unresolved("deferred-execution-context"), None
|
|
79
|
+
if len(scope.bindings.get(node.id, [])) != 1:
|
|
80
|
+
return unresolved("not-a-unique-same-owner-binding"), None
|
|
81
|
+
assignment = self.assignments[scope].get(node.id)
|
|
82
|
+
if assignment is None or not isinstance(assignment.value, ast.Call):
|
|
83
|
+
return unresolved("not-a-direct-wrapper-assignment"), None
|
|
84
|
+
if not before(assignment, node):
|
|
85
|
+
return unresolved("assignment-not-before-use"), None
|
|
86
|
+
construction = assignment.value
|
|
87
|
+
target, _ = self.bindings.resolve(construction.func, scope)
|
|
88
|
+
if target != usage.WRAPPER:
|
|
89
|
+
return unresolved("unmatched-wrapper-constructor"), None
|
|
90
|
+
if not construction.args or isinstance(construction.args[0], ast.Starred):
|
|
91
|
+
return unresolved("unresolved-argument-layout"), None
|
|
92
|
+
location = usage.extent(construction)
|
|
93
|
+
return {"status": "stored-wrapper-syntax", "reason": "unique-direct-same-owner-assignment",
|
|
94
|
+
"definition": {"name": node.id, "assignment": usage.extent(assignment),
|
|
95
|
+
"constructionCallId": usage.fingerprint([owner_id, location]),
|
|
96
|
+
"construction": location, "importTarget": target}}, construction.args[0]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ContextCalls(usage.Calls):
|
|
100
|
+
def __init__(self, bindings, node, owner, provenance):
|
|
101
|
+
super().__init__(bindings, node, owner)
|
|
102
|
+
self.provenance, self.contexts = provenance, []
|
|
103
|
+
|
|
104
|
+
def record(self, node, context, shadowed, parent):
|
|
105
|
+
super().record(node, context, shadowed, parent)
|
|
106
|
+
call = self.rows[-1]
|
|
107
|
+
local, wrapper, callback = None, None, None
|
|
108
|
+
callback_node = None
|
|
109
|
+
if call["category"] in {"offload-call", "offload-wrapper-construction", "offload-wrapped-invocation"}:
|
|
110
|
+
helper = node.func if call["category"] == "offload-wrapped-invocation" else node
|
|
111
|
+
if helper.args and not isinstance(helper.args[0], ast.Starred):
|
|
112
|
+
callback_node = helper.args[0]
|
|
113
|
+
if call["binding"]["status"] == "unresolved" and isinstance(node.func, ast.Name):
|
|
114
|
+
local = self.provenance.reference(node.func, self.scope, shadowed)
|
|
115
|
+
wrapper, callback_node = self.provenance.wrapper(node.func, self.scope, shadowed, context, self.owner["id"])
|
|
116
|
+
if callback_node is not None:
|
|
117
|
+
# A stored wrapper's callback was evaluated at construction, outside a use-site comprehension.
|
|
118
|
+
callback_shadowed = frozenset() if wrapper and wrapper["status"] == "stored-wrapper-syntax" else shadowed
|
|
119
|
+
callback = {"kind": type(callback_node).__name__,
|
|
120
|
+
"evaluation": "eager-call-expression" if isinstance(callback_node, ast.Call) else "callback-expression-only",
|
|
121
|
+
"reference": self.provenance.reference(callback_node, self.scope, callback_shadowed)}
|
|
122
|
+
self.contexts.append({"callId": call["id"], "executionContext": context, "shape": shape(node.func),
|
|
123
|
+
"unresolvedReason": call["binding"]["reason"] if call["binding"]["status"] == "unresolved" else None,
|
|
124
|
+
"localCallee": local, "storedWrapper": wrapper, "callback": callback})
|
|
125
|
+
|
|
126
|
+
def measure(self, node):
|
|
127
|
+
measurement = super().measure(node)
|
|
128
|
+
by_id = {row["callId"]: row for row in self.contexts}
|
|
129
|
+
return measurement, [by_id[call["id"]] for call in measurement["calls"]]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Standalone Async/Blocking Usage scoring study; no target execution or v3 gates."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import io
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import tokenize
|
|
10
|
+
|
|
11
|
+
import async_helper_paths as helpers
|
|
12
|
+
from review_cli import run
|
|
13
|
+
|
|
14
|
+
usage = helpers.usage
|
|
15
|
+
POLICY = "python/async-blocking-owner-rate-v1"
|
|
16
|
+
INTENT_POLICY = "adjacent-simple-statement-declared-blocking-prose-v1"
|
|
17
|
+
MAX_NODES = 1000000
|
|
18
|
+
LADDER = [
|
|
19
|
+
{"percent": 1, "inclusive": False, "score": 10},
|
|
20
|
+
{"percent": 2, "inclusive": False, "score": 9},
|
|
21
|
+
{"percent": 5, "inclusive": False, "score": 8},
|
|
22
|
+
{"percent": 10, "inclusive": True, "score": 6},
|
|
23
|
+
{"percent": 15, "inclusive": True, "score": 4},
|
|
24
|
+
{"percent": 20, "inclusive": True, "score": 2},
|
|
25
|
+
]
|
|
26
|
+
SIMPLE = (ast.Expr, ast.Return, ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Raise, ast.Assert)
|
|
27
|
+
BOUNDARIES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
|
|
28
|
+
DIRECTIVE = re.compile(r"^(?:noqa\b|type:|pragma:|fmt:|pylint:|ruff:)", re.I)
|
|
29
|
+
KEYWORD = re.compile(r"\b(?:blocking|synchronous|sync-over-async|sleep)\b", re.I)
|
|
30
|
+
MARKER = re.compile(r"\b(?:TODO|FIXME|HACK|XXX)\b", re.I)
|
|
31
|
+
INTENT = re.compile(r"\b(?:intentional(?:ly)?|deliberate(?:ly)?|required|necessary|unavoidable)\b", re.I)
|
|
32
|
+
CAUTION = re.compile(r"\b(?:not|never|avoid|remove|replace|shouldn't|mustn't|unnecessary)\b", re.I)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def owner_score(affected, eligible):
|
|
36
|
+
"""Exact comparisons; displayed percentages never select a band."""
|
|
37
|
+
if type(eligible) is not int or type(affected) is not int or not 0 <= affected <= eligible:
|
|
38
|
+
raise ValueError("Owner counts must be integers with 0 <= affected <= eligible.")
|
|
39
|
+
if not eligible:
|
|
40
|
+
return None
|
|
41
|
+
for band in LADDER:
|
|
42
|
+
left, right = 100 * affected, band["percent"] * eligible
|
|
43
|
+
if left < right or (band["inclusive"] and left == right):
|
|
44
|
+
return band["score"]
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def rationale(text):
|
|
49
|
+
if (DIRECTIVE.match(text) or MARKER.search(text) or not KEYWORD.search(text)
|
|
50
|
+
or not INTENT.search(text) or CAUTION.search(text)):
|
|
51
|
+
return False
|
|
52
|
+
if len(re.findall(r"[A-Za-z]+", text)) < 2:
|
|
53
|
+
return False
|
|
54
|
+
try:
|
|
55
|
+
ast.parse(text)
|
|
56
|
+
except SyntaxError:
|
|
57
|
+
return True
|
|
58
|
+
return False # Commented-out code is not an explanation.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IntentIndex:
|
|
62
|
+
"""Attach actual comment tokens only to a single simple statement."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, source, budget):
|
|
65
|
+
self.lines = source.splitlines()
|
|
66
|
+
tree = ast.parse(source)
|
|
67
|
+
self.parents, self.calls, self.statements = {}, {}, []
|
|
68
|
+
for node in ast.walk(tree):
|
|
69
|
+
budget[0] += 1
|
|
70
|
+
if budget[0] > MAX_NODES:
|
|
71
|
+
raise ValueError("Async scoring source-index budget exceeded.")
|
|
72
|
+
for child in ast.iter_child_nodes(node):
|
|
73
|
+
self.parents[child] = node
|
|
74
|
+
if isinstance(node, ast.Call):
|
|
75
|
+
self.calls[self.key(usage.extent(node))] = node
|
|
76
|
+
if isinstance(node, ast.stmt):
|
|
77
|
+
self.statements.append(node)
|
|
78
|
+
self.starts = Counter(node.lineno for node in self.statements)
|
|
79
|
+
self.ends = Counter(node.end_lineno for node in self.statements if isinstance(node, SIMPLE))
|
|
80
|
+
self.comments = {}
|
|
81
|
+
for token in tokenize.generate_tokens(io.StringIO(source).readline):
|
|
82
|
+
if token.type == tokenize.COMMENT:
|
|
83
|
+
self.comments[token.start[0]] = token
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def key(location):
|
|
87
|
+
return tuple(location[name] for name in ("line", "column", "endLine", "endColumn"))
|
|
88
|
+
|
|
89
|
+
def documents(self, location):
|
|
90
|
+
node = self.calls.get(self.key(location))
|
|
91
|
+
if node is None:
|
|
92
|
+
raise ValueError("Blocking path has no matching source call.")
|
|
93
|
+
while not isinstance(node, ast.stmt):
|
|
94
|
+
node = self.parents.get(node)
|
|
95
|
+
if node is None or isinstance(node, BOUNDARIES):
|
|
96
|
+
return []
|
|
97
|
+
if not isinstance(node, SIMPLE):
|
|
98
|
+
return []
|
|
99
|
+
# Do not let a semicolon, one-line compound suite, or sibling inherit prose.
|
|
100
|
+
if self.starts[node.lineno] != 1 or self.ends[node.end_lineno] != 1:
|
|
101
|
+
return []
|
|
102
|
+
prefix = self.lines[node.lineno - 1].encode("utf-8")[:node.col_offset].decode("utf-8")
|
|
103
|
+
if prefix.strip():
|
|
104
|
+
return []
|
|
105
|
+
found = []
|
|
106
|
+
for line in (node.lineno - 1, node.end_lineno):
|
|
107
|
+
token = self.comments.get(line)
|
|
108
|
+
if token is None:
|
|
109
|
+
continue
|
|
110
|
+
before = self.lines[line - 1][:token.start[1]]
|
|
111
|
+
if line == node.lineno - 1:
|
|
112
|
+
attached = before == prefix
|
|
113
|
+
else:
|
|
114
|
+
end = len(self.lines[line - 1].encode("utf-8")[:node.end_col_offset].decode("utf-8"))
|
|
115
|
+
attached = token.start[1] >= end and not self.lines[line - 1][end:token.start[1]].strip()
|
|
116
|
+
text = token.string[1:].strip()
|
|
117
|
+
if attached and rationale(text):
|
|
118
|
+
found.append({"line": line, "text": text})
|
|
119
|
+
return found
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def classify(trace, indexes):
|
|
123
|
+
result = {**trace, "documentation": []}
|
|
124
|
+
if trace["executionContext"] != "async-body":
|
|
125
|
+
return {**result, "classification": "excluded-execution-context"}
|
|
126
|
+
documents = []
|
|
127
|
+
for step in trace["path"]:
|
|
128
|
+
documents.extend({"file": step["file"], "callId": step["callId"], **item}
|
|
129
|
+
for item in indexes[step["file"]].documents(step))
|
|
130
|
+
return {**result, "documentation": documents,
|
|
131
|
+
"classification": "documented-choice" if documents else "scored-signal"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def contributions(base, indexes):
|
|
135
|
+
rows = []
|
|
136
|
+
for owner in base["functions"]:
|
|
137
|
+
observations = [classify(trace, indexes) for trace in owner["traces"] if trace["status"] == "blocking-candidate"]
|
|
138
|
+
eligible = owner["population"] == "production"
|
|
139
|
+
unresolved = [trace for trace in owner["traces"] if trace["status"] == "unresolved"]
|
|
140
|
+
rows.append({**{key: owner[key] for key in ("id", "file", "member", "line", "population", "sourceSha256")},
|
|
141
|
+
"eligible": eligible, "eligibilityReason": "production-async-implementation" if eligible else "nonproduction",
|
|
142
|
+
"affected": eligible and any(item["classification"] == "scored-signal" for item in observations),
|
|
143
|
+
"observations": observations, "unresolvedPaths": unresolved})
|
|
144
|
+
return rows
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def summarize(rows):
|
|
148
|
+
eligible = [row for row in rows if row["eligible"]]
|
|
149
|
+
affected = sum(row["affected"] for row in eligible)
|
|
150
|
+
observations = [item for row in eligible for item in row["observations"]]
|
|
151
|
+
unresolved = [item for row in eligible for item in row["unresolvedPaths"]]
|
|
152
|
+
return {"availability": "available" if eligible else "unavailable", "eligibleOwners": len(eligible),
|
|
153
|
+
"affectedOwners": affected, "score": owner_score(affected, len(eligible)),
|
|
154
|
+
"affectedRate": {"numerator": affected, "denominator": len(eligible)} if eligible else None,
|
|
155
|
+
"excludedOwnersByPopulation": dict(sorted(Counter(row["population"] for row in rows if not row["eligible"]).items())),
|
|
156
|
+
"observationPathsByClassification": dict(sorted(Counter(item["classification"] for item in observations).items())),
|
|
157
|
+
"distinctScoredSourceCalls": len({item["path"][-1]["callId"] for item in observations if item["classification"] == "scored-signal"}),
|
|
158
|
+
"ownersWithUnresolvedPaths": sum(bool(row["unresolvedPaths"]) for row in eligible),
|
|
159
|
+
"unresolvedPathsByReason": dict(sorted(Counter(item["reason"] for item in unresolved).items()))}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def review(target, facts, base):
|
|
163
|
+
configuration = {"policy": POLICY, "helperConfigurationFingerprint": base["configurationFingerprint"],
|
|
164
|
+
"intentPolicy": INTENT_POLICY, "maxSourceIndexNodes": MAX_NODES, "ladder": LADDER,
|
|
165
|
+
"population": "selected-production-async-implementations", "unresolvedTreatment": "visible-without-deduction"}
|
|
166
|
+
report = {"schemaVersion": 1, "artifactKind": "experimental-python-async-scoring",
|
|
167
|
+
"producer": {"name": "codemetrics-ai/python-async-scoring", "version": "0.1.0-experimental"},
|
|
168
|
+
"generatedAtUtc": datetime.now(timezone.utc).isoformat(), "runId": base["runId"], "auditId": base["auditId"],
|
|
169
|
+
"policy": POLICY, "configuration": configuration, "configurationFingerprint": usage.fingerprint(configuration),
|
|
170
|
+
"helperRowsSha256": usage.fingerprint(base["functions"]), "subject": base["subject"], "selection": facts["selection"],
|
|
171
|
+
"sourceFactsProducer": facts["producer"], "sourceFactsPolicy": facts["measurementPolicy"],
|
|
172
|
+
"status": "incomplete", "diagnostics": [], "functions": [], "summary": None,
|
|
173
|
+
"scope": "Partial static blocking-catalogue usage score. Import spelling and bounded helper paths, not runtime identity, reachability, throughput, latency, or proof of safe async behavior. Standalone study; no scorecard v3 aggregate or gates. Error Handling remains unscored."}
|
|
174
|
+
try:
|
|
175
|
+
original = usage.review(target, facts, run_id=base["runId"], audit_id=base["auditId"])
|
|
176
|
+
fresh = helpers.review(target, facts, original)
|
|
177
|
+
stable = lambda value: {key: item for key, item in value.items() if key != "generatedAtUtc"}
|
|
178
|
+
if fresh["status"] != "complete" or stable(fresh) != stable(base):
|
|
179
|
+
raise ValueError("Async helper input is incomplete, stale, or inconsistent.")
|
|
180
|
+
target = Path(target).absolute()
|
|
181
|
+
root = target if target.is_dir() else target.parent
|
|
182
|
+
needed = {step["file"] for row in base["functions"] for trace in row["traces"]
|
|
183
|
+
if trace["status"] == "blocking-candidate" and trace["executionContext"] == "async-body" for step in trace["path"]}
|
|
184
|
+
indexes, budget = {}, [0]
|
|
185
|
+
for record in facts["selection"]["files"]:
|
|
186
|
+
if record["file"] in needed:
|
|
187
|
+
indexes[record["file"]] = IntentIndex(usage.read_verified(root, record), budget)
|
|
188
|
+
rows = contributions(base, indexes)
|
|
189
|
+
report.update(status="complete", functions=rows, summary=summarize(rows))
|
|
190
|
+
except (OSError, SyntaxError, UnicodeError, ValueError, RecursionError, KeyError, TypeError, tokenize.TokenError) as error:
|
|
191
|
+
report["diagnostics"].append({"code": "async-scoring-analysis", "message": str(error)})
|
|
192
|
+
return report
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def analyze(target, *, run_id=None, audit_id=None, **selection):
|
|
196
|
+
facts = usage.source_facts.analyze(target, **selection)
|
|
197
|
+
original = usage.review(target, facts, run_id=run_id, audit_id=audit_id)
|
|
198
|
+
base = helpers.review(target, facts, original)
|
|
199
|
+
return review(target, facts, base)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
raise SystemExit(run(analyze, __doc__))
|