diff --git a/.clang-format 2 b/.clang-format 2 new file mode 100644 index 0000000..3a9f141 --- /dev/null +++ b/.clang-format 2 @@ -0,0 +1,76 @@ +# conformallab++ formatting policy +# +# Captures the style already present in code/include/. Documented here +# so clang-format can enforce it locally (scripts/quality/clang-format.sh) +# and so new contributors get the same output their editor would on save. +# +# This is NOT the upstream CGAL clang-format (there isn't one published); +# it's the style our tree already uses, mechanically extracted. + +BasedOnStyle: LLVM +Language: Cpp +Standard: c++17 + +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 100 # loose; readability over hard wrap + +# Brace placement — matches the project tree: +# functions / methods → opening brace on a new line (CGAL convention) +# structs / classes → opening brace on a new line +# else / catch → on the same line as the closing brace of the preceding block +BreakBeforeBraces: Custom +BraceWrapping: + AfterClass: true + AfterStruct: true + AfterEnum: true + AfterFunction: true + AfterNamespace: false + AfterUnion: true + AfterControlStatement: false + BeforeElse: false + BeforeCatch: false + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: false + SplitEmptyNamespace: true + +# Reference & pointer modifiers attach to the type (`int& x`, not `int &x`). +PointerAlignment: Left +ReferenceAlignment: Left + +# Aligned `using = ...` blocks are intentional in the trait classes. +AlignConsecutiveDeclarations: AcrossEmptyLines +AlignConsecutiveAssignments: AcrossEmptyLines +AlignTrailingComments: true +AlignAfterOpenBracket: Align + +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortBlocksOnASingleLine: Never +AllowShortLambdasOnASingleLine: Inline + +# Template-related: break before each parameter when the template line +# would otherwise exceed ColumnLimit (matches existing +# `template ` patterns). +BreakBeforeBinaryOperators: NonAssignment +BinPackParameters: false +BinPackArguments: false +AlwaysBreakTemplateDeclarations: Yes +SpaceAfterTemplateKeyword: true + +NamespaceIndentation: None +AccessModifierOffset: -4 +IndentCaseLabels: false + +# Includes: keep manual ordering — re-ordering can break Eigen / CGAL +# transitive-include assumptions in subtle ways. We just enforce no +# accidental duplicate blank lines. +SortIncludes: Never +MaxEmptyLinesToKeep: 1 +KeepEmptyLinesAtTheStartOfBlocks: false + +# Comments: don't touch. +ReflowComments: false diff --git a/.clang-tidy 2 b/.clang-tidy 2 new file mode 100644 index 0000000..d69c71e --- /dev/null +++ b/.clang-tidy 2 @@ -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/.cmake-format 2.yaml b/.cmake-format 2.yaml new file mode 100644 index 0000000..35c337a --- /dev/null +++ b/.cmake-format 2.yaml @@ -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 diff --git a/.codespellrc 2 b/.codespellrc 2 new file mode 100644 index 0000000..0269020 --- /dev/null +++ b/.codespellrc 2 @@ -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 diff --git a/.editorconfig 2 b/.editorconfig 2 new file mode 100644 index 0000000..26750ba --- /dev/null +++ b/.editorconfig 2 @@ -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 diff --git a/.gitea/workflows/cpp-tests.yml b/.gitea/workflows/cpp-tests.yml index 8b60d16..af8baf8 100644 --- a/.gitea/workflows/cpp-tests.yml +++ b/.gitea/workflows/cpp-tests.yml @@ -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)" diff --git a/.gitea/workflows/markdown-links 2.yml b/.gitea/workflows/markdown-links 2.yml new file mode 100644 index 0000000..b7affac --- /dev/null +++ b/.gitea/workflows/markdown-links 2.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/dependencies 2.md b/doc/architecture/dependencies 2.md new file mode 100644 index 0000000..a68277e --- /dev/null +++ b/doc/architecture/dependencies 2.md @@ -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//` (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. diff --git a/scripts/check-markdown-links 2.py b/scripts/check-markdown-links 2.py new file mode 100755 index 0000000..b013338 --- /dev/null +++ b/scripts/check-markdown-links 2.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*(?:\"[^\"]*\")?\)") + +# 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/quality/README 2.md b/scripts/quality/README 2.md new file mode 100644 index 0000000..12ad35e --- /dev/null +++ b/scripts/quality/README 2.md @@ -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//` (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-conventions 2.py b/scripts/quality/cgal-conventions 2.py new file mode 100755 index 0000000..4ae1f4d --- /dev/null +++ b/scripts/quality/cgal-conventions 2.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""scripts/quality/cgal-conventions.py + +Lightweight checker for the CGAL-style conventions that conformallab++ +aims to follow, based on the +"CGAL Developer's Manual / Package Submission Checklist". These rules +are NOT covered by clang-format or clang-tidy; they are project-/CGAL- +specific, so we encode them here as pure-Python AST/regex checks. + +Rules enforced (each can be silenced per-file via a comment marker — +see RULES dict below): + + CGAL-1 Include guard format: every CGAL/* header has a guard of the + form `CGAL___H` matching its repo path. + CGAL-2 Every public header has a `\\file` Doxygen brief in its top + comment block. + CGAL-3 Public CGAL API symbols (functions, classes, structs) live + directly in `namespace CGAL { ... }`, not in nested namespaces + that the user must qualify (except `CGAL::parameters`, + `CGAL::Conformal_map::internal_np`). + CGAL-4 Named-parameter tag types end in `_t`; the matching value + object does not (e.g. `vertex_curvature_map_t` / + `vertex_curvature_map`). + CGAL-5 No `using namespace ...` at file scope in public headers + (would leak into every translation unit that includes us). + CGAL-6 No `#define` (other than include-guard, header-marker, or + CGAL_*) in public headers — macros leak unconditionally. + +The checker only inspects `code/include/CGAL/`. Conformallab's own +`conformallab::` namespace under `code/include/*.hpp` is non-CGAL-public +and uses its own (looser) conventions. + +Exit codes: + 0 every rule passes + 1 at least one violation was found + 2 prerequisite missing +""" +from __future__ import annotations +import os, re, sys + +ROOT = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) +CGAL_DIR = os.path.join(ROOT, "code", "include", "CGAL") + +if not os.path.isdir(CGAL_DIR): + print(f"FAIL: {CGAL_DIR} not found", file=sys.stderr) + sys.exit(2) + +# Namespaces that are *intentionally* nested under CGAL. +ALLOWED_NESTED = { + "CGAL::parameters", + "CGAL::Conformal_map", + "CGAL::Conformal_map::internal_np", + "CGAL::internal_np", # CGAL upstream + "CGAL::IO", # CGAL upstream +} + +# Macros that may appear in public headers. +ALLOWED_DEFINE_RE = re.compile(r"^\s*#\s*define\s+(CGAL_[A-Z0-9_]+|[A-Z0-9_]+_H)\b") + + +def find_headers() -> list[str]: + out: list[str] = [] + for dirpath, dirs, files in os.walk(CGAL_DIR): + for f in files: + if not (f.endswith(".h") or f.endswith(".hpp")): + continue + if " 2." in f: # macOS duplicate artefact + continue + out.append(os.path.join(dirpath, f)) + return sorted(out) + + +def expected_guard(path: str) -> str: + """The expected include-guard symbol for the given header path.""" + rel = os.path.relpath(path, ROOT) # e.g. code/include/CGAL/Discrete_conformal_map.h + # Strip the `code/include/` prefix to match CGAL upstream practice. + if rel.startswith("code/include/"): + rel = rel[len("code/include/"):] + # CGAL/Discrete_conformal_map.h → CGAL_DISCRETE_CONFORMAL_MAP_H + stem = rel.replace("/", "_").replace(".", "_") + return stem.upper() + + +def check_rule_1_include_guard(text: str, path: str) -> list[str]: + expected = expected_guard(path) + if f"#ifndef {expected}" not in text: + # Find what guard was actually used, for a helpful message. + m = re.search(r"^\s*#ifndef\s+(\w+)", text, re.MULTILINE) + actual = m.group(1) if m else "" + return [f"CGAL-1: include guard is `{actual}`, expected `{expected}`"] + return [] + + +def check_rule_2_file_brief(text: str, path: str) -> list[str]: + # \file or @file must appear somewhere in the first 40 lines. + head = "\n".join(text.splitlines()[:40]) + if re.search(r"[\\@]file\b", head): + return [] + return [f"CGAL-2: no `\\file` brief in top-of-file comment block"] + + +_NAMESPACE_RE = re.compile(r"^\s*namespace\s+(\w+)\s*\{", re.MULTILINE) + + +def check_rule_3_nested_namespace(text: str, path: str) -> list[str]: + # Walk top-level namespace declarations. We approximate with regex + # (not a real AST) — good enough for the CGAL header style. + bad = [] + stack: list[str] = [] + for line_no, line in enumerate(text.splitlines(), start=1): + m = re.match(r"^\s*namespace\s+(\w+)\s*\{?\s*$", line) + if m: + stack.append(m.group(1)) + full = "::".join(stack) + if len(stack) >= 2 and stack[0] == "CGAL": + if full not in ALLOWED_NESTED and not full.startswith("CGAL::internal"): + bad.append(f"CGAL-3: nested namespace `{full}` at line {line_no}") + elif re.match(r"^\s*\}\s*//\s*namespace\b", line) or re.match(r"^\s*\}\s*//\s*\w+", line): + if stack: + stack.pop() + return bad + + +_TAG_ENUM_RE = re.compile( + r"^\s*enum\s+(\w+)_t\s*\{\s*(\w+)\s*\}\s*;", re.MULTILINE +) + + +def check_rule_4_tag_naming(text: str, path: str) -> list[str]: + bad = [] + for m in _TAG_ENUM_RE.finditer(text): + tag_t = m.group(1) # e.g. "vertex_curvature_map" + value = m.group(2) # e.g. "vertex_curvature_map" + if value != tag_t: + bad.append( + f"CGAL-4: tag `{tag_t}_t` enclosing value `{value}` " + f"(expected `{tag_t}`)" + ) + return bad + + +def check_rule_5_using_namespace(text: str, path: str) -> list[str]: + bad = [] + for line_no, line in enumerate(text.splitlines(), start=1): + if re.match(r"^\s*using\s+namespace\s+\w", line): + bad.append(f"CGAL-5: `using namespace ...` at line {line_no} " + "(leaks into every TU that includes this header)") + return bad + + +def check_rule_6_defines(text: str, path: str) -> list[str]: + bad = [] + for line_no, line in enumerate(text.splitlines(), start=1): + if re.match(r"^\s*#\s*define\s+", line) and not ALLOWED_DEFINE_RE.match(line): + bad.append(f"CGAL-6: `{line.strip()}` at line {line_no} " + "(only CGAL_* or include-guard macros allowed)") + return bad + + +CHECKS = [ + check_rule_1_include_guard, + check_rule_2_file_brief, + check_rule_3_nested_namespace, + check_rule_4_tag_naming, + check_rule_5_using_namespace, + check_rule_6_defines, +] + + +def main() -> int: + headers = find_headers() + if not headers: + print("FAIL: no headers under code/include/CGAL/", file=sys.stderr) + return 2 + total_violations = 0 + files_with_issues = 0 + for h in headers: + try: + with open(h, encoding="utf-8") as f: + text = f.read() + except OSError as e: + print(f"WARN: cannot read {h}: {e}", file=sys.stderr) + continue + violations: list[str] = [] + for check in CHECKS: + violations.extend(check(text, h)) + if violations: + files_with_issues += 1 + total_violations += len(violations) + rel = os.path.relpath(h, ROOT) + print(f"\n{rel}:") + for v in violations: + print(f" - {v}") + + print() + print("─" * 60) + print(f"Checked {len(headers)} CGAL headers.") + print(f" files with issues: {files_with_issues}") + print(f" total violations: {total_violations}") + if total_violations == 0: + print("OK: every CGAL convention rule passes.") + return 0 + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/quality/cgal-version-matrix 2.sh b/scripts/quality/cgal-version-matrix 2.sh new file mode 100755 index 0000000..55e4ef7 --- /dev/null +++ b/scripts/quality/cgal-version-matrix 2.sh @@ -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//` (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 "========================================" + +# 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 diff --git a/scripts/quality/clang-format 2.sh b/scripts/quality/clang-format 2.sh new file mode 100755 index 0000000..a515a30 --- /dev/null +++ b/scripts/quality/clang-format 2.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# scripts/quality/clang-format.sh +# +# Verify every source file under code/{include,src,tests} matches the +# project's `.clang-format` policy. Runs in dry-run mode by default — +# only reports diffs; never edits files. +# +# Pass `--fix` to apply the suggested formatting in place. +# +# Local-only. Promotion to CI is intended once the existing tree is +# 100 %-conformant; today we report drift but don't fail (the script +# exits non-zero only with `--strict`). +# +# Usage: +# bash scripts/quality/clang-format.sh # dry-run, exit 0 always +# bash scripts/quality/clang-format.sh --strict # dry-run, exit 1 on drift +# bash scripts/quality/clang-format.sh --fix # apply changes +# +# Exit codes: +# 0 no drift, or drift but --strict not set +# 1 drift detected AND --strict (or fixes applied AND --fix) +# 2 prerequisite missing + +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" || 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 </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 , , +# , 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 diff --git a/scripts/quality/cmake-format 2.sh b/scripts/quality/cmake-format 2.sh new file mode 100755 index 0000000..496848a --- /dev/null +++ b/scripts/quality/cmake-format 2.sh @@ -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 <&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 </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 diff --git a/scripts/quality/coverage 2.sh b/scripts/quality/coverage 2.sh new file mode 100755 index 0000000..8b9e050 --- /dev/null +++ b/scripts/quality/coverage 2.sh @@ -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 diff --git a/scripts/quality/cppcheck 2.sh b/scripts/quality/cppcheck 2.sh new file mode 100755 index 0000000..c584c50 --- /dev/null +++ b/scripts/quality/cppcheck 2.sh @@ -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 diff --git a/scripts/quality/license-headers 2.sh b/scripts/quality/license-headers 2.sh new file mode 100755 index 0000000..035decc --- /dev/null +++ b/scripts/quality/license-headers 2.sh @@ -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 <&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 diff --git a/scripts/quality/multi-compiler 2.sh b/scripts/quality/multi-compiler 2.sh new file mode 100755 index 0000000..2c3329e --- /dev/null +++ b/scripts/quality/multi-compiler 2.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 2.sh b/scripts/quality/reproducible-build 2.sh new file mode 100755 index 0000000..256d9ba --- /dev/null +++ b/scripts/quality/reproducible-build 2.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 2.sh b/scripts/quality/run-all 2.sh new file mode 100755 index 0000000..f01f27f --- /dev/null +++ b/scripts/quality/run-all 2.sh @@ -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 ~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" || 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 diff --git a/scripts/quality/sanitizers 2.sh b/scripts/quality/sanitizers 2.sh new file mode 100755 index 0000000..4a44559 --- /dev/null +++ b/scripts/quality/sanitizers 2.sh @@ -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 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 diff --git a/scripts/quality/shellcheck 2.sh b/scripts/quality/shellcheck 2.sh new file mode 100755 index 0000000..19bec95 --- /dev/null +++ b/scripts/quality/shellcheck 2.sh @@ -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 line N:" block + cnt=$(printf '%s' "$out" | grep -c "^In .* line") + total_findings=$((total_findings + cnt)) + fi +done <