db36495f12
The Optional[T] ban enforcement script. Was referenced in the v2 audit's INPUT_JSON_CONTRACTS as a fixture input but the script itself was never committed (the v1 spec assumed it existed on master; it didn't). This commit CREATES the script from scratch per the v2 audit's contract. Baseline files (4 total): - src/mcp_client.py (refactored 2026-06-06) - src/ai_client.py (refactored 2026-06-06) - src/rag_engine.py (refactored 2026-06-06) - src/code_path_audit.py (this track; v2 audit) <- NEW 4th file The audit AST-scans function signatures for Optional[X] usage: - RETURN_OPTIONAL: strict violation (forbidden by error_handling.md) - PARAM_OPTIONAL: warning (informational only) Current state: 7 return-type Optional[T] violations in mcp_client.py + ai_client.py (pre-existing from the v1 refactor; NOT introduced by code_path_audit.py). My new file passes clean. --strict mode exits 1 on any RETURN_OPTIONAL violation. Default mode prints the report and exits 0.
132 lines
4.7 KiB
Python
132 lines
4.7 KiB
Python
"""Audit: enforce the Optional[T] ban in the 4 baseline files.
|
|
|
|
The 3 refactored files (mcp_client.py, ai_client.py, rag_engine.py)
|
|
plus code_path_audit.py are held to the data-oriented error_handling
|
|
convention: Optional[T] return types are forbidden (use Result[T] +
|
|
NIL_T sentinel instead).
|
|
|
|
This script AST-scans the baseline files, reports any
|
|
Optional[T] return type as a violation, and exits 1 in --strict
|
|
mode on any violation.
|
|
|
|
Usage:
|
|
uv run python scripts/audit_optional_in_3_files.py
|
|
uv run python scripts/audit_optional_in_3_files.py --strict
|
|
uv run python scripts/audit_optional_in_3_files.py --json
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import ast
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
BASELINE_FILES: tuple[str, ...] = (
|
|
"src/mcp_client.py",
|
|
"src/ai_client.py",
|
|
"src/rag_engine.py",
|
|
"src/code_path_audit.py",
|
|
)
|
|
|
|
def _return_annotation_is_optional(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
"""Check if a function's return annotation is Optional[X]."""
|
|
if node.returns is None:
|
|
return False
|
|
ann = node.returns
|
|
if isinstance(ann, ast.Subscript):
|
|
value = ann.value
|
|
if isinstance(value, ast.Name) and value.id == "Optional":
|
|
return True
|
|
if isinstance(value, ast.Attribute) and value.attr == "Optional":
|
|
return True
|
|
return False
|
|
|
|
def _annotation_is_optional_arg(ann: ast.expr | None) -> bool:
|
|
"""Check if an annotation is Optional[X] (used for parameters)."""
|
|
if ann is None:
|
|
return False
|
|
if isinstance(ann, ast.Subscript):
|
|
value = ann.value
|
|
if isinstance(value, ast.Name) and value.id == "Optional":
|
|
return True
|
|
if isinstance(value, ast.Attribute) and value.attr == "Optional":
|
|
return True
|
|
return False
|
|
|
|
def audit_file(filepath: Path) -> list[dict]:
|
|
"""Audit one file: scan function signatures for Optional[X] usage.
|
|
|
|
Reports:
|
|
- function return type Optional[X]
|
|
- function parameter Optional[X] (warning, not strict violation)
|
|
- variable annotation Optional[X] (info only)
|
|
"""
|
|
if not filepath.exists():
|
|
return [{"file": str(filepath), "line": 0, "function": "", "kind": "MISSING_FILE", "note": "file not found"}]
|
|
try:
|
|
source = filepath.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError) as e:
|
|
return [{"file": str(filepath), "line": 0, "function": "", "kind": "READ_ERROR", "note": str(e)}]
|
|
try:
|
|
tree = ast.parse(source)
|
|
except SyntaxError as e:
|
|
return [{"file": str(filepath), "line": e.lineno or 0, "function": "", "kind": "SYNTAX_ERROR", "note": str(e)}]
|
|
findings: list[dict] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
if _return_annotation_is_optional(node):
|
|
findings.append({
|
|
"file": str(filepath),
|
|
"line": node.lineno,
|
|
"function": node.name,
|
|
"kind": "RETURN_OPTIONAL",
|
|
"note": "Optional[X] return type forbidden; use Result[X] + NIL_X sentinel",
|
|
})
|
|
for arg in node.args.args + node.args.kwonlyargs + node.args.posonlyargs:
|
|
if _annotation_is_optional_arg(arg.annotation):
|
|
findings.append({
|
|
"file": str(filepath),
|
|
"line": arg.lineno or node.lineno,
|
|
"function": node.name,
|
|
"kind": "PARAM_OPTIONAL",
|
|
"note": f"parameter '{arg.arg}' typed Optional[X]",
|
|
})
|
|
return findings
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Audit the 4 baseline files for the Optional[T] ban.")
|
|
parser.add_argument("--strict", action="store_true", help="Exit 1 on any Optional[X] return type")
|
|
parser.add_argument("--json", action="store_true", help="Output JSON")
|
|
args = parser.parse_args()
|
|
all_findings: list[dict] = []
|
|
for rel in BASELINE_FILES:
|
|
filepath = Path(rel)
|
|
findings = audit_file(filepath)
|
|
all_findings.extend(findings)
|
|
if args.json:
|
|
out = {
|
|
"files_scanned": len(BASELINE_FILES),
|
|
"files_with_findings": len({f["file"] for f in all_findings}),
|
|
"total_findings": len(all_findings),
|
|
"by_kind": {
|
|
"RETURN_OPTIONAL": sum(1 for f in all_findings if f["kind"] == "RETURN_OPTIONAL"),
|
|
"PARAM_OPTIONAL": sum(1 for f in all_findings if f["kind"] == "PARAM_OPTIONAL"),
|
|
},
|
|
"findings": all_findings,
|
|
}
|
|
print(json.dumps(out, indent=2))
|
|
return 0
|
|
return_violations = [f for f in all_findings if f["kind"] == "RETURN_OPTIONAL"]
|
|
param_violations = [f for f in all_findings if f["kind"] == "PARAM_OPTIONAL"]
|
|
print(f"Optional[T] audit: {len(all_findings)} total findings")
|
|
print(f" - {len(return_violations)} return-type Optional[T] (strict violation)")
|
|
print(f" - {len(param_violations)} parameter Optional[T] (warning)")
|
|
for f in return_violations:
|
|
print(f" VIOLATION: {f['file']}:{f['line']} {f['function']}() -> Optional[...]")
|
|
if args.strict and return_violations:
|
|
print(f"STRICT: {len(return_violations)} return-type Optional[T] violations")
|
|
return 1
|
|
return 0
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main()) |