quality: add code-style + CGAL-convention checkers (local-only)
Closes the gap "no code-quality / convention gate" from the structural
review. Three new artefacts, all local-only (CI promotion deferred
until the existing tree is 100 % clean under each):
1. .clang-format — project's existing style mechanically captured
(4-space indent, opening brace on new line for class/struct/function,
left-aligned pointer/reference modifiers, aligned `using = ...` blocks,
100-col loose limit, no include re-ordering — matches code/include/
today).
2. scripts/quality/clang-format.sh — drift detector. Dry-run mode by
default (always exits 0); --strict to fail on drift; --fix to apply
suggested changes in place. Skips code/deps/ and macOS-duplicate
files.
3. scripts/quality/cgal-conventions.py — checker for the CGAL idioms
that clang-format/clang-tidy cannot express:
CGAL-1 include-guard format `CGAL_<DIRS>_<FILE>_H`
CGAL-2 every public header has a `\\file` Doxygen brief
CGAL-3 no nested namespaces beyond the allowed set
(CGAL::parameters, CGAL::Conformal_map, internal_np, IO)
CGAL-4 named-parameter tag types end in `_t`; value object does not
CGAL-5 no `using namespace ...` at file scope (header leakage)
CGAL-6 no #define beyond CGAL_* / include-guard
Result on the current tree: 6 CGAL public headers, 0 violations.
The checker therefore doubles as documentation of the conventions
we already follow.
Both are wired into scripts/quality/run-all.sh's fast subset (~5 s
combined wall time). README.md updated to split the gates into a
"style/convention" group (cheap, run-on-every-commit material) and a
"correctness/quality" group (slow, run-before-tag material).
The reviewer-facing locked-vs-flexible.md gains another "✅ Closed"
row documenting both gates and the 0-violation baseline.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
207
scripts/quality/cgal-conventions.py
Executable file
207
scripts/quality/cgal-conventions.py
Executable file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""scripts/quality/cgal-conventions.py
|
||||
|
||||
Lightweight checker for the CGAL-style conventions that conformallab++
|
||||
aims to follow, based on the
|
||||
"CGAL Developer's Manual / Package Submission Checklist". These rules
|
||||
are NOT covered by clang-format or clang-tidy; they are project-/CGAL-
|
||||
specific, so we encode them here as pure-Python AST/regex checks.
|
||||
|
||||
Rules enforced (each can be silenced per-file via a comment marker —
|
||||
see RULES dict below):
|
||||
|
||||
CGAL-1 Include guard format: every CGAL/* header has a guard of the
|
||||
form `CGAL_<DIRS>_<FILENAME>_H` matching its repo path.
|
||||
CGAL-2 Every public header has a `\\file` Doxygen brief in its top
|
||||
comment block.
|
||||
CGAL-3 Public CGAL API symbols (functions, classes, structs) live
|
||||
directly in `namespace CGAL { ... }`, not in nested namespaces
|
||||
that the user must qualify (except `CGAL::parameters`,
|
||||
`CGAL::Conformal_map::internal_np`).
|
||||
CGAL-4 Named-parameter tag types end in `_t`; the matching value
|
||||
object does not (e.g. `vertex_curvature_map_t` /
|
||||
`vertex_curvature_map`).
|
||||
CGAL-5 No `using namespace ...` at file scope in public headers
|
||||
(would leak into every translation unit that includes us).
|
||||
CGAL-6 No `#define` (other than include-guard, header-marker, or
|
||||
CGAL_*) in public headers — macros leak unconditionally.
|
||||
|
||||
The checker only inspects `code/include/CGAL/`. Conformallab's own
|
||||
`conformallab::` namespace under `code/include/*.hpp` is non-CGAL-public
|
||||
and uses its own (looser) conventions.
|
||||
|
||||
Exit codes:
|
||||
0 every rule passes
|
||||
1 at least one violation was found
|
||||
2 prerequisite missing
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os, re, sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
CGAL_DIR = os.path.join(ROOT, "code", "include", "CGAL")
|
||||
|
||||
if not os.path.isdir(CGAL_DIR):
|
||||
print(f"FAIL: {CGAL_DIR} not found", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Namespaces that are *intentionally* nested under CGAL.
|
||||
ALLOWED_NESTED = {
|
||||
"CGAL::parameters",
|
||||
"CGAL::Conformal_map",
|
||||
"CGAL::Conformal_map::internal_np",
|
||||
"CGAL::internal_np", # CGAL upstream
|
||||
"CGAL::IO", # CGAL upstream
|
||||
}
|
||||
|
||||
# Macros that may appear in public headers.
|
||||
ALLOWED_DEFINE_RE = re.compile(r"^\s*#\s*define\s+(CGAL_[A-Z0-9_]+|[A-Z0-9_]+_H)\b")
|
||||
|
||||
|
||||
def find_headers() -> list[str]:
|
||||
out: list[str] = []
|
||||
for dirpath, dirs, files in os.walk(CGAL_DIR):
|
||||
for f in files:
|
||||
if not (f.endswith(".h") or f.endswith(".hpp")):
|
||||
continue
|
||||
if " 2." in f: # macOS duplicate artefact
|
||||
continue
|
||||
out.append(os.path.join(dirpath, f))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def expected_guard(path: str) -> str:
|
||||
"""The expected include-guard symbol for the given header path."""
|
||||
rel = os.path.relpath(path, ROOT) # e.g. code/include/CGAL/Discrete_conformal_map.h
|
||||
# Strip the `code/include/` prefix to match CGAL upstream practice.
|
||||
if rel.startswith("code/include/"):
|
||||
rel = rel[len("code/include/"):]
|
||||
# CGAL/Discrete_conformal_map.h → CGAL_DISCRETE_CONFORMAL_MAP_H
|
||||
stem = rel.replace("/", "_").replace(".", "_")
|
||||
return stem.upper()
|
||||
|
||||
|
||||
def check_rule_1_include_guard(text: str, path: str) -> list[str]:
|
||||
expected = expected_guard(path)
|
||||
if f"#ifndef {expected}" not in text:
|
||||
# Find what guard was actually used, for a helpful message.
|
||||
m = re.search(r"^\s*#ifndef\s+(\w+)", text, re.MULTILINE)
|
||||
actual = m.group(1) if m else "<none>"
|
||||
return [f"CGAL-1: include guard is `{actual}`, expected `{expected}`"]
|
||||
return []
|
||||
|
||||
|
||||
def check_rule_2_file_brief(text: str, path: str) -> list[str]:
|
||||
# \file or @file must appear somewhere in the first 40 lines.
|
||||
head = "\n".join(text.splitlines()[:40])
|
||||
if re.search(r"[\\@]file\b", head):
|
||||
return []
|
||||
return [f"CGAL-2: no `\\file` brief in top-of-file comment block"]
|
||||
|
||||
|
||||
_NAMESPACE_RE = re.compile(r"^\s*namespace\s+(\w+)\s*\{", re.MULTILINE)
|
||||
|
||||
|
||||
def check_rule_3_nested_namespace(text: str, path: str) -> list[str]:
|
||||
# Walk top-level namespace declarations. We approximate with regex
|
||||
# (not a real AST) — good enough for the CGAL header style.
|
||||
bad = []
|
||||
stack: list[str] = []
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
m = re.match(r"^\s*namespace\s+(\w+)\s*\{?\s*$", line)
|
||||
if m:
|
||||
stack.append(m.group(1))
|
||||
full = "::".join(stack)
|
||||
if len(stack) >= 2 and stack[0] == "CGAL":
|
||||
if full not in ALLOWED_NESTED and not full.startswith("CGAL::internal"):
|
||||
bad.append(f"CGAL-3: nested namespace `{full}` at line {line_no}")
|
||||
elif re.match(r"^\s*\}\s*//\s*namespace\b", line) or re.match(r"^\s*\}\s*//\s*\w+", line):
|
||||
if stack:
|
||||
stack.pop()
|
||||
return bad
|
||||
|
||||
|
||||
_TAG_ENUM_RE = re.compile(
|
||||
r"^\s*enum\s+(\w+)_t\s*\{\s*(\w+)\s*\}\s*;", re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
def check_rule_4_tag_naming(text: str, path: str) -> list[str]:
|
||||
bad = []
|
||||
for m in _TAG_ENUM_RE.finditer(text):
|
||||
tag_t = m.group(1) # e.g. "vertex_curvature_map"
|
||||
value = m.group(2) # e.g. "vertex_curvature_map"
|
||||
if value != tag_t:
|
||||
bad.append(
|
||||
f"CGAL-4: tag `{tag_t}_t` enclosing value `{value}` "
|
||||
f"(expected `{tag_t}`)"
|
||||
)
|
||||
return bad
|
||||
|
||||
|
||||
def check_rule_5_using_namespace(text: str, path: str) -> list[str]:
|
||||
bad = []
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
if re.match(r"^\s*using\s+namespace\s+\w", line):
|
||||
bad.append(f"CGAL-5: `using namespace ...` at line {line_no} "
|
||||
"(leaks into every TU that includes this header)")
|
||||
return bad
|
||||
|
||||
|
||||
def check_rule_6_defines(text: str, path: str) -> list[str]:
|
||||
bad = []
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
if re.match(r"^\s*#\s*define\s+", line) and not ALLOWED_DEFINE_RE.match(line):
|
||||
bad.append(f"CGAL-6: `{line.strip()}` at line {line_no} "
|
||||
"(only CGAL_* or include-guard macros allowed)")
|
||||
return bad
|
||||
|
||||
|
||||
CHECKS = [
|
||||
check_rule_1_include_guard,
|
||||
check_rule_2_file_brief,
|
||||
check_rule_3_nested_namespace,
|
||||
check_rule_4_tag_naming,
|
||||
check_rule_5_using_namespace,
|
||||
check_rule_6_defines,
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
headers = find_headers()
|
||||
if not headers:
|
||||
print("FAIL: no headers under code/include/CGAL/", file=sys.stderr)
|
||||
return 2
|
||||
total_violations = 0
|
||||
files_with_issues = 0
|
||||
for h in headers:
|
||||
try:
|
||||
with open(h, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except OSError as e:
|
||||
print(f"WARN: cannot read {h}: {e}", file=sys.stderr)
|
||||
continue
|
||||
violations: list[str] = []
|
||||
for check in CHECKS:
|
||||
violations.extend(check(text, h))
|
||||
if violations:
|
||||
files_with_issues += 1
|
||||
total_violations += len(violations)
|
||||
rel = os.path.relpath(h, ROOT)
|
||||
print(f"\n{rel}:")
|
||||
for v in violations:
|
||||
print(f" - {v}")
|
||||
|
||||
print()
|
||||
print("─" * 60)
|
||||
print(f"Checked {len(headers)} CGAL headers.")
|
||||
print(f" files with issues: {files_with_issues}")
|
||||
print(f" total violations: {total_violations}")
|
||||
if total_violations == 0:
|
||||
print("OK: every CGAL convention rule passes.")
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user