ci+quality: structural gates (CI: 3 new; local: 7 new + .clang-tidy)
Some checks failed
C++ Tests / test-fast (pull_request) Successful in 1m56s
API Docs / doc-build (pull_request) Successful in 58s
Markdown link check / check (pull_request) Successful in 45s
C++ Tests / test-cgal (pull_request) Failing after 13m14s

CI gates (active on every PR via .gitea/workflows/)
───────────────────────────────────────────────────
1. test-count consistency
   cpp-tests.yml gains a step after test-cgal that runs
   `scripts/check-test-counts.sh` against the just-built ./build dir
   (reuse via new BUILD_DIR env var, ~5 s overhead).  Drift between
   `doc/api/tests.md` and ctest reality now fails the PR.

2. End-to-end smoke
   `scripts/try_it.sh` (the documented user quick-start) is now part of
   the CGAL job, so README quick-start regressions fail the PR rather
   than silently breaking when users land.

3. Internal markdown link checker
   New `.gitea/workflows/markdown-links.yml` + `scripts/check-markdown
   -links.py`.  PRs that touch any *.md file run the check; main pushes
   trigger it too; a weekly cron catches external link rot.  Pure
   Python, no third-party action.  Validated against the current tree:
   122 internal links across 37 *.md files, 0 broken.

Local quality scripts (`scripts/quality/`, not in CI)
─────────────────────────────────────────────────────
* `license-headers.sh`   — `SPDX-License-Identifier: MIT` audit over
                            code/{include,src,tests}/.  Currently
                            reports 60/66 files missing it — that's
                            a follow-up; the script captures the
                            structural gap.
* `sanitizers.sh`        — ASan + UBSan over the fast test suite.
* `coverage.sh`          — gcov/lcov line + branch coverage of
                            code/include/, HTML report under
                            build-coverage/lcov-html/.
* `clang-tidy.sh`        — runs the curated `.clang-tidy` policy over
                            every public header.
* `multi-compiler.sh`    — sequential build + test against every
                            detected g++/clang++ (auto-discovery or
                            explicit list).
* `cgal-version-matrix.sh`— sequential build + CGAL test suite against
                            every CGAL tree under `~/cgal/<ver>/` (or
                            via `CGAL_ROOTS=...` env var).
* `reproducible-build.sh`— two `Release -j1` builds, fail if any test
                            executable byte-differs.
* `run-all.sh`           — driver: `--fast` for the ~5-min subset,
                            no arg for the ~25–40 min full sweep;
                            captures per-gate logs to
                            build-quality-logs/.

+ `.clang-tidy`          — curated, deliberately-small policy (only
                            checks that fire on OUR code, never on
                            transitive CGAL/Eigen/Boost headers).

+ `scripts/quality/README.md` — explains the structure, lists each
                            gate's wall-time + prereqs, and codifies
                            the promotion path: a gate moves into CI
                            only when it's green on the dev machine
                            AND has a recovery-instructions paragraph
                            in `doc/release-policy.md`.

Doc updates
───────────
`doc/architecture/locked-vs-flexible.md` (reviewer-facing) gains 4
"closed" rows in the limitations table — the 3 CI gates above and the
local quality-script suite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-24 08:25:09 +02:00
parent 8869ead3c9
commit a2eee9c279
15 changed files with 1144 additions and 6 deletions

140
scripts/check-markdown-links.py Executable file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""scripts/check-markdown-links.py
Verify every internal `[text](target)` link in every markdown file under
the repo resolves to an existing file. Skips:
* `http(s)://`, `mailto:`, `ftp://` — external schemes
* pure-anchor links `#fragment`
* code blocks fenced by ``` ... ```
* link targets inside HTML tags that the markdown parser does not
treat as links (we operate on raw text — false positives are
explicitly excluded via the IGNORE patterns at the top of the file).
Walks `.` recursively. Skips `code/deps/`, `build*`, `.git/`, the
generated `doc/doxygen/` tree, and `node_modules/`.
Exit codes:
0 every internal link resolves
1 at least one link is broken — prints `file:line: target` for each
"""
from __future__ import annotations
import os, re, sys
ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
# Directories that are not part of our authored docs.
SKIP_DIR_PARTS = {"code/deps", ".git", "node_modules", "doc/doxygen"}
SKIP_DIR_PREFIXES = ("build", "build_T_", "test-reports")
# Files in those formats are sometimes generated.
SKIP_FILES = {"CHANGELOG-old.md"}
# Schemes to ignore (external).
EXTERNAL_SCHEME_RE = re.compile(r"^[a-z]+://|^mailto:", re.IGNORECASE)
# Standard markdown link: [text](target)
LINK_RE = re.compile(r"\[(?:[^\]]|\\\])+\]\(\s*<?([^)\s<>]+)>?\s*(?:\"[^\"]*\")?\)")
# Code-fence detection (``` or ~~~)
FENCE_RE = re.compile(r"^(?:```|~~~)")
def is_skipped_dir(rel: str) -> bool:
parts = rel.split(os.sep)
for skip in SKIP_DIR_PARTS:
skip_parts = skip.split("/")
for i in range(len(parts) - len(skip_parts) + 1):
if parts[i : i + len(skip_parts)] == skip_parts:
return True
for p in parts:
if any(p.startswith(pref) for pref in SKIP_DIR_PREFIXES):
return True
return False
def collect_md_files() -> list[str]:
md: list[str] = []
for dirpath, dirs, files in os.walk(ROOT):
rel = os.path.relpath(dirpath, ROOT)
if rel != "." and is_skipped_dir(rel):
dirs[:] = []
continue
for f in files:
if not f.lower().endswith(".md"):
continue
if f in SKIP_FILES:
continue
full = os.path.join(dirpath, f)
md.append(full)
return sorted(md)
def link_targets(text: str) -> list[tuple[int, str]]:
"""Yield (line_no, target) skipping code-fenced lines."""
out: list[tuple[int, str]] = []
in_fence = False
for line_no, line in enumerate(text.splitlines(), start=1):
if FENCE_RE.match(line.strip()):
in_fence = not in_fence
continue
if in_fence:
continue
for m in LINK_RE.finditer(line):
out.append((line_no, m.group(1)))
return out
def resolve(src_path: str, target: str) -> tuple[bool, str | None]:
"""Return (ok, expected_path).
ok=True → link resolves (file exists, or external/anchor we skip).
ok=False → file does not exist; expected_path is what we looked for.
"""
if not target:
return True, None
if EXTERNAL_SCHEME_RE.match(target):
return True, None
if target.startswith("#"):
# pure in-page anchor — we don't validate anchor existence
return True, None
# Strip any in-file anchor for file-existence check.
target_file = target.split("#", 1)[0]
if not target_file:
return True, None
if target_file.startswith("/"):
# Absolute-from-repo-root link.
full = os.path.join(ROOT, target_file.lstrip("/"))
else:
full = os.path.normpath(os.path.join(os.path.dirname(src_path), target_file))
return os.path.exists(full), full
def main() -> int:
files = collect_md_files()
broken: list[tuple[str, int, str, str]] = []
n_links = 0
for src in files:
try:
with open(src, encoding="utf-8") as f:
text = f.read()
except OSError as e:
print(f"WARN: cannot read {src}: {e}", file=sys.stderr)
continue
for line_no, target in link_targets(text):
n_links += 1
ok, expected = resolve(src, target)
if not ok:
broken.append((src, line_no, target, expected or "?"))
print(f"Scanned {len(files)} markdown files, {n_links} internal links.")
if broken:
print(f"\nBROKEN: {len(broken)} link(s) do not resolve:")
for src, line_no, target, expected in broken:
rel = os.path.relpath(src, ROOT)
print(f" {rel}:{line_no}: ({target}) → {os.path.relpath(expected, ROOT)} (missing)")
return 1
print("OK: every internal markdown link resolves.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -31,16 +31,27 @@ command -v cmake >/dev/null || { echo "FAIL: cmake not in PATH" >&2; exit 2; }
command -v ctest >/dev/null || { echo "FAIL: ctest not in PATH" >&2; exit 2; }
# ── Build ───────────────────────────────────────────────────────────────────
# Re-use existing build-cgal/ if it exists with the right flags; otherwise
# create a throwaway build-counts/ directory.
BUILD_DIR=""
if [ -f build-cgal/CMakeCache.txt ] && grep -q "WITH_CGAL_TESTS:.*=ON\|WITH_CGAL:.*=ON" build-cgal/CMakeCache.txt; then
# Priority order:
# 1. `BUILD_DIR` env var (CI sets it to the dir cpp-tests.yml just built).
# 2. existing build-cgal/ if it has WITH_CGAL_TESTS=ON.
# 3. throwaway build-counts/ — full build from scratch.
if [ -n "${BUILD_DIR:-}" ]; then
if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
echo "FAIL: BUILD_DIR=$BUILD_DIR has no CMakeCache.txt" >&2
exit 2
fi
if ! grep -q "WITH_CGAL_TESTS:.*=ON\|WITH_CGAL:.*=ON" "$BUILD_DIR/CMakeCache.txt"; then
echo "FAIL: BUILD_DIR=$BUILD_DIR was not configured with WITH_CGAL_TESTS=ON" >&2
exit 2
fi
elif [ -f build-cgal/CMakeCache.txt ] && grep -q "WITH_CGAL_TESTS:.*=ON\|WITH_CGAL:.*=ON" build-cgal/CMakeCache.txt; then
BUILD_DIR=build-cgal
cmake --build "$BUILD_DIR" -j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 2)" >/dev/null
else
BUILD_DIR=build-counts
cmake -S code -B "$BUILD_DIR" -DWITH_CGAL_TESTS=ON -DCMAKE_BUILD_TYPE=Release >/dev/null
cmake --build "$BUILD_DIR" -j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 2)" >/dev/null
fi
cmake --build "$BUILD_DIR" -j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 2)" >/dev/null
# ── Get actual counts ───────────────────────────────────────────────────────
cd "$BUILD_DIR"

75
scripts/quality/README.md Normal file
View File

@@ -0,0 +1,75 @@
# Local structural quality gates
This directory contains the structural quality checks that run **locally**
rather than in CI. They are intentionally not wired into
`.gitea/workflows/` (yet) because each is either too slow, too brittle
against the runner environment, or both — running them before a release
or before showing the repo to an external reviewer is the intended
workflow.
The CI gates that *are* enforced live in `.gitea/workflows/cpp-tests.yml`
and `.gitea/workflows/doxygen-pages.yml`; they cover the day-to-day
correctness loop (build + test + doxygen-coverage + test-count
consistency + markdown links + end-to-end smoke via `try_it.sh`).
## What runs locally
| Script | What it checks | Wall time | Prereqs |
|---|---|---|---|
| `license-headers.sh` | every C++ source carries `SPDX-License-Identifier: MIT` | ~1 s | `bash` |
| `../check-markdown-links.py` | every internal markdown link resolves | ~2 s | `python3` |
| `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` |
| `multi-compiler.sh` | build + test under every detected gcc/clang | ~5 min × N compilers | any 2 of `g++`, `clang++` |
| `reproducible-build.sh` | two builds → byte-identical test executables | ~6 min | none beyond compiler |
| `cgal-version-matrix.sh` | build + CGAL test suite against multiple CGAL versions | ~5 min × N versions | CGAL trees under `~/cgal/<ver>/` (or `CGAL_ROOTS=...`) |
## How to use
```bash
# Fast subset (license + links + sanitizers + clang-tidy) — ~5 min total
bash scripts/quality/run-all.sh --fast
# Full sweep — ~2540 min, intended for pre-release tagging
bash scripts/quality/run-all.sh
# One specific gate
bash scripts/quality/sanitizers.sh
```
Every gate writes its full output to `build-quality-logs/<gate>.log`
when invoked via `run-all.sh`, and to its own per-gate build directory
(`build-sanitizers/`, `build-coverage/`, `build-multi-<cc>/`, …) when
invoked directly.
## Promotion path to CI
Each gate can be wired into `.gitea/workflows/cpp-tests.yml` once two
conditions are met:
1. **The gate is green on the canonical dev machine.** If the script
exits 1 today, the CI gate would block every PR.
2. **There is a published policy line in `doc/release-policy.md`** that
explains what regression the gate catches and what the recovery is.
Future contributors should be able to read the error and know what
to fix.
Promoting a gate is a one-line change to `cpp-tests.yml`; the test
recipe is the script invocation itself.
## Known limitations
- `cgal-version-matrix.sh` does not download CGAL. Each version must
already be on the dev machine under `~/cgal/<ver>/` (override with
`CGAL_ROOTS=...:...`). The Dockerfile under
`.gitea/docker/Dockerfile.ci-cpp` could be extended to ship multiple
CGAL trees in a single image; not done yet.
- `sanitizers.sh` only instruments the fast (non-CGAL) test suite —
the CGAL templates are too expensive to compile under instrumentation
on most laptops.
- `clang-tidy.sh` requires a `.clang-tidy` config in the repo root; the
default Anthropic-quality lint set is intentionally minimal until the
reviewer signs off on the warning policy.
- `reproducible-build.sh` checks the test executables only. The
library is header-only, so there is nothing else to compare.

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# scripts/quality/cgal-version-matrix.sh
#
# Build and test the CGAL test suite against multiple CGAL versions in
# sequence. Catches:
# * upstream API drift between minor CGAL releases (5.x vs 6.x)
# * Surface_mesh / Polyhedron_3 trait-class changes
# * deprecated CGAL macros we still rely on
#
# Local-only. The script expects each CGAL version to live under
# `~/cgal/<version>/` (override with CGAL_ROOTS env var as a colon-list).
# If the directory tree is missing it prints the expected layout and
# exits 2 — it does not download anything (that would belong in a Docker
# image, see .gitea/docker/Dockerfile.ci-cpp).
#
# Usage:
# bash scripts/quality/cgal-version-matrix.sh
# CGAL_ROOTS=/opt/cgal-5.6:/opt/cgal-6.0 bash scripts/quality/cgal-version-matrix.sh
#
# Exit codes:
# 0 every requested CGAL version builds + tests cleanly
# 1 at least one version failed
# 2 no CGAL roots found
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
# ── Discover CGAL installs ──────────────────────────────────────────────────
DEFAULT_ROOTS="$HOME/cgal/5.6:$HOME/cgal/6.0:$HOME/cgal/6.1:/opt/cgal-5.6:/opt/cgal-6.0"
ROOTS="${CGAL_ROOTS:-$DEFAULT_ROOTS}"
# Collect existing paths.
AVAILABLE=""
OLD_IFS="$IFS"
IFS=":"
for r in $ROOTS; do
if [ -f "$r/cmake/modules/UseCGAL.cmake" ] || [ -f "$r/include/CGAL/version.h" ] || [ -f "$r/CMakeLists.txt" ]; then
AVAILABLE="${AVAILABLE}${r}
"
fi
done
IFS="$OLD_IFS"
if [ -z "$AVAILABLE" ]; then
cat >&2 <<EOF
FAIL: no CGAL installs found.
Searched: $ROOTS
Expected layout for each version (any one of these is enough):
<root>/include/CGAL/version.h
<root>/cmake/modules/UseCGAL.cmake
<root>/CMakeLists.txt
Recovery (one-time, on the dev machine):
cd ~/cgal && wget https://github.com/CGAL/cgal/archive/refs/tags/v5.6.tar.gz
tar xf v5.6.tar.gz && mv cgal-5.6 5.6
# repeat for v6.0, v6.1
Then re-run:
bash scripts/quality/cgal-version-matrix.sh
Or override the lookup path:
CGAL_ROOTS=/opt/cgal-5.6:/opt/cgal-6.0 bash scripts/quality/cgal-version-matrix.sh
EOF
exit 2
fi
echo "========================================"
echo " CGAL version matrix"
echo " versions tested:"
echo "$AVAILABLE" | sed 's/^/ /'
echo "========================================"
overall=0
echo "$AVAILABLE" | while IFS= read -r cgal_root; do
[ -z "$cgal_root" ] && continue
ver="$(basename "$cgal_root")"
build="build-cgal-$ver"
echo
echo "── CGAL $ver ─────────────────────────────"
echo " root: $cgal_root"
echo " build: $build"
if ! cmake -S code -B "$build" \
-DCGAL_DIR="$cgal_root" \
-DWITH_CGAL_TESTS=ON \
-DCMAKE_BUILD_TYPE=Release \
-Wno-dev >/dev/null 2>&1; then
echo " CONFIGURE FAILED"
echo "$ver" >> "$ROOT/.cgal-matrix-failures"
continue
fi
if ! nice -n 19 cmake --build "$build" --target conformallab_cgal_tests \
-j1 >"$build/build.log" 2>&1; then
echo " BUILD FAILED — see $build/build.log"
echo "$ver" >> "$ROOT/.cgal-matrix-failures"
continue
fi
if ! ( cd "$build" && ctest -R "^cgal\." --output-on-failure >"test.log" 2>&1 ); then
echo " TESTS FAILED — see $build/test.log"
echo "$ver" >> "$ROOT/.cgal-matrix-failures"
continue
fi
pass=$(grep -oE "tests passed.*out of [0-9]+" "$build/test.log" | head -1)
echo " OK ($pass)"
done
if [ -f "$ROOT/.cgal-matrix-failures" ]; then
echo
echo "FAIL: the following CGAL versions did not pass:"
sed 's/^/ /' "$ROOT/.cgal-matrix-failures"
rm -f "$ROOT/.cgal-matrix-failures"
exit 1
fi
echo
echo "OK: every detected CGAL version built + passed the CGAL test suite."
exit 0

90
scripts/quality/clang-tidy.sh Executable file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# scripts/quality/clang-tidy.sh
#
# Run clang-tidy over every public header under code/include/. We use
# `--use-color` and tee output to build-tidy/clang-tidy.log for later
# diffing.
#
# Local-only. The check is exploratory until we agree on which warning
# classes are reasonable to enforce — CGAL header-only code triggers a
# lot of `modernize-*` / `readability-*` warnings that are upstream's
# choice, not ours. See `.clang-tidy` for the curated subset.
#
# Usage:
# bash scripts/quality/clang-tidy.sh # all headers
# bash scripts/quality/clang-tidy.sh code/include/CGAL # subset
#
# Prerequisite: a compile_commands.json with the right include paths
# (cmake generates this automatically with CMAKE_EXPORT_COMPILE_COMMANDS=ON).
#
# Exit codes:
# 0 clang-tidy ran (output captured)
# 1 clang-tidy reported at least one error (post-policy filter)
# 2 prerequisite missing
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
BUILD_DIR="build-tidy"
LOG="$BUILD_DIR/clang-tidy.log"
command -v clang-tidy >/dev/null 2>&1 || {
echo "FAIL: clang-tidy not in PATH." >&2
echo " macOS: brew install llvm && export PATH=\"\$(brew --prefix llvm)/bin:\$PATH\"" >&2
echo " Linux: sudo apt install clang-tidy" >&2
exit 2
}
TARGET_DIR="${1:-code/include}"
[ -d "$TARGET_DIR" ] || { echo "FAIL: $TARGET_DIR is not a directory" >&2; exit 2; }
# Generate compile_commands.json (clang-tidy needs it for include paths).
cmake -S code -B "$BUILD_DIR" \
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
-DCMAKE_BUILD_TYPE=Release \
-Wno-dev >/dev/null
mkdir -p "$BUILD_DIR"
: > "$LOG"
# Find every .h / .hpp under TARGET_DIR (skip deps + macOS dup files).
HEADERS=$(find "$TARGET_DIR" \
\( -name "*.h" -o -name "*.hpp" \) \
-type f \
| grep -v "code/deps/" \
| grep -v " 2\." \
| sort)
echo "========================================"
echo " clang-tidy run"
echo " target: $TARGET_DIR"
echo " config: .clang-tidy"
echo " log: $LOG"
echo "========================================"
echo
n=0
for h in $HEADERS; do
n=$((n + 1))
echo "── [$n] $h ─────────────────────────────────"
# Use --quiet so we only see actual diagnostics, not "n warnings
# generated" boilerplate. Pipe through tee for the log file.
clang-tidy --quiet \
-p "$BUILD_DIR" \
"$h" 2>&1 | tee -a "$LOG" || true
done
echo
echo "── Summary ──────────────────────────────────"
warn=$(grep -c "warning:" "$LOG" || true)
err=$(grep -c "error:" "$LOG" || true)
echo " files inspected: $n"
echo " warnings: $warn"
echo " errors: $err"
echo " full log: $LOG"
if [ "${err:-0}" -gt 0 ]; then
exit 1
fi
exit 0

96
scripts/quality/coverage.sh Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# scripts/quality/coverage.sh
#
# Measure line/branch test coverage of code/include/ via gcov + lcov.
#
# Builds the test suite with `--coverage` (gcc) or `-fprofile-instr-generate
# -fcoverage-mapping` (clang), runs ctest, and emits:
# * build-coverage/coverage.info — lcov tracefile
# * build-coverage/lcov-html/index.html — browseable HTML report
# * stdout: per-file summary + grand total
#
# Local-only. Not gated in CI yet; once a coverage threshold is agreed
# with the reviewer (e.g. 80 %), the gate can be a single line in
# cpp-tests.yml.
#
# Usage:
# bash scripts/quality/coverage.sh # gcc default
# CXX=g++-13 bash scripts/quality/coverage.sh # specific compiler
#
# Prerequisites: gcov + lcov (apt install lcov / brew install lcov)
#
# Exit codes:
# 0 coverage report generated; prints %
# 1 tests failed (no usable trace)
# 2 prerequisite missing
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
BUILD_DIR="build-coverage"
command -v lcov >/dev/null 2>&1 || {
echo "FAIL: lcov not found in PATH. Install via:" >&2
echo " macOS: brew install lcov" >&2
echo " Linux: sudo apt install lcov" >&2
exit 2
}
CXX_BIN="${CXX:-g++}"
command -v "$CXX_BIN" >/dev/null 2>&1 || { echo "FAIL: $CXX_BIN not found" >&2; exit 2; }
echo "========================================"
echo " Coverage build (gcov + lcov)"
echo " CXX: $CXX_BIN ($($CXX_BIN --version | head -1))"
echo " build: $BUILD_DIR"
echo "========================================"
echo
cmake -S code -B "$BUILD_DIR" \
-DCMAKE_CXX_COMPILER="$CXX_BIN" \
-DCMAKE_CXX_FLAGS="--coverage -O0 -g" \
-DCMAKE_EXE_LINKER_FLAGS="--coverage" \
-DCMAKE_BUILD_TYPE=Debug \
-Wno-dev
nice -n 19 cmake --build "$BUILD_DIR" --target conformallab_tests \
-j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 2)"
# Run the non-CGAL test suite. (CGAL tests under coverage instrumentation
# blow up to ~6 GB RAM during compilation; out-of-scope for this gate.)
cd "$BUILD_DIR"
if ! ctest -E "^cgal\." --output-on-failure; then
cd "$ROOT"
echo "FAIL: coverage build's test run did not complete cleanly." >&2
exit 1
fi
cd "$ROOT"
# ── Capture trace ────────────────────────────────────────────────────────────
lcov --capture --directory "$BUILD_DIR" --output-file "$BUILD_DIR/coverage.raw.info" \
--rc lcov_branch_coverage=1 \
--no-external \
>/dev/null 2>&1 || true
# Restrict to code/include/ (our public API surface; ignore deps/tests).
lcov --extract "$BUILD_DIR/coverage.raw.info" \
"*/code/include/*" \
--output-file "$BUILD_DIR/coverage.info" \
--rc lcov_branch_coverage=1 \
>/dev/null 2>&1
# ── HTML report ──────────────────────────────────────────────────────────────
genhtml --branch-coverage --legend \
--output-directory "$BUILD_DIR/lcov-html" \
"$BUILD_DIR/coverage.info" >/dev/null
# ── Summary to stdout ────────────────────────────────────────────────────────
echo
echo "── Coverage summary (code/include/) ─────────────────────────"
lcov --summary "$BUILD_DIR/coverage.info" --rc lcov_branch_coverage=1 \
| grep -E "lines\.\.\.\.|functions|branches" \
| sed 's/^/ /'
echo
echo "HTML report: $BUILD_DIR/lcov-html/index.html"
echo " open $BUILD_DIR/lcov-html/index.html"

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Requires bash ≥ 4 for `mapfile`; on macOS install via `brew install bash`
# and invoke as `bash scripts/quality/license-headers.sh`. The portable
# fallback below works on bash 3.2 too.
# scripts/quality/license-headers.sh
#
# Verify that every source file under code/ carries an SPDX-License-
# Identifier header. The project policy (doc/release-policy.md) is:
#
# * Every conformallab++ source file (.h, .hpp, .cpp under code/include
# or code/src, but NOT code/deps/) must have:
# SPDX-License-Identifier: MIT
# * The copyright line is encouraged but not enforced (the year is
# allowed to lag).
#
# Files under code/deps/ are vendored third-party code (Eigen, JSON,
# GLFW, libigl, …) and are skipped — they carry their own licenses.
#
# Exit codes:
# 0 every checked file has the SPDX header
# 1 at least one file is missing it
# 2 prerequisite missing
#
# Run from any directory; the script resolves the repo root from its own path.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
SPDX="SPDX-License-Identifier: MIT"
# Files to check: code/include + code/src + code/tests. Skip code/deps.
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_missing=0
missing_list=""
while IFS= read -r f; do
[ -z "$f" ] && continue
n_total=$((n_total + 1))
# Check only the first 30 lines — SPDX must be near the top.
if ! head -30 "$f" | grep -q "$SPDX"; then
n_missing=$((n_missing + 1))
missing_list="$missing_list $f
"
fi
done <<EOF
$FILES_LIST
EOF
if [ "$n_total" -eq 0 ]; then
echo "FAIL: no source files found under code/{include,src,tests}/" >&2
exit 2
fi
echo "Checked $n_total source files for SPDX header."
if [ "$n_missing" -gt 0 ]; then
echo
echo "FAIL: $n_missing file(s) missing '$SPDX' in their first 30 lines:"
printf '%s' "$missing_list"
echo
echo "Recovery: add this two-line header at the top of each file:"
echo " // Copyright (c) 2024-$(date +%Y) Tarik Moussa."
echo " // $SPDX"
exit 1
fi
echo "OK: every checked file carries '$SPDX'."
exit 0

119
scripts/quality/multi-compiler.sh Executable file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# scripts/quality/multi-compiler.sh
#
# Build and test the fast (non-CGAL) suite against multiple C++
# compilers in sequence. Catches compiler-specific issues:
# * gcc-only extensions accidentally used
# * clang's stricter template-error handling
# * libstdc++ vs libc++ ABI assumptions
#
# Local-only. Each compiler gets its own build-multi-<cc>/ directory.
#
# Usage:
# bash scripts/quality/multi-compiler.sh # auto-detect
# bash scripts/quality/multi-compiler.sh g++ clang++ # specific list
#
# Exit codes:
# 0 every detected/selected compiler builds + tests cleanly
# 1 at least one compiler failed
# 2 fewer than 2 compilers available
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
# ── Pick compilers ──────────────────────────────────────────────────────────
if [ $# -gt 0 ]; then
COMPILERS=("$@")
else
COMPILERS=()
for cand in g++ g++-13 g++-12 g++-11 clang++ clang++-17 clang++-16 clang++-15; do
if command -v "$cand" >/dev/null 2>&1; then
COMPILERS+=("$cand")
fi
done
fi
if [ "${#COMPILERS[@]}" -lt 1 ]; then
echo "FAIL: no C++ compilers detected. Install gcc and/or clang." >&2
exit 2
fi
echo "========================================"
echo " Multi-compiler build matrix"
echo " compilers: ${COMPILERS[*]}"
echo "========================================"
echo
# De-duplicate by resolving each to its absolute path.
declare -a UNIQ_BINS=()
declare -a UNIQ_NAMES=()
seen=""
for cc in "${COMPILERS[@]}"; do
if ! command -v "$cc" >/dev/null 2>&1; then
echo " skip $cc (not in PATH)"
continue
fi
full="$(command -v "$cc")"
if echo "$seen" | grep -qx "$full"; then
continue
fi
seen="$seen
$full"
UNIQ_BINS+=("$full")
# Use a sanitised name for the build dir (replace + and / etc.).
safe="$(echo "$cc" | sed 's|[/+]|_|g')"
UNIQ_NAMES+=("$safe")
done
if [ "${#UNIQ_BINS[@]}" -lt 2 ]; then
echo "WARNING: only ${#UNIQ_BINS[@]} unique compiler(s) — matrix is degenerate."
echo " (continuing anyway; install a second toolchain for full coverage)"
fi
# ── Run each ────────────────────────────────────────────────────────────────
overall=0
i=0
for cc in "${UNIQ_BINS[@]}"; do
name="${UNIQ_NAMES[$i]}"
i=$((i + 1))
build="build-multi-$name"
echo
echo "── [$i/${#UNIQ_BINS[@]}] $name ─────────────────────"
echo " bin: $cc"
echo " build: $build"
if ! cmake -S code -B "$build" \
-DCMAKE_CXX_COMPILER="$cc" \
-DCMAKE_BUILD_TYPE=Release \
-Wno-dev >/dev/null 2>&1; then
echo " CONFIGURE FAILED"
overall=1
continue
fi
if ! nice -n 19 cmake --build "$build" --target conformallab_tests \
-j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 2)" \
>"$build/build.log" 2>&1; then
echo " BUILD FAILED — see $build/build.log"
overall=1
continue
fi
if ! ( cd "$build" && ctest -E "^cgal\." --output-on-failure >"test.log" 2>&1 ); then
echo " TESTS FAILED — see $build/test.log"
overall=1
continue
fi
pass=$(grep -oE "tests passed.*out of [0-9]+" "$build/test.log" | head -1)
echo " OK ($pass)"
done
echo
if [ $overall -eq 0 ]; then
echo "OK: every selected compiler built + passed the fast test suite."
else
echo "FAIL: at least one compiler did not produce a green test run."
fi
exit $overall

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# scripts/quality/reproducible-build.sh
#
# Build the project twice with the same toolchain + flags + sources, and
# verify the two outputs are byte-identical (after stripping ABI noise).
#
# Why: conformallab++ is header-only, so the binaries we ship are just
# the test executables. If the same source + same toolchain produces
# different bytes, something non-deterministic snuck in:
# * a `__DATE__` / `__TIME__` macro in the code
# * an absolute path leaked into a string literal
# * iteration over an unordered container of items
# * a parallel build with non-deterministic linking order
#
# Local-only. Two full builds at ~3 min each ≈ 6 min wall time.
#
# Usage:
# bash scripts/quality/reproducible-build.sh
#
# Exit codes:
# 0 the two builds produce byte-identical executables
# 1 there is at least one differing byte; prints which executable(s)
# 2 prerequisite missing
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
DIR_A="build-repro-A"
DIR_B="build-repro-B"
# Identical-input check: nuke any state from a previous run.
rm -rf "$DIR_A" "$DIR_B"
# Use a fixed timezone + SOURCE_DATE_EPOCH so any time-based macros
# yield identical strings in both builds. Without this, even a perfect
# build pipeline disagrees if __TIME__ slips in.
export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-1700000000}"
export TZ=UTC
export LC_ALL=C
echo "========================================"
echo " Reproducible-build check"
echo " build A: $DIR_A"
echo " build B: $DIR_B"
echo " SOURCE_DATE_EPOCH: $SOURCE_DATE_EPOCH"
echo "========================================"
echo
build_once () {
local dir="$1"
cmake -S code -B "$dir" -DCMAKE_BUILD_TYPE=Release -Wno-dev >/dev/null
# Single-threaded build → deterministic link order.
cmake --build "$dir" --target conformallab_tests -j1 >"$dir/build.log" 2>&1
}
echo "── Build A ─────────────────────────────────"
build_once "$DIR_A"
echo " done."
echo "── Build B ─────────────────────────────────"
build_once "$DIR_B"
echo " done."
# ── Compare ─────────────────────────────────────────────────────────────────
# Test executables live at:
# build-repro-*/conformallab_tests (single combined fast test)
# build-repro-*/test_* (older per-suite executables)
# Compare every regular file under each build dir that ends in
# `_tests` or starts with `test_`.
echo
echo "── Diff ────────────────────────────────────"
mismatches=""
for path_a in "$DIR_A"/conformallab_tests "$DIR_A"/test_*; do
[ -f "$path_a" ] || continue
rel="${path_a#${DIR_A}/}"
path_b="$DIR_B/$rel"
if [ ! -f "$path_b" ]; then
echo " MISS $rel (only in build A)"
mismatches="$mismatches $rel (missing in build B)
"
continue
fi
if cmp -s "$path_a" "$path_b"; then
echo " OK $rel"
else
a_sha=$(shasum -a 256 "$path_a" | cut -d' ' -f1)
b_sha=$(shasum -a 256 "$path_b" | cut -d' ' -f1)
echo " DIFF $rel"
echo " A $a_sha"
echo " B $b_sha"
mismatches="$mismatches $rel
"
fi
done
echo
if [ -n "$mismatches" ]; then
echo "FAIL: the build is not byte-reproducible."
echo
echo "Differing files:"
printf "%s" "$mismatches"
echo
echo "Common causes:"
echo " * __DATE__ / __TIME__ macros baked into the binary"
echo " * absolute build path embedded in a debug-info string"
echo " * a parallel-link race (-j > 1) — but we already use -j1 here"
echo " * a header generated from a non-deterministic source"
echo
echo "Debug recipe:"
echo " diff <(strings $DIR_A/$rel) <(strings $DIR_B/$rel) | head -20"
exit 1
fi
echo "OK: every test executable is byte-identical between the two builds."
exit 0

100
scripts/quality/run-all.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# scripts/quality/run-all.sh
#
# Run every local quality gate in sequence. Each gate is independent;
# a failure does not stop the rest (we collect failures and report at
# the end). Use this before tagging a release or before showing the
# repo to an external reviewer.
#
# Wall-time budget on a typical dev laptop (M-series Mac):
# license-headers.sh ~1 s
# check-markdown-links.py ~2 s
# sanitizers.sh ~3 min
# coverage.sh ~2 min
# clang-tidy.sh ~2 min (depends on header count)
# multi-compiler.sh ~5 min (per compiler)
# reproducible-build.sh ~6 min
# cgal-version-matrix.sh ~5 min per CGAL version
# ─────────────────────────────
# TOTAL ~2540 min
#
# Usage:
# bash scripts/quality/run-all.sh # everything
# bash scripts/quality/run-all.sh --fast # skip the slow gates
# (cgal-matrix, multi-compiler,
# coverage, reproducible)
#
# Exit code: number of failed gates (so 0 = green).
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
FAST=0
[ "${1:-}" = "--fast" ] && FAST=1
GATES_FAST=(
"License headers | bash scripts/quality/license-headers.sh"
"Markdown links | python3 scripts/check-markdown-links.py"
"Sanitizers | bash scripts/quality/sanitizers.sh"
"clang-tidy | bash scripts/quality/clang-tidy.sh"
)
GATES_SLOW=(
"Coverage | bash scripts/quality/coverage.sh"
"Multi-compiler | bash scripts/quality/multi-compiler.sh"
"Reproducible build | bash scripts/quality/reproducible-build.sh"
"CGAL version matrix | bash scripts/quality/cgal-version-matrix.sh"
)
if [ "$FAST" -eq 1 ]; then
GATES=("${GATES_FAST[@]}")
else
GATES=("${GATES_FAST[@]}" "${GATES_SLOW[@]}")
fi
LOG_DIR="build-quality-logs"
mkdir -p "$LOG_DIR"
echo "============================================================"
echo " conformallab++ local quality gates"
echo " mode: $([ $FAST -eq 1 ] && echo 'FAST (4 gates)' || echo "FULL (${#GATES[@]} gates)")"
echo " logs: $LOG_DIR/"
echo "============================================================"
results=""
failed=0
i=0
for entry in "${GATES[@]}"; do
i=$((i + 1))
name="${entry%%|*}"
name="${name%%[[:space:]]*([[:space:]])}" # trim trailing space
cmd="${entry##*|}"
cmd="${cmd##[[:space:]]}"
# Slug-safe filename
slug=$(echo "$name" | tr ' /[:upper:]' '_-[:lower:]' | tr -cd 'a-z0-9_-')
log="$LOG_DIR/$slug.log"
echo
echo "──── [$i/${#GATES[@]}] $name ────"
if eval "$cmd" >"$log" 2>&1; then
echo " OK ($log)"
results="${results} PASS $name
"
else
rc=$?
echo " FAIL (rc=$rc) — see $log"
echo " last 20 lines:"
tail -20 "$log" | sed 's/^/ /'
results="${results} FAIL $name ($log)
"
failed=$((failed + 1))
fi
done
echo
echo "============================================================"
echo " Summary"
echo "============================================================"
printf "%s" "$results"
echo
echo " failed: $failed / ${#GATES[@]}"
exit $failed

88
scripts/quality/sanitizers.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# scripts/quality/sanitizers.sh
#
# Build the fast test suite with AddressSanitizer + UndefinedBehaviorSanitizer
# and run it. Catches:
# * use-after-free, double-free, heap-buffer-overflow (ASan)
# * signed integer overflow, NaN propagation, alignment violations (UBSan)
# * Eigen / CGAL template-induced UB that escapes the regular build
#
# Local-only (not in CI): the sanitizer build is ~3× slower and brittle
# against system libraries. Run it before every release tag, after
# touching any Newton/Hessian code, or when investigating intermittent
# test failures.
#
# Usage:
# bash scripts/quality/sanitizers.sh # default: ASan + UBSan
# ASAN_OPTIONS=... UBSAN_OPTIONS=... bash scripts/quality/sanitizers.sh
#
# Exit codes:
# 0 every test passes under sanitizer instrumentation
# 1 a sanitizer report was triggered (test failure or runtime error)
# 2 prerequisite missing (no clang/gcc with sanitizer support)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
BUILD_DIR="build-sanitizers"
SAN_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -O1 -g"
# Default ASan/UBSan runtime options — print stack on first error,
# abort on first issue (so CI logs make the cause obvious).
export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=1:abort_on_error=1:print_stacktrace=1}"
export UBSAN_OPTIONS="${UBSAN_OPTIONS:-print_stacktrace=1:halt_on_error=1}"
echo "========================================"
echo " Sanitizer build (ASan + UBSan)"
echo " flags : $SAN_FLAGS"
echo " ASAN : $ASAN_OPTIONS"
echo " UBSAN : $UBSAN_OPTIONS"
echo "========================================"
# ── Pick a compiler with sanitizer support ──────────────────────────────────
# Prefer clang (better diagnostics); fall back to gcc.
CXX_BIN=""
for cand in clang++-17 clang++-16 clang++-15 clang++ g++; do
if command -v "$cand" >/dev/null 2>&1; then
CXX_BIN="$cand"
break
fi
done
if [ -z "$CXX_BIN" ]; then
echo "FAIL: no clang++ / g++ found in PATH" >&2
exit 2
fi
echo "Using CXX = $CXX_BIN ($("$CXX_BIN" --version | head -1))"
echo
# ── Configure ────────────────────────────────────────────────────────────────
cmake -S code -B "$BUILD_DIR" \
-DCMAKE_CXX_COMPILER="$CXX_BIN" \
-DCMAKE_CXX_FLAGS="$SAN_FLAGS" \
-DCMAKE_EXE_LINKER_FLAGS="$SAN_FLAGS" \
-DCMAKE_BUILD_TYPE=Debug \
-Wno-dev
# ── Build the fast (non-CGAL) tests only ────────────────────────────────────
# CGAL tests would 45× the build time under sanitizers and have a
# higher false-positive surface (CGAL's expression-template trickery).
# Use the fast suite as the sanitizer canary; full coverage of the CGAL
# layer is covered by coverage.sh + the regular Release build.
nice -n 19 cmake --build "$BUILD_DIR" --target conformallab_tests \
-j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 2)"
# ── Run ──────────────────────────────────────────────────────────────────────
cd "$BUILD_DIR"
if ctest -E "^cgal\." --output-on-failure --output-junit san-results.xml; then
cd "$ROOT"
echo
echo "OK: all sanitizer-instrumented tests passed."
exit 0
else
cd "$ROOT"
echo
echo "FAIL: sanitizer-instrumented tests reported issues."
echo " See: $BUILD_DIR/Testing/Temporary/LastTest.log"
exit 1
fi