Files
ConformalLabpp/scripts/quality/cgal-conventions 2.py
Tarik Moussa 7b097fbdd1
Some checks failed
C++ Tests / test-fast (pull_request) Successful in 2m2s
API Docs / doc-build (pull_request) Successful in 46s
Markdown link check / check (pull_request) Successful in 47s
C++ Tests / test-cgal (pull_request) Failing after 10m51s
C++ Tests / quality-gates (pull_request) Successful in 2m21s
ci+licenses: promote 4 trivial gates to required CI + third-party license doc
Two reviewer-facing additions:

1. New `quality-gates` job in .gitea/workflows/cpp-tests.yml
   ──────────────────────────────────────────────────────────
   Runs in parallel with test-cgal after test-fast.  Installs
   `codespell` + `shellcheck` (apt) into the existing ci-cpp container,
   then executes four scripts strictly (exit 1 on any finding):
     * license-headers.sh   — 66/66 files carry SPDX MIT
     * cgal-conventions.py  — 0 violations across 6 CGAL public headers
     * codespell.sh         — 0 typos across docs + source + scripts
     * shellcheck.sh        — 0 findings across 16 shell scripts

   Each ran at 0 findings locally for weeks before promotion.  The
   gates are now contractual: a regression fails the PR.  Total
   wall-time on the eulernest runner: ~30 s.

2. New code/deps/THIRD-PARTY-LICENSES.md
   ──────────────────────────────────────
   Enumerates every vendored dependency under code/deps/, plus the
   auto-fetched GoogleTest, plus the system-required Boost, with:
     * upstream project + version + SPDX identifier
     * compatibility note for MIT distribution
     * a downstream-packager license matrix (header-only consumer
       vs CLI binary) clarifying the LGPL §3 vs §4 distinction
       relevant to CGAL's header-only consumption

   Required for any future Linux-distribution packaging and for the
   CGAL submission's compliance check.  Cross-referenced from
   doc/architecture/dependencies.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 20:06:58 +02:00

208 lines
7.4 KiB
Python
Executable File

#!/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())