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