31ce04c655
Merge the beta-ready Yao Meta Skill architecture, report, evidence gate, and release-boundary updates.\n\nRelease boundary: beta/public testing is allowed; formal world-class, fully reviewed, or superiority claims remain blocked until the pending evidence gates are accepted.
352 lines
12 KiB
Python
352 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import io
|
|
import json
|
|
import token
|
|
import tokenize
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SCRIPT_INTERFACE = "cli"
|
|
SCRIPT_INTERFACE_REASON = "Checks repository Python source for syntax that can pass locally but fail on the supported CI interpreter."
|
|
|
|
EXCLUDED_DIRS = {
|
|
".git",
|
|
".mypy_cache",
|
|
".previews",
|
|
".pytest_cache",
|
|
".ruff_cache",
|
|
".venv",
|
|
"__pycache__",
|
|
"dist",
|
|
"node_modules",
|
|
"venv",
|
|
}
|
|
MAX_FILE_BYTES = 1_000_000
|
|
|
|
|
|
def rel_path(path: Path, root: Path) -> str:
|
|
try:
|
|
return str(path.resolve().relative_to(root.resolve()))
|
|
except ValueError:
|
|
return str(path.resolve())
|
|
|
|
|
|
def resolve_path(raw_path: str, root: Path) -> Path:
|
|
path = Path(raw_path)
|
|
if not path.is_absolute():
|
|
path = root / path
|
|
return path.resolve()
|
|
|
|
|
|
def is_ignored(path: Path, root: Path) -> bool:
|
|
try:
|
|
parts = path.resolve().relative_to(root.resolve()).parts
|
|
except ValueError:
|
|
return True
|
|
if any(part in EXCLUDED_DIRS for part in parts):
|
|
return True
|
|
if len(parts) >= 2 and parts[0] == "tests" and parts[1].startswith("tmp"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def expand_scan_paths(root: Path, raw_paths: list[str]) -> list[Path]:
|
|
candidates: list[Path] = []
|
|
explicit_paths = bool(raw_paths)
|
|
if raw_paths:
|
|
for raw_path in raw_paths:
|
|
path = resolve_path(raw_path, root)
|
|
if path.is_dir():
|
|
candidates.extend(path.rglob("*.py"))
|
|
elif path.exists():
|
|
candidates.append(path)
|
|
else:
|
|
candidates.extend(root.rglob("*.py"))
|
|
files = []
|
|
for path in candidates:
|
|
if not path.is_file() or path.is_symlink() or path.suffix != ".py":
|
|
continue
|
|
if not explicit_paths and is_ignored(path, root):
|
|
continue
|
|
try:
|
|
if path.stat().st_size > MAX_FILE_BYTES:
|
|
continue
|
|
except OSError:
|
|
continue
|
|
files.append(path)
|
|
return sorted(set(files), key=lambda item: rel_path(item, root))
|
|
|
|
|
|
def issue(path: Path, root: Path, line: int, column: int, rule: str, message: str, excerpt: str = "") -> dict[str, Any]:
|
|
return {
|
|
"path": rel_path(path, root),
|
|
"line": line,
|
|
"column": column,
|
|
"rule": rule,
|
|
"message": message,
|
|
"excerpt": excerpt.strip()[:220],
|
|
}
|
|
|
|
|
|
def compile_issues(path: Path, root: Path, source: str) -> list[dict[str, Any]]:
|
|
try:
|
|
compile(source, str(path), "exec")
|
|
except SyntaxError as exc:
|
|
return [
|
|
issue(
|
|
path,
|
|
root,
|
|
exc.lineno or 0,
|
|
exc.offset or 0,
|
|
"current-python-syntax",
|
|
str(exc.msg),
|
|
exc.text or "",
|
|
)
|
|
]
|
|
return []
|
|
|
|
|
|
def token_name(token_type: int) -> str:
|
|
return token.tok_name.get(token_type, "")
|
|
|
|
|
|
def scan_modern_fstring_tokens(path: Path, root: Path, source: str) -> tuple[bool, list[dict[str, Any]]]:
|
|
findings: list[dict[str, Any]] = []
|
|
try:
|
|
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
|
|
except tokenize.TokenError:
|
|
return False, findings
|
|
has_modern_fstring_tokens = any(token_name(item.type) == "FSTRING_START" for item in tokens)
|
|
if not has_modern_fstring_tokens:
|
|
return False, findings
|
|
fstring_depth = 0
|
|
expression_depth = 0
|
|
for item in tokens:
|
|
name = token_name(item.type)
|
|
if name == "FSTRING_START":
|
|
fstring_depth += 1
|
|
expression_depth = 0
|
|
continue
|
|
if name == "FSTRING_END":
|
|
fstring_depth = max(0, fstring_depth - 1)
|
|
expression_depth = 0
|
|
continue
|
|
if not fstring_depth:
|
|
continue
|
|
if item.type == token.OP and item.string == "{":
|
|
expression_depth += 1
|
|
continue
|
|
if item.type == token.OP and item.string == "}":
|
|
expression_depth = max(0, expression_depth - 1)
|
|
continue
|
|
if expression_depth > 0 and "\\" in item.string:
|
|
findings.append(
|
|
issue(
|
|
path,
|
|
root,
|
|
item.start[0],
|
|
item.start[1] + 1,
|
|
"fstring-expression-backslash",
|
|
"Python 3.11 rejects backslashes inside f-string expressions.",
|
|
item.line,
|
|
)
|
|
)
|
|
return True, findings
|
|
|
|
|
|
def split_string_token(token_text: str) -> tuple[str, str] | None:
|
|
index = 0
|
|
while index < len(token_text) and token_text[index].isalpha():
|
|
index += 1
|
|
prefix = token_text[:index].lower()
|
|
if "f" not in prefix:
|
|
return None
|
|
quote = ""
|
|
for candidate in ('"""', "'''", '"', "'"):
|
|
if token_text[index:].startswith(candidate):
|
|
quote = candidate
|
|
break
|
|
if not quote or not token_text.endswith(quote):
|
|
return None
|
|
return prefix, token_text[index + len(quote) : -len(quote)]
|
|
|
|
|
|
def scan_legacy_fstring_body(path: Path, root: Path, token_text: str, start_line: int, start_column: int) -> list[dict[str, Any]]:
|
|
parsed = split_string_token(token_text)
|
|
if not parsed:
|
|
return []
|
|
_, body = parsed
|
|
findings: list[dict[str, Any]] = []
|
|
expression_depth = 0
|
|
line = start_line
|
|
column = start_column
|
|
index = 0
|
|
while index < len(body):
|
|
char = body[index]
|
|
next_char = body[index + 1] if index + 1 < len(body) else ""
|
|
if expression_depth == 0:
|
|
if char == "{" and next_char == "{":
|
|
index += 2
|
|
column += 2
|
|
continue
|
|
if char == "{" and next_char != "{":
|
|
expression_depth = 1
|
|
else:
|
|
if char == "\\":
|
|
findings.append(
|
|
issue(
|
|
path,
|
|
root,
|
|
line,
|
|
column + 1,
|
|
"fstring-expression-backslash",
|
|
"Python 3.11 rejects backslashes inside f-string expressions.",
|
|
token_text,
|
|
)
|
|
)
|
|
elif char == "{":
|
|
expression_depth += 1
|
|
elif char == "}":
|
|
expression_depth = max(0, expression_depth - 1)
|
|
if char == "\n":
|
|
line += 1
|
|
column = 0
|
|
else:
|
|
column += 1
|
|
index += 1
|
|
return findings
|
|
|
|
|
|
def fstring_compat_issues(path: Path, root: Path, source: str) -> list[dict[str, Any]]:
|
|
handled, findings = scan_modern_fstring_tokens(path, root, source)
|
|
if handled:
|
|
return findings
|
|
try:
|
|
tokens = tokenize.generate_tokens(io.StringIO(source).readline)
|
|
for item in tokens:
|
|
if item.type == token.STRING:
|
|
findings.extend(scan_legacy_fstring_body(path, root, item.string, item.start[0], item.start[1]))
|
|
except tokenize.TokenError:
|
|
return findings
|
|
return findings
|
|
|
|
|
|
def check_file(path: Path, root: Path) -> dict[str, Any]:
|
|
try:
|
|
source = path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError as exc:
|
|
issues = [issue(path, root, 0, 0, "read-error", str(exc))]
|
|
else:
|
|
issues = compile_issues(path, root, source)
|
|
issues.extend(fstring_compat_issues(path, root, source))
|
|
return {
|
|
"path": rel_path(path, root),
|
|
"ok": not issues,
|
|
"issue_count": len(issues),
|
|
"issues": issues,
|
|
}
|
|
|
|
|
|
def build_report(root: Path, raw_paths: list[str], target_python: str, generated_at: str) -> dict[str, Any]:
|
|
files = expand_scan_paths(root, raw_paths)
|
|
checked = [check_file(path, root) for path in files]
|
|
issues = [item for file_report in checked for item in file_report["issues"]]
|
|
syntax_error_count = sum(1 for item in issues if item["rule"] == "current-python-syntax")
|
|
fstring_count = sum(1 for item in issues if item["rule"] == "fstring-expression-backslash")
|
|
return {
|
|
"schema_version": "1.0",
|
|
"ok": not issues,
|
|
"generated_at": generated_at,
|
|
"root": rel_path(root, ROOT),
|
|
"summary": {
|
|
"target_python": target_python,
|
|
"file_count": len(checked),
|
|
"issue_count": len(issues),
|
|
"syntax_error_count": syntax_error_count,
|
|
"fstring_311_violation_count": fstring_count,
|
|
"decision": "pass" if not issues else "block-python-compat",
|
|
},
|
|
"rules": [
|
|
{
|
|
"key": "current-python-syntax",
|
|
"reason": "Every scanned Python source file must compile under the running interpreter.",
|
|
},
|
|
{
|
|
"key": "fstring-expression-backslash",
|
|
"reason": "Python 3.11 rejects backslashes inside f-string expressions; keep escaping outside the expression.",
|
|
},
|
|
],
|
|
"files": checked,
|
|
"issues": issues,
|
|
"artifacts": {
|
|
"json": "reports/python_compatibility.json",
|
|
"markdown": "reports/python_compatibility.md",
|
|
},
|
|
}
|
|
|
|
|
|
def render_markdown(report: dict[str, Any]) -> str:
|
|
summary = report["summary"]
|
|
lines = [
|
|
"# Python Compatibility",
|
|
"",
|
|
f"Generated at: `{report['generated_at']}`",
|
|
"",
|
|
"## Summary",
|
|
"",
|
|
f"- decision: `{summary['decision']}`",
|
|
f"- target python: `{summary['target_python']}`",
|
|
f"- files scanned: `{summary['file_count']}`",
|
|
f"- issues: `{summary['issue_count']}`",
|
|
f"- syntax errors: `{summary['syntax_error_count']}`",
|
|
f"- f-string 3.11 violations: `{summary['fstring_311_violation_count']}`",
|
|
"",
|
|
"This report catches Python syntax and compatibility hazards that can pass on a newer local interpreter but fail in the supported CI/runtime interpreter.",
|
|
"",
|
|
"## Issues",
|
|
"",
|
|
"| Path | Line | Rule | Message |",
|
|
"| --- | ---: | --- | --- |",
|
|
]
|
|
if report["issues"]:
|
|
for item in report["issues"]:
|
|
message = str(item["message"]).replace("|", "\\|")
|
|
lines.append(f"| `{item['path']}` | {item['line']} | `{item['rule']}` | {message} |")
|
|
else:
|
|
lines.append("| `none` | 0 | `none` | none |")
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Check Python source compatibility for supported CI/runtime versions.")
|
|
parser.add_argument("skill_dir", nargs="?", default=".")
|
|
parser.add_argument("--path", action="append", default=[], help="Optional file or directory to scan relative to skill_dir.")
|
|
parser.add_argument("--target-python", default="3.11")
|
|
parser.add_argument("--output-json", default="reports/python_compatibility.json")
|
|
parser.add_argument("--output-md", default="reports/python_compatibility.md")
|
|
parser.add_argument("--generated-at", default=date.today().isoformat())
|
|
args = parser.parse_args()
|
|
|
|
root = Path(args.skill_dir).resolve()
|
|
report = build_report(root, args.path, args.target_python, args.generated_at)
|
|
output_json = Path(args.output_json)
|
|
output_md = Path(args.output_md)
|
|
if not output_json.is_absolute():
|
|
output_json = root / output_json
|
|
if not output_md.is_absolute():
|
|
output_md = root / output_md
|
|
output_json.parent.mkdir(parents=True, exist_ok=True)
|
|
output_md.parent.mkdir(parents=True, exist_ok=True)
|
|
output_json.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
output_md.write_text(render_markdown(report), encoding="utf-8")
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
raise SystemExit(0 if report["ok"] else 2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|