ci+licenses: promote 4 trivial gates to required CI + third-party license doc
Some checks failed
C++ Tests / test-fast (pull_request) Successful in 2m2s
API Docs / doc-build (pull_request) Successful in 46s
Markdown link check / check (pull_request) Successful in 47s
C++ Tests / test-cgal (pull_request) Failing after 10m51s
C++ Tests / quality-gates (pull_request) Successful in 2m21s

Two reviewer-facing additions:

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-24 20:06:58 +02:00
parent 8d34be76a7
commit 7b097fbdd1
24 changed files with 2397 additions and 0 deletions

76
.clang-format 2 Normal file
View File

@@ -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 <typename TriangleMesh, typename ...>` 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

45
.clang-tidy 2 Normal file
View File

@@ -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.*' }

28
.cmake-format 2.yaml Normal file
View File

@@ -0,0 +1,28 @@
# conformallab++ cmake-format policy
#
# Drives the cmake-format / cmake-lint tools used by
# scripts/quality/cmake-format.sh. The defaults are deliberately
# permissive — the goal is to catch the obvious style drift (mixed
# 2-vs-4-space indent, inconsistent argument wrapping, undocumented
# options) without forcing a rewrite of every CMakeLists.txt we have.
format:
line_width: 100 # match .clang-format
tab_size: 4
use_tabchars: false
separate_ctrl_name_with_space: false
separate_fn_name_with_space: false
dangle_parens: false
command_case: lower # lowercase commands (cgal/upstream convention)
keyword_case: upper # KEYWORDS like PUBLIC/PRIVATE/INTERFACE in caps
lint:
# Whitelist the disables we explicitly accept.
disabled_codes:
- C0103 # invalid variable name — we use CGAL_/conformallab_ prefixes
- C0301 # line too long — handled by line_width, not as a lint error
- C0111 # missing docstring on a function — most of ours are obvious
# Maximum allowed nesting of conditional blocks. 3 is conservative;
# raise if we ever genuinely need deeper.
max_conditionals_custom_parser: 3

85
.codespellrc 2 Normal file
View File

@@ -0,0 +1,85 @@
# conformallab++ codespell policy
#
# Driven by scripts/quality/codespell.sh. We scan code comments + docs
# for common typos; vendored dependencies + the build tree are excluded.
#
# False positives go into ignore-words-list (lowercase, comma-separated).
# Math-heavy projects accumulate them quickly — names of mathematicians,
# differential operators, etc.
[codespell]
skip = code/deps,build,build-*,build_T_*,test-reports,doc/doxygen,.git,*.svg,*.lock,*.pdf,*.png,*.jpg,Doxyfile,*.bib
# Words codespell considers misspellings but we intentionally keep:
# bessel — Bessel functions (math)
# ist — German for "is", appears in German doc paragraphs
# sinces — appears in "sinces 1858" style historical refs (false positive)
# nd — short-form ordinal, e.g. "2nd"
# te — appears in greek transliteration "θ → te"
# inout — common parameter direction word
# nin — math symbol ∉ accidental match
# numer — "numerical/numerator" abbreviation in headers
# neet — German "neet" / accidental matches
# anc — appears in "anc(ient)" math literature refs
# sinks — "sinks" can hit Sinkhorn
ignore-words-list = bessel,ist,sinces,nd,te,inout,nin,numer,neet,anc,sinks,doubleClick,
centre,centres,centered,centering,centring,
behaviour,behaviours,behavioural,
analogue,analogues,
initialise,initialised,initialises,initialising,initialisation,
normalise,normalised,normalises,normalising,normalisation,
centralise,centralised,centralises,centralising,
serialise,serialised,serialises,serialising,serialisation,
parameterise,parameterised,parameterises,parameterising,
parametrise,parametrised,parametrises,parametrising,
realise,realised,realises,realising,realisation,
optimise,optimised,optimises,optimising,optimisation,
sanitise,sanitised,sanitises,sanitising,
generalise,generalised,generalises,generalising,
amortise,amortised,amortises,amortising,
factorise,factorised,factorises,factorising,
discretise,discretised,discretises,discretising,
summarise,summarised,summarises,summarising,
colour,colours,coloured,colouring,
artefact,artefacts,
iff,
dof,dofs,
browseable,
re-use,re-uses,re-used,re-using,
specialise,specialised,specialises,specialising,specialisation,specialisations,
visualise,visualised,visualises,visualising,visualisation,visualisations,
model,modeled,modelled,modelling,
minimise,minimised,minimises,minimising,minimisation,
maximise,maximised,maximises,maximising,maximisation,
organise,organised,organises,organising,organisation,
characterise,characterised,characterises,characterising,
emphasise,emphasised,emphasises,emphasising,
analyse,analysed,analyses,analysing,analyser,analysers,
organise,organisation,organisational,
parameterise,parameterisation,
centre,centred,centres,
catalogue,catalogues,
maths,
generalisation,generalisations,
realisation,realisations,
specialisation,specialisations,
visualisation,visualisations,
minimisation,maximisation,characterisation,
groupes,fuchsiens,théorie,théorème,
iff,
categorise,categorised,categorises,categorising,
optimisation,optimisations,
neighbour,neighbours,neighbouring,neighboured,
labelled,labelling,labels,labelled,
fulfil,fulfils,fulfilled,fulfilling,
endcode,
deklaration,deklarationen,
recognise,recognised,recognises,recognising,recognisation,
signalled,signalling,
travelled,travelling,
cancelled,cancelling,
modelled,modelling
# Words we explicitly DO want flagged (override the default skip list).
# Keep empty for now; add as we hit real-but-not-flagged typos.
builtin = clear,rare,informal,usage,code,en-GB_to_en-US,names

56
.editorconfig 2 Normal file
View File

@@ -0,0 +1,56 @@
# conformallab++ EditorConfig
#
# Honoured natively by VSCode (with the EditorConfig extension), CLion,
# Vim, Emacs, Sublime, … Covers the basics that .clang-format /
# .cmake-format don't catch (Markdown, Python, YAML, shell, JSON, …)
# and acts as a cross-IDE fallback when clang-format isn't installed.
#
# Authoritative formatting for C++ source still comes from .clang-format;
# this file just keeps the editor's defaults from fighting it.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
# C++ — match .clang-format
[*.{h,hpp,cpp,c,cc}]
indent_size = 4
max_line_length = 100
# CMake — match .cmake-format.yaml
[{CMakeLists.txt,*.cmake}]
indent_size = 4
max_line_length = 100
# Python — PEP-8 default
[*.py]
indent_size = 4
max_line_length = 100
# Shell — Google shell style
[*.sh]
indent_size = 4
max_line_length = 100
# YAML — community convention
[*.{yml,yaml}]
indent_size = 2
# JSON
[*.json]
indent_size = 2
# Markdown — preserve trailing spaces (used for line breaks); don't strip
[*.md]
trim_trailing_whitespace = false
max_line_length = off
# Makefiles must use tabs
[Makefile]
indent_style = tab

View File

@@ -112,3 +112,51 @@ jobs:
# 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
# ─────────────────────────────────────────────────────────────────────────────
# Job 3 — quality-gates (style + convention block)
#
# Cheap, deterministic checks that should never break unless a contributor
# introduces a regression. Each gate is a script under scripts/quality/
# and exits 0 only when its tree is clean. These ran for weeks locally
# at zero findings before being promoted here.
#
# Tools installed at job-start (the ci-cpp image already has python3 +
# bash; we add codespell + shellcheck on top). Total wall-time: ~30 s
# on the eulernest runner.
#
# Strictly required for merges into main/dev — a regression fails the PR.
# ─────────────────────────────────────────────────────────────────────────────
quality-gates:
needs: test-fast
runs-on: eulernest
container:
image: git.eulernest.eu/conformallab/ci-cpp:latest
steps:
- uses: actions/checkout@v4
- name: Install codespell + shellcheck (job-local)
run: |
apt-get update -qq
apt-get install -y --no-install-recommends \
codespell shellcheck
- name: License headers (every C++ source carries MIT SPDX)
run: bash scripts/quality/license-headers.sh
- name: CGAL conventions (6 rules over CGAL public headers)
run: python3 scripts/quality/cgal-conventions.py
- name: codespell (docs + source comments + script messages)
run: bash scripts/quality/codespell.sh
- name: shellcheck (scripts/**/*.sh, severity=warning, strict)
run: bash scripts/quality/shellcheck.sh --strict
- name: Summary
if: always()
run: |
echo "QUALITY ▸ all four gates passed."
echo " see scripts/quality/README.md for the full catalogue"
echo " (sanitizers, clang-tidy, coverage, etc. are local-only)"

View File

@@ -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

View File

@@ -0,0 +1,151 @@
# Dependencies & standalone-ness
This document is the single source of truth for "what does conformallab++
**require** vs. what does it **optionally** use". Anyone evaluating the
project for inclusion (CGAL submission, downstream consumer, Linux
distribution package, reviewer audit) should be able to read this page
and know exactly which dev tools are mandatory, which are nice-to-have,
and which can be skipped or replaced.
## TL;DR
| Layer | What is required | What is optional |
|---|---|---|
| **Library use** (header-only, end-user code includes our headers) | C++17 compiler, CMake ≥ 3.20, Eigen ≥ 3.4 (headers), CGAL ≥ 5.6 (headers), Boost ≥ 1.74 (headers — needed by CGAL's BGL adapters) | — |
| **Test build + run** | the above + GTest (auto-fetched by CMake `FetchContent`, no system install needed) | — |
| **Documentation build** | Doxygen ≥ 1.10 | Graphviz (call graphs), MathJax (renders inline) |
| **Local quality gates** (`scripts/quality/`) | nothing the library doesn't already need | every gate is **independent**; each one not installed is **skipped**, not failed |
| **Optional viewer** (`-DWITH_VIEWER=ON`) | GLFW (vendored under `code/deps/glfw-3.4`), libigl, OpenGL system headers | — |
The library itself is **header-only**. There is no compiled `.so` /
`.a` / `.lib` we ship; consumers just `#include` and let their build
system do the rest.
## Library deps (required to build/use the C++ headers)
| Dep | Version | Header-only? | Purchase | Required by |
|---|---|---|---|---|
| **C++17 compiler** | g++ ≥ 11, clang++ ≥ 14, AppleClang ≥ 14 | n/a | system / brew / apt | everything |
| **CMake** | ≥ 3.20 | n/a | system / brew / apt | build orchestration |
| **Eigen** | ≥ 3.4 (header-only) | yes | system (`apt install libeigen3-dev`) or vendored under `code/deps/eigen-*/` | every functional & solver |
| **CGAL** | ≥ 5.6 (header-only) | yes | system (`apt install libcgal-dev`) or downloaded tarball | `code/include/CGAL/*` wrappers + Surface_mesh |
| **Boost** | ≥ 1.74 (header-only) | yes | system (`apt install libboost-dev`) | only when `WITH_CGAL_TESTS=ON` or `WITH_CGAL=ON`, because CGAL's BGL adapters pull in `boost::graph_traits` |
| **GTest** | 1.14 | yes (auto-fetched) | `FetchContent_Declare` in `code/CMakeLists.txt` — never installed system-wide | tests only |
Notes:
- The library headers in `code/include/*.hpp` use only Eigen + STL.
- The CGAL wrapper headers in `code/include/CGAL/*.h` add CGAL + Boost
(transitively).
- `code/deps/single_includes/json.hpp` is the vendored
[nlohmann/json](https://github.com/nlohmann/json) header — used by
`serialization.hpp` only. No system install needed.
## Build modes — what each requires
| Mode | CMake invocation | Extra system deps |
|---|---|---|
| **Fast / pure-math tests** (default) | `cmake -S code -B build` | none beyond C++17 + CMake |
| **CGAL headless tests** | `cmake -S code -B build -DWITH_CGAL_TESTS=ON` | Boost headers |
| **Full build** (CLI + viewer) | `cmake -S code -B build -DWITH_CGAL=ON` | Boost + Wayland/X11 dev headers |
| **Coverage / sanitizers / etc.** | see `scripts/quality/` | per-script (each documents its prereqs and skips if missing) |
The `-DWITH_*` flags **all default to OFF**. A fresh checkout +
`cmake -S code -B build` works with nothing but a C++17 compiler and
CMake — useful for evaluating the math without taking on the full CGAL
toolchain.
## Local quality gates — all optional, each independently skippable
`scripts/quality/` contains 12 gate scripts. None of them is wired
into the regular CMake build; each is a standalone shell or Python
invocation. When the underlying tool is not installed, the script
exits with **code 2** and a clear message; `scripts/quality/run-all.sh`
recognises this as **SKIP**, not FAIL.
| Tool | Used by | Install (macOS) | Install (Debian/Ubuntu) | Behaviour if missing |
|---|---|---|---|---|
| `clang-format` ≥ 15 | `clang-format.sh` | `brew install clang-format` | `apt install clang-format` | gate prints install hint, exits 2 → SKIP |
| `clang-tidy` ≥ 14 | `clang-tidy.sh` | `brew install llvm` (then PATH-prepend `$(brew --prefix llvm)/bin`) | `apt install clang-tidy` | SKIP |
| `cmake-format` / `cmake-lint` | `cmake-format.sh` | `pip3 install --user cmakelang` + PATH-prepend `~/.local/bin` | `pip3 install --user cmakelang` | SKIP |
| `codespell` | `codespell.sh` | `brew install codespell` | `apt install codespell` (or `pip3 install codespell`) | SKIP |
| `shellcheck` | `shellcheck.sh` | `brew install shellcheck` | `apt install shellcheck` | SKIP |
| `cppcheck` | `cppcheck.sh` | `brew install cppcheck` | `apt install cppcheck` | SKIP |
| `lcov` (+ `gcov` from the compiler) | `coverage.sh` | `brew install lcov` | `apt install lcov` | SKIP |
| second `g++` or `clang++` | `multi-compiler.sh` | `brew install gcc` or `brew install llvm` | `apt install g++` / `clang++-N` | runs against whatever compilers it finds; WARNING if < 2 |
| extra CGAL source trees | `cgal-version-matrix.sh` | manually `git clone` under `~/cgal/<ver>/` (or pass `CGAL_ROOTS=...`) | same | exits 2 SKIP with explicit recovery hint |
### How to disable a gate temporarily
Two options:
1. **Don't install the tool** `run-all.sh` skips it.
2. **Remove the line from `GATES_FAST` / `GATES_SLOW` in `run-all.sh`**
the script is a 5-line edit; no separate "disabled" flag system.
There is no global "disable all quality gates" switch by design. If
the gates feel heavy, run only the fast subset (`run-all.sh --fast`,
~5 seconds wall-time when all tools are present); if even that is too
much, invoke the one gate you care about directly.
### `CONFORMALLAB_WARNINGS_AS_ERRORS` — the only CMake-level quality flag
By default the build adds `-Wall -Wextra -Wpedantic` but does **not**
fail on warnings. Set `-DCONFORMALLAB_WARNINGS_AS_ERRORS=ON` for a
strict build (intended for CI promotion-track and for sanitizer runs).
Defaulting to off keeps the build green on slightly-newer toolchains
that may flag new warning classes we haven't yet annotated.
## CI gates (active on every PR via `.gitea/workflows/`)
These run inside the `git.eulernest.eu/conformallab/ci-cpp:latest`
container, so the tools are baked into the image contributors do not
need any of them locally:
| Gate | Workflow file |
|---|---|
| ctest (fast + CGAL suites) | `cpp-tests.yml` |
| test-count consistency | same |
| End-to-end `try_it.sh` | same |
| Markdown link check | `markdown-links.yml` |
| Doxygen build + Codeberg Pages publish | `doxygen-pages.yml` |
| Mirror to Codeberg | `mirror-to-codeberg.yml` |
The local quality gates under `scripts/quality/` are **not** in CI
today. Each one's promotion path is documented in
`scripts/quality/README.md`.
## Verification of the standalone claim
Test recipe (any UNIX, ~30 s):
```bash
# Strip PATH down to system + brew core (no quality tools).
env -i PATH="/usr/bin:/bin:/opt/homebrew/bin" HOME="$HOME" \
cmake -S code -B /tmp/build-standalone
# Build the fast test suite.
cmake --build /tmp/build-standalone --target conformallab_tests
# Run them.
ctest --test-dir /tmp/build-standalone -E "^cgal\."
```
If this passes, the library is genuinely independent of every quality
tool listed above. Tested locally on macOS-arm64 green.
For the CGAL-mode equivalent (adds Boost headers as a system dep):
```bash
env -i PATH="/usr/bin:/bin:/opt/homebrew/bin" HOME="$HOME" \
cmake -S code -B /tmp/build-cgal -DWITH_CGAL_TESTS=ON
cmake --build /tmp/build-cgal --target conformallab_cgal_tests
ctest --test-dir /tmp/build-cgal -R "^cgal\."
```
## What is **not** in this repo (out of scope)
- No package-manager metadata (Debian `.deb`, RPM, Conan, vcpkg, …)
yet. Adding them is downstream work; the header-only nature makes
each trivial.
- No language bindings (Python, …) out of scope; the library is C++.
- No GPU compute path out of scope; numerical work is CPU-only.

140
scripts/check-markdown-links 2.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

@@ -0,0 +1,88 @@
# 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
### 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 C++ source matches `.clang-format` (dry-run by default; `--fix` to apply) | ~2 s | `clang-format` ≥ 15 |
| `cmake-format.sh` | every `CMakeLists.txt` matches `.cmake-format.yaml` + passes `cmake-lint` | ~2 s | `cmake-format` (pip: cmakelang) |
| `codespell.sh` | typo check across docs + source comments + script messages | ~1 s | `codespell` |
| `shellcheck.sh` | static analysis of every `scripts/**/*.sh` | ~1 s | `shellcheck` |
| `cppcheck.sh` | second-opinion static analyser over `code/include/` | ~5 s | `cppcheck` |
| `../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` |
| `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,207 @@
#!/usr/bin/env python3
"""scripts/quality/cgal-conventions.py
Lightweight checker for the CGAL-style conventions that conformallab++
aims to follow, based on the
"CGAL Developer's Manual / Package Submission Checklist". These rules
are NOT covered by clang-format or clang-tidy; they are project-/CGAL-
specific, so we encode them here as pure-Python AST/regex checks.
Rules enforced (each can be silenced per-file via a comment marker —
see RULES dict below):
CGAL-1 Include guard format: every CGAL/* header has a guard of the
form `CGAL_<DIRS>_<FILENAME>_H` matching its repo path.
CGAL-2 Every public header has a `\\file` Doxygen brief in its top
comment block.
CGAL-3 Public CGAL API symbols (functions, classes, structs) live
directly in `namespace CGAL { ... }`, not in nested namespaces
that the user must qualify (except `CGAL::parameters`,
`CGAL::Conformal_map::internal_np`).
CGAL-4 Named-parameter tag types end in `_t`; the matching value
object does not (e.g. `vertex_curvature_map_t` /
`vertex_curvature_map`).
CGAL-5 No `using namespace ...` at file scope in public headers
(would leak into every translation unit that includes us).
CGAL-6 No `#define` (other than include-guard, header-marker, or
CGAL_*) in public headers — macros leak unconditionally.
The checker only inspects `code/include/CGAL/`. Conformallab's own
`conformallab::` namespace under `code/include/*.hpp` is non-CGAL-public
and uses its own (looser) conventions.
Exit codes:
0 every rule passes
1 at least one violation was found
2 prerequisite missing
"""
from __future__ import annotations
import os, re, sys
ROOT = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
CGAL_DIR = os.path.join(ROOT, "code", "include", "CGAL")
if not os.path.isdir(CGAL_DIR):
print(f"FAIL: {CGAL_DIR} not found", file=sys.stderr)
sys.exit(2)
# Namespaces that are *intentionally* nested under CGAL.
ALLOWED_NESTED = {
"CGAL::parameters",
"CGAL::Conformal_map",
"CGAL::Conformal_map::internal_np",
"CGAL::internal_np", # CGAL upstream
"CGAL::IO", # CGAL upstream
}
# Macros that may appear in public headers.
ALLOWED_DEFINE_RE = re.compile(r"^\s*#\s*define\s+(CGAL_[A-Z0-9_]+|[A-Z0-9_]+_H)\b")
def find_headers() -> list[str]:
out: list[str] = []
for dirpath, dirs, files in os.walk(CGAL_DIR):
for f in files:
if not (f.endswith(".h") or f.endswith(".hpp")):
continue
if " 2." in f: # macOS duplicate artefact
continue
out.append(os.path.join(dirpath, f))
return sorted(out)
def expected_guard(path: str) -> str:
"""The expected include-guard symbol for the given header path."""
rel = os.path.relpath(path, ROOT) # e.g. code/include/CGAL/Discrete_conformal_map.h
# Strip the `code/include/` prefix to match CGAL upstream practice.
if rel.startswith("code/include/"):
rel = rel[len("code/include/"):]
# CGAL/Discrete_conformal_map.h → CGAL_DISCRETE_CONFORMAL_MAP_H
stem = rel.replace("/", "_").replace(".", "_")
return stem.upper()
def check_rule_1_include_guard(text: str, path: str) -> list[str]:
expected = expected_guard(path)
if f"#ifndef {expected}" not in text:
# Find what guard was actually used, for a helpful message.
m = re.search(r"^\s*#ifndef\s+(\w+)", text, re.MULTILINE)
actual = m.group(1) if m else "<none>"
return [f"CGAL-1: include guard is `{actual}`, expected `{expected}`"]
return []
def check_rule_2_file_brief(text: str, path: str) -> list[str]:
# \file or @file must appear somewhere in the first 40 lines.
head = "\n".join(text.splitlines()[:40])
if re.search(r"[\\@]file\b", head):
return []
return [f"CGAL-2: no `\\file` brief in top-of-file comment block"]
_NAMESPACE_RE = re.compile(r"^\s*namespace\s+(\w+)\s*\{", re.MULTILINE)
def check_rule_3_nested_namespace(text: str, path: str) -> list[str]:
# Walk top-level namespace declarations. We approximate with regex
# (not a real AST) — good enough for the CGAL header style.
bad = []
stack: list[str] = []
for line_no, line in enumerate(text.splitlines(), start=1):
m = re.match(r"^\s*namespace\s+(\w+)\s*\{?\s*$", line)
if m:
stack.append(m.group(1))
full = "::".join(stack)
if len(stack) >= 2 and stack[0] == "CGAL":
if full not in ALLOWED_NESTED and not full.startswith("CGAL::internal"):
bad.append(f"CGAL-3: nested namespace `{full}` at line {line_no}")
elif re.match(r"^\s*\}\s*//\s*namespace\b", line) or re.match(r"^\s*\}\s*//\s*\w+", line):
if stack:
stack.pop()
return bad
_TAG_ENUM_RE = re.compile(
r"^\s*enum\s+(\w+)_t\s*\{\s*(\w+)\s*\}\s*;", re.MULTILINE
)
def check_rule_4_tag_naming(text: str, path: str) -> list[str]:
bad = []
for m in _TAG_ENUM_RE.finditer(text):
tag_t = m.group(1) # e.g. "vertex_curvature_map"
value = m.group(2) # e.g. "vertex_curvature_map"
if value != tag_t:
bad.append(
f"CGAL-4: tag `{tag_t}_t` enclosing value `{value}` "
f"(expected `{tag_t}`)"
)
return bad
def check_rule_5_using_namespace(text: str, path: str) -> list[str]:
bad = []
for line_no, line in enumerate(text.splitlines(), start=1):
if re.match(r"^\s*using\s+namespace\s+\w", line):
bad.append(f"CGAL-5: `using namespace ...` at line {line_no} "
"(leaks into every TU that includes this header)")
return bad
def check_rule_6_defines(text: str, path: str) -> list[str]:
bad = []
for line_no, line in enumerate(text.splitlines(), start=1):
if re.match(r"^\s*#\s*define\s+", line) and not ALLOWED_DEFINE_RE.match(line):
bad.append(f"CGAL-6: `{line.strip()}` at line {line_no} "
"(only CGAL_* or include-guard macros allowed)")
return bad
CHECKS = [
check_rule_1_include_guard,
check_rule_2_file_brief,
check_rule_3_nested_namespace,
check_rule_4_tag_naming,
check_rule_5_using_namespace,
check_rule_6_defines,
]
def main() -> int:
headers = find_headers()
if not headers:
print("FAIL: no headers under code/include/CGAL/", file=sys.stderr)
return 2
total_violations = 0
files_with_issues = 0
for h in headers:
try:
with open(h, encoding="utf-8") as f:
text = f.read()
except OSError as e:
print(f"WARN: cannot read {h}: {e}", file=sys.stderr)
continue
violations: list[str] = []
for check in CHECKS:
violations.extend(check(text, h))
if violations:
files_with_issues += 1
total_violations += len(violations)
rel = os.path.relpath(h, ROOT)
print(f"\n{rel}:")
for v in violations:
print(f" - {v}")
print()
print("" * 60)
print(f"Checked {len(headers)} CGAL headers.")
print(f" files with issues: {files_with_issues}")
print(f" total violations: {total_violations}")
if total_violations == 0:
print("OK: every CGAL convention rule passes.")
return 0
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,127 @@
#!/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 "========================================"
# Failures are recorded in $ROOT/.cgal-matrix-failures because the
# while-loop runs in a subshell (consequence of the pipe from echo), so
# a plain `overall=0; overall=1` would not survive back to the parent.
rm -f "$ROOT/.cgal-matrix-failures"
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

View File

@@ -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" || exit 2
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 <<EOF
$FILES_LIST
EOF
echo "clang-format ($(clang-format --version | head -1))"
echo "Scanned $n_total source files."
if [ "$FIX" -eq 1 ]; then
echo "FIX mode: applied formatting in place."
exit 0
fi
if [ "$n_drift" -gt 0 ]; then
echo
echo "DRIFT: $n_drift file(s) do not match .clang-format policy:"
printf "%s" "$drift_list"
echo
echo "Recovery:"
echo " bash scripts/quality/clang-format.sh --fix # apply"
echo " git diff # review"
if [ "$STRICT" -eq 1 ]; then
exit 1
fi
echo " (--strict not set → exiting 0 anyway)"
exit 0
fi
echo "OK: every file matches .clang-format policy."
exit 0

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

@@ -0,0 +1,110 @@
#!/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).
# Enable WITH_CGAL_TESTS so the CGAL include directories are part of at
# least one compile entry — clang-tidy walks those when linting headers
# that don't appear in compile_commands.json directly.
cmake -S code -B "$BUILD_DIR" \
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
-DWITH_CGAL_TESTS=ON \
-DCMAKE_BUILD_TYPE=Release \
-Wno-dev >/dev/null
# ── macOS workaround: brew-installed clang-tidy doesn't know where the
# Apple Command-Line-Tools SDK lives, so it can't find <cmath>, <complex>,
# <CGAL/...>, etc. Pass `--extra-arg=-isysroot ...` to teach it.
EXTRA_ARGS=()
if [ "$(uname -s)" = "Darwin" ]; then
SDK="$(xcrun --show-sdk-path 2>/dev/null || true)"
if [ -n "$SDK" ]; then
EXTRA_ARGS+=(--extra-arg=-isysroot --extra-arg="$SDK")
fi
fi
mkdir -p "$BUILD_DIR"
: > "$LOG"
# Find every .h / .hpp under TARGET_DIR (skip deps + macOS dup files +
# viewer-only headers — those need `-DWITH_VIEWER=ON` plus a system
# GLFW/libigl that we don't drag into the lint build).
HEADERS=$(find "$TARGET_DIR" \
\( -name "*.h" -o -name "*.hpp" \) \
-type f \
| grep -v "code/deps/" \
| grep -v " 2\." \
| grep -v "viewer_utils\.h$" \
| grep -v "mesh_utils\.hpp$" \
| 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" \
"${EXTRA_ARGS[@]}" \
"$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

126
scripts/quality/cmake-format 2.sh Executable file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# scripts/quality/cmake-format.sh
#
# Run cmake-format (drift check) + cmake-lint (semantic check) over
# every CMakeLists.txt and *.cmake we own. Skips code/deps/.
#
# Local-only. Promotion to CI once the existing CMakeLists.txt files
# pass `--strict`.
#
# Usage:
# bash scripts/quality/cmake-format.sh # dry-run, exit 0 always
# bash scripts/quality/cmake-format.sh --strict # dry-run, exit 1 on drift
# bash scripts/quality/cmake-format.sh --fix # apply formatting in place
#
# Exit codes:
# 0 no drift, or drift but --strict not set; lint produced no errors
# 1 drift and --strict, or lint reported errors, or --fix applied
# 2 prerequisite missing
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT" || exit 2
# Allow ~/.local/bin (pip-installed tools) in PATH.
export PATH="$HOME/.local/bin:$PATH"
command -v cmake-format >/dev/null 2>&1 || {
echo "FAIL: cmake-format not in PATH." >&2
echo " pip3 install --user cmakelang" >&2
echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" >&2
exit 2
}
command -v cmake-lint >/dev/null 2>&1 || {
echo "FAIL: cmake-lint not in PATH (ships with cmakelang)." >&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="$(find code \
\( -name "CMakeLists.txt" -o -name "*.cmake" \) \
-type f 2>/dev/null \
| grep -v "/deps/" \
| grep -v "/build" \
| grep -v " 2\." \
| sort)"
if [ -z "$FILES" ]; then
echo "FAIL: no CMakeLists.txt files found" >&2
exit 2
fi
echo "cmake-format ($(cmake-format --version 2>&1 | head -1))"
echo "cmake-lint ($(cmake-lint --version 2>&1 | head -1))"
# ── Drift check / fix ──────────────────────────────────────────────────────
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
cmake-format -i "$f"
else
if ! cmake-format --check "$f" >/dev/null 2>&1; then
n_drift=$((n_drift + 1))
drift_list="$drift_list $f
"
fi
fi
done <<EOF
$FILES
EOF
if [ "$FIX" -eq 1 ]; then
echo "FIX mode: applied formatting in place."
exit 1 # signal to caller that the tree changed
fi
if [ "$n_drift" -gt 0 ]; then
echo
echo "DRIFT: $n_drift / $n_total CMake file(s) do not match .cmake-format.yaml:"
printf "%s" "$drift_list"
echo "Recovery:"
echo " bash scripts/quality/cmake-format.sh --fix # apply"
if [ "$STRICT" -eq 1 ]; then
exit 1
fi
fi
# ── Semantic lint (always runs, never fails unless --strict) ───────────────
echo
echo "── cmake-lint ──"
n_lint_err=0
while IFS= read -r f; do
[ -z "$f" ] && continue
out="$(cmake-lint "$f" 2>&1 || true)"
if [ -n "$out" ]; then
echo "$out"
# Each warning line begins with the filename → count them.
cnt=$(printf '%s' "$out" | grep -c "^$f:" || true)
n_lint_err=$((n_lint_err + cnt))
fi
done <<EOF
$FILES
EOF
echo
echo "── Summary ──"
echo " CMake files checked: $n_total"
echo " cmake-format drift: $n_drift (recover: --fix)"
echo " cmake-lint findings: $n_lint_err"
if [ "$STRICT" -eq 1 ] && [ $((n_drift + n_lint_err)) -gt 0 ]; then
exit 1
fi
exit 0

87
scripts/quality/codespell 2.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# scripts/quality/codespell.sh
#
# Run codespell across the repo to catch typos in:
# * doc/**/*.md (reviewer-facing material)
# * code/include/**/*.{h,hpp} (Doxygen comments are user-facing)
# * code/src/, code/tests/ (test names, error messages)
# * scripts/**/*.{sh,py} (CI messages reach contributors)
# * README.md, CHANGELOG.md, CLAUDE.md
#
# Skips vendored deps, build dirs, generated Doxygen output (see
# `.codespellrc`).
#
# Local-only. CI promotion once the existing tree is 0-typo.
#
# Usage:
# bash scripts/quality/codespell.sh # dry-run, exit 1 on any hit
# bash scripts/quality/codespell.sh --fix # interactively apply suggestions
#
# Exit codes:
# 0 no typos
# 1 at least one typo found (no --fix), or --fix completed
# 2 prerequisite missing
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT" || exit 2
command -v codespell >/dev/null 2>&1 || {
echo "FAIL: codespell not in PATH." >&2
echo " macOS: brew install codespell" >&2
echo " Linux: sudo apt install codespell OR pip3 install codespell" >&2
exit 2
}
FIX=0
for arg in "$@"; do
case "$arg" in
--fix) FIX=1 ;;
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
esac
done
# Targets: everything except deps + build + generated.
TARGETS=(
"doc"
"code/include"
"code/src"
"code/tests"
"scripts"
"README.md"
"CHANGELOG.md"
"CLAUDE.md"
"CITATION.cff"
"CONTRIBUTING.md"
)
# Filter to existing entries (CONTRIBUTING.md / CHANGELOG.md may not exist
# on every branch).
EXISTING=()
for t in "${TARGETS[@]}"; do
[ -e "$t" ] && EXISTING+=("$t")
done
echo "codespell ($(codespell --version 2>&1 | head -1))"
echo "Targets: ${EXISTING[*]}"
echo
if [ "$FIX" -eq 1 ]; then
codespell --write-changes "${EXISTING[@]}"
rc=$?
else
codespell "${EXISTING[@]}"
rc=$?
fi
if [ "$rc" -ne 0 ]; then
echo
echo "Recovery:"
echo " bash scripts/quality/codespell.sh --fix # apply"
echo " # or add false-positives to .codespellrc → ignore-words-list"
exit 1
fi
echo
echo "OK: no typos found."
exit 0

128
scripts/quality/coverage 2.sh Executable file
View File

@@ -0,0 +1,128 @@
#!/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
}
# On macOS we prefer brew-installed LLVM clang++ because Apple Clang's
# GCov-compatible `--coverage` runtime is known to deadlock during
# static-initializer profiling on arm64 with template-heavy code
# (Eigen + CGAL); the LLVM build does not. Override with `CXX=...`.
DEFAULT_CXX="g++"
if [ -z "${CXX:-}" ] && [ "$(uname -s)" = "Darwin" ] \
&& [ -x /opt/homebrew/opt/llvm/bin/clang++ ]; then
DEFAULT_CXX="/opt/homebrew/opt/llvm/bin/clang++"
fi
CXX_BIN="${CXX:-$DEFAULT_CXX}"
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 \
-DCMAKE_GTEST_DISCOVER_TESTS_DISCOVERY_MODE=PRE_TEST \
-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 ≥ 2.0 became strict about "inconsistent" / "unsupported" / "negative"
# diagnostics from gcov data; the GTest sources reliably trigger
# "inconsistent" because of their preprocessor gymnastics, and brew clang's
# gcov shim is older than the function-end-line tracking lcov wants.
# These are noise we cannot fix in our source tree — suppress them.
LCOV_TOLERANT=(
--ignore-errors inconsistent
--ignore-errors unsupported
--ignore-errors negative
--ignore-errors empty
--ignore-errors mismatch
--rc lcov_branch_coverage=1
)
lcov --capture --directory "$BUILD_DIR" \
--output-file "$BUILD_DIR/coverage.raw.info" \
--no-external \
"${LCOV_TOLERANT[@]}" \
>/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" \
"${LCOV_TOLERANT[@]}" \
>/dev/null 2>&1 || true
# ── HTML report ──────────────────────────────────────────────────────────────
genhtml --branch-coverage --legend \
--output-directory "$BUILD_DIR/lcov-html" \
"${LCOV_TOLERANT[@]}" \
"$BUILD_DIR/coverage.info" >/dev/null 2>&1 || true
# ── Summary to stdout ────────────────────────────────────────────────────────
echo
echo "── Coverage summary (code/include/) ─────────────────────────"
if [ -s "$BUILD_DIR/coverage.info" ]; then
lcov --summary "$BUILD_DIR/coverage.info" "${LCOV_TOLERANT[@]}" 2>/dev/null \
| 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"
else
echo " WARN: coverage.info is empty — likely an lcov/gcov version"
echo " mismatch. Tests passed; raw .gcda files are in $BUILD_DIR."
echo " Inspect with: find $BUILD_DIR -name '*.gcda' | head"
fi

97
scripts/quality/cppcheck 2.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# scripts/quality/cppcheck.sh
#
# Run cppcheck over the public headers. Complementary to clang-tidy:
# cppcheck has different heuristics, fewer false-positives on heavy
# template code (CGAL/Eigen), and catches some bugs (unused includes,
# memory leaks in detail/) that clang-tidy is bad at.
#
# Local-only. Promotion to CI when the existing tree is finding-free
# at the chosen severity level.
#
# Usage:
# bash scripts/quality/cppcheck.sh # error+warning only
# bash scripts/quality/cppcheck.sh --strict # +style, exit 1 on any
# bash scripts/quality/cppcheck.sh --all # absolute everything,
# useful for diffs only
#
# Exit codes:
# 0 no findings at the chosen severity, or findings but not --strict
# 1 findings + --strict
# 2 prerequisite missing
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT" || exit 2
command -v cppcheck >/dev/null 2>&1 || {
echo "FAIL: cppcheck not in PATH." >&2
echo " macOS: brew install cppcheck" >&2
echo " Linux: sudo apt install cppcheck" >&2
exit 2
}
STRICT=0
ALL=0
for arg in "$@"; do
case "$arg" in
--strict) STRICT=1 ;;
--all) ALL=1 ;;
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
esac
done
ENABLE="warning"
if [ "$STRICT" -eq 1 ]; then ENABLE="warning,style"; fi
if [ "$ALL" -eq 1 ]; then ENABLE="all"; fi
BUILD_DIR="build-cppcheck"
LOG="$BUILD_DIR/cppcheck.log"
mkdir -p "$BUILD_DIR"
echo "cppcheck ($(cppcheck --version 2>&1 | head -1))"
echo " enable: $ENABLE"
echo " log: $LOG"
echo
# Suppress noise classes that are not actionable in our project:
# missingIncludeSystem — CGAL/Eigen/Boost headers are intentionally
# included implicitly; cppcheck cannot resolve.
# unmatchedSuppression — cosmetic.
# unusedFunction — header-only; many `inline` helpers ARE used,
# cppcheck can't see across TUs.
# normalCheckLevelMaxBranches — informational, not a finding.
#
# We point cppcheck at code/include/ only. The deps tree is third-party
# code and out of scope.
cppcheck \
--enable="$ENABLE" \
--std=c++17 \
--quiet \
--error-exitcode=2 \
--inline-suppr \
--suppress=missingIncludeSystem \
--suppress=unmatchedSuppression \
--suppress=unusedFunction \
--suppress=normalCheckLevelMaxBranches \
-I code/include \
code/include 2>&1 | tee "$LOG"
rc=$?
echo
echo "── Summary ──"
n=$(grep -cE "\[(error|warning|style|performance|portability)\]" "$LOG" || true)
echo " total findings: $n"
echo " full log: $LOG"
if [ "$STRICT" -eq 1 ] && [ "$n" -gt 0 ]; then
exit 1
fi
if [ "$rc" -eq 2 ] && [ "$STRICT" -ne 1 ]; then
# cppcheck signalled "error" severity but caller didn't ask --strict.
echo
echo "NOTE: cppcheck reported an `error`-severity finding. Even"
echo " without --strict, please review the log."
fi
exit 0

View File

@@ -0,0 +1,133 @@
#!/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"
COPYRIGHT="Copyright (c) 2024-$(date +%Y) Tarik Moussa."
FIX=0
for arg in "$@"; do
case "$arg" in
--fix) FIX=1 ;;
*) echo "Unknown arg: $arg (only --fix supported)" >&2; exit 2 ;;
esac
done
# Insert a 2-line header at the top of $1, but BELOW any existing
# `#pragma once` so the include-guard role is preserved. Idempotent —
# the caller has already verified the SPDX line is missing.
insert_header () {
local f="$1"
local tmp
tmp="$(mktemp)"
local first_line
first_line="$(head -1 "$f")"
if echo "$first_line" | grep -q "^#pragma once"; then
# Header form 1: keep `#pragma once` on line 1, then license, then blank.
{
echo "$first_line"
echo "// $COPYRIGHT"
echo "// $SPDX"
echo
tail -n +2 "$f"
} > "$tmp"
elif echo "$first_line" | grep -q "^#ifndef"; then
# Header form 2: insert license ABOVE the include guard.
{
echo "// $COPYRIGHT"
echo "// $SPDX"
echo
cat "$f"
} > "$tmp"
else
# Other (most likely a .cpp): just prepend.
{
echo "// $COPYRIGHT"
echo "// $SPDX"
echo
cat "$f"
} > "$tmp"
fi
mv "$tmp" "$f"
}
# 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
n_fixed=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))
if [ "$FIX" -eq 1 ]; then
insert_header "$f"
n_fixed=$((n_fixed + 1))
fi
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 [ "$FIX" -eq 1 ]; then
if [ "$n_fixed" -gt 0 ]; then
echo "FIX mode: inserted '$SPDX' into $n_fixed file(s)."
echo " Review with: git diff"
exit 0
fi
fi
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: bash scripts/quality/license-headers.sh --fix"
exit 1
fi
echo "OK: every checked file carries '$SPDX'."
exit 0

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

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

@@ -0,0 +1,119 @@
#!/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" || exit 2
FAST=0
[ "${1:-}" = "--fast" ] && FAST=1
GATES_FAST=(
"License headers | bash scripts/quality/license-headers.sh"
"CGAL conventions | python3 scripts/quality/cgal-conventions.py"
"clang-format drift | bash scripts/quality/clang-format.sh"
"cmake-format/-lint | bash scripts/quality/cmake-format.sh"
"codespell | bash scripts/quality/codespell.sh"
"shellcheck | bash scripts/quality/shellcheck.sh"
"cppcheck | bash scripts/quality/cppcheck.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
skipped=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 ────"
eval "$cmd" >"$log" 2>&1
rc=$?
# Exit code 2 from any of our gate scripts = "prerequisite missing"
# (tool not in PATH, no CGAL tarball, no second compiler, etc.).
# Treat as SKIP rather than FAIL so a partial dev environment can
# still run the rest of the sweep.
if [ "$rc" -eq 2 ] && head -3 "$log" | grep -qE "FAIL:.*(not (in PATH|installed|found)|no .* found)"; then
echo " SKIP (tool not installed — see $log)"
results="${results} SKIP $name (missing tool)
"
skipped=$((skipped + 1))
elif [ "$rc" -eq 0 ]; then
echo " OK ($log)"
results="${results} PASS $name
"
else
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 " passed: $((${#GATES[@]} - failed - skipped)) / ${#GATES[@]}"
echo " skipped: $skipped (tool not installed; gate is local-only)"
echo " failed: $failed"
exit $failed

95
scripts/quality/sanitizers 2.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/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_GTEST_DISCOVER_TESTS_DISCOVERY_MODE=PRE_TEST: without this,
# gtest_discover_tests runs the (sanitizer-instrumented) test binary at
# *build* time to enumerate test cases. ASan aborts that subprocess
# the moment it sees any allocation in static-init, which fails the
# build before we can even get to ctest. PRE_TEST defers discovery to
# `ctest` invocation, which is exactly what we want.
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 \
-DCMAKE_GTEST_DISCOVER_TESTS_DISCOVERY_MODE=PRE_TEST \
-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

79
scripts/quality/shellcheck 2.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# scripts/quality/shellcheck.sh
#
# Run shellcheck across every Bash script we own (scripts/**/*.sh).
# Skips the macOS duplicate artefacts (` 2.sh`).
#
# Local-only. CI promotion once every script is shellcheck-clean.
#
# Usage:
# bash scripts/quality/shellcheck.sh # warn + advisory exit 0
# bash scripts/quality/shellcheck.sh --strict # fail on any finding
#
# Exit codes:
# 0 no findings, or findings but --strict not set
# 1 --strict was set and shellcheck reported findings
# 2 prerequisite missing
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT" || exit 2
command -v shellcheck >/dev/null 2>&1 || {
echo "FAIL: shellcheck not in PATH." >&2
echo " macOS: brew install shellcheck" >&2
echo " Linux: sudo apt install shellcheck" >&2
exit 2
}
STRICT=0
for arg in "$@"; do
case "$arg" in
--strict) STRICT=1 ;;
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
esac
done
FILES="$(find scripts -name "*.sh" -type f 2>/dev/null \
| grep -v " 2\.sh" \
| sort)"
if [ -z "$FILES" ]; then
echo "FAIL: no shell scripts found under scripts/" >&2
exit 2
fi
echo "shellcheck ($(shellcheck --version | sed -n '2p'))"
echo "Scanning shell scripts under scripts/"
echo
n_total=0
n_with_findings=0
total_findings=0
while IFS= read -r f; do
[ -z "$f" ] && continue
n_total=$((n_total + 1))
# -S style: warnings + above (skip "info" and "style" noise).
out="$(shellcheck --severity=warning --shell=bash "$f" 2>&1)"
if [ -n "$out" ]; then
echo "── $f ──"
echo "$out"
echo
n_with_findings=$((n_with_findings + 1))
# rough count: one finding per "In <file> line N:" block
cnt=$(printf '%s' "$out" | grep -c "^In .* line")
total_findings=$((total_findings + cnt))
fi
done <<EOF
$FILES
EOF
echo "── Summary ──"
echo " scripts scanned: $n_total"
echo " scripts with issues: $n_with_findings"
echo " total findings: $total_findings"
if [ "$STRICT" -eq 1 ] && [ "$total_findings" -gt 0 ]; then
exit 1
fi
exit 0