414 lines
21 KiB
Python
414 lines
21 KiB
Python
import argparse
|
|
import json
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path, PurePosixPath
|
|
from urllib.parse import unquote
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from nid_names import CPP_TOKEN
|
|
|
|
RULES = {
|
|
"comment": ("error", "docs/dev/CONVENTIONS.md", "Comments are only for technical debt, so they need a change to docs/dev/TechnicalDebt.md or docs/dev/debt/ in the same pull request; #endif and namespace ends are always allowed"),
|
|
"silent-stub": ("error", "CONTRIBUTING.md#code", "Unimplemented exports call NotImplemented_nid_no_patch(__func__); silent stubs are listed under Silent stubs in docs/dev/debt/<library>.md, by their file or by name with a path in their library"),
|
|
"debt-file": ("error", "CONTRIBUTING.md#documentation", "Technical debt about one library or component goes in docs/dev/debt/<name>.md, named after the first core/ path it links: <library> for core/libs/prx/<library>, otherwise <component> for core/<component>"),
|
|
"duplicate-export": ("error", "CONTRIBUTING.md#code", "Already exported by another library: remove the copy, or use APS5_DUMMY_FUN if nothing is left"),
|
|
"system-dependency": ("error", "CONTRIBUTING.md#code", "Third-party code is a submodule under 3rdparty/ built from source, not found on the system"),
|
|
"title-specific": ("error", "CONTRIBUTING.md#code", "Implement the general behaviour, not what one title needs; title-specific code belongs in its sce_module"),
|
|
"extension": ("error", "CONTRIBUTING.md#code", "Avoid non-standard extensions where standard C++ is enough"),
|
|
"notes-file": ("error", "CONTRIBUTING.md#branches-and-pull-requests", "Notes, investigation and agent files go in the pull request, not in the repository"),
|
|
"binary": ("error", "docs/dev/CONVENTIONS.md", "Only UTF-8 text files in the repository; images go in the gist comments"),
|
|
"ci-gate": ("error", "CONTRIBUTING.md#build-and-test", "A job that runs for pull requests needs a file filter: paths on the pull_request trigger, or needs eligibility and an if on its outputs"),
|
|
"doc-link": ("error", "CONTRIBUTING.md#documentation", "Relative link to a file that does not exist"),
|
|
"commit-subject": ("error", "docs/dev/CONVENTIONS.md", "Commit subject is not Conventional Commits"),
|
|
"pr-title": ("error", "docs/dev/CONVENTIONS.md", "Pull request title is not Conventional Commits"),
|
|
"pr-template": ("error", ".github/pull_request_template.md", "Fill in the pull request template"),
|
|
}
|
|
|
|
CPP = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".inl", ".ipp"}
|
|
IMAGES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp", ".ico", ".tga", ".dds"}
|
|
NOTES = {".md", ".markdown", ".rst", ".log", ".patch", ".diff"}
|
|
AGENT_FILES = {"agents.md", "claude.md", "gemini.md", ".cursorrules", "copilot-instructions.md"}
|
|
DEBT = "docs/dev/TechnicalDebt.md"
|
|
DEBT_DIR = "docs/dev/debt/"
|
|
DEBT_TARGET = re.compile(r"\]\((?:\.\./)+(core/[^)#\s]+)")
|
|
|
|
CONVENTIONAL = re.compile(r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: \S")
|
|
STRINGS = re.compile(r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'')
|
|
ALLOWED_COMMENT = re.compile(r"^\s*(#\s*endif\b|}\s*;?\s*//\s*(end\s+)?namespace\b)")
|
|
EXPORT = re.compile(r"\bAPS5_VABI\s+(\w+)\s*\(")
|
|
STUB_BODY = re.compile(r"^(?:\(void\)\s*\w+\s*;|static_cast<void>\(\s*\w+\s*\)\s*;)*"
|
|
r"(?:return\s*\(?\s*(?:-?(?:0x[0-9a-fA-F]+|\d+)[uUlL]*|nullptr|true|false|\w*_OK)?\s*\)?\s*;)?$")
|
|
SYSTEM_DEPENDENCY = re.compile(r"\b(find_package\s*\((?!\s*(Python3|Threads|Git)\b)|pkg_check_modules\b|pkg_search_module\b|find_(library|path|program|file)\b)", re.I)
|
|
TITLE = re.compile(r"\b(PPSA|CUSA)\d{5}\b")
|
|
EXTENSION = re.compile(r"__attribute__|__declspec|#\s*pragma\s+(?!once\b)")
|
|
LINK = re.compile(r"\]\(([^)\s]+)")
|
|
SECTION = re.compile(r"^###(?!#)\s*(.+?)\s*$", re.M)
|
|
KEY = re.compile(r"^( *)([A-Za-z0-9_-]+|'[^']*'|\"[^\"]*\"):(?:\s+(.*?))?\s*$")
|
|
|
|
|
|
def git(*args, text=True):
|
|
output = subprocess.run(["git", "-c", "core.quotePath=false", *args], capture_output=True, check=True).stdout
|
|
return output.decode("utf-8", errors="replace") if text else output
|
|
|
|
|
|
class Check:
|
|
def __init__(self, base, head):
|
|
self.head = head
|
|
self.base = git("merge-base", base, head).strip()
|
|
self.findings = []
|
|
self.added = defaultdict(list)
|
|
self.status = {}
|
|
self.numstat = {}
|
|
entries = [entry.split("\t", 1) for entry in git("ls-tree", "-rz", "--full-tree", head).split("\0") if entry]
|
|
self.modes = {path: metadata.split()[0] for metadata, path in entries}
|
|
self.tree = set(self.modes)
|
|
self.dirs = {str(parent) for path in self.tree for parent in PurePosixPath(path).parents}
|
|
statuses = git("diff", "--name-status", "--no-renames", "-z", self.base, head).split("\0")
|
|
for status, path in zip(statuses[::2], statuses[1::2]):
|
|
self.status[path] = status
|
|
for line in git("diff", "--numstat", "--no-renames", "-z", self.base, head).split("\0"):
|
|
if not line:
|
|
continue
|
|
added, deleted, path = line.split("\t", 2)
|
|
self.numstat[path] = (added, deleted)
|
|
paths = iter(self.status)
|
|
path, number = None, None
|
|
for line in git("diff", "-U0", "--no-color", "--no-renames", self.base, head).split("\n"):
|
|
if line.startswith("diff --git "):
|
|
path, number = next(paths), None
|
|
elif line.startswith("@@"):
|
|
number = int(re.search(r"\+(\d+)", line).group(1))
|
|
elif path and number is not None and line.startswith("+"):
|
|
self.added[path].append((number, line[1:]))
|
|
number += 1
|
|
|
|
def report(self, rule, path, line, detail=""):
|
|
self.findings.append((rule, path, line, detail))
|
|
|
|
def files(self, predicate):
|
|
return [(path, lines) for path, lines in self.added.items() if not path.startswith("3rdparty/") and predicate(PurePosixPath(path))]
|
|
|
|
def show(self, path):
|
|
return git("show", f"{self.head}:{path}")
|
|
|
|
def code(self):
|
|
debt = [entry for path in sorted(self.tree) if is_debt(path) for entry in silent_stub_debt(self.show(path), path)]
|
|
exports = {}
|
|
for path, lines in self.files(lambda p: p.suffix in CPP):
|
|
core = path.startswith("core/") and "tests" not in PurePosixPath(path).parts
|
|
source = self.show(path).split("\n")
|
|
masked = cpp_text("\n".join(source), preserve_comments=True).split("\n")
|
|
for number, text in lines:
|
|
bare = masked[number - 1]
|
|
if ("//" in bare or "/*" in bare) and not ALLOWED_COMMENT.match(bare):
|
|
self.report("comment", path, number)
|
|
if EXTENSION.search(bare):
|
|
self.report("extension", path, number, EXTENSION.search(bare).group(0))
|
|
if core and TITLE.search(text):
|
|
self.report("title-specific", path, number, TITLE.search(text).group(0))
|
|
match = EXPORT.search(bare)
|
|
if match and core and path.startswith("core/libs/prx/") and not match.group(1).endswith(("_nid_no_patch", "_nid_no_patch_cut")):
|
|
source = source or self.show(path).splitlines()
|
|
body = function_body(source, number - 1)
|
|
if body is None:
|
|
continue
|
|
exports[match.group(1)] = (path, number)
|
|
if match.group(1).startswith("sce") and STUB_BODY.match(body) and not listed(debt, match.group(1), path):
|
|
self.report("silent-stub", path, number, match.group(1))
|
|
self.duplicates(exports)
|
|
for path, lines in self.files(lambda p: p.suffix in (".py", ".cmake") or p.name == "CMakeLists.txt"):
|
|
for number, text in lines:
|
|
if "#" in STRINGS.sub('""', text) and not text.startswith("#!"):
|
|
self.report("comment", path, number)
|
|
if any(is_debt(path) for path in self.status):
|
|
self.findings = [finding for finding in self.findings if finding[0] != "comment"]
|
|
|
|
def duplicates(self, exports):
|
|
if not exports:
|
|
return
|
|
pattern = r"(^|[^[:alnum:]_])APS5_VABI[[:space:]]+(" + "|".join(exports) + r")[[:space:]]*\("
|
|
result = subprocess.run(["git", "grep", "-nE", "--full-name", pattern, self.head, "--", ":(top)core/libs/prx"], capture_output=True, encoding="utf-8", errors="replace")
|
|
for line in filter(None, result.stdout.split("\n")):
|
|
_, path, number, text = line.split(":", 3)
|
|
name = EXPORT.search(text).group(1)
|
|
own, own_number = exports[name]
|
|
if library(path) != library(own) and "tests" not in PurePosixPath(path).parts and function_body(self.show(path).splitlines(), int(number) - 1) is not None:
|
|
self.report("duplicate-export", own, own_number, f"{name} is also in {library(path)}")
|
|
|
|
def build(self):
|
|
for path, lines in self.files(lambda p: p.name == "CMakeLists.txt" or p.suffix == ".cmake"):
|
|
if path == "cmake/SystemDeps.cmake":
|
|
continue
|
|
for number, text in lines:
|
|
if SYSTEM_DEPENDENCY.search(text.split("#", 1)[0]):
|
|
self.report("system-dependency", path, number, text.strip())
|
|
for number, text in self.added.get(".gitmodules", []):
|
|
match = re.match(r"\s*path\s*=\s*(\S+)", text)
|
|
if match and not match.group(1).startswith("3rdparty/"):
|
|
self.report("system-dependency", ".gitmodules", number, match.group(1))
|
|
|
|
def submodules(self):
|
|
result = subprocess.run(["git", "config", "--no-includes", "-z", "--blob", f"{self.head}:.gitmodules", "--list"],
|
|
capture_output=True, encoding="utf-8", errors="replace")
|
|
values = {key: value for key, _, value in (entry.partition("\n") for entry in result.stdout.split("\0") if entry)}
|
|
return {path for key, path in values.items() if key.endswith(".path") and values.get(f"{key[:-5]}.url")}
|
|
|
|
def repository(self):
|
|
gitlinks = {path for path, mode in self.modes.items() if mode == "160000" and path.startswith("3rdparty/")}
|
|
checked = gitlinks if ".gitmodules" in self.status else gitlinks & self.status.keys()
|
|
if checked:
|
|
for path in sorted(checked - self.submodules()):
|
|
self.report("system-dependency", path, 0, "submodule without a url in .gitmodules")
|
|
for path, (added, deleted) in self.numstat.items():
|
|
if path.startswith("3rdparty/"):
|
|
continue
|
|
if self.modes.get(path) == "160000":
|
|
self.report("system-dependency", path, 0, "submodule outside 3rdparty/")
|
|
continue
|
|
suffix = PurePosixPath(path).suffix.lower()
|
|
if self.status.get(path) != "D" and (added == "-" or suffix in IMAGES or not utf8(git("show", f"{self.head}:{path}", text=False))):
|
|
self.report("binary", path, 0)
|
|
if self.status.get(path) == "A" and allowed_notes(path) is False:
|
|
self.report("notes-file", path, 0)
|
|
|
|
def docs(self):
|
|
for path, lines in self.files(lambda p: p.suffix == ".md"):
|
|
for number, text in lines:
|
|
name = debt_name(text) if is_debt(path) and text.startswith("- ") else None
|
|
if name and path != f"{DEBT_DIR}{name}.md":
|
|
self.report("debt-file", path, number, f"{DEBT_DIR}{name}.md")
|
|
for target, resolved in links(path, text):
|
|
if resolved not in self.tree and resolved not in self.dirs:
|
|
self.report("doc-link", path, number, target)
|
|
|
|
def workflows(self):
|
|
for path in sorted(self.status):
|
|
if self.status[path] == "D" or not re.fullmatch(r"\.github/workflows/[^/]+\.ya?ml", path):
|
|
continue
|
|
source = self.show(path).splitlines()
|
|
lines = [(number, KEY.match(re.sub(r"(^|\s)#.*", "", text))) for number, text in enumerate(source, 1)]
|
|
lines = [(number, len(match[1]), match[2].strip("'\""), match[3] or "") for number, match in lines if match]
|
|
triggers = [entry for entry in lines if entry[1] == 0 and entry[2] == "on"]
|
|
if not triggers or pull_request_filtered(lines, triggers[0]):
|
|
continue
|
|
for number, name, needs, condition in jobs(lines, source):
|
|
if name != "eligibility" and ("eligibility" not in needs or "needs.eligibility.outputs." not in condition):
|
|
self.report("ci-gate", path, number, name)
|
|
|
|
def commits(self):
|
|
for line in git("log", "--no-merges", "--format=%h %s", f"{self.base}..{self.head}").splitlines():
|
|
sha, subject = line.split(" ", 1)
|
|
if not CONVENTIONAL.match(subject):
|
|
self.report("commit-subject", "", 0, f"{sha} {subject}")
|
|
|
|
def pull_request(self, title, body):
|
|
if not CONVENTIONAL.match(title):
|
|
self.report("pr-title", "", 0, title)
|
|
body = re.sub(r"<!--.*?-->", "", body or "", flags=re.S)
|
|
sections = dict(zip(SECTION.findall(body), SECTION.split(body)[2::2]))
|
|
for name in ("What", "Tested", "Checklist"):
|
|
if not sections.get(name, "").strip():
|
|
self.report("pr-template", "", 0, f"section '{name}' is missing or empty")
|
|
checklist = sections.get("Checklist", "")
|
|
for item in re.findall(r"^\s*[-*+]\s*\[ \]\s*(.+)$", checklist, re.M):
|
|
self.report("pr-template", "", 0, f"unchecked: {item.strip()}")
|
|
if checklist and not re.search(r"AI-assisted:\s*(yes|no)\b", checklist, re.I):
|
|
self.report("pr-template", "", 0, "answer AI-assisted with yes or no")
|
|
if checklist and not re.search(r"Depends on:\s*(#\d+|none)\b", checklist, re.I):
|
|
self.report("pr-template", "", 0, "fill Depends on with #N or none")
|
|
|
|
|
|
def utf8(data):
|
|
try:
|
|
data.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def links(path, text):
|
|
result = []
|
|
for target in LINK.findall(text):
|
|
if re.match(r"[A-Za-z][A-Za-z0-9+.-]*:|//|#", target):
|
|
continue
|
|
target = target.split("#", 1)[0].split("?", 1)[0]
|
|
decoded = unquote(target)
|
|
result.append((target, posixpath.normpath(decoded.lstrip("/") if decoded.startswith("/") else str(PurePosixPath(path).parent / decoded))))
|
|
return result
|
|
|
|
|
|
def is_debt(path):
|
|
return path == DEBT or (path.startswith(DEBT_DIR) and path.endswith(".md") and "/" not in path[len(DEBT_DIR):])
|
|
|
|
|
|
def debt_name(text):
|
|
match = DEBT_TARGET.search(text)
|
|
if not match:
|
|
return None
|
|
parts = match.group(1).split("/")
|
|
return parts[3] if parts[1:3] == ["libs", "prx"] and len(parts) > 3 else parts[1]
|
|
|
|
|
|
def silent_stub_debt(text, path=DEBT):
|
|
debt = []
|
|
section_level = 0
|
|
for line in text.splitlines():
|
|
heading = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
|
|
if heading:
|
|
level = len(heading[1])
|
|
if heading[2] == "Silent stubs":
|
|
section_level = level
|
|
elif level <= section_level:
|
|
section_level = 0
|
|
if section_level:
|
|
debt.append((line, links(path, line)))
|
|
return debt
|
|
|
|
|
|
def block(lines, start):
|
|
_, indent, _, _ = lines[start]
|
|
end = start + 1
|
|
while end < len(lines) and lines[end][1] > indent:
|
|
end += 1
|
|
return lines[start + 1:end]
|
|
|
|
|
|
def pull_request_filtered(lines, trigger):
|
|
_, _, _, value = trigger
|
|
if value:
|
|
return "pull_request" not in re.findall(r"[A-Za-z_]+", value)
|
|
triggers = block(lines, lines.index(trigger))
|
|
for index, (_, indent, name, value) in enumerate(triggers):
|
|
if name == "pull_request" and indent == triggers[0][1]:
|
|
return any(entry[2] in ("paths", "paths-ignore") for entry in block(triggers, index))
|
|
return True
|
|
|
|
|
|
def field(source, entry):
|
|
number, indent, _, value = entry
|
|
for line in (line for line in source[number:] if line.strip()):
|
|
if len(line) - len(line.lstrip(" ")) <= indent:
|
|
break
|
|
value += " " + line.strip()
|
|
return value
|
|
|
|
|
|
def jobs(lines, source):
|
|
top = [index for index, entry in enumerate(lines) if entry[1] == 0 and entry[2] == "jobs"]
|
|
if not top:
|
|
return []
|
|
entries = block(lines, top[0])
|
|
result = []
|
|
for index, (number, indent, name, _) in enumerate(entries):
|
|
if indent != entries[0][1]:
|
|
continue
|
|
fields = {entry[2]: field(source, entry) for entry in block(entries, index) if entry[1] == indent + 2}
|
|
result.append((number, name, re.findall(r"[A-Za-z0-9_-]+", fields.get("needs", "")), fields.get("if", "")))
|
|
return result
|
|
|
|
|
|
def listed(debt, name, path):
|
|
own = PurePosixPath(path).parts[:4]
|
|
for text, targets in debt:
|
|
for _, resolved in targets:
|
|
if resolved == path or (PurePosixPath(resolved).parts[:4] == own and re.search(rf"\b{name}\b", text)):
|
|
return True
|
|
return False
|
|
|
|
|
|
def library(path):
|
|
return PurePosixPath(path).parts[3]
|
|
|
|
|
|
def allowed_notes(path):
|
|
p = PurePosixPath(path)
|
|
if p.name.lower() in AGENT_FILES:
|
|
return False
|
|
if p.suffix.lower() not in NOTES:
|
|
return None
|
|
return path in ("README.md", "CONTRIBUTING.md") or p.parts[0] == ".github" or p.parts[:2] in (("docs", "dev"), ("docs", "user"))
|
|
|
|
|
|
def cpp_text(text, preserve_comments=False):
|
|
def mask(match):
|
|
if match.group("code") is not None or preserve_comments and match.group("comment") is not None:
|
|
return match.group()
|
|
blank = re.sub(r"[^\n]", " ", match.group())
|
|
return blank if match.group("comment") is not None else '""' + blank[2:]
|
|
|
|
return CPP_TOKEN.sub(mask, text)
|
|
|
|
|
|
def function_body(lines, start):
|
|
text = cpp_text("\n".join(lines[start:start + 400]))
|
|
brace, semicolon = text.find("{"), text.find(";")
|
|
if brace < 0 or 0 <= semicolon < brace:
|
|
return None
|
|
depth = 0
|
|
for index in range(brace, len(text)):
|
|
depth += {"{": 1, "}": -1}.get(text[index], 0)
|
|
if depth == 0:
|
|
return re.sub(r"\s+", " ", text[brace + 1:index]).replace(" ", "")
|
|
return None
|
|
|
|
|
|
def output(check, repo):
|
|
errors = 0
|
|
rows = []
|
|
actions = os.environ.get("GITHUB_ACTIONS") == "true"
|
|
for rule, path, line, detail in sorted(check.findings, key=lambda f: (not f[1], f[1], f[2], f[0])):
|
|
level, doc, message = RULES[rule]
|
|
errors += level == "error"
|
|
text = f"{message}" + (f": {detail}" if detail else "")
|
|
if actions:
|
|
escaped_path = path.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A").replace(":", "%3A").replace(",", "%2C")
|
|
location = f" file={escaped_path},line={max(line, 1)}," if path else " "
|
|
print(f"::{level}{location}title={rule}::" + text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A"))
|
|
else:
|
|
print(f"{path or '(pull request)'}{f':{line}' if line else ''}: {level}: [{rule}] {text}")
|
|
where = f"`{path}:{line}`" if line else f"`{path}`" if path else ""
|
|
cell = text.replace("|", "\\|")
|
|
rows.append(f"| {level} | [{rule}](https://github.com/{repo}/blob/main/{doc}) | {where} | {cell} |")
|
|
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
if summary:
|
|
with open(summary, "a", encoding="utf-8") as out:
|
|
if rows:
|
|
out.write("| Level | Rule | Where | What |\n|---|---|---|---|\n" + "\n".join(rows) + "\n")
|
|
else:
|
|
out.write("All conventions checks passed.\n")
|
|
print(f"{len(rows)} findings, {errors} errors; GitHub shows at most 10 error annotations, the job summary lists all"
|
|
if rows else "All conventions checks passed.")
|
|
return 1 if errors else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
if hasattr(sys.stderr, "reconfigure"):
|
|
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
parser = argparse.ArgumentParser(description="check a branch against the contributing rules; errors fail the check")
|
|
parser.add_argument("--base", default="origin/main", help="branch the pull request targets (default: origin/main)")
|
|
parser.add_argument("--head", default="HEAD")
|
|
parser.add_argument("--title", help="pull request title to check")
|
|
parser.add_argument("--body", help="file with the pull request description to check")
|
|
parser.add_argument("--event", default=os.environ.get("GITHUB_EVENT_PATH"), help="GitHub event JSON (set in CI)")
|
|
args = parser.parse_args()
|
|
check = Check(args.base, args.head)
|
|
check.code()
|
|
check.build()
|
|
check.repository()
|
|
check.docs()
|
|
check.workflows()
|
|
check.commits()
|
|
pr = json.load(open(args.event, encoding="utf-8")).get("pull_request") if args.event else None
|
|
if pr:
|
|
check.pull_request(pr["title"], pr["body"])
|
|
elif args.title is not None or args.body:
|
|
check.pull_request(args.title or "", open(args.body, encoding="utf-8-sig").read() if args.body else "")
|
|
raise SystemExit(output(check, os.environ.get("GITHUB_REPOSITORY", "boykopovar/AnyPS5")))
|