diff --git a/.clang-tidy b/.clang-tidy
new file mode 100644
index 0000000..d69c71e
--- /dev/null
+++ b/.clang-tidy
@@ -0,0 +1,45 @@
+# conformallab++ clang-tidy policy
+#
+# Curated, deliberately small. The CGAL header tree triggers tens of
+# thousands of warnings under default settings (CGAL's chosen style is
+# pre-C++17 in many places). Restricting to checks that fire on OUR
+# code, not on transitive CGAL/Eigen/Boost headers, keeps the signal
+# meaningful.
+#
+# Promotion gate: a check moves into this list only when (a) it fires
+# on code we authored AND (b) the fix is mechanical (no algorithmic
+# rewrite required). Anything algorithmic belongs in a code review,
+# not in a static analyser.
+
+Checks: >
+ -*,
+ bugprone-too-small-loop-variable,
+ bugprone-use-after-move,
+ bugprone-undefined-memory-manipulation,
+ bugprone-integer-division,
+ bugprone-suspicious-string-compare,
+ bugprone-misplaced-widening-cast,
+ bugprone-sizeof-expression,
+ cppcoreguidelines-init-variables,
+ cppcoreguidelines-pro-type-member-init,
+ performance-for-range-copy,
+ performance-implicit-conversion-in-loop,
+ performance-unnecessary-copy-initialization,
+ performance-unnecessary-value-param,
+ readability-misleading-indentation,
+ readability-redundant-smartptr-get,
+ modernize-use-nullptr,
+ modernize-use-override,
+ modernize-deprecated-headers
+
+# Only emit warnings on our own headers. CGAL/Eigen/etc. live under
+# `code/deps/` (vendored) or are installed system-wide; we never want
+# clang-tidy fixes for them.
+HeaderFilterRegex: '^.*/code/include/(?!deps/).*$'
+
+WarningsAsErrors: ''
+
+CheckOptions:
+ - { key: cppcoreguidelines-init-variables.IgnoreArrays, value: true }
+ - { key: performance-for-range-copy.WarnOnAllAutoCopies, value: true }
+ - { key: performance-unnecessary-value-param.AllowedTypes, value: 'Eigen::Vector.*;Eigen::Matrix.*' }
diff --git a/.gitea/workflows/cpp-tests.yml b/.gitea/workflows/cpp-tests.yml
index f35369d..8b60d16 100644
--- a/.gitea/workflows/cpp-tests.yml
+++ b/.gitea/workflows/cpp-tests.yml
@@ -96,3 +96,19 @@ jobs:
passed=$(( ${total:-0} - ${failed:-0} - ${skipped:-0} ))
echo "CGAL ▸ TOTAL ${total:-0} | PASSED $passed | FAILED ${failed:-0} | SKIPPED ${skipped:-0}"
fi
+
+ # ── Structural gate: doc/api/tests.md totals match ctest reality ───
+ # Single source of truth for test counts (see doc/release-policy.md).
+ # Reuses the already-built ./build dir via BUILD_DIR env var, so this
+ # adds ~5 s on top of the existing CGAL job.
+ - name: Verify test-count consistency (doc/api/tests.md)
+ run: BUILD_DIR=build bash scripts/check-test-counts.sh
+
+ # ── Structural gate: end-to-end smoke (try_it.sh) ──────────────────
+ # The user-facing quick-start script: configure + build + run the
+ # full ctest + run the Euclidean example on a bundled mesh. If
+ # this regresses, README quick-start instructions are broken.
+ # try_it.sh creates its own build-try/ — accept the ~3 min cost as
+ # the price of guaranteeing the documented workflow stays working.
+ - name: End-to-end smoke test (scripts/try_it.sh)
+ run: bash scripts/try_it.sh
diff --git a/.gitea/workflows/markdown-links.yml b/.gitea/workflows/markdown-links.yml
new file mode 100644
index 0000000..b7affac
--- /dev/null
+++ b/.gitea/workflows/markdown-links.yml
@@ -0,0 +1,40 @@
+name: Markdown link check
+
+# Verify every internal markdown link in the repo resolves to an existing
+# file (or anchor). External http(s) links are also probed but with a
+# loose timeout — flaky third-party hosts must not break our CI.
+#
+# Trigger: PRs that touch any *.md file, plus a weekly cron so external
+# link rot is caught even when nobody is editing docs.
+
+on:
+ pull_request:
+ paths:
+ - "**/*.md"
+ - ".gitea/workflows/markdown-links.yml"
+ push:
+ branches:
+ - main
+ paths:
+ - "**/*.md"
+ - ".gitea/workflows/markdown-links.yml"
+ schedule:
+ - cron: "0 5 * * 1" # Monday 05:00 UTC weekly link-rot check
+ workflow_dispatch: {}
+
+jobs:
+ check:
+ runs-on: eulernest
+ container:
+ image: git.eulernest.eu/conformallab/ci-cpp:latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ # ── Pure-python internal link check (no external network needed) ────
+ # We use the same logic that found the 2 broken links before the
+ # reviewer meeting: parse every [text](path) link, check that the
+ # target file exists relative to the source file's directory. Skips
+ # http(s)://, mailto:, and pure-anchor (#fragment) links.
+ - name: Internal link check (all *.md files)
+ run: python3 scripts/check-markdown-links.py
diff --git a/doc/architecture/locked-vs-flexible.md b/doc/architecture/locked-vs-flexible.md
index 3eb5724..101f19d 100644
--- a/doc/architecture/locked-vs-flexible.md
+++ b/doc/architecture/locked-vs-flexible.md
@@ -262,7 +262,10 @@ mechanical next step rather than a missing piece of theory.
| **Named-parameter chaining: `\|`-operator only, no `.a().b().c()`.** Member-style chaining would need a patch to CGAL's upstream `parameters_interface.h`, which we treat as a read-only vendored dependency. The pipe operator is documented, ADL-discoverable, and equivalent in expressive power. | pipe shipped, member-chain deferred until upstream extension point exists | ~2 days (only if upstream PR is accepted) |
| **Phase 9b-analytic: derivation complete, code uses block-FD.** The Schläfli-based analytic HyperIdeal Hessian is fully derived in [`hyperideal-hessian-derivation.md`](../math/hyperideal-hessian-derivation.md) (805 lines, all sign pitfalls covered). The shipped code still uses per-face block-FD (already 96× faster than the legacy full-FD path). | research-ready writeup; implementation gated on the reviewer's view of whether the additional ~6× is worth it | ~2 weeks |
| **Doxygen `WARN_IF_UNDOCUMENTED = NO`.** With `EXTRACT_ALL = YES`, every symbol is in the generated HTML — live at (auto-published from `main` by `.gitea/workflows/doxygen-pages.yml`) — but symbols without explicit doc comments show only their signature. Public API surface (entry functions, named-parameter helpers, traits typedefs) has hand-written Doxygen; internal helpers vary. | clean (0 warnings) under current policy; not yet enforced "no undocumented symbol"; pursued on a separate branch | ~3 days to drive `WARN_IF_UNDOCUMENTED = YES` to zero |
-| **`check-test-counts.sh` not wired into CI.** The script exists, runs locally, and gives the correct answer (23 + 234 = 257 / 0 skipped). CI does not yet fail on a mismatch. | local guard exists, CI integration pending next workflow touch | ~1 hour |
+| **`check-test-counts.sh` not wired into CI.** ✅ Closed by branch `ci/structural-tests`. Step is now part of `.gitea/workflows/cpp-tests.yml` (re-uses the just-built `build/` dir; ~5 s overhead). | gate active on every PR | done |
+| **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 |
| **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/check-markdown-links.py b/scripts/check-markdown-links.py
new file mode 100755
index 0000000..b013338
--- /dev/null
+++ b/scripts/check-markdown-links.py
@@ -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())
diff --git a/scripts/check-test-counts.sh b/scripts/check-test-counts.sh
index 0394f1f..ffce3d6 100755
--- a/scripts/check-test-counts.sh
+++ b/scripts/check-test-counts.sh
@@ -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"
diff --git a/scripts/quality/README.md b/scripts/quality/README.md
new file mode 100644
index 0000000..d146f36
--- /dev/null
+++ b/scripts/quality/README.md
@@ -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//` (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 — ~25–40 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/.log`
+when invoked via `run-all.sh`, and to its own per-gate build directory
+(`build-sanitizers/`, `build-coverage/`, `build-multi-/`, …) 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//` (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.
diff --git a/scripts/quality/cgal-version-matrix.sh b/scripts/quality/cgal-version-matrix.sh
new file mode 100755
index 0000000..5e215f3
--- /dev/null
+++ b/scripts/quality/cgal-version-matrix.sh
@@ -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//` (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 </include/CGAL/version.h
+ /cmake/modules/UseCGAL.cmake
+ /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
diff --git a/scripts/quality/clang-tidy.sh b/scripts/quality/clang-tidy.sh
new file mode 100755
index 0000000..584ecdf
--- /dev/null
+++ b/scripts/quality/clang-tidy.sh
@@ -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
diff --git a/scripts/quality/coverage.sh b/scripts/quality/coverage.sh
new file mode 100755
index 0000000..b264168
--- /dev/null
+++ b/scripts/quality/coverage.sh
@@ -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"
diff --git a/scripts/quality/license-headers.sh b/scripts/quality/license-headers.sh
new file mode 100755
index 0000000..b045df6
--- /dev/null
+++ b/scripts/quality/license-headers.sh
@@ -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 <&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
diff --git a/scripts/quality/multi-compiler.sh b/scripts/quality/multi-compiler.sh
new file mode 100755
index 0000000..2c3329e
--- /dev/null
+++ b/scripts/quality/multi-compiler.sh
@@ -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-/ 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
diff --git a/scripts/quality/reproducible-build.sh b/scripts/quality/reproducible-build.sh
new file mode 100755
index 0000000..256d9ba
--- /dev/null
+++ b/scripts/quality/reproducible-build.sh
@@ -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
diff --git a/scripts/quality/run-all.sh b/scripts/quality/run-all.sh
new file mode 100755
index 0000000..14666e3
--- /dev/null
+++ b/scripts/quality/run-all.sh
@@ -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 ~25–40 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
diff --git a/scripts/quality/sanitizers.sh b/scripts/quality/sanitizers.sh
new file mode 100755
index 0000000..7334bc5
--- /dev/null
+++ b/scripts/quality/sanitizers.sh
@@ -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 4–5× 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