diff --git a/.agents/skills/coder/SKILL.md b/.agents/skills/coder/SKILL.md index 4b8bb8b..35e73d6 100644 --- a/.agents/skills/coder/SKILL.md +++ b/.agents/skills/coder/SKILL.md @@ -20,8 +20,10 @@ Read `AGENTS.md` first. This skill adds implementation guidance only. 1. Inspect the touched code, build shape, and tests. 2. Keep interfaces simple and behavior-oriented. 3. Implement the narrowest change that solves the problem. -4. Add or update tests close to the changed behavior. -5. Run relevant build/test/analyzer targets. +4. Add or update tests close to the changed behavior using `frame_test.h` + macros (`FRAME_TEST`, `FRAME_EXPECT_EQ`, `FRAME_EXPECT_TRUE`, etc.). +5. Run `format` before committing, then run relevant build/test/analyzer + targets. ## C++ Rules diff --git a/.agents/skills/project-config-and-tests/SKILL.md b/.agents/skills/project-config-and-tests/SKILL.md index 78bec3e..d1d006e 100644 --- a/.agents/skills/project-config-and-tests/SKILL.md +++ b/.agents/skills/project-config-and-tests/SKILL.md @@ -13,4 +13,7 @@ small deterministic test coverage. - keep config parsing non-fatal where that preserves recovery/help paths - keep defaults, example config, and docs aligned - prefer deterministic tests around parsing, normalization, and helper seams + using `frame_test.h` macros - keep WHAT/HOW/WHY commentary current in repo-owned tests +- add benchmarks for performance-sensitive helpers using `frame_bench.h` +- use the `coverage` preset and target to verify test coverage for new code diff --git a/.agents/skills/project-core-dev/SKILL.md b/.agents/skills/project-core-dev/SKILL.md index 60379b0..b8ac79e 100644 --- a/.agents/skills/project-core-dev/SKILL.md +++ b/.agents/skills/project-core-dev/SKILL.md @@ -16,7 +16,9 @@ repo-owned C++ code. ## Validation -- build out of tree +- prefer `cmake --preset dev` for development builds - run `ctest --output-on-failure` for covered changes +- run `cmake --build "$BUILD_DIR" --target format-check` before committing - run `frame_cli --help` as a lightweight smoke test - add docs/analyzer/Valgrind validation when the change surface justifies it +- run `cppcheck` target when available for additional static analysis diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..bcf6c07 --- /dev/null +++ b/.clang-format @@ -0,0 +1,24 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 100 +BreakBeforeBraces: Custom +BraceWrapping: + AfterFunction: true + AfterNamespace: false + AfterStruct: true + AfterClass: true + AfterEnum: true + BeforeElse: true + BeforeCatch: true + SplitEmptyFunction: false +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortLambdasOnASingleLine: Inline +AlwaysBreakTemplateDeclarations: Yes +PointerAlignment: Right +ReferenceAlignment: Pointer +SpaceAfterCStyleCast: false +SpaceBeforeParens: ControlStatements +IncludeBlocks: Preserve +SortIncludes: CaseSensitive diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c6556c..12ab2f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ + clang-format \ cmake \ doxygen \ g++ \ @@ -41,6 +42,9 @@ jobs: - name: Configure run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_STANDARD=23 + - name: Check formatting + run: cmake --build build --target format-check + - name: Build run: cmake --build build diff --git a/AGENTS.md b/AGENTS.md index 02f0745..47d5b40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,11 +12,14 @@ Baseline shape: - Out-of-tree builds are the default workflow - Repo-owned code should stay portable, local-first, and easy to validate - One small library target plus one CLI target form the default example shape -- Deterministic `CTest`, `clang-tidy`, Doxygen, release-hygiene, and Valgrind - lanes are part of the maintained contract +- Deterministic `CTest`, `clang-tidy`, `clang-format`, `cppcheck`, Doxygen, + release-hygiene, Valgrind, and coverage lanes are part of the maintained + contract - Qt/Clazy provide the example UI stack, not the baseline assumption - Public docs are generated from repo-owned headers and `docs/mainpage.md` - Feature plans live under `upcoming_features/` as tracked Markdown files only + (see `upcoming_features/TEMPLATE.md` for the expected format) +- CMake presets provide named configurations for common workflows Repository principles: @@ -31,20 +34,33 @@ Repository principles: ## Key Paths - `src/`: repo-owned library and CLI code -- `tests/`: deterministic example tests and test registration +- `tests/`: deterministic example tests, test registration, and `frame_test.h` + micro-framework - `docs/`: Doxygen config and API-focused main page - `scripts/`: hygiene, release, and diagnostics helpers - `contrib/`: optional service/desktop integration examples - `cmake/`: reusable analyzer helper scripts - `.agents/skills/`: project-local agent overlays and merged skills - `.github/workflows/`: generic CI and release workflow templates +- `benchmarks/`: optional chrono-based micro-benchmarks and `frame_bench.h` + harness - `upcoming_features/`: forward-looking implementation plans ## Build And Validation Read `README.md` first before changing build, setup, or release behavior. -Use an out-of-tree build: +Prefer CMake presets for common workflows: + +```bash +cmake --preset dev +cmake --build build/dev -j"$(nproc)" +``` + +Available presets: `dev` (debug + sanitizers), `release` (optimized + LTO), +`ci` (matches GitHub Actions), `coverage` (gcov instrumentation). + +Manual out-of-tree build (when presets are unavailable): ```bash BUILD_DIR="$(mktemp -d /tmp/cpp-frame-build-XXXXXX)" @@ -54,7 +70,7 @@ cmake --build "$BUILD_DIR" -j"$(nproc)" If `ninja-build` is unavailable, omit `-G Ninja`. -Optional sanitizer lane: +Optional sanitizer lane (included in the `dev` preset): ```bash cmake -S . -B "$BUILD_DIR" -G Ninja \ @@ -74,7 +90,9 @@ Use the smallest validation set that proves the change, then extend as needed: - `cmake --build "$BUILD_DIR" --target clang-tidy` - `cmake --build "$BUILD_DIR" --target clazy` when the project uses the example Qt-based UI stack and the tool is available -- `cmake --build "$BUILD_DIR" --target lint` +- `cmake --build "$BUILD_DIR" --target format-check` +- `cmake --build "$BUILD_DIR" --target lint` (includes clang-tidy and cppcheck + when available) - `cmake --build "$BUILD_DIR" --target docs` - `bash scripts/run-valgrind.sh "$BUILD_DIR"` - `bash scripts/check-release-hygiene.sh` @@ -86,9 +104,55 @@ INSTALL_DIR="$(mktemp -d /tmp/cpp-frame-install-XXXXXX)" cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR" ``` +## Formatting + +Run `cmake --build "$BUILD_DIR" --target format` before committing to keep +source files consistently formatted. CI enforces `format-check` and will reject +unformatted code. The `.clang-format` config at the repo root defines the +canonical style. + +## Coverage + +Build with the `coverage` preset and run the `coverage` target: + +```bash +cmake --preset coverage +cmake --build build/coverage +cmake --build build/coverage --target coverage +``` + +This runs tests and collects coverage via `gcovr`. HTML output lands in +`build/coverage/coverage/`. New code should maintain or improve line coverage. + +## Benchmarks + +Build with `-DFRAME_ENABLE_BENCHMARKS=ON` and run manually: + +```bash +cmake --build "$BUILD_DIR" --target example_bench +"$BUILD_DIR/benchmarks/example_bench" +``` + +Add benchmarks for hot paths, algorithms, and performance-sensitive code. +Use `FRAME_BENCHMARK(name, iterations)` from `benchmarks/frame_bench.h`. + +## Project Setup + +When adapting this frame for a new project, use the init script: + +```bash +./scripts/init-project.sh --name "Your Project Name" # dry-run +./scripts/init-project.sh --name "Your Project Name" --apply +``` + +This renames all placeholder targets, namespaces, prefixes, and filenames. + ## Testing Rules - Keep repo-owned tests deterministic and headless under `CTest` +- Use the `frame_test.h` micro-framework: `FRAME_TEST(name)` for registration, + `FRAME_EXPECT_EQ`, `FRAME_EXPECT_TRUE`, `FRAME_EXPECT_FALSE`, + `FRAME_EXPECT_THROWS` for assertions, `FRAME_RUN_TESTS()` in main - Keep `WHAT/HOW/WHY` commentary near the start of real test bodies; the repo scripts enforce that contract - Prefer pure helper seams and injected fakes over environment-heavy tests diff --git a/CMakeLists.txt b/CMakeLists.txt index 2755452..f35612e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,8 @@ set(CMAKE_CXX_EXTENSIONS OFF) option(FRAME_ENABLE_ASAN "Enable AddressSanitizer for repo-owned code" OFF) option(FRAME_ENABLE_UBSAN "Enable UndefinedBehaviorSanitizer for repo-owned code" OFF) +option(FRAME_ENABLE_COVERAGE "Enable gcov/llvm-cov coverage instrumentation" OFF) +option(FRAME_ENABLE_BENCHMARKS "Build the benchmarks directory" OFF) option(FRAME_ENABLE_QT "Enable the example Qt-based UI integration path" OFF) option(FRAME_ENABLE_CLAZY "Enable the clazy analyzer lane for the Qt-based UI path" OFF) @@ -41,7 +43,7 @@ set_target_properties(frame_cli PROPERTIES OUTPUT_NAME frame_cli ) -function(frame_enable_sanitizers target_name) +function(frame_enable_sanitizers target_name visibility) if(NOT CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") message(WARNING "Sanitizers were requested, but ${CMAKE_CXX_COMPILER_ID} is not configured for repo-owned sanitizer flags") return() @@ -61,13 +63,28 @@ function(frame_enable_sanitizers target_name) endif() if(sanitizer_compile_flags) - target_compile_options(${target_name} PRIVATE ${sanitizer_compile_flags}) - target_link_options(${target_name} PRIVATE ${sanitizer_link_flags}) + target_compile_options(${target_name} ${visibility} ${sanitizer_compile_flags}) + target_link_options(${target_name} ${visibility} ${sanitizer_link_flags}) endif() endfunction() -frame_enable_sanitizers(project_core) -frame_enable_sanitizers(frame_cli) +frame_enable_sanitizers(project_core PUBLIC) +frame_enable_sanitizers(frame_cli PRIVATE) + +function(frame_enable_coverage target_name visibility) + if(NOT FRAME_ENABLE_COVERAGE) + return() + endif() + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + message(WARNING "Coverage requested but ${CMAKE_CXX_COMPILER_ID} is not supported") + return() + endif() + target_compile_options(${target_name} ${visibility} --coverage -fprofile-arcs -ftest-coverage) + target_link_options(${target_name} ${visibility} --coverage) +endfunction() + +frame_enable_coverage(project_core PUBLIC) +frame_enable_coverage(frame_cli PRIVATE) set(FRAME_CLAZY_CHECKS "level0" CACHE STRING "Checks passed to clazy-standalone") find_program(FRAME_CLANG_TIDY_BIN NAMES clang-tidy) @@ -109,6 +126,63 @@ add_custom_target(valgrind VERBATIM ) +find_program(FRAME_CLANG_FORMAT_BIN NAMES clang-format) +if(FRAME_CLANG_FORMAT_BIN) + file(GLOB_RECURSE FRAME_FORMAT_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/tests/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/benchmarks/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/benchmarks/*.h + ) + + add_custom_target(format + COMMAND ${FRAME_CLANG_FORMAT_BIN} -i ${FRAME_FORMAT_SOURCES} + COMMENT "Running clang-format on repo-owned sources" + VERBATIM + ) + + add_custom_target(format-check + COMMAND ${FRAME_CLANG_FORMAT_BIN} --dry-run --Werror ${FRAME_FORMAT_SOURCES} + COMMENT "Checking clang-format compliance" + USES_TERMINAL + VERBATIM + ) +else() + message(STATUS "clang-format not found; format and format-check targets will be unavailable") +endif() + +find_program(FRAME_CPPCHECK_BIN NAMES cppcheck) +add_custom_target(cppcheck + COMMAND ${CMAKE_COMMAND} + -DTOOL_BIN=${FRAME_CPPCHECK_BIN} + -DTOOL_NAME=cppcheck + -DBUILD_DIR=${CMAKE_BINARY_DIR} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/run-cppcheck.cmake + COMMENT "Running cppcheck analysis" + USES_TERMINAL + VERBATIM +) +add_dependencies(lint cppcheck) + +if(FRAME_ENABLE_COVERAGE) + find_program(FRAME_GCOVR_BIN NAMES gcovr) + add_custom_target(coverage + COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} --output-on-failure + COMMAND ${CMAKE_COMMAND} -E echo "---" + COMMAND ${CMAKE_COMMAND} -E echo "Collecting coverage data..." + COMMAND ${FRAME_GCOVR_BIN} + --root ${CMAKE_CURRENT_SOURCE_DIR} + --filter ${CMAKE_CURRENT_SOURCE_DIR}/src/ + --print-summary + --html-details ${CMAKE_BINARY_DIR}/coverage/index.html + COMMENT "Running tests and collecting coverage" + USES_TERMINAL + VERBATIM + ) +endif() + if(DOXYGEN_FOUND) set(FRAME_DOXYGEN_OUTPUT_DIR "${CMAKE_BINARY_DIR}/docs/doxygen") set(FRAME_DOXYGEN_CONFIG "${CMAKE_BINARY_DIR}/Doxyfile") @@ -137,3 +211,7 @@ install(FILES contrib/org.example.project.desktop DESTINATION ${CMAKE_INSTALL_DA if(BUILD_TESTING) add_subdirectory(tests) endif() + +if(FRAME_ENABLE_BENCHMARKS) + add_subdirectory(benchmarks) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..e8c7752 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,96 @@ +{ + "version": 6, + "configurePresets": [ + { + "name": "dev", + "displayName": "Development", + "description": "Debug build with sanitizers and compile_commands.json", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/dev", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_STANDARD": "23", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "FRAME_ENABLE_ASAN": "ON", + "FRAME_ENABLE_UBSAN": "ON" + } + }, + { + "name": "release", + "displayName": "Release", + "description": "Optimized release build with LTO", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/release", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_CXX_STANDARD": "23", + "CMAKE_INTERPROCEDURAL_OPTIMIZATION": "ON" + } + }, + { + "name": "ci", + "displayName": "CI", + "description": "Debug build matching GitHub Actions configuration", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/ci", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_STANDARD": "23", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "coverage", + "displayName": "Coverage", + "description": "Debug build with gcov coverage instrumentation", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/coverage", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_CXX_STANDARD": "23", + "FRAME_ENABLE_COVERAGE": "ON" + } + } + ], + "buildPresets": [ + { + "name": "dev", + "configurePreset": "dev" + }, + { + "name": "release", + "configurePreset": "release" + }, + { + "name": "ci", + "configurePreset": "ci" + }, + { + "name": "coverage", + "configurePreset": "coverage" + } + ], + "testPresets": [ + { + "name": "dev", + "configurePreset": "dev", + "output": { + "outputOnFailure": true + } + }, + { + "name": "ci", + "configurePreset": "ci", + "output": { + "outputOnFailure": true + } + }, + { + "name": "coverage", + "configurePreset": "coverage", + "output": { + "outputOnFailure": true + } + } + ] +} diff --git a/README.md b/README.md index 228b941..0c03881 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,17 @@ Defaults: ## Quick Start +Using CMake presets (recommended): + +```bash +cmake --preset dev +cmake --build build/dev -j"$(nproc)" +ctest --test-dir build/dev --output-on-failure +./build/dev/frame_cli --help +``` + +Or manually: + ```bash BUILD_DIR="$(mktemp -d /tmp/cpp-frame-build-XXXXXX)" cmake -S . -B "$BUILD_DIR" -G Ninja @@ -39,7 +50,8 @@ ctest --test-dir "$BUILD_DIR" --output-on-failure "$BUILD_DIR/frame_cli" --help ``` -If you do not have `ninja-build`, omit `-G Ninja`. +Available presets: `dev` (debug + sanitizers), `release` (optimized + LTO), +`ci` (matches GitHub Actions), `coverage` (gcov instrumentation). ## What This Frame Includes @@ -47,11 +59,14 @@ If you do not have `ninja-build`, omit `-G Ninja`. - `.agents/skills/`: generic project-local skills for coding, documenting, planning, security, release work, vendor-boundary work, and diagnostics - `src/`: small example library + CLI -- `tests/`: deterministic example tests and `CTest` registration +- `tests/`: deterministic example tests, `CTest` registration, and + `frame_test.h` micro-framework - `docs/`: API-focused Doxygen setup - `scripts/`: hygiene, commentary, release, and Valgrind helpers - `.github/workflows/`: CI and release workflow templates - `contrib/`: optional system integration examples +- `benchmarks/`: optional chrono-based micro-benchmark harness +- `CMakePresets.json`: named build configurations for dev/release/ci/coverage - `upcoming_features/`: tracked plan folder ## Project Layout @@ -59,12 +74,13 @@ If you do not have `ninja-build`, omit `-G Ninja`. ```text .agents/skills/ Project-local agent skills .github/workflows/ Generic CI and release workflow templates +benchmarks/ Optional micro-benchmark harness cmake/ Analyzer helper scripts contrib/ Optional service/desktop integration examples docs/ Doxygen config and API-focused docs -scripts/ Hygiene, release, and diagnostics helpers +scripts/ Hygiene, release, diagnostics, and init helpers src/ Example library and CLI -tests/ Deterministic example tests +tests/ Deterministic example tests and frame_test.h upcoming_features/ Forward-looking implementation plans ``` @@ -72,20 +88,27 @@ upcoming_features/ Forward-looking implementation plans Optional local tooling: +- `clang-format` - `clang-tidy` - `clazy-standalone` when using the example Qt-based UI stack +- `cppcheck` - `doxygen` +- `gcovr` (for coverage HTML reports) - `valgrind` - `ninja-build` Useful targets when the tools are installed: ```bash +cmake --build "$BUILD_DIR" --target format # auto-format sources +cmake --build "$BUILD_DIR" --target format-check # CI formatting check cmake --build "$BUILD_DIR" --target clang-tidy +cmake --build "$BUILD_DIR" --target cppcheck cmake --build "$BUILD_DIR" --target clazy cmake --build "$BUILD_DIR" --target docs -cmake --build "$BUILD_DIR" --target lint +cmake --build "$BUILD_DIR" --target lint # aggregates analyzers cmake --build "$BUILD_DIR" --target valgrind +cmake --build "$BUILD_DIR" --target coverage # tests + gcov report ``` ## Example Install Validation @@ -100,7 +123,11 @@ test -x "$INSTALL_DIR/bin/frame_cli" Typical first steps: -1. Rename the example targets, namespaces, and install paths. +1. Run the init script to rename all placeholders: + ```bash + ./scripts/init-project.sh --name "Your Project Name" # dry-run + ./scripts/init-project.sh --name "Your Project Name" --apply + ``` 2. Replace the example library/CLI code in `src/`. 3. Update `AGENTS.md` with project-specific constraints. 4. Trim or extend `.agents/skills/` to match the real project. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 4764d38..e96ad94 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -34,7 +34,13 @@ bash scripts/run-release-checklist.sh - Confirm the build configuration still matches the repo baseline language standard, which is C++23. -- Configure a fresh out-of-tree build: +- Configure a fresh out-of-tree build (prefer presets when available): + +```bash +cmake --preset ci +``` + +Or manually: ```bash BUILD_DIR="$(mktemp -d /tmp/cpp-frame-build-XXXXXX)" @@ -47,6 +53,12 @@ cmake -S . -B "$BUILD_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Debug cmake --build "$BUILD_DIR" -j"$(nproc)" ``` +- Verify formatting compliance: + +```bash +cmake --build "$BUILD_DIR" --target format-check +``` + - Run tests: ```bash @@ -63,11 +75,12 @@ bash scripts/run-valgrind.sh "$BUILD_DIR" ```bash cmake --build "$BUILD_DIR" --target clang-tidy +cmake --build "$BUILD_DIR" --target cppcheck cmake --build "$BUILD_DIR" --target clazy ``` - Treat `clazy` as part of the Qt-based UI variant rather than a baseline lane - for CLI-only projects. + for CLI-only projects. Treat `cppcheck` as optional when not installed. - If repo-owned public headers or docs changed, also run: @@ -99,8 +112,11 @@ cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR" ## Documentation And User Flow - Review [README.md](README.md) for consistency with current behavior. +- Review [AGENTS.md](AGENTS.md) for alignment with current tooling and + workflows. - Review [docs/mainpage.md](docs/mainpage.md) and [docs/Doxyfile.in](docs/Doxyfile.in) if the release touched repo-owned API docs or docs/CI wiring. +- Confirm `CMakePresets.json` presets still match the documented workflows. - Confirm docs still describe the intended build, install, test, and release flows. diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt new file mode 100644 index 0000000..735d14f --- /dev/null +++ b/benchmarks/CMakeLists.txt @@ -0,0 +1,8 @@ +add_executable(example_bench example_bench.cpp) +target_include_directories(example_bench PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../src +) +target_link_libraries(example_bench PRIVATE project_core) +frame_enable_sanitizers(example_bench PRIVATE) +frame_enable_coverage(example_bench PRIVATE) diff --git a/benchmarks/example_bench.cpp b/benchmarks/example_bench.cpp new file mode 100644 index 0000000..dc1b4a0 --- /dev/null +++ b/benchmarks/example_bench.cpp @@ -0,0 +1,30 @@ +#include "frame_bench.h" +#include "projectcore.h" + +// WHAT: Benchmark normalizedProjectName with typical and large inputs. +// HOW: Run the function many times and report min/avg/max duration. +// WHY: Establishes a baseline for the core string-normalization path +// so regressions are visible before they reach production. + +FRAME_BENCHMARK(normalizedProjectName_typical, 100000) +{ + volatile auto result = frame::normalizedProjectName(" Example Project Name "); + (void)result; +} + +FRAME_BENCHMARK(normalizedProjectName_short, 100000) +{ + volatile auto result = frame::normalizedProjectName("a"); + (void)result; +} + +FRAME_BENCHMARK(trimCopy_padded, 100000) +{ + volatile auto result = frame::trimCopy(" some padded input "); + (void)result; +} + +int main() +{ + return FRAME_RUN_BENCHMARKS(); +} diff --git a/benchmarks/frame_bench.h b/benchmarks/frame_bench.h new file mode 100644 index 0000000..80512bc --- /dev/null +++ b/benchmarks/frame_bench.h @@ -0,0 +1,104 @@ +#pragma once + +/// @file frame_bench.h +/// Zero-dependency micro-benchmark harness using std::chrono. +/// +/// Usage: +/// #include "frame_bench.h" +/// +/// FRAME_BENCHMARK(myBench, 100000) +/// { +/// volatile auto result = someFunction("input"); +/// } +/// +/// int main() { return FRAME_RUN_BENCHMARKS(); } + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace frame::bench { + +using BenchFn = void (*)(); + +struct BenchmarkCase +{ + std::string name; + std::size_t iterations; + BenchFn body; +}; + +inline std::vector ®istry() +{ + static std::vector cases; + return cases; +} + +struct Registrar +{ + Registrar(const char *name, std::size_t iterations, BenchFn body) + { + registry().push_back({.name = name, .iterations = iterations, .body = body}); + } +}; + +inline int runAll() +{ + using Clock = std::chrono::high_resolution_clock; + using Micros = std::chrono::duration; + + constexpr int nameWidth = 40; + constexpr int numWidth = 12; + + std::cout << std::left << std::setw(nameWidth) << "benchmark" << std::right + << std::setw(numWidth) << "iterations" << std::setw(numWidth) << "min (us)" + << std::setw(numWidth) << "avg (us)" << std::setw(numWidth) << "max (us)" << '\n'; + std::cout << std::string(static_cast(nameWidth + (numWidth * 4)), '-') << '\n'; + + for (const auto &bc : registry()) { + double minUs = std::numeric_limits::max(); + double maxUs = 0.0; + double totalUs = 0.0; + + for (std::size_t i = 0; i < bc.iterations; ++i) { + const auto start = Clock::now(); + bc.body(); + const auto elapsed = Micros(Clock::now() - start).count(); + + totalUs += elapsed; + minUs = std::min(minUs, elapsed); + maxUs = std::max(maxUs, elapsed); + } + + const double avgUs = totalUs / static_cast(bc.iterations); + + std::cout << std::left << std::setw(nameWidth) << bc.name << std::right << std::fixed + << std::setprecision(3) << std::setw(numWidth) << bc.iterations + << std::setw(numWidth) << minUs << std::setw(numWidth) << avgUs + << std::setw(numWidth) << maxUs << '\n'; + } + + return 0; +} + +} // namespace frame::bench + +// --------------------------------------------------------------------------- +// Public macros +// --------------------------------------------------------------------------- + +/// Register a benchmark. Use like: FRAME_BENCHMARK(name, iterations) { ... } +// NOLINTBEGIN(misc-use-anonymous-namespace,cert-err58-cpp,cppcoreguidelines-avoid-non-const-global-variables,bugprone-throwing-static-initialization) +#define FRAME_BENCHMARK(name, iterations) \ + static void frameBench_##name(); \ + static ::frame::bench::Registrar frameBenchReg_##name(#name, (iterations), frameBench_##name); \ + static void frameBench_##name() +// NOLINTEND(misc-use-anonymous-namespace,cert-err58-cpp,cppcoreguidelines-avoid-non-const-global-variables,bugprone-throwing-static-initialization) + +/// Run all registered benchmarks and print results. +#define FRAME_RUN_BENCHMARKS() ::frame::bench::runAll() diff --git a/cmake/run-cppcheck.cmake b/cmake/run-cppcheck.cmake new file mode 100644 index 0000000..c81b241 --- /dev/null +++ b/cmake/run-cppcheck.cmake @@ -0,0 +1,24 @@ +if(NOT TOOL_BIN) + message(FATAL_ERROR "cppcheck target requested, but cppcheck was not found in PATH") +endif() + +if(NOT EXISTS "${BUILD_DIR}/compile_commands.json") + message(FATAL_ERROR + "cppcheck target requires ${BUILD_DIR}/compile_commands.json. " + "Reconfigure with CMAKE_EXPORT_COMPILE_COMMANDS=ON." + ) +endif() + +execute_process( + COMMAND "${TOOL_BIN}" + --enable=warning,style,performance,portability + --std=c++23 + --quiet + --error-exitcode=1 + "--project=${BUILD_DIR}/compile_commands.json" + RESULT_VARIABLE cppcheck_result +) + +if(NOT cppcheck_result EQUAL 0) + message(FATAL_ERROR "cppcheck reported failures") +endif() diff --git a/scripts/init-project.sh b/scripts/init-project.sh new file mode 100755 index 0000000..c2cbd32 --- /dev/null +++ b/scripts/init-project.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# init-project.sh — Rename all frame placeholders to your project's names. +# +# Usage: +# ./scripts/init-project.sh --name "My Cool App" [--prefix mca] [--apply] +# +# By default runs in dry-run mode, showing a diff of proposed changes. +# Pass --apply to execute the replacements. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +NAME="" +PREFIX="" +APPLY=false + +usage() { + cat < "MyCoolApp" +PASCAL_NAME=$(echo "$NAME" | sed -E 's/(^| )(.)/\U\2/g' | tr -d ' ') + +echo "Project setup:" +echo " Name: $NAME" +echo " Snake name: $SNAKE_NAME" +echo " Prefix: $PREFIX_LOWER" +echo " Prefix upper: $PREFIX_UPPER" +echo " PascalCase: $PASCAL_NAME" +echo "" + +# Files to process (tracked files only, excluding this script and binary/generated content) +TRACKED_FILES=$(cd "$REPO_ROOT" && git ls-files -- \ + '*.cpp' '*.h' '*.cmake' '*.txt' '*.md' '*.yml' '*.yaml' '*.json' '*.in' '*.service' '*.desktop' \ + | grep -v 'scripts/init-project.sh' \ + | grep -v 'docs/superpowers/' \ + || true) + +do_replace() { + local file="$1" + local filepath="$REPO_ROOT/$file" + + [[ -f "$filepath" ]] || return 0 + + # Order matters: longest/most-specific patterns first + sed -i \ + -e "s/cpp_agentic_development_frame/${SNAKE_NAME}/g" \ + -e "s/ProjectCoreTest/${PASCAL_NAME}CoreTest/g" \ + -e "s/ProjectCore/${PASCAL_NAME}Core/g" \ + -e "s/projectcore/${PREFIX_LOWER}core/g" \ + -e "s/project_core/${PREFIX_LOWER}_core/g" \ + -e "s/frame_cli/${PREFIX_LOWER}_cli/g" \ + -e "s/frame::/${PREFIX_LOWER}::/g" \ + -e "s/FRAME_/${PREFIX_UPPER}_/g" \ + -e "s/frame_enable_/${PREFIX_LOWER}_enable_/g" \ + -e "s/frame_add_test/${PREFIX_LOWER}_add_test/g" \ + -e "s/frame_bench/${PREFIX_LOWER}_bench/g" \ + -e "s/frame_test/${PREFIX_LOWER}_test/g" \ + "$filepath" +} + +do_rename_file() { + local old_path="$REPO_ROOT/$1" + local dir=$(dirname "$old_path") + local old_name=$(basename "$old_path") + local new_name=$(echo "$old_name" \ + | sed "s/projectcore/${PREFIX_LOWER}core/g" \ + | sed "s/frame_test/${PREFIX_LOWER}_test/g" \ + | sed "s/frame_bench/${PREFIX_LOWER}_bench/g") + + if [[ "$old_name" != "$new_name" ]]; then + if $APPLY; then + git -C "$REPO_ROOT" mv "$old_path" "$dir/$new_name" + else + echo " rename: $1 -> $(dirname "$1")/$new_name" + fi + fi +} + +if $APPLY; then + echo "Applying changes..." + echo "" + + # Content replacements + for file in $TRACKED_FILES; do + do_replace "$file" + done + + # File renames (after content replacement) + for file in $TRACKED_FILES; do + do_rename_file "$file" + done + + echo "Done. Review changes with: git diff" +else + echo "=== DRY RUN ===" + echo "" + + # Show what file renames would happen + echo "File renames:" + for file in $TRACKED_FILES; do + do_rename_file "$file" + done + echo "" + + # Create a temporary copy to show diff + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + + for file in $TRACKED_FILES; do + mkdir -p "$TMPDIR/$(dirname "$file")" + cp "$REPO_ROOT/$file" "$TMPDIR/$file" + done + + # Apply replacements to temp copies + for file in $TRACKED_FILES; do + [[ -f "$TMPDIR/$file" ]] || continue + sed -i \ + -e "s/cpp_agentic_development_frame/${SNAKE_NAME}/g" \ + -e "s/ProjectCoreTest/${PASCAL_NAME}CoreTest/g" \ + -e "s/ProjectCore/${PASCAL_NAME}Core/g" \ + -e "s/projectcore/${PREFIX_LOWER}core/g" \ + -e "s/project_core/${PREFIX_LOWER}_core/g" \ + -e "s/frame_cli/${PREFIX_LOWER}_cli/g" \ + -e "s/frame::/${PREFIX_LOWER}::/g" \ + -e "s/FRAME_/${PREFIX_UPPER}_/g" \ + -e "s/frame_enable_/${PREFIX_LOWER}_enable_/g" \ + -e "s/frame_add_test/${PREFIX_LOWER}_add_test/g" \ + -e "s/frame_bench/${PREFIX_LOWER}_bench/g" \ + -e "s/frame_test/${PREFIX_LOWER}_test/g" \ + "$TMPDIR/$file" + done + + echo "Content changes:" + for file in $TRACKED_FILES; do + [[ -f "$TMPDIR/$file" ]] || continue + diff -u "$REPO_ROOT/$file" "$TMPDIR/$file" --label "a/$file" --label "b/$file" 2>/dev/null || true + done + + echo "" + echo "Run with --apply to execute these changes." +fi diff --git a/src/main.cpp b/src/main.cpp index b6a964e..5ee642c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,14 +20,16 @@ int main(int argc, char *argv[]) { const std::span arguments(argv, static_cast(argc)); const bool hasSecondArgument = arguments.size() > 1U; - const std::string_view secondArgument = hasSecondArgument ? std::string_view(*std::next(arguments.begin())) : std::string_view{}; + const std::string_view secondArgument = + hasSecondArgument ? std::string_view(*std::next(arguments.begin())) : std::string_view{}; if (secondArgument == "--help") { writeHelp(); return 0; } - const std::string_view input = hasSecondArgument ? secondArgument : std::string_view("Example Project"); + const std::string_view input = + hasSecondArgument ? secondArgument : std::string_view("Example Project"); std::cout << frame::normalizedProjectName(input) << '\n'; return 0; } diff --git a/src/projectcore.cpp b/src/projectcore.cpp index 47a6f91..ecf04e1 100644 --- a/src/projectcore.cpp +++ b/src/projectcore.cpp @@ -17,12 +17,11 @@ namespace { std::string trimCopy(std::string_view text) { - const auto first = std::ranges::find_if_not(text, [](const char ch) { - return isSpace(static_cast(ch)); - }); + const auto first = std::ranges::find_if_not( + text, [](const char ch) { return isSpace(static_cast(ch)); }); const auto last = std::ranges::find_if_not(std::views::reverse(text), [](const char ch) { - return isSpace(static_cast(ch)); - }).base(); + return isSpace(static_cast(ch)); + }).base(); if (first >= last) { return {}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e67fc27..eea68e8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,7 +1,12 @@ function(frame_add_test target_name) add_executable(${target_name} ${ARGN}) - target_include_directories(${target_name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src) + target_include_directories(${target_name} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../src + ${CMAKE_CURRENT_SOURCE_DIR} + ) target_link_libraries(${target_name} PRIVATE project_core) + frame_enable_sanitizers(${target_name} PRIVATE) + frame_enable_coverage(${target_name} PRIVATE) add_test(NAME ${target_name} COMMAND ${target_name}) endfunction() diff --git a/tests/clismoketest.cpp b/tests/clismoketest.cpp index 8bb847a..3484311 100644 --- a/tests/clismoketest.cpp +++ b/tests/clismoketest.cpp @@ -1,45 +1,17 @@ +#include "frame_test.h" #include "projectcore.h" -#include -#include -#include -#include - -namespace { - -class CliSmokeTest final -{ -public: - void normalizedProjectNamePreservesSingleWordInput(); -}; - -void expectEqual(const std::string &actual, const std::string &expected, const char *message) -{ - if (actual != expected) { - throw std::runtime_error(std::string(message) + "\nexpected: " + expected + "\nactual: " + actual); - } -} - -} // namespace - -void CliSmokeTest::normalizedProjectNamePreservesSingleWordInput() +FRAME_TEST(normalizedProjectNamePreservesSingleWordInput) { // WHAT: Verify that a single already-normalized project token is preserved. // HOW: Pass a lowercase no-space string through the helper and compare the // result against the unchanged expected token. // WHY: The example frame should include at least one fast-path test so the // sample suite demonstrates both normalization and preservation behavior. - expectEqual(frame::normalizedProjectName("frame_cli"), "frame_cli", "normalizedProjectName should preserve normalized input"); + FRAME_EXPECT_EQ(frame::normalizedProjectName("frame_cli"), std::string("frame_cli")); } int main() { - try { - CliSmokeTest test; - test.normalizedProjectNamePreservesSingleWordInput(); - return 0; - } catch (const std::exception &error) { - std::cerr << error.what() << '\n'; - return 1; - } + return FRAME_RUN_TESTS(); } diff --git a/tests/frame_test.h b/tests/frame_test.h new file mode 100644 index 0000000..6997090 --- /dev/null +++ b/tests/frame_test.h @@ -0,0 +1,159 @@ +#pragma once + +/// @file frame_test.h +/// Zero-dependency test micro-framework for the development frame. +/// +/// Usage: +/// #include "frame_test.h" +/// +/// FRAME_TEST(myTest) +/// { +/// FRAME_EXPECT_EQ(1 + 1, 2); +/// FRAME_EXPECT_TRUE(true); +/// } +/// +/// int main() { return FRAME_RUN_TESTS(); } + +#include +#include +#include +#include +#include +#include + +namespace frame::test { + +using TestFn = void (*)(); + +struct TestCase +{ + std::string name; + TestFn body; +}; + +inline std::vector ®istry() +{ + static std::vector cases; + return cases; +} + +struct Registrar +{ + Registrar(const char *name, TestFn body) + { + registry().push_back({.name = name, .body = body}); + } +}; + +struct TestFailure : std::exception +{ + std::string message; + + explicit TestFailure(std::string msg) : message(std::move(msg)) {} + + [[nodiscard]] const char *what() const noexcept override + { + return message.c_str(); + } +}; + +inline int runAll() +{ + std::size_t passed = 0; + std::size_t failed = 0; + + for (const auto &tc : registry()) { + try { + tc.body(); + std::cout << "[PASS] " << tc.name << '\n'; + ++passed; + } + catch (const TestFailure &e) { + std::cerr << "[FAIL] " << tc.name << ": " << e.what() << '\n'; + ++failed; + } + catch (const std::exception &e) { + std::cerr << "[FAIL] " << tc.name << ": unexpected exception: " << e.what() << '\n'; + ++failed; + } + } + + std::cout << "---\n" + << passed << " passed, " << failed << " failed, " << (passed + failed) << " total\n"; + + return failed == 0 ? 0 : 1; +} + +} // namespace frame::test + +// --------------------------------------------------------------------------- +// Public macros +// --------------------------------------------------------------------------- + +/// Register a test function. Use like: FRAME_TEST(name) { ... } +// NOLINTBEGIN(misc-use-anonymous-namespace,cert-err58-cpp,cppcoreguidelines-avoid-non-const-global-variables,bugprone-throwing-static-initialization) +#define FRAME_TEST(name) \ + static void frameTest_##name(); \ + static ::frame::test::Registrar frameReg_##name(#name, frameTest_##name); \ + static void frameTest_##name() +// NOLINTEND(misc-use-anonymous-namespace,cert-err58-cpp,cppcoreguidelines-avoid-non-const-global-variables,bugprone-throwing-static-initialization) + +/// Run all registered tests and return 0 on success, 1 on failure. +#define FRAME_RUN_TESTS() ::frame::test::runAll() + +// --------------------------------------------------------------------------- +// Assertion helpers — stringify via ostringstream for broad type support +// --------------------------------------------------------------------------- + +// NOLINTBEGIN(cppcoreguidelines-avoid-do-while) + +#define FRAME_EXPECT_EQ(actual, expected) \ + do { \ + const auto &frameActual_ = (actual); \ + const auto &frameExpected_ = (expected); \ + if (!(frameActual_ == frameExpected_)) { \ + std::ostringstream frameMsg_; \ + frameMsg_ << "expected: " << frameExpected_ << "\n actual: " << frameActual_ \ + << "\n at " << __FILE__ << ":" << __LINE__; \ + throw ::frame::test::TestFailure(frameMsg_.str()); \ + } \ + } while (false) + +#define FRAME_EXPECT_TRUE(expr) \ + do { \ + if (!(expr)) { \ + std::ostringstream frameMsg_; \ + frameMsg_ << "expected true: " #expr "\n at " << __FILE__ << ":" << __LINE__; \ + throw ::frame::test::TestFailure(frameMsg_.str()); \ + } \ + } while (false) + +#define FRAME_EXPECT_FALSE(expr) \ + do { \ + if ((expr)) { \ + std::ostringstream frameMsg_; \ + frameMsg_ << "expected false: " #expr "\n at " << __FILE__ << ":" << __LINE__; \ + throw ::frame::test::TestFailure(frameMsg_.str()); \ + } \ + } while (false) + +#define FRAME_EXPECT_THROWS(expr, exception_type) \ + do { \ + bool frameCaught_ = false; \ + try { \ + (expr); \ + } \ + catch (const exception_type &) { \ + frameCaught_ = true; \ + } \ + catch (...) { \ + } \ + if (!frameCaught_) { \ + std::ostringstream frameMsg_; \ + frameMsg_ << "expected " #exception_type " from: " #expr "\n at " << __FILE__ << ":" \ + << __LINE__; \ + throw ::frame::test::TestFailure(frameMsg_.str()); \ + } \ + } while (false) + +// NOLINTEND(cppcoreguidelines-avoid-do-while) diff --git a/tests/projectcoretest.cpp b/tests/projectcoretest.cpp index 600f52a..c0b134f 100644 --- a/tests/projectcoretest.cpp +++ b/tests/projectcoretest.cpp @@ -1,39 +1,17 @@ #include "projectcore.h" +#include "frame_test.h" -#include -#include -#include -#include - -namespace { - -class ProjectCoreTest final -{ -public: - void trimCopyRemovesLeadingAndTrailingWhitespace(); - void normalizedProjectNameLowercasesAndReplacesSpaces(); -}; - -void expectEqual(const std::string &actual, const std::string &expected, const char *message) -{ - if (actual != expected) { - throw std::runtime_error(std::string(message) + "\nexpected: " + expected + "\nactual: " + actual); - } -} - -} // namespace - -void ProjectCoreTest::trimCopyRemovesLeadingAndTrailingWhitespace() +FRAME_TEST(trimCopyRemovesLeadingAndTrailingWhitespace) { // WHAT: Verify that trimming removes leading and trailing ASCII whitespace. // HOW: Pass a padded string through the helper and compare the returned copy // against the expected core text. // WHY: Trimming is a common utility boundary, and deterministic behavior // here keeps config, CLI, and logging code from reimplementing it. - expectEqual(frame::trimCopy(" example value \t"), "example value", "trimCopy should trim outer whitespace"); + FRAME_EXPECT_EQ(frame::trimCopy(" example value \t"), std::string("example value")); } -void ProjectCoreTest::normalizedProjectNameLowercasesAndReplacesSpaces() +FRAME_TEST(normalizedProjectNameLowercasesAndReplacesSpaces) { // WHAT: Verify that project-name normalization lowercases text and replaces // inner spaces with underscores. @@ -41,18 +19,11 @@ void ProjectCoreTest::normalizedProjectNameLowercasesAndReplacesSpaces() // expected identifier-like output. // WHY: The example library API is intentionally small, and this function is // the main deterministic behavior showcased by the frame. - expectEqual(frame::normalizedProjectName(" Example Project Name "), "example_project_name", "normalizedProjectName should normalize whitespace and case"); + FRAME_EXPECT_EQ(frame::normalizedProjectName(" Example Project Name "), + std::string("example_project_name")); } int main() { - try { - ProjectCoreTest test; - test.trimCopyRemovesLeadingAndTrailingWhitespace(); - test.normalizedProjectNameLowercasesAndReplacesSpaces(); - return 0; - } catch (const std::exception &error) { - std::cerr << error.what() << '\n'; - return 1; - } + return FRAME_RUN_TESTS(); } diff --git a/upcoming_features/TEMPLATE.md b/upcoming_features/TEMPLATE.md new file mode 100644 index 0000000..515f1f0 --- /dev/null +++ b/upcoming_features/TEMPLATE.md @@ -0,0 +1,23 @@ +# Feature: + +## Motivation + +Why this feature is needed and what problem it solves. + +## Proposed Behavior + +What the feature does from the user's perspective. Include examples if helpful. + +## Files to Add/Modify + +- `src/...` — description of changes +- `tests/...` — test coverage plan +- `CMakeLists.txt` — build integration (if applicable) + +## Testing Strategy + +How to verify the feature works correctly. Prefer deterministic, headless tests. + +## Open Questions + +Unresolved decisions or trade-offs that need input before implementation.