diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..3a9f141 --- /dev/null +++ b/.clang-format @@ -0,0 +1,76 @@ +# conformallab++ formatting policy +# +# Captures the style already present in code/include/. Documented here +# so clang-format can enforce it locally (scripts/quality/clang-format.sh) +# and so new contributors get the same output their editor would on save. +# +# This is NOT the upstream CGAL clang-format (there isn't one published); +# it's the style our tree already uses, mechanically extracted. + +BasedOnStyle: LLVM +Language: Cpp +Standard: c++17 + +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 100 # loose; readability over hard wrap + +# Brace placement — matches the project tree: +# functions / methods → opening brace on a new line (CGAL convention) +# structs / classes → opening brace on a new line +# else / catch → on the same line as the closing brace of the preceding block +BreakBeforeBraces: Custom +BraceWrapping: + AfterClass: true + AfterStruct: true + AfterEnum: true + AfterFunction: true + AfterNamespace: false + AfterUnion: true + AfterControlStatement: false + BeforeElse: false + BeforeCatch: false + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: false + SplitEmptyNamespace: true + +# Reference & pointer modifiers attach to the type (`int& x`, not `int &x`). +PointerAlignment: Left +ReferenceAlignment: Left + +# Aligned `using = ...` blocks are intentional in the trait classes. +AlignConsecutiveDeclarations: AcrossEmptyLines +AlignConsecutiveAssignments: AcrossEmptyLines +AlignTrailingComments: true +AlignAfterOpenBracket: Align + +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortBlocksOnASingleLine: Never +AllowShortLambdasOnASingleLine: Inline + +# Template-related: break before each parameter when the template line +# would otherwise exceed ColumnLimit (matches existing +# `template ` patterns). +BreakBeforeBinaryOperators: NonAssignment +BinPackParameters: false +BinPackArguments: false +AlwaysBreakTemplateDeclarations: Yes +SpaceAfterTemplateKeyword: true + +NamespaceIndentation: None +AccessModifierOffset: -4 +IndentCaseLabels: false + +# Includes: keep manual ordering — re-ordering can break Eigen / CGAL +# transitive-include assumptions in subtle ways. We just enforce no +# accidental duplicate blank lines. +SortIncludes: Never +MaxEmptyLinesToKeep: 1 +KeepEmptyLinesAtTheStartOfBlocks: false + +# Comments: don't touch. +ReflowComments: false diff --git a/doc/architecture/locked-vs-flexible.md b/doc/architecture/locked-vs-flexible.md index 101f19d..a52a8fa 100644 --- a/doc/architecture/locked-vs-flexible.md +++ b/doc/architecture/locked-vs-flexible.md @@ -266,6 +266,7 @@ mechanical next step rather than a missing piece of theory. | **End-to-end smoke (`try_it.sh`) not in CI.** ✅ Closed by `ci/structural-tests`. Added as a step after the CGAL job. | gate active on every PR | done | | **Internal markdown link checker.** ✅ Closed by `ci/structural-tests`. New `.gitea/workflows/markdown-links.yml` runs on every PR that touches a `*.md` file, plus a weekly cron for external link rot. | gate active on every PR + weekly | done | | **Local quality gates (sanitizers, coverage, clang-tidy, multi-compiler, CGAL-version-matrix, reproducible-build, license-headers).** Eight scripts under `scripts/quality/`, driven by `run-all.sh`. Documented in `scripts/quality/README.md` with promotion-to-CI checklist. | local-only by design; promotion gated on policy text in release-policy.md | done as scripts; 60 SPDX headers missing in `code/include/` will be a follow-up | +| **Code-style / convention gates (clang-format + CGAL-conventions checker).** ✅ Closed by `ci/structural-tests`. Adds `.clang-format` (project style mechanically captured) + `clang-format.sh` (drift detector, `--fix` mode); `cgal-conventions.py` enforces 6 CGAL-specific idioms (include-guard format, `\file` brief, namespace nesting, named-parameter tag `_t` suffix, no `using namespace`, no stray `#define`). Both are intentionally local-only — promotion to CI once the existing tree passes `--strict` (today CGAL-conventions does pass: 0/6 violations across 6 CGAL public headers; clang-format drift TBD pending toolchain install). | done as scripts; promotion deferred | done | | **CP-Euclidean and Inversive-Distance research-track entries.** Both ship a working DCE solver, but lack the auxiliary utilities the Euclidean / HyperIdeal entries have (curvature inspection helpers, edge-flip Delaunay maintenance for ID). Out of port scope; listed in [`research-track.md`](../roadmap/research-track.md). | research-track, not blocking | per-utility | | **`StereographicUnwrapper`, `CircleDomainUnwrapper`, `CuttingUtility`, `KoebePolyhedron`.** Mentioned in roadmap + research-track docs but not yet ported. Java versions still authoritative. | documented as Phase 11+ / optional | weeks each — explicit "out of port scope unless requested" | diff --git a/scripts/quality/README.md b/scripts/quality/README.md index d146f36..8067971 100644 --- a/scripts/quality/README.md +++ b/scripts/quality/README.md @@ -14,10 +14,19 @@ consistency + markdown links + end-to-end smoke via `try_it.sh`). ## What runs locally +### Style / convention gates (run on every commit; cheap) + | Script | What it checks | Wall time | Prereqs | |---|---|---|---| | `license-headers.sh` | every C++ source carries `SPDX-License-Identifier: MIT` | ~1 s | `bash` | +| `cgal-conventions.py` | CGAL-1…6: include-guard format, `\file` brief, namespace nesting, tag-naming, no `using namespace`, no stray `#define` | ~1 s | `python3` | +| `clang-format.sh` | every source file matches `.clang-format` (dry-run by default; `--fix` to apply) | ~2 s | `clang-format` ≥ 15 | | `../check-markdown-links.py` | every internal markdown link resolves | ~2 s | `python3` | + +### Correctness / quality gates (run before tagging or reviewer demos) + +| Script | What it checks | Wall time | Prereqs | +|---|---|---|---| | `sanitizers.sh` | fast test suite under ASan + UBSan | ~3 min | `clang++` ≥ 14 or `g++` ≥ 11 | | `coverage.sh` | gcov/lcov line + branch coverage of `code/include/` | ~2 min | `lcov` | | `clang-tidy.sh` | curated clang-tidy checks over public headers | ~2 min | `clang-tidy` ≥ 14, `.clang-tidy` | diff --git a/scripts/quality/cgal-conventions.py b/scripts/quality/cgal-conventions.py new file mode 100755 index 0000000..4ae1f4d --- /dev/null +++ b/scripts/quality/cgal-conventions.py @@ -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___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 "" + 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()) diff --git a/scripts/quality/clang-format.sh b/scripts/quality/clang-format.sh new file mode 100755 index 0000000..4d3d58d --- /dev/null +++ b/scripts/quality/clang-format.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# scripts/quality/clang-format.sh +# +# Verify every source file under code/{include,src,tests} matches the +# project's `.clang-format` policy. Runs in dry-run mode by default — +# only reports diffs; never edits files. +# +# Pass `--fix` to apply the suggested formatting in place. +# +# Local-only. Promotion to CI is intended once the existing tree is +# 100 %-conformant; today we report drift but don't fail (the script +# exits non-zero only with `--strict`). +# +# Usage: +# bash scripts/quality/clang-format.sh # dry-run, exit 0 always +# bash scripts/quality/clang-format.sh --strict # dry-run, exit 1 on drift +# bash scripts/quality/clang-format.sh --fix # apply changes +# +# Exit codes: +# 0 no drift, or drift but --strict not set +# 1 drift detected AND --strict (or fixes applied AND --fix) +# 2 prerequisite missing + +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +command -v clang-format >/dev/null 2>&1 || { + echo "FAIL: clang-format not in PATH." >&2 + echo " macOS: brew install clang-format" >&2 + echo " Linux: sudo apt install clang-format" >&2 + exit 2 +} + +STRICT=0 +FIX=0 +for arg in "$@"; do + case "$arg" in + --strict) STRICT=1 ;; + --fix) FIX=1 ;; + *) echo "Unknown arg: $arg" >&2; exit 2 ;; + esac +done + +FILES_LIST="$(find code/include code/src code/tests \ + \( -name "*.h" -o -name "*.hpp" -o -name "*.cpp" \) \ + -type f 2>/dev/null \ + | grep -v "^code/deps/" \ + | grep -v " 2\." \ + | sort)" + +n_total=0 +n_drift=0 +drift_list="" +while IFS= read -r f; do + [ -z "$f" ] && continue + n_total=$((n_total + 1)) + if [ "$FIX" -eq 1 ]; then + clang-format -i "$f" + else + # --dry-run + -Werror sets non-zero exit when changes would be made. + if ! clang-format --dry-run -Werror "$f" >/dev/null 2>&1; then + n_drift=$((n_drift + 1)) + drift_list="$drift_list $f +" + fi + fi +done <