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