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.
Files changed (106) hide show
  1. package/README.md +51 -5
  2. package/dist/analyzer.d.ts +1 -0
  3. package/dist/analyzer.js +11 -5
  4. package/dist/cli.js +7 -3
  5. package/dist/dependency-evidence.d.ts +5 -0
  6. package/dist/dependency-evidence.js +30 -0
  7. package/dist/dependency-inventory.d.ts +67 -0
  8. package/dist/dependency-inventory.js +203 -0
  9. package/dist/dependency-metadata.d.ts +65 -0
  10. package/dist/dependency-metadata.js +125 -0
  11. package/dist/dependency-pnpm.d.ts +4 -0
  12. package/dist/dependency-pnpm.js +237 -0
  13. package/dist/dependency-spec.d.ts +13 -0
  14. package/dist/dependency-spec.js +30 -0
  15. package/dist/error-handling-scoring.d.ts +1 -0
  16. package/dist/error-handling-scoring.js +15 -0
  17. package/dist/error-handling.d.ts +4 -1
  18. package/dist/error-handling.js +50 -17
  19. package/dist/evidence-reader.js +3 -1
  20. package/dist/evidence-tools.js +4 -0
  21. package/dist/failure-contracts.d.ts +19 -0
  22. package/dist/failure-contracts.js +261 -0
  23. package/dist/function-scoring.d.ts +2 -1
  24. package/dist/function-scoring.js +13 -24
  25. package/dist/handler-disposition.d.ts +9 -0
  26. package/dist/handler-disposition.js +156 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +1 -0
  29. package/dist/python/ASYNC_ADAPTERS.md +38 -0
  30. package/dist/python/ASYNC_CONTEXT.md +58 -0
  31. package/dist/python/ASYNC_SCORING.md +174 -0
  32. package/dist/python/ASYNC_USAGE.md +62 -0
  33. package/dist/python/BEHAVIOR_REVIEW.md +84 -0
  34. package/dist/python/COVERAGE.md +96 -0
  35. package/dist/python/DECOMPOSITION.md +46 -0
  36. package/dist/python/DUPLICATION.md +36 -0
  37. package/dist/python/ERROR_HANDLING.md +45 -0
  38. package/dist/python/ERROR_HANDLING_CONTEXT.md +61 -0
  39. package/dist/python/ERROR_HANDLING_OPERATIONS.md +54 -0
  40. package/dist/python/ERROR_HANDLING_TRANSFERS.md +64 -0
  41. package/dist/python/METHOD_COMPLEXITY.md +46 -0
  42. package/dist/python/README.md +162 -0
  43. package/dist/python/analysis.py +54 -0
  44. package/dist/python/async_adapters.py +243 -0
  45. package/dist/python/async_bindings.py +180 -0
  46. package/dist/python/async_context.py +143 -0
  47. package/dist/python/async_helper_paths.py +186 -0
  48. package/dist/python/async_provenance.py +129 -0
  49. package/dist/python/async_scoring.py +203 -0
  50. package/dist/python/async_usage.py +244 -0
  51. package/dist/python/coverage_collect.py +121 -0
  52. package/dist/python/coverage_provenance.py +77 -0
  53. package/dist/python/coverage_review.py +273 -0
  54. package/dist/python/coverage_worker.py +46 -0
  55. package/dist/python/decomposition.py +199 -0
  56. package/dist/python/duplication.py +177 -0
  57. package/dist/python/error_handling.py +249 -0
  58. package/dist/python/error_handling_context.py +294 -0
  59. package/dist/python/error_handling_operations.py +228 -0
  60. package/dist/python/error_handling_transfers.py +211 -0
  61. package/dist/python/review_cli.py +36 -0
  62. package/dist/python/source_facts.py +293 -0
  63. package/dist/python/source_selection.py +157 -0
  64. package/dist/python-analyzer.d.ts +21 -0
  65. package/dist/python-analyzer.js +123 -0
  66. package/dist/python-async-adapters.d.ts +67 -0
  67. package/dist/python-async-adapters.js +197 -0
  68. package/dist/python-async-context.d.ts +73 -0
  69. package/dist/python-async-context.js +181 -0
  70. package/dist/python-async-scoring.d.ts +89 -0
  71. package/dist/python-async-scoring.js +249 -0
  72. package/dist/python-async-usage.d.ts +56 -0
  73. package/dist/python-async-usage.js +143 -0
  74. package/dist/python-cli.d.ts +2 -0
  75. package/dist/python-cli.js +53 -0
  76. package/dist/python-collect-cli.d.ts +2 -0
  77. package/dist/python-collect-cli.js +28 -0
  78. package/dist/python-complexity.d.ts +18 -0
  79. package/dist/python-complexity.js +36 -0
  80. package/dist/python-coverage.d.ts +63 -0
  81. package/dist/python-coverage.js +173 -0
  82. package/dist/python-decomposition.d.ts +56 -0
  83. package/dist/python-decomposition.js +102 -0
  84. package/dist/python-duplication.d.ts +65 -0
  85. package/dist/python-duplication.js +89 -0
  86. package/dist/python-error-context.d.ts +84 -0
  87. package/dist/python-error-context.js +165 -0
  88. package/dist/python-error-handling.d.ts +56 -0
  89. package/dist/python-error-handling.js +120 -0
  90. package/dist/python-error-operations.d.ts +44 -0
  91. package/dist/python-error-operations.js +155 -0
  92. package/dist/python-error-transfers.d.ts +38 -0
  93. package/dist/python-error-transfers.js +125 -0
  94. package/dist/python-evidence.d.ts +23 -0
  95. package/dist/python-evidence.js +255 -0
  96. package/dist/score-arithmetic.d.ts +1 -12
  97. package/dist/score-arithmetic.js +1 -40
  98. package/dist/shared-source-facts/arithmetic.d.mts +9 -0
  99. package/dist/shared-source-facts/arithmetic.mjs +32 -0
  100. package/dist/shared-source-facts/contract.d.mts +35 -0
  101. package/dist/shared-source-facts/contract.mjs +95 -0
  102. package/dist/shared-source-facts/population.d.mts +12 -0
  103. package/dist/shared-source-facts/population.mjs +41 -0
  104. package/dist/source-facts.d.ts +4 -0
  105. package/dist/source-facts.js +16 -0
  106. 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())