diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..b1bd2a2 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,35 @@ +--- +# Patternia clang-tidy configuration. +# +# Scope: correctness and performance findings in library headers. +# Style/readability checks stay with clang-format; two bugprone +# checks are disabled because they conflict with deliberate design +# idioms (see comments inline). +Checks: > + bugprone-*, + clang-analyzer-*, + performance-*, + portability-*, + concurrency-*, + -bugprone-crtp-constructor-accessibility, + -bugprone-branch-clone, + -bugprone-easily-swappable-parameters, + -bugprone-exception-escape + +# Only library headers are checked; test/sample code and +# third-party headers are out of scope. +HeaderFilterRegex: 'include/ptn/' + +# CI treats every warning as an error. +WarningsAsErrors: '*' + +# Rationale for disabled checks: +# - bugprone-crtp-constructor-accessibility: pattern_base and +# guard_operator are intentional public CRTP mixins. +# - bugprone-branch-clone: repeated branch bodies are a normal +# consequence of constexpr/if-constexpr metaprogramming. +# - bugprone-easily-swappable-parameters: fires pervasively on +# forwarding-reference machinery where parameter order is part +# of the DSL contract. +# - bugprone-exception-escape: noisy on executable mains (samples), +# irrelevant for a header-only library. diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml new file mode 100644 index 0000000..c9f401d --- /dev/null +++ b/.github/workflows/clang-tidy.yml @@ -0,0 +1,46 @@ +name: clang-tidy + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + clang-tidy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install clang-tidy + run: | + sudo apt-get update + sudo apt-get install -y clang clang-tidy + clang-tidy --version + + - name: Configure (compile_commands.json) + run: | + cmake -S . -B build \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DPTN_BUILD_TESTS=ON + + - name: Run clang-tidy on tests and samples + shell: bash + run: | + set -euo pipefail + # WarningsAsErrors is set in .clang-tidy, so any finding + # fails the step. json_dispatch samples are excluded: + # they need nlohmann/json which is not part of the build. + for f in tests/*.cpp samples/*.cpp; do + echo "=== $f" + clang-tidy -p build "$f" + done diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..562b7f8 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,59 @@ +name: fuzz + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + # Weekly deep run, Monday 03:00 UTC. + - cron: '0 3 * * 1' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install clang + run: | + sudo apt-get update + sudo apt-get install -y clang + + - name: Build fuzz target + run: | + cmake -S . -B build \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DPTN_BUILD_FUZZ=ON + cmake --build build --target ptn_fuzz_match + + - name: Fuzz (oracle + ASan/UBSan) + shell: bash + run: | + set -euo pipefail + TIME=180 + if [ "${{ github.event_name }}" = "schedule" ]; then + TIME=900 + fi + mkdir -p fuzz_corpus artifacts + ./build/fuzz/ptn_fuzz_match fuzz_corpus \ + -max_total_time="$TIME" \ + -rss_limit_mb=2048 \ + -artifact_prefix=artifacts/ \ + -print_final_stats=1 + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crash-${{ github.sha }} + path: artifacts/ + if-no-files-found: ignore diff --git a/CMakeLists.txt b/CMakeLists.txt index d5a637a..9c2c6e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,7 @@ option(PTN_INSTALL "Generate install/export targets" ${PTN_MASTER_PROJECT}) option(PTN_BUILD_TESTS "Build unit tests" ${PTN_MASTER_PROJECT}) option(PTN_BUILD_BENCHMARKS "Build benchmarks" ${PTN_MASTER_PROJECT}) option(PTN_BUILD_SAMPLES "Build sample programs" ${PTN_MASTER_PROJECT}) +option(PTN_BUILD_FUZZ "Build fuzz targets (requires Clang)" OFF) option(PTN_DEV_INDEX "Create local indexable target for IDEs (not installed)" ON) option(PTN_SKIP_COMPILER_CHECK "Skip compiler version check" OFF) @@ -182,6 +183,14 @@ if(PTN_BUILD_TESTS) endif() endif() +if(PTN_BUILD_FUZZ) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(FATAL_ERROR + "PTN_BUILD_FUZZ requires Clang (libFuzzer entry point).") + endif() + add_subdirectory(fuzz) +endif() + if(PTN_BUILD_BENCHMARKS) add_subdirectory(bench) endif() diff --git a/fuzz/CMakeLists.txt b/fuzz/CMakeLists.txt new file mode 100644 index 0000000..dfca264 --- /dev/null +++ b/fuzz/CMakeLists.txt @@ -0,0 +1,33 @@ +# Fuzz targets (libFuzzer). Built only when PTN_BUILD_FUZZ=ON. +# +# Sanitizer set is platform-dependent: Windows supports fuzzer and +# address reliably; Linux additionally gets undefined. + +if(WIN32) + set(PTN_FUZZ_SANITIZERS "fuzzer,address") +else() + set(PTN_FUZZ_SANITIZERS "fuzzer,address,undefined") +endif() + +add_executable(ptn_fuzz_match fuzz_match.cpp) +target_link_libraries(ptn_fuzz_match PRIVATE patternia) +target_compile_features(ptn_fuzz_match PRIVATE cxx_std_17) +target_compile_options(ptn_fuzz_match PRIVATE + -fsanitize=${PTN_FUZZ_SANITIZERS} -g -O1 +) +target_link_options(ptn_fuzz_match PRIVATE + -fsanitize=${PTN_FUZZ_SANITIZERS} +) + +if(WIN32) + # clang_rt.fuzzer ships as MT_StaticRelease. Pin the fuzz target + # to the static release CRT (otherwise Debug trees produce + # msvcrtd objects and /failifmismatch rejects the link), and + # disable iterator debugging for the same reason. + set_target_properties(ptn_fuzz_match PROPERTIES + MSVC_RUNTIME_LIBRARY "MultiThreaded" + ) + target_compile_options(ptn_fuzz_match PRIVATE + -U_DEBUG -DNDEBUG -D_ITERATOR_DEBUG_LEVEL=0 + ) +endif() diff --git a/fuzz/fuzz_match.cpp b/fuzz/fuzz_match.cpp new file mode 100644 index 0000000..d268827 --- /dev/null +++ b/fuzz/fuzz_match.cpp @@ -0,0 +1,177 @@ +// Fuzz target for match evaluation. +// +// Strategy: oracle comparison. Every battery evaluates the same +// subject through patternia and through a hand-written reference +// implementation; any divergence traps. ASan/UBSan reports and +// divergences both count as failures. + +#include +#include +#include +#include +#include + +#include + +using namespace ptn; + +namespace { + + struct Point { + int x; + int y; + }; + + PTN_BIND(Point, x, y); + + [[noreturn]] void diverged(const char *battery) { + // Print then trap: libFuzzer reports the input on abnormal + // termination. + std::fprintf(stderr, "oracle divergence in %s\n", battery); + std::abort(); + } + + // --- literal dispatch battery --- + + int lit_ref(int v) { + if (v == 0) + return 10; + if (v == 1) + return 11; + if (v == 2) + return 12; + if (v == 3) + return 13; + return -1; + } + + int lit_match(int v) { + return match(v) + | on( // + lit(0) >> 10, // + lit(1) >> 11, // + val<2> >> 12, // + val<3> >> 13, // + _ >> -1); + } + + // --- guard battery --- + + int guard_ref(int v) { + if (v < 0) + return 1; + if (v >= 0 && v <= 9) + return 2; + if (v >= 10 && v < 100) + return 3; + return 4; + } + + int guard_match(int v) { + return match(v) + | on( // + $[_ < 0] >> 1, // + $[rng(0, 9)] >> 2, // + $[_ >= 10 && _ < 100] >> 3, // + _ >> 4); + } + + // --- structural battery --- + + int struct_ref(const Point &p) { + if (p.x * p.x + p.y * p.y == 25) + return p.x + p.y; + if (p.x == 0 && p.y == 0) + return 0; + return -1; + } + + int struct_match(const Point &p) { + return match(p) + | on( // + $(has<&Point::x, &Point::y>)[x * x + y * y == 25] >> + [](int a, int b) { return a + b; }, + has<&Point::x, &Point::y>[x == 0 && y == 0] >> 0, + _ >> -1); + } + + // --- variant battery --- + + using Value = std::variant; + + std::string variant_ref(const Value &v) { + if (std::holds_alternative(v)) + return "i:" + std::to_string(std::get(v)); + return "s:" + std::get(v); + } + + std::string variant_match(const Value &v) { + return match(v) + | on( // + $(is) >> + [](int i) { return "i:" + std::to_string(i); }, + $(is) >> + [](const std::string &s) { return "s:" + s; }, + _ >> [] { return std::string("?"); }); + } + + // --- combinator battery --- + + int comb_ref(int v) { + if (v == 1 || v == 2 || v == 3) + return 1; + if (v != 0 && v % 2 == 0) + return 2; + return 3; + } + + int comb_match(int v) { + auto even = [](int n) { return n % 2 == 0; }; + return match(v) + | on( // + (lit(1) || lit(2) || lit(3)) >> 1, // + (!lit(0) && pred(even)) >> 2, // + _ >> 3); + } + + // Reads sizeof(int) bytes starting at off (wrapping) as a + // big-endian int. + int read_int(const uint8_t *data, size_t size, size_t off) { + int v = 0; + for (size_t i = 0; i < sizeof(int); ++i) { + v = (v << 8) | data[(off + i) % size]; + } + return v; + } + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, + size_t size) { + if (size < 8 || size > 4096) + return 0; + + const int a = read_int(data, size, 0); + const int b = read_int(data, size, 4); + + if (lit_match(a) != lit_ref(a)) + diverged("literal"); + if (guard_match(a) != guard_ref(a)) + diverged("guard"); + if (comb_match(b) != comb_ref(b)) + diverged("combinator"); + + const Point p{a & 0xFF, b & 0xFF}; + if (struct_match(p) != struct_ref(p)) + diverged("structural"); + + const size_t slen = static_cast(b & 0x3F) % (size + 1); + const Value v = (a & 1) ? Value{a} + : Value{std::string( + reinterpret_cast(data), + slen)}; + if (variant_match(v) != variant_ref(v)) + diverged("variant"); + + return 0; +} diff --git a/include/ptn/core/common/common_traits.hpp b/include/ptn/core/common/common_traits.hpp index 13ef371..5d60f46 100644 --- a/include/ptn/core/common/common_traits.hpp +++ b/include/ptn/core/common/common_traits.hpp @@ -1,5 +1,7 @@ #pragma once +#include + // Core type traits used by the matching engine. // // This header provides fundamental type traits and utilities for @@ -68,7 +70,7 @@ namespace ptn::core::traits { // Fallback Semantics (pattern-level vs match-level) - enum class fallback_level { + enum class fallback_level : std::uint8_t { none, pattern, // e.g. wildcard '_' match // e.g. otherwise(...) diff --git a/include/ptn/core/common/eval.hpp b/include/ptn/core/common/eval.hpp index f0c5ad8..ebddf98 100644 --- a/include/ptn/core/common/eval.hpp +++ b/include/ptn/core/common/eval.hpp @@ -132,13 +132,11 @@ namespace ptn::core::common { decltype(std::get(std::forward(t)))...>; if constexpr (std::is_void_v) { - std::invoke(std::forward(f), - std::get(std::forward(t))...); + std::invoke(std::forward(f), std::get(t)...); return; } else { - return std::invoke(std::forward(f), - std::get(std::forward(t))...); + return std::invoke(std::forward(f), std::get(t)...); } } diff --git a/include/ptn/core/common/optimize.hpp b/include/ptn/core/common/optimize.hpp index 935bf0c..e58410c 100644 --- a/include/ptn/core/common/optimize.hpp +++ b/include/ptn/core/common/optimize.hpp @@ -121,7 +121,7 @@ namespace ptn::core::common { constexpr std::size_t k_runtime_literal_dense_dispatch_min_cases = 4; - enum class variant_dispatch_tier { + enum class variant_dispatch_tier : std::uint8_t { hot_inline, warm_segmented, cold_compact @@ -820,7 +820,7 @@ namespace ptn::core::common { // Describes the primary discriminator a single case contributes // to the IR. This is the key the planner may use before any // residual checking. - enum class case_discriminator_kind { + enum class case_discriminator_kind : std::uint8_t { opaque, wildcard, runtime_literal, @@ -832,7 +832,7 @@ namespace ptn::core::common { // routing. `guard` means the key is usable, but the bucket must // still evaluate a guard. `structural` means the bucket must // re-enter general matching. - enum class case_residual_kind { + enum class case_residual_kind : std::uint8_t { none, guard, structural @@ -841,7 +841,7 @@ namespace ptn::core::common { // Describes how much the case's binding behavior constrains // lowering. `general` means the planner must assume full matcher // replay semantics. - enum class case_binding_kind { + enum class case_binding_kind : std::uint8_t { none, direct_ref, general @@ -1314,7 +1314,7 @@ namespace ptn::core::common { // `full` means direct keyed dispatch is legal, `bucketed` means // keyed dispatch may narrow the search before replay, and `none` // means the planner must fall back to sequential evaluation. - enum class lowering_legality { + enum class lowering_legality : std::uint8_t { none, bucketed, full @@ -1323,7 +1323,7 @@ namespace ptn::core::common { // Names the concrete runtime shape selected after legality // analysis. Multiple plan kinds may share the same legality // grade. - enum class dispatch_plan_kind { + enum class dispatch_plan_kind : std::uint8_t { sequential, literal_linear, static_literal_dense, diff --git a/include/ptn/pattern/base/fwd.h b/include/ptn/pattern/base/fwd.h index 80aef24..ae1447e 100644 --- a/include/ptn/pattern/base/fwd.h +++ b/include/ptn/pattern/base/fwd.h @@ -1,5 +1,7 @@ #pragma once +#include + // Forward declarations for Patternia Pattern Base Layer. namespace ptn::pat::base { @@ -12,14 +14,15 @@ namespace ptn::pat::base { struct pattern_base; // Pattern identification base - enum class pattern_kind; + enum class pattern_kind : std::uint8_t; // Pattern identification base template struct binding_args; template - using binding_args_t = typename binding_args::type; + using binding_args_t = typename binding_args::type; template struct binding_pattern_base; diff --git a/include/ptn/pattern/base/pattern_kind.hpp b/include/ptn/pattern/base/pattern_kind.hpp index 49e0e72..bc31901 100644 --- a/include/ptn/pattern/base/pattern_kind.hpp +++ b/include/ptn/pattern/base/pattern_kind.hpp @@ -1,7 +1,9 @@ #pragma once + +#include namespace ptn::pat::base { - enum class pattern_kind { + enum class pattern_kind : std::uint8_t { literal, relational, predicate, diff --git a/include/ptn/pattern/modifiers/fwd.h b/include/ptn/pattern/modifiers/fwd.h index 3afebed..3692305 100644 --- a/include/ptn/pattern/modifiers/fwd.h +++ b/include/ptn/pattern/modifiers/fwd.h @@ -3,6 +3,7 @@ // Forward declarations for Patternia Pattern Mod Layer. #include +#include namespace ptn::pat::mod { @@ -53,7 +54,7 @@ namespace ptn::pat::mod { struct max_tuple_guard_index; // Forward declare range predicate and related types - enum class range_mode : int; + enum class range_mode : std::uint8_t; struct closed_t; struct open_t; diff --git a/include/ptn/pattern/modifiers/guard.hpp b/include/ptn/pattern/modifiers/guard.hpp index a3781c7..b409c4b 100644 --- a/include/ptn/pattern/modifiers/guard.hpp +++ b/include/ptn/pattern/modifiers/guard.hpp @@ -8,6 +8,7 @@ // to bound values. #include +#include #include #include #include @@ -85,11 +86,14 @@ namespace ptn::pat::mod { } // Evaluates binary expression. + // + // The bound tuple is read through an lvalue on purpose: guard + // evaluation is read-only, and forwarding the same tuple into + // both operands would be unsequenced (bugprone-use-after-move). template constexpr decltype(auto) eval(const bin_expr &e, Tuple &&t) { - return std::decay_t{}(eval(e.l, std::forward(t)), - eval(e.r, std::forward(t))); + return std::decay_t{}(eval(e.l, t), eval(e.r, t)); } // Evaluates unary expression. @@ -605,7 +609,7 @@ namespace ptn::pat::mod { } // Range modes for interval predicates. - enum class range_mode { + enum class range_mode : std::uint8_t { closed, open, open_closed, diff --git a/include/ptn/pattern/structural.hpp b/include/ptn/pattern/structural.hpp index bcf3ed0..cd87624 100644 --- a/include/ptn/pattern/structural.hpp +++ b/include/ptn/pattern/structural.hpp @@ -103,7 +103,13 @@ namespace ptn::pat { : base::pattern_base> { Pred pred; - template + // Constrained so it cannot hide the copy/move constructors + // (bugprone-forwarding-reference-overload). + template < + typename P, + std::enable_if_t< + !std::is_same_v, has_guarded_pattern>, + int> = 0> constexpr explicit has_guarded_pattern(P &&p) : pred(std::forward

(p)) { }