From b6595974c2e8b66ee438a1b55ea017189736712f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 16 Mar 2026 14:31:49 -0700 Subject: [PATCH] feat: add C++ language support and hook tests (#539) - agents: cpp-build-resolver, cpp-reviewer - commands: cpp-build, cpp-review, cpp-test - rules: cpp/ (coding-style, hooks, patterns, security, testing) - tests: 9 new hook test files with comprehensive coverage Cherry-picked from PR #436. --- agents/cpp-build-resolver.md | 90 ++++ agents/cpp-reviewer.md | 72 ++++ commands/cpp-build.md | 173 ++++++++ commands/cpp-review.md | 132 ++++++ commands/cpp-test.md | 251 +++++++++++ rules/cpp/coding-style.md | 44 ++ rules/cpp/hooks.md | 39 ++ rules/cpp/patterns.md | 51 +++ rules/cpp/security.md | 51 +++ rules/cpp/testing.md | 44 ++ tests/hooks/auto-tmux-dev.test.js | 145 +++++++ tests/hooks/check-hook-enabled.test.js | 108 +++++ tests/hooks/cost-tracker.test.js | 131 ++++++ tests/hooks/doc-file-warning.test.js | 152 +++++++ tests/hooks/hook-flags.test.js | 397 ++++++++++++++++++ tests/hooks/post-bash-hooks.test.js | 207 +++++++++ tests/hooks/pre-bash-dev-server-block.test.js | 121 ++++++ tests/hooks/pre-bash-reminders.test.js | 104 +++++ tests/hooks/quality-gate.test.js | 159 +++++++ 19 files changed, 2471 insertions(+) create mode 100644 agents/cpp-build-resolver.md create mode 100644 agents/cpp-reviewer.md create mode 100644 commands/cpp-build.md create mode 100644 commands/cpp-review.md create mode 100644 commands/cpp-test.md create mode 100644 rules/cpp/coding-style.md create mode 100644 rules/cpp/hooks.md create mode 100644 rules/cpp/patterns.md create mode 100644 rules/cpp/security.md create mode 100644 rules/cpp/testing.md create mode 100644 tests/hooks/auto-tmux-dev.test.js create mode 100644 tests/hooks/check-hook-enabled.test.js create mode 100644 tests/hooks/cost-tracker.test.js create mode 100644 tests/hooks/doc-file-warning.test.js create mode 100644 tests/hooks/hook-flags.test.js create mode 100644 tests/hooks/post-bash-hooks.test.js create mode 100644 tests/hooks/pre-bash-dev-server-block.test.js create mode 100644 tests/hooks/pre-bash-reminders.test.js create mode 100644 tests/hooks/quality-gate.test.js diff --git a/agents/cpp-build-resolver.md b/agents/cpp-build-resolver.md new file mode 100644 index 00000000..59c76144 --- /dev/null +++ b/agents/cpp-build-resolver.md @@ -0,0 +1,90 @@ +--- +name: cpp-build-resolver +description: C++ build, CMake, and compilation error resolution specialist. Fixes build errors, linker issues, and template errors with minimal changes. Use when C++ builds fail. +tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"] +model: sonnet +--- + +# C++ Build Error Resolver + +You are an expert C++ build error resolution specialist. Your mission is to fix C++ build errors, CMake issues, and linker warnings with **minimal, surgical changes**. + +## Core Responsibilities + +1. Diagnose C++ compilation errors +2. Fix CMake configuration issues +3. Resolve linker errors (undefined references, multiple definitions) +4. Handle template instantiation errors +5. Fix include and dependency problems + +## Diagnostic Commands + +Run these in order: + +```bash +cmake --build build 2>&1 | head -100 +cmake -B build -S . 2>&1 | tail -30 +clang-tidy src/*.cpp -- -std=c++17 2>/dev/null || echo "clang-tidy not available" +cppcheck --enable=all src/ 2>/dev/null || echo "cppcheck not available" +``` + +## Resolution Workflow + +```text +1. cmake --build build -> Parse error message +2. Read affected file -> Understand context +3. Apply minimal fix -> Only what's needed +4. cmake --build build -> Verify fix +5. ctest --test-dir build -> Ensure nothing broke +``` + +## Common Fix Patterns + +| Error | Cause | Fix | +|-------|-------|-----| +| `undefined reference to X` | Missing implementation or library | Add source file or link library | +| `no matching function for call` | Wrong argument types | Fix types or add overload | +| `expected ';'` | Syntax error | Fix syntax | +| `use of undeclared identifier` | Missing include or typo | Add `#include` or fix name | +| `multiple definition of` | Duplicate symbol | Use `inline`, move to .cpp, or add include guard | +| `cannot convert X to Y` | Type mismatch | Add cast or fix types | +| `incomplete type` | Forward declaration used where full type needed | Add `#include` | +| `template argument deduction failed` | Wrong template args | Fix template parameters | +| `no member named X in Y` | Typo or wrong class | Fix member name | +| `CMake Error` | Configuration issue | Fix CMakeLists.txt | + +## CMake Troubleshooting + +```bash +cmake -B build -S . -DCMAKE_VERBOSE_MAKEFILE=ON +cmake --build build --verbose +cmake --build build --clean-first +``` + +## Key Principles + +- **Surgical fixes only** -- don't refactor, just fix the error +- **Never** suppress warnings with `#pragma` without approval +- **Never** change function signatures unless necessary +- Fix root cause over suppressing symptoms +- One fix at a time, verify after each + +## Stop Conditions + +Stop and report if: +- Same error persists after 3 fix attempts +- Fix introduces more errors than it resolves +- Error requires architectural changes beyond scope + +## Output Format + +```text +[FIXED] src/handler/user.cpp:42 +Error: undefined reference to `UserService::create` +Fix: Added missing method implementation in user_service.cpp +Remaining errors: 3 +``` + +Final: `Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list` + +For detailed C++ patterns and code examples, see `skill: cpp-coding-standards`. diff --git a/agents/cpp-reviewer.md b/agents/cpp-reviewer.md new file mode 100644 index 00000000..5946108e --- /dev/null +++ b/agents/cpp-reviewer.md @@ -0,0 +1,72 @@ +--- +name: cpp-reviewer +description: Expert C++ code reviewer specializing in memory safety, modern C++ idioms, concurrency, and performance. Use for all C++ code changes. MUST BE USED for C++ projects. +tools: ["Read", "Grep", "Glob", "Bash"] +model: sonnet +--- + +You are a senior C++ code reviewer ensuring high standards of modern C++ and best practices. + +When invoked: +1. Run `git diff -- '*.cpp' '*.hpp' '*.cc' '*.hh' '*.cxx' '*.h'` to see recent C++ file changes +2. Run `clang-tidy` and `cppcheck` if available +3. Focus on modified C++ files +4. Begin review immediately + +## Review Priorities + +### CRITICAL -- Memory Safety +- **Raw new/delete**: Use `std::unique_ptr` or `std::shared_ptr` +- **Buffer overflows**: C-style arrays, `strcpy`, `sprintf` without bounds +- **Use-after-free**: Dangling pointers, invalidated iterators +- **Uninitialized variables**: Reading before assignment +- **Memory leaks**: Missing RAII, resources not tied to object lifetime +- **Null dereference**: Pointer access without null check + +### CRITICAL -- Security +- **Command injection**: Unvalidated input in `system()` or `popen()` +- **Format string attacks**: User input in `printf` format string +- **Integer overflow**: Unchecked arithmetic on untrusted input +- **Hardcoded secrets**: API keys, passwords in source +- **Unsafe casts**: `reinterpret_cast` without justification + +### HIGH -- Concurrency +- **Data races**: Shared mutable state without synchronization +- **Deadlocks**: Multiple mutexes locked in inconsistent order +- **Missing lock guards**: Manual `lock()`/`unlock()` instead of `std::lock_guard` +- **Detached threads**: `std::thread` without `join()` or `detach()` + +### HIGH -- Code Quality +- **No RAII**: Manual resource management +- **Rule of Five violations**: Incomplete special member functions +- **Large functions**: Over 50 lines +- **Deep nesting**: More than 4 levels +- **C-style code**: `malloc`, C arrays, `typedef` instead of `using` + +### MEDIUM -- Performance +- **Unnecessary copies**: Pass large objects by value instead of `const&` +- **Missing move semantics**: Not using `std::move` for sink parameters +- **String concatenation in loops**: Use `std::ostringstream` or `reserve()` +- **Missing `reserve()`**: Known-size vector without pre-allocation + +### MEDIUM -- Best Practices +- **`const` correctness**: Missing `const` on methods, parameters, references +- **`auto` overuse/underuse**: Balance readability with type deduction +- **Include hygiene**: Missing include guards, unnecessary includes +- **Namespace pollution**: `using namespace std;` in headers + +## Diagnostic Commands + +```bash +clang-tidy --checks='*,-llvmlibc-*' src/*.cpp -- -std=c++17 +cppcheck --enable=all --suppress=missingIncludeSystem src/ +cmake --build build 2>&1 | head -50 +``` + +## Approval Criteria + +- **Approve**: No CRITICAL or HIGH issues +- **Warning**: MEDIUM issues only +- **Block**: CRITICAL or HIGH issues found + +For detailed C++ coding standards and anti-patterns, see `skill: cpp-coding-standards`. diff --git a/commands/cpp-build.md b/commands/cpp-build.md new file mode 100644 index 00000000..dfb23103 --- /dev/null +++ b/commands/cpp-build.md @@ -0,0 +1,173 @@ +--- +description: Fix C++ build errors, CMake issues, and linker problems incrementally. Invokes the cpp-build-resolver agent for minimal, surgical fixes. +--- + +# C++ Build and Fix + +This command invokes the **cpp-build-resolver** agent to incrementally fix C++ build errors with minimal changes. + +## What This Command Does + +1. **Run Diagnostics**: Execute `cmake --build`, `clang-tidy`, `cppcheck` +2. **Parse Errors**: Group by file and sort by severity +3. **Fix Incrementally**: One error at a time +4. **Verify Each Fix**: Re-run build after each change +5. **Report Summary**: Show what was fixed and what remains + +## When to Use + +Use `/cpp-build` when: +- `cmake --build build` fails with errors +- Linker errors (undefined references, multiple definitions) +- Template instantiation failures +- Include/dependency issues +- After pulling changes that break the build + +## Diagnostic Commands Run + +```bash +# CMake configure +cmake -B build -S . + +# Build +cmake --build build 2>&1 | head -100 + +# Static analysis (if available) +clang-tidy src/*.cpp -- -std=c++17 +cppcheck --enable=all src/ +``` + +## Example Session + +```text +User: /cpp-build + +Agent: +# C++ Build Resolution + +## Initial Diagnostics + +```bash +$ cmake --build build +src/service/user.cpp:25:15: error: use of undeclared identifier 'UserRepository' +src/handler/api.cpp:42:9: error: no matching function for call to 'process' +src/handler/api.cpp:58:1: error: non-void function does not return a value +``` + +Errors found: 3 + +## Fix 1: Undeclared Identifier + +File: src/service/user.cpp:25 +Error: use of undeclared identifier 'UserRepository' +Cause: Missing include + +```cpp +// Added include +#include "repository/user_repository.hpp" +``` + +```bash +$ cmake --build build +# 2 errors remaining +``` + +## Fix 2: No Matching Function + +File: src/handler/api.cpp:42 +Error: no matching function for call to 'process' + +```cpp +// Changed +process(params.get("count")); +// To +process(std::stoi(params.get("count"))); +``` + +```bash +$ cmake --build build +# 1 error remaining +``` + +## Fix 3: Missing Return + +File: src/handler/api.cpp:58 +Error: non-void function does not return a value + +```cpp +std::optional getUser(const std::string& id) { + if (id.empty()) { + return std::nullopt; + } + auto user = findUser(id); + // Added missing return + return user; +} +``` + +```bash +$ cmake --build build +# Build successful! +``` + +## Final Verification + +```bash +$ ctest --test-dir build --output-on-failure +Test project build + 1/5 Test #1: unit_tests ........ Passed 0.02 sec + 2/5 Test #2: integration_tests Passed 0.15 sec +All tests passed. +``` + +## Summary + +| Metric | Count | +|--------|-------| +| Build errors fixed | 3 | +| Linker errors fixed | 0 | +| Files modified | 2 | +| Remaining issues | 0 | + +Build Status: ✅ SUCCESS +``` + +## Common Errors Fixed + +| Error | Typical Fix | +|-------|-------------| +| `undeclared identifier` | Add `#include` or fix typo | +| `no matching function` | Fix argument types or add overload | +| `undefined reference` | Link library or add implementation | +| `multiple definition` | Use `inline` or move to .cpp | +| `incomplete type` | Replace forward decl with `#include` | +| `no member named X` | Fix member name or include | +| `cannot convert X to Y` | Add appropriate cast | +| `CMake Error` | Fix CMakeLists.txt configuration | + +## Fix Strategy + +1. **Compilation errors first** - Code must compile +2. **Linker errors second** - Resolve undefined references +3. **Warnings third** - Fix with `-Wall -Wextra` +4. **One fix at a time** - Verify each change +5. **Minimal changes** - Don't refactor, just fix + +## Stop Conditions + +The agent will stop and report if: +- Same error persists after 3 attempts +- Fix introduces more errors +- Requires architectural changes +- Missing external dependencies + +## Related Commands + +- `/cpp-test` - Run tests after build succeeds +- `/cpp-review` - Review code quality +- `/verify` - Full verification loop + +## Related + +- Agent: `agents/cpp-build-resolver.md` +- Skill: `skills/cpp-coding-standards/` diff --git a/commands/cpp-review.md b/commands/cpp-review.md new file mode 100644 index 00000000..a510c1db --- /dev/null +++ b/commands/cpp-review.md @@ -0,0 +1,132 @@ +--- +description: Comprehensive C++ code review for memory safety, modern C++ idioms, concurrency, and security. Invokes the cpp-reviewer agent. +--- + +# C++ Code Review + +This command invokes the **cpp-reviewer** agent for comprehensive C++-specific code review. + +## What This Command Does + +1. **Identify C++ Changes**: Find modified `.cpp`, `.hpp`, `.cc`, `.h` files via `git diff` +2. **Run Static Analysis**: Execute `clang-tidy` and `cppcheck` +3. **Memory Safety Scan**: Check for raw new/delete, buffer overflows, use-after-free +4. **Concurrency Review**: Analyze thread safety, mutex usage, data races +5. **Modern C++ Check**: Verify code follows C++17/20 conventions and best practices +6. **Generate Report**: Categorize issues by severity + +## When to Use + +Use `/cpp-review` when: +- After writing or modifying C++ code +- Before committing C++ changes +- Reviewing pull requests with C++ code +- Onboarding to a new C++ codebase +- Checking for memory safety issues + +## Review Categories + +### CRITICAL (Must Fix) +- Raw `new`/`delete` without RAII +- Buffer overflows and use-after-free +- Data races without synchronization +- Command injection via `system()` +- Uninitialized variable reads +- Null pointer dereferences + +### HIGH (Should Fix) +- Rule of Five violations +- Missing `std::lock_guard` / `std::scoped_lock` +- Detached threads without proper lifetime management +- C-style casts instead of `static_cast`/`dynamic_cast` +- Missing `const` correctness + +### MEDIUM (Consider) +- Unnecessary copies (pass by value instead of `const&`) +- Missing `reserve()` on known-size containers +- `using namespace std;` in headers +- Missing `[[nodiscard]]` on important return values +- Overly complex template metaprogramming + +## Automated Checks Run + +```bash +# Static analysis +clang-tidy --checks='*,-llvmlibc-*' src/*.cpp -- -std=c++17 + +# Additional analysis +cppcheck --enable=all --suppress=missingIncludeSystem src/ + +# Build with warnings +cmake --build build -- -Wall -Wextra -Wpedantic +``` + +## Example Usage + +```text +User: /cpp-review + +Agent: +# C++ Code Review Report + +## Files Reviewed +- src/handler/user.cpp (modified) +- src/service/auth.cpp (modified) + +## Static Analysis Results +✓ clang-tidy: 2 warnings +✓ cppcheck: No issues + +## Issues Found + +[CRITICAL] Memory Leak +File: src/service/auth.cpp:45 +Issue: Raw `new` without matching `delete` +```cpp +auto* session = new Session(userId); // Memory leak! +cache[userId] = session; +``` +Fix: Use `std::unique_ptr` +```cpp +auto session = std::make_unique(userId); +cache[userId] = std::move(session); +``` + +[HIGH] Missing const Reference +File: src/handler/user.cpp:28 +Issue: Large object passed by value +```cpp +void processUser(User user) { // Unnecessary copy +``` +Fix: Pass by const reference +```cpp +void processUser(const User& user) { +``` + +## Summary +- CRITICAL: 1 +- HIGH: 1 +- MEDIUM: 0 + +Recommendation: ❌ Block merge until CRITICAL issue is fixed +``` + +## Approval Criteria + +| Status | Condition | +|--------|-----------| +| ✅ Approve | No CRITICAL or HIGH issues | +| ⚠️ Warning | Only MEDIUM issues (merge with caution) | +| ❌ Block | CRITICAL or HIGH issues found | + +## Integration with Other Commands + +- Use `/cpp-test` first to ensure tests pass +- Use `/cpp-build` if build errors occur +- Use `/cpp-review` before committing +- Use `/code-review` for non-C++ specific concerns + +## Related + +- Agent: `agents/cpp-reviewer.md` +- Skills: `skills/cpp-coding-standards/`, `skills/cpp-testing/` diff --git a/commands/cpp-test.md b/commands/cpp-test.md new file mode 100644 index 00000000..2e9aad86 --- /dev/null +++ b/commands/cpp-test.md @@ -0,0 +1,251 @@ +--- +description: Enforce TDD workflow for C++. Write GoogleTest tests first, then implement. Verify coverage with gcov/lcov. +--- + +# C++ TDD Command + +This command enforces test-driven development methodology for C++ code using GoogleTest/GoogleMock with CMake/CTest. + +## What This Command Does + +1. **Define Interfaces**: Scaffold class/function signatures first +2. **Write Tests**: Create comprehensive GoogleTest test cases (RED) +3. **Run Tests**: Verify tests fail for the right reason +4. **Implement Code**: Write minimal code to pass (GREEN) +5. **Refactor**: Improve while keeping tests green +6. **Check Coverage**: Ensure 80%+ coverage + +## When to Use + +Use `/cpp-test` when: +- Implementing new C++ functions or classes +- Adding test coverage to existing code +- Fixing bugs (write failing test first) +- Building critical business logic +- Learning TDD workflow in C++ + +## TDD Cycle + +``` +RED → Write failing GoogleTest test +GREEN → Implement minimal code to pass +REFACTOR → Improve code, tests stay green +REPEAT → Next test case +``` + +## Example Session + +```` +User: /cpp-test I need a function to validate email addresses + +Agent: +# TDD Session: Email Validator + +## Step 1: Define Interface + +```cpp +// validator/email.hpp +#pragma once +#include +#include + +enum class EmailError { + Empty, + InvalidFormat +}; + +std::expected validate_email(const std::string& email); +``` + +## Step 2: Write Tests (RED) + +```cpp +// validator/email_test.cpp +#include +#include "email.hpp" + +TEST(ValidateEmail, AcceptsSimpleEmail) { + auto result = validate_email("user@example.com"); + EXPECT_TRUE(result.has_value()); +} + +TEST(ValidateEmail, AcceptsSubdomain) { + EXPECT_TRUE(validate_email("user@mail.example.com").has_value()); +} + +TEST(ValidateEmail, AcceptsPlus) { + EXPECT_TRUE(validate_email("user+tag@example.com").has_value()); +} + +TEST(ValidateEmail, RejectsEmpty) { + auto result = validate_email(""); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), EmailError::Empty); +} + +TEST(ValidateEmail, RejectsNoAtSign) { + EXPECT_FALSE(validate_email("userexample.com").has_value()); +} + +TEST(ValidateEmail, RejectsNoDomain) { + EXPECT_FALSE(validate_email("user@").has_value()); +} + +TEST(ValidateEmail, RejectsNoLocalPart) { + EXPECT_FALSE(validate_email("@example.com").has_value()); +} +``` + +## Step 3: Run Tests - Verify FAIL + +```bash +$ cmake --build build && ctest --test-dir build --output-on-failure + +1/1 Test #1: email_validator_test .....***Failed + --- undefined reference to `validate_email` + +FAIL +``` + +✓ Tests fail as expected (unimplemented). + +## Step 4: Implement Minimal Code (GREEN) + +```cpp +// validator/email.cpp +#include "email.hpp" +#include + +std::expected validate_email(const std::string& email) { + if (email.empty()) { + return std::unexpected(EmailError::Empty); + } + static const std::regex pattern(R"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})"); + if (!std::regex_match(email, pattern)) { + return std::unexpected(EmailError::InvalidFormat); + } + return {}; +} +``` + +## Step 5: Run Tests - Verify PASS + +```bash +$ cmake --build build && ctest --test-dir build --output-on-failure + +1/1 Test #1: email_validator_test ..... Passed 0.01 sec + +100% tests passed. +``` + +✓ All tests passing! + +## Step 6: Check Coverage + +```bash +$ cmake -DCMAKE_CXX_FLAGS="--coverage" -B build && cmake --build build +$ ctest --test-dir build +$ lcov --capture --directory build --output-file coverage.info +$ lcov --list coverage.info + +validator/email.cpp | 100% +``` + +✓ Coverage: 100% + +## TDD Complete! +```` + +## Test Patterns + +### Basic Tests +```cpp +TEST(SuiteName, TestName) { + EXPECT_EQ(add(2, 3), 5); + EXPECT_NE(result, nullptr); + EXPECT_TRUE(is_valid); + EXPECT_THROW(func(), std::invalid_argument); +} +``` + +### Fixtures +```cpp +class DatabaseTest : public ::testing::Test { +protected: + void SetUp() override { db_ = create_test_db(); } + void TearDown() override { db_.reset(); } + std::unique_ptr db_; +}; + +TEST_F(DatabaseTest, InsertsRecord) { + db_->insert("key", "value"); + EXPECT_EQ(db_->get("key"), "value"); +} +``` + +### Parameterized Tests +```cpp +class PrimeTest : public ::testing::TestWithParam> {}; + +TEST_P(PrimeTest, ChecksPrimality) { + auto [input, expected] = GetParam(); + EXPECT_EQ(is_prime(input), expected); +} + +INSTANTIATE_TEST_SUITE_P(Primes, PrimeTest, ::testing::Values( + std::make_pair(2, true), + std::make_pair(4, false), + std::make_pair(7, true) +)); +``` + +## Coverage Commands + +```bash +# Build with coverage +cmake -DCMAKE_CXX_FLAGS="--coverage" -DCMAKE_EXE_LINKER_FLAGS="--coverage" -B build + +# Run tests +cmake --build build && ctest --test-dir build + +# Generate coverage report +lcov --capture --directory build --output-file coverage.info +lcov --remove coverage.info '/usr/*' --output-file coverage.info +genhtml coverage.info --output-directory coverage_html +``` + +## Coverage Targets + +| Code Type | Target | +|-----------|--------| +| Critical business logic | 100% | +| Public APIs | 90%+ | +| General code | 80%+ | +| Generated code | Exclude | + +## TDD Best Practices + +**DO:** +- Write test FIRST, before any implementation +- Run tests after each change +- Use `EXPECT_*` (continues) over `ASSERT_*` (stops) when appropriate +- Test behavior, not implementation details +- Include edge cases (empty, null, max values, boundary conditions) + +**DON'T:** +- Write implementation before tests +- Skip the RED phase +- Test private methods directly (test through public API) +- Use `sleep` in tests +- Ignore flaky tests + +## Related Commands + +- `/cpp-build` - Fix build errors +- `/cpp-review` - Review code after implementation +- `/verify` - Run full verification loop + +## Related + +- Skill: `skills/cpp-testing/` +- Skill: `skills/tdd-workflow/` diff --git a/rules/cpp/coding-style.md b/rules/cpp/coding-style.md new file mode 100644 index 00000000..3550077d --- /dev/null +++ b/rules/cpp/coding-style.md @@ -0,0 +1,44 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with C++ specific content. + +## Modern C++ (C++17/20/23) + +- Prefer **modern C++ features** over C-style constructs +- Use `auto` when the type is obvious from context +- Use `constexpr` for compile-time constants +- Use structured bindings: `auto [key, value] = map_entry;` + +## Resource Management + +- **RAII everywhere** — no manual `new`/`delete` +- Use `std::unique_ptr` for exclusive ownership +- Use `std::shared_ptr` only when shared ownership is truly needed +- Use `std::make_unique` / `std::make_shared` over raw `new` + +## Naming Conventions + +- Types/Classes: `PascalCase` +- Functions/Methods: `snake_case` or `camelCase` (follow project convention) +- Constants: `kPascalCase` or `UPPER_SNAKE_CASE` +- Namespaces: `lowercase` +- Member variables: `snake_case_` (trailing underscore) or `m_` prefix + +## Formatting + +- Use **clang-format** — no style debates +- Run `clang-format -i ` before committing + +## Reference + +See skill: `cpp-coding-standards` for comprehensive C++ coding standards and guidelines. diff --git a/rules/cpp/hooks.md b/rules/cpp/hooks.md new file mode 100644 index 00000000..4ab677a0 --- /dev/null +++ b/rules/cpp/hooks.md @@ -0,0 +1,39 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with C++ specific content. + +## Build Hooks + +Run these checks before committing C++ changes: + +```bash +# Format check +clang-format --dry-run --Werror src/*.cpp src/*.hpp + +# Static analysis +clang-tidy src/*.cpp -- -std=c++17 + +# Build +cmake --build build + +# Tests +ctest --test-dir build --output-on-failure +``` + +## Recommended CI Pipeline + +1. **clang-format** — formatting check +2. **clang-tidy** — static analysis +3. **cppcheck** — additional analysis +4. **cmake build** — compilation +5. **ctest** — test execution with sanitizers diff --git a/rules/cpp/patterns.md b/rules/cpp/patterns.md new file mode 100644 index 00000000..0c156e8d --- /dev/null +++ b/rules/cpp/patterns.md @@ -0,0 +1,51 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with C++ specific content. + +## RAII (Resource Acquisition Is Initialization) + +Tie resource lifetime to object lifetime: + +```cpp +class FileHandle { +public: + explicit FileHandle(const std::string& path) : file_(std::fopen(path.c_str(), "r")) {} + ~FileHandle() { if (file_) std::fclose(file_); } + FileHandle(const FileHandle&) = delete; + FileHandle& operator=(const FileHandle&) = delete; +private: + std::FILE* file_; +}; +``` + +## Rule of Five/Zero + +- **Rule of Zero**: Prefer classes that need no custom destructor, copy/move constructors, or assignments +- **Rule of Five**: If you define any of destructor/copy-ctor/copy-assign/move-ctor/move-assign, define all five + +## Value Semantics + +- Pass small/trivial types by value +- Pass large types by `const&` +- Return by value (rely on RVO/NRVO) +- Use move semantics for sink parameters + +## Error Handling + +- Use exceptions for exceptional conditions +- Use `std::optional` for values that may not exist +- Use `std::expected` (C++23) or result types for expected failures + +## Reference + +See skill: `cpp-coding-standards` for comprehensive C++ patterns and anti-patterns. diff --git a/rules/cpp/security.md b/rules/cpp/security.md new file mode 100644 index 00000000..0ee9f5f0 --- /dev/null +++ b/rules/cpp/security.md @@ -0,0 +1,51 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Security + +> This file extends [common/security.md](../common/security.md) with C++ specific content. + +## Memory Safety + +- Never use raw `new`/`delete` — use smart pointers +- Never use C-style arrays — use `std::array` or `std::vector` +- Never use `malloc`/`free` — use C++ allocation +- Avoid `reinterpret_cast` unless absolutely necessary + +## Buffer Overflows + +- Use `std::string` over `char*` +- Use `.at()` for bounds-checked access when safety matters +- Never use `strcpy`, `strcat`, `sprintf` — use `std::string` or `fmt::format` + +## Undefined Behavior + +- Always initialize variables +- Avoid signed integer overflow +- Never dereference null or dangling pointers +- Use sanitizers in CI: + ```bash + cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" .. + ``` + +## Static Analysis + +- Use **clang-tidy** for automated checks: + ```bash + clang-tidy --checks='*' src/*.cpp + ``` +- Use **cppcheck** for additional analysis: + ```bash + cppcheck --enable=all src/ + ``` + +## Reference + +See skill: `cpp-coding-standards` for detailed security guidelines. diff --git a/rules/cpp/testing.md b/rules/cpp/testing.md new file mode 100644 index 00000000..7c283551 --- /dev/null +++ b/rules/cpp/testing.md @@ -0,0 +1,44 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Testing + +> This file extends [common/testing.md](../common/testing.md) with C++ specific content. + +## Framework + +Use **GoogleTest** (gtest/gmock) with **CMake/CTest**. + +## Running Tests + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +## Coverage + +```bash +cmake -DCMAKE_CXX_FLAGS="--coverage" -DCMAKE_EXE_LINKER_FLAGS="--coverage" .. +cmake --build . +ctest --output-on-failure +lcov --capture --directory . --output-file coverage.info +``` + +## Sanitizers + +Always run tests with sanitizers in CI: + +```bash +cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" .. +``` + +## Reference + +See skill: `cpp-testing` for detailed C++ testing patterns, TDD workflow, and GoogleTest/GMock usage. diff --git a/tests/hooks/auto-tmux-dev.test.js b/tests/hooks/auto-tmux-dev.test.js new file mode 100644 index 00000000..ac2b37fd --- /dev/null +++ b/tests/hooks/auto-tmux-dev.test.js @@ -0,0 +1,145 @@ +/** + * Tests for scripts/hooks/auto-tmux-dev.js + * + * Tests dev server command transformation for tmux wrapping. + * + * Run with: node tests/hooks/auto-tmux-dev.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'auto-tmux-dev.js'); + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runScript(input) { + const result = spawnSync('node', [script], { + encoding: 'utf8', + input: typeof input === 'string' ? input : JSON.stringify(input), + timeout: 10000, + }); + return { + code: result.status || 0, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +function runTests() { + console.log('\n=== Testing auto-tmux-dev.js ===\n'); + + let passed = 0; + let failed = 0; + + // Check if tmux is available for conditional tests + const tmuxAvailable = spawnSync('which', ['tmux'], { encoding: 'utf8' }).status === 0; + + console.log('Dev server detection:'); + + if (test('transforms npm run dev command', () => { + const result = runScript({ tool_input: { command: 'npm run dev' } }); + assert.strictEqual(result.code, 0); + const output = JSON.parse(result.stdout); + if (process.platform !== 'win32' && tmuxAvailable) { + assert.ok(output.tool_input.command.includes('tmux'), 'Should contain tmux'); + assert.ok(output.tool_input.command.includes('npm run dev'), 'Should contain original command'); + } + })) passed++; else failed++; + + if (test('transforms pnpm dev command', () => { + const result = runScript({ tool_input: { command: 'pnpm dev' } }); + assert.strictEqual(result.code, 0); + const output = JSON.parse(result.stdout); + if (process.platform !== 'win32' && tmuxAvailable) { + assert.ok(output.tool_input.command.includes('tmux')); + } + })) passed++; else failed++; + + if (test('transforms yarn dev command', () => { + const result = runScript({ tool_input: { command: 'yarn dev' } }); + assert.strictEqual(result.code, 0); + const output = JSON.parse(result.stdout); + if (process.platform !== 'win32' && tmuxAvailable) { + assert.ok(output.tool_input.command.includes('tmux')); + } + })) passed++; else failed++; + + if (test('transforms bun run dev command', () => { + const result = runScript({ tool_input: { command: 'bun run dev' } }); + assert.strictEqual(result.code, 0); + const output = JSON.parse(result.stdout); + if (process.platform !== 'win32' && tmuxAvailable) { + assert.ok(output.tool_input.command.includes('tmux')); + } + })) passed++; else failed++; + + console.log('\nNon-dev commands (pass-through):'); + + if (test('does not transform npm install', () => { + const input = { tool_input: { command: 'npm install' } }; + const result = runScript(input); + assert.strictEqual(result.code, 0); + const output = JSON.parse(result.stdout); + assert.strictEqual(output.tool_input.command, 'npm install'); + })) passed++; else failed++; + + if (test('does not transform npm test', () => { + const input = { tool_input: { command: 'npm test' } }; + const result = runScript(input); + assert.strictEqual(result.code, 0); + const output = JSON.parse(result.stdout); + assert.strictEqual(output.tool_input.command, 'npm test'); + })) passed++; else failed++; + + if (test('does not transform npm run build', () => { + const input = { tool_input: { command: 'npm run build' } }; + const result = runScript(input); + assert.strictEqual(result.code, 0); + const output = JSON.parse(result.stdout); + assert.strictEqual(output.tool_input.command, 'npm run build'); + })) passed++; else failed++; + + if (test('does not transform npm run develop (partial match)', () => { + const input = { tool_input: { command: 'npm run develop' } }; + const result = runScript(input); + assert.strictEqual(result.code, 0); + const output = JSON.parse(result.stdout); + assert.strictEqual(output.tool_input.command, 'npm run develop'); + })) passed++; else failed++; + + console.log('\nEdge cases:'); + + if (test('handles empty input gracefully', () => { + const result = runScript('{}'); + assert.strictEqual(result.code, 0); + })) passed++; else failed++; + + if (test('handles invalid JSON gracefully', () => { + const result = runScript('not json'); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, 'not json'); + })) passed++; else failed++; + + if (test('passes through missing command field', () => { + const input = { tool_input: {} }; + const result = runScript(input); + assert.strictEqual(result.code, 0); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/check-hook-enabled.test.js b/tests/hooks/check-hook-enabled.test.js new file mode 100644 index 00000000..fac77115 --- /dev/null +++ b/tests/hooks/check-hook-enabled.test.js @@ -0,0 +1,108 @@ +/** + * Tests for scripts/hooks/check-hook-enabled.js + * + * Tests the CLI wrapper around isHookEnabled. + * + * Run with: node tests/hooks/check-hook-enabled.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'check-hook-enabled.js'); + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runScript(args = [], envOverrides = {}) { + const env = { ...process.env, ...envOverrides }; + // Remove potentially interfering env vars unless explicitly set + if (!envOverrides.ECC_HOOK_PROFILE) delete env.ECC_HOOK_PROFILE; + if (!envOverrides.ECC_DISABLED_HOOKS) delete env.ECC_DISABLED_HOOKS; + + const result = spawnSync('node', [script, ...args], { + encoding: 'utf8', + timeout: 10000, + env, + }); + return { + code: result.status || 0, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +function runTests() { + console.log('\n=== Testing check-hook-enabled.js ===\n'); + + let passed = 0; + let failed = 0; + + console.log('No arguments:'); + + if (test('returns yes when no hookId provided', () => { + const result = runScript([]); + assert.strictEqual(result.stdout, 'yes'); + })) passed++; else failed++; + + console.log('\nDefault profile (standard):'); + + if (test('returns yes for hook with default profiles', () => { + const result = runScript(['my-hook']); + assert.strictEqual(result.stdout, 'yes'); + })) passed++; else failed++; + + if (test('returns yes for hook with standard,strict profiles', () => { + const result = runScript(['my-hook', 'standard,strict']); + assert.strictEqual(result.stdout, 'yes'); + })) passed++; else failed++; + + if (test('returns no for hook with only strict profile', () => { + const result = runScript(['my-hook', 'strict']); + assert.strictEqual(result.stdout, 'no'); + })) passed++; else failed++; + + if (test('returns no for hook with only minimal profile', () => { + const result = runScript(['my-hook', 'minimal']); + assert.strictEqual(result.stdout, 'no'); + })) passed++; else failed++; + + console.log('\nDisabled hooks:'); + + if (test('returns no when hook is disabled via env', () => { + const result = runScript(['my-hook'], { ECC_DISABLED_HOOKS: 'my-hook' }); + assert.strictEqual(result.stdout, 'no'); + })) passed++; else failed++; + + if (test('returns yes when different hook is disabled', () => { + const result = runScript(['my-hook'], { ECC_DISABLED_HOOKS: 'other-hook' }); + assert.strictEqual(result.stdout, 'yes'); + })) passed++; else failed++; + + console.log('\nProfile overrides:'); + + if (test('returns yes for strict profile with strict-only hook', () => { + const result = runScript(['my-hook', 'strict'], { ECC_HOOK_PROFILE: 'strict' }); + assert.strictEqual(result.stdout, 'yes'); + })) passed++; else failed++; + + if (test('returns yes for minimal profile with minimal-only hook', () => { + const result = runScript(['my-hook', 'minimal'], { ECC_HOOK_PROFILE: 'minimal' }); + assert.strictEqual(result.stdout, 'yes'); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js new file mode 100644 index 00000000..a64c5448 --- /dev/null +++ b/tests/hooks/cost-tracker.test.js @@ -0,0 +1,131 @@ +/** + * Tests for cost-tracker.js hook + * + * Run with: node tests/hooks/cost-tracker.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { spawnSync } = require('child_process'); + +const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'cost-tracker.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'cost-tracker-test-')); +} + +function runScript(input, envOverrides = {}) { + const inputStr = typeof input === 'string' ? input : JSON.stringify(input); + const result = spawnSync('node', [script], { + encoding: 'utf8', + input: inputStr, + timeout: 10000, + env: { ...process.env, ...envOverrides }, + }); + return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' }; +} + +function runTests() { + console.log('\n=== Testing cost-tracker.js ===\n'); + + let passed = 0; + let failed = 0; + + // 1. Passes through input on stdout + (test('passes through input on stdout', () => { + const input = { + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + }; + const inputStr = JSON.stringify(input); + const result = runScript(input); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input'); + }) ? passed++ : failed++); + + // 2. Creates metrics file when given valid usage data + (test('creates metrics file when given valid usage data', () => { + const tmpHome = makeTempDir(); + const input = { + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 1000, output_tokens: 500 }, + }; + const result = runScript(input, { HOME: tmpHome }); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl'); + assert.ok(fs.existsSync(metricsFile), `Expected metrics file to exist at ${metricsFile}`); + + const content = fs.readFileSync(metricsFile, 'utf8').trim(); + const row = JSON.parse(content); + assert.strictEqual(row.input_tokens, 1000, 'Expected input_tokens to be 1000'); + assert.strictEqual(row.output_tokens, 500, 'Expected output_tokens to be 500'); + assert.ok(row.timestamp, 'Expected timestamp to be present'); + assert.ok(typeof row.estimated_cost_usd === 'number', 'Expected estimated_cost_usd to be a number'); + assert.ok(row.estimated_cost_usd > 0, 'Expected estimated_cost_usd to be positive'); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + + // 3. Handles empty input gracefully + (test('handles empty input gracefully', () => { + const tmpHome = makeTempDir(); + const result = runScript('', { HOME: tmpHome }); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + // stdout should be empty since input was empty + assert.strictEqual(result.stdout, '', 'Expected empty stdout for empty input'); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + + // 4. Handles invalid JSON gracefully + (test('handles invalid JSON gracefully', () => { + const tmpHome = makeTempDir(); + const invalidInput = 'not valid json {{{'; + const result = runScript(invalidInput, { HOME: tmpHome }); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + // Should still pass through the raw input on stdout + assert.strictEqual(result.stdout, invalidInput, 'Expected stdout to contain original invalid input'); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + + // 5. Handles missing usage fields gracefully + (test('handles missing usage fields gracefully', () => { + const tmpHome = makeTempDir(); + const input = { model: 'claude-sonnet-4-20250514' }; + const inputStr = JSON.stringify(input); + const result = runScript(input, { HOME: tmpHome }); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input'); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl'); + assert.ok(fs.existsSync(metricsFile), 'Expected metrics file to exist even with missing usage'); + + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.strictEqual(row.input_tokens, 0, 'Expected input_tokens to be 0 when missing'); + assert.strictEqual(row.output_tokens, 0, 'Expected output_tokens to be 0 when missing'); + assert.strictEqual(row.estimated_cost_usd, 0, 'Expected estimated_cost_usd to be 0 when no tokens'); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/doc-file-warning.test.js b/tests/hooks/doc-file-warning.test.js new file mode 100644 index 00000000..7c393fb8 --- /dev/null +++ b/tests/hooks/doc-file-warning.test.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'doc-file-warning.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runScript(input) { + const result = spawnSync('node', [script], { + encoding: 'utf8', + input: JSON.stringify(input), + timeout: 10000, + }); + return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' }; +} + +function runTests() { + console.log('\n=== Testing doc-file-warning.js ===\n'); + let passed = 0; + let failed = 0; + + // 1. Allowed standard doc files - no warning in stderr + const standardFiles = [ + 'README.md', + 'CLAUDE.md', + 'AGENTS.md', + 'CONTRIBUTING.md', + 'CHANGELOG.md', + 'LICENSE.md', + 'SKILL.md', + 'MEMORY.md', + 'WORKLOG.md', + ]; + for (const file of standardFiles) { + (test(`allows standard doc file: ${file}`, () => { + const { code, stderr } = runScript({ tool_input: { file_path: file } }); + assert.strictEqual(code, 0, `expected exit code 0, got ${code}`); + assert.strictEqual(stderr, '', `expected no warning for ${file}, got: ${stderr}`); + }) ? passed++ : failed++); + } + + // 2. Allowed directory paths - no warning + const allowedDirPaths = [ + 'docs/foo.md', + 'docs/guide/setup.md', + 'skills/bar.md', + 'skills/testing/tdd.md', + '.history/session.md', + 'memory/patterns.md', + '.claude/commands/deploy.md', + '.claude/plans/roadmap.md', + '.claude/projects/myproject.md', + ]; + for (const file of allowedDirPaths) { + (test(`allows directory path: ${file}`, () => { + const { code, stderr } = runScript({ tool_input: { file_path: file } }); + assert.strictEqual(code, 0, `expected exit code 0, got ${code}`); + assert.strictEqual(stderr, '', `expected no warning for ${file}, got: ${stderr}`); + }) ? passed++ : failed++); + } + + // 3. Allowed .plan.md files - no warning + (test('allows .plan.md files', () => { + const { code, stderr } = runScript({ tool_input: { file_path: 'feature.plan.md' } }); + assert.strictEqual(code, 0); + assert.strictEqual(stderr, '', `expected no warning for .plan.md, got: ${stderr}`); + }) ? passed++ : failed++); + + (test('allows nested .plan.md files', () => { + const { code, stderr } = runScript({ tool_input: { file_path: 'src/refactor.plan.md' } }); + assert.strictEqual(code, 0); + assert.strictEqual(stderr, '', `expected no warning for nested .plan.md, got: ${stderr}`); + }) ? passed++ : failed++); + + // 4. Non-md/txt files always pass - no warning + const nonDocFiles = ['foo.js', 'app.py', 'styles.css', 'data.json', 'image.png']; + for (const file of nonDocFiles) { + (test(`allows non-doc file: ${file}`, () => { + const { code, stderr } = runScript({ tool_input: { file_path: file } }); + assert.strictEqual(code, 0); + assert.strictEqual(stderr, '', `expected no warning for ${file}, got: ${stderr}`); + }) ? passed++ : failed++); + } + + // 5. Non-standard doc files - warning in stderr + const nonStandardFiles = ['random-notes.md', 'TODO.md', 'notes.txt', 'scratch.md', 'ideas.txt']; + for (const file of nonStandardFiles) { + (test(`warns on non-standard doc file: ${file}`, () => { + const { code, stderr } = runScript({ tool_input: { file_path: file } }); + assert.strictEqual(code, 0, 'should still exit 0 (warn only)'); + assert.ok(stderr.includes('WARNING'), `expected warning in stderr for ${file}, got: ${stderr}`); + assert.ok(stderr.includes(file), `expected file path in stderr for ${file}`); + }) ? passed++ : failed++); + } + + // 6. Invalid/empty input - passes through without error + (test('handles empty object input without error', () => { + const { code, stderr } = runScript({}); + assert.strictEqual(code, 0); + assert.strictEqual(stderr, '', `expected no warning for empty input, got: ${stderr}`); + }) ? passed++ : failed++); + + (test('handles missing file_path without error', () => { + const { code, stderr } = runScript({ tool_input: {} }); + assert.strictEqual(code, 0); + assert.strictEqual(stderr, '', `expected no warning for missing file_path, got: ${stderr}`); + }) ? passed++ : failed++); + + (test('handles empty file_path without error', () => { + const { code, stderr } = runScript({ tool_input: { file_path: '' } }); + assert.strictEqual(code, 0); + assert.strictEqual(stderr, '', `expected no warning for empty file_path, got: ${stderr}`); + }) ? passed++ : failed++); + + // 7. Stdout always contains the original input (pass-through) + (test('passes through input to stdout for allowed file', () => { + const input = { tool_input: { file_path: 'README.md' } }; + const { stdout } = runScript(input); + assert.strictEqual(stdout, JSON.stringify(input)); + }) ? passed++ : failed++); + + (test('passes through input to stdout for warned file', () => { + const input = { tool_input: { file_path: 'random-notes.md' } }; + const { stdout } = runScript(input); + assert.strictEqual(stdout, JSON.stringify(input)); + }) ? passed++ : failed++); + + (test('passes through input to stdout for empty input', () => { + const input = {}; + const { stdout } = runScript(input); + assert.strictEqual(stdout, JSON.stringify(input)); + }) ? passed++ : failed++); + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/hook-flags.test.js b/tests/hooks/hook-flags.test.js new file mode 100644 index 00000000..a8e926eb --- /dev/null +++ b/tests/hooks/hook-flags.test.js @@ -0,0 +1,397 @@ +/** + * Tests for scripts/lib/hook-flags.js + * + * Run with: node tests/hooks/hook-flags.test.js + */ + +const assert = require('assert'); + +// Import the module +const { + VALID_PROFILES, + normalizeId, + getHookProfile, + getDisabledHookIds, + parseProfiles, + isHookEnabled, +} = require('../../scripts/lib/hook-flags'); + +// Test helper +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +// Helper to save and restore env vars +function withEnv(vars, fn) { + const saved = {}; + for (const key of Object.keys(vars)) { + saved[key] = process.env[key]; + if (vars[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = vars[key]; + } + } + try { + fn(); + } finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + } +} + +// Test suite +function runTests() { + console.log('\n=== Testing hook-flags.js ===\n'); + + let passed = 0; + let failed = 0; + + // VALID_PROFILES tests + console.log('VALID_PROFILES:'); + + if (test('is a Set', () => { + assert.ok(VALID_PROFILES instanceof Set); + })) passed++; else failed++; + + if (test('contains minimal, standard, strict', () => { + assert.ok(VALID_PROFILES.has('minimal')); + assert.ok(VALID_PROFILES.has('standard')); + assert.ok(VALID_PROFILES.has('strict')); + })) passed++; else failed++; + + if (test('contains exactly 3 profiles', () => { + assert.strictEqual(VALID_PROFILES.size, 3); + })) passed++; else failed++; + + // normalizeId tests + console.log('\nnormalizeId:'); + + if (test('returns empty string for null', () => { + assert.strictEqual(normalizeId(null), ''); + })) passed++; else failed++; + + if (test('returns empty string for undefined', () => { + assert.strictEqual(normalizeId(undefined), ''); + })) passed++; else failed++; + + if (test('returns empty string for empty string', () => { + assert.strictEqual(normalizeId(''), ''); + })) passed++; else failed++; + + if (test('trims whitespace', () => { + assert.strictEqual(normalizeId(' hello '), 'hello'); + })) passed++; else failed++; + + if (test('converts to lowercase', () => { + assert.strictEqual(normalizeId('MyHook'), 'myhook'); + })) passed++; else failed++; + + if (test('handles mixed case with whitespace', () => { + assert.strictEqual(normalizeId(' My-Hook-ID '), 'my-hook-id'); + })) passed++; else failed++; + + if (test('converts numbers to string', () => { + assert.strictEqual(normalizeId(123), '123'); + })) passed++; else failed++; + + if (test('returns empty string for whitespace-only input', () => { + assert.strictEqual(normalizeId(' '), ''); + })) passed++; else failed++; + + // getHookProfile tests + console.log('\ngetHookProfile:'); + + if (test('defaults to standard when env var not set', () => { + withEnv({ ECC_HOOK_PROFILE: undefined }, () => { + assert.strictEqual(getHookProfile(), 'standard'); + }); + })) passed++; else failed++; + + if (test('returns minimal when set to minimal', () => { + withEnv({ ECC_HOOK_PROFILE: 'minimal' }, () => { + assert.strictEqual(getHookProfile(), 'minimal'); + }); + })) passed++; else failed++; + + if (test('returns standard when set to standard', () => { + withEnv({ ECC_HOOK_PROFILE: 'standard' }, () => { + assert.strictEqual(getHookProfile(), 'standard'); + }); + })) passed++; else failed++; + + if (test('returns strict when set to strict', () => { + withEnv({ ECC_HOOK_PROFILE: 'strict' }, () => { + assert.strictEqual(getHookProfile(), 'strict'); + }); + })) passed++; else failed++; + + if (test('is case-insensitive', () => { + withEnv({ ECC_HOOK_PROFILE: 'STRICT' }, () => { + assert.strictEqual(getHookProfile(), 'strict'); + }); + })) passed++; else failed++; + + if (test('trims whitespace from env var', () => { + withEnv({ ECC_HOOK_PROFILE: ' minimal ' }, () => { + assert.strictEqual(getHookProfile(), 'minimal'); + }); + })) passed++; else failed++; + + if (test('defaults to standard for invalid value', () => { + withEnv({ ECC_HOOK_PROFILE: 'invalid' }, () => { + assert.strictEqual(getHookProfile(), 'standard'); + }); + })) passed++; else failed++; + + if (test('defaults to standard for empty string', () => { + withEnv({ ECC_HOOK_PROFILE: '' }, () => { + assert.strictEqual(getHookProfile(), 'standard'); + }); + })) passed++; else failed++; + + // getDisabledHookIds tests + console.log('\ngetDisabledHookIds:'); + + if (test('returns empty Set when env var not set', () => { + withEnv({ ECC_DISABLED_HOOKS: undefined }, () => { + const result = getDisabledHookIds(); + assert.ok(result instanceof Set); + assert.strictEqual(result.size, 0); + }); + })) passed++; else failed++; + + if (test('returns empty Set for empty string', () => { + withEnv({ ECC_DISABLED_HOOKS: '' }, () => { + assert.strictEqual(getDisabledHookIds().size, 0); + }); + })) passed++; else failed++; + + if (test('returns empty Set for whitespace-only string', () => { + withEnv({ ECC_DISABLED_HOOKS: ' ' }, () => { + assert.strictEqual(getDisabledHookIds().size, 0); + }); + })) passed++; else failed++; + + if (test('parses single hook id', () => { + withEnv({ ECC_DISABLED_HOOKS: 'my-hook' }, () => { + const result = getDisabledHookIds(); + assert.strictEqual(result.size, 1); + assert.ok(result.has('my-hook')); + }); + })) passed++; else failed++; + + if (test('parses multiple comma-separated hook ids', () => { + withEnv({ ECC_DISABLED_HOOKS: 'hook-a,hook-b,hook-c' }, () => { + const result = getDisabledHookIds(); + assert.strictEqual(result.size, 3); + assert.ok(result.has('hook-a')); + assert.ok(result.has('hook-b')); + assert.ok(result.has('hook-c')); + }); + })) passed++; else failed++; + + if (test('trims whitespace around hook ids', () => { + withEnv({ ECC_DISABLED_HOOKS: ' hook-a , hook-b ' }, () => { + const result = getDisabledHookIds(); + assert.strictEqual(result.size, 2); + assert.ok(result.has('hook-a')); + assert.ok(result.has('hook-b')); + }); + })) passed++; else failed++; + + if (test('normalizes hook ids to lowercase', () => { + withEnv({ ECC_DISABLED_HOOKS: 'MyHook,ANOTHER' }, () => { + const result = getDisabledHookIds(); + assert.ok(result.has('myhook')); + assert.ok(result.has('another')); + }); + })) passed++; else failed++; + + if (test('filters out empty entries from trailing commas', () => { + withEnv({ ECC_DISABLED_HOOKS: 'hook-a,,hook-b,' }, () => { + const result = getDisabledHookIds(); + assert.strictEqual(result.size, 2); + assert.ok(result.has('hook-a')); + assert.ok(result.has('hook-b')); + }); + })) passed++; else failed++; + + // parseProfiles tests + console.log('\nparseProfiles:'); + + if (test('returns fallback for null input', () => { + const result = parseProfiles(null); + assert.deepStrictEqual(result, ['standard', 'strict']); + })) passed++; else failed++; + + if (test('returns fallback for undefined input', () => { + const result = parseProfiles(undefined); + assert.deepStrictEqual(result, ['standard', 'strict']); + })) passed++; else failed++; + + if (test('uses custom fallback when provided', () => { + const result = parseProfiles(null, ['minimal']); + assert.deepStrictEqual(result, ['minimal']); + })) passed++; else failed++; + + if (test('parses comma-separated string', () => { + const result = parseProfiles('minimal,strict'); + assert.deepStrictEqual(result, ['minimal', 'strict']); + })) passed++; else failed++; + + if (test('parses single string value', () => { + const result = parseProfiles('strict'); + assert.deepStrictEqual(result, ['strict']); + })) passed++; else failed++; + + if (test('parses array of profiles', () => { + const result = parseProfiles(['minimal', 'standard']); + assert.deepStrictEqual(result, ['minimal', 'standard']); + })) passed++; else failed++; + + if (test('filters invalid profiles from string', () => { + const result = parseProfiles('minimal,invalid,strict'); + assert.deepStrictEqual(result, ['minimal', 'strict']); + })) passed++; else failed++; + + if (test('filters invalid profiles from array', () => { + const result = parseProfiles(['minimal', 'bogus', 'strict']); + assert.deepStrictEqual(result, ['minimal', 'strict']); + })) passed++; else failed++; + + if (test('returns fallback when all string values are invalid', () => { + const result = parseProfiles('invalid,bogus'); + assert.deepStrictEqual(result, ['standard', 'strict']); + })) passed++; else failed++; + + if (test('returns fallback when all array values are invalid', () => { + const result = parseProfiles(['invalid', 'bogus']); + assert.deepStrictEqual(result, ['standard', 'strict']); + })) passed++; else failed++; + + if (test('is case-insensitive for string input', () => { + const result = parseProfiles('MINIMAL,STRICT'); + assert.deepStrictEqual(result, ['minimal', 'strict']); + })) passed++; else failed++; + + if (test('is case-insensitive for array input', () => { + const result = parseProfiles(['MINIMAL', 'STRICT']); + assert.deepStrictEqual(result, ['minimal', 'strict']); + })) passed++; else failed++; + + if (test('trims whitespace in string input', () => { + const result = parseProfiles(' minimal , strict '); + assert.deepStrictEqual(result, ['minimal', 'strict']); + })) passed++; else failed++; + + if (test('handles null values in array', () => { + const result = parseProfiles([null, 'strict']); + assert.deepStrictEqual(result, ['strict']); + })) passed++; else failed++; + + // isHookEnabled tests + console.log('\nisHookEnabled:'); + + if (test('returns true by default for a hook (standard profile)', () => { + withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled('my-hook'), true); + }); + })) passed++; else failed++; + + if (test('returns true for empty hookId', () => { + withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled(''), true); + }); + })) passed++; else failed++; + + if (test('returns true for null hookId', () => { + withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled(null), true); + }); + })) passed++; else failed++; + + if (test('returns false when hook is in disabled list', () => { + withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: 'my-hook' }, () => { + assert.strictEqual(isHookEnabled('my-hook'), false); + }); + })) passed++; else failed++; + + if (test('disabled check is case-insensitive', () => { + withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: 'MY-HOOK' }, () => { + assert.strictEqual(isHookEnabled('my-hook'), false); + }); + })) passed++; else failed++; + + if (test('returns true when hook is not in disabled list', () => { + withEnv({ ECC_HOOK_PROFILE: undefined, ECC_DISABLED_HOOKS: 'other-hook' }, () => { + assert.strictEqual(isHookEnabled('my-hook'), true); + }); + })) passed++; else failed++; + + if (test('returns false when current profile is not in allowed profiles', () => { + withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled('my-hook', { profiles: 'strict' }), false); + }); + })) passed++; else failed++; + + if (test('returns true when current profile is in allowed profiles', () => { + withEnv({ ECC_HOOK_PROFILE: 'strict', ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled('my-hook', { profiles: 'standard,strict' }), true); + }); + })) passed++; else failed++; + + if (test('returns true when current profile matches single allowed profile', () => { + withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled('my-hook', { profiles: 'minimal' }), true); + }); + })) passed++; else failed++; + + if (test('disabled hooks take precedence over profile match', () => { + withEnv({ ECC_HOOK_PROFILE: 'strict', ECC_DISABLED_HOOKS: 'my-hook' }, () => { + assert.strictEqual(isHookEnabled('my-hook', { profiles: 'strict' }), false); + }); + })) passed++; else failed++; + + if (test('uses default profiles (standard, strict) when none specified', () => { + withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled('my-hook'), false); + }); + })) passed++; else failed++; + + if (test('allows standard profile by default', () => { + withEnv({ ECC_HOOK_PROFILE: 'standard', ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled('my-hook'), true); + }); + })) passed++; else failed++; + + if (test('allows strict profile by default', () => { + withEnv({ ECC_HOOK_PROFILE: 'strict', ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled('my-hook'), true); + }); + })) passed++; else failed++; + + if (test('accepts array profiles option', () => { + withEnv({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: undefined }, () => { + assert.strictEqual(isHookEnabled('my-hook', { profiles: ['minimal', 'standard'] }), true); + }); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/post-bash-hooks.test.js b/tests/hooks/post-bash-hooks.test.js new file mode 100644 index 00000000..eb6e2a16 --- /dev/null +++ b/tests/hooks/post-bash-hooks.test.js @@ -0,0 +1,207 @@ +/** + * Tests for post-bash-build-complete.js and post-bash-pr-created.js + * + * Run with: node tests/hooks/post-bash-hooks.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const buildCompleteScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-build-complete.js'); +const prCreatedScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-pr-created.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runScript(scriptPath, input) { + return spawnSync('node', [scriptPath], { + encoding: 'utf8', + input, + stdio: ['pipe', 'pipe', 'pipe'] + }); +} + +let passed = 0; +let failed = 0; + +// ── post-bash-build-complete.js ────────────────────────────────── + +console.log('\nPost-Bash Build Complete Hook Tests'); +console.log('====================================\n'); + +console.log('Build command detection:'); + +if (test('stderr contains "Build completed" for npm run build command', () => { + const input = JSON.stringify({ tool_input: { command: 'npm run build' } }); + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.ok(result.stderr.includes('Build completed'), `stderr should contain "Build completed", got: ${result.stderr}`); +})) passed++; else failed++; + +if (test('stderr contains "Build completed" for pnpm build command', () => { + const input = JSON.stringify({ tool_input: { command: 'pnpm build' } }); + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.ok(result.stderr.includes('Build completed'), `stderr should contain "Build completed", got: ${result.stderr}`); +})) passed++; else failed++; + +if (test('stderr contains "Build completed" for yarn build command', () => { + const input = JSON.stringify({ tool_input: { command: 'yarn build' } }); + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.ok(result.stderr.includes('Build completed'), `stderr should contain "Build completed", got: ${result.stderr}`); +})) passed++; else failed++; + +console.log('\nNon-build command detection:'); + +if (test('no stderr message for npm test command', () => { + const input = JSON.stringify({ tool_input: { command: 'npm test' } }); + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.strictEqual(result.stderr, '', 'stderr should be empty for non-build command'); +})) passed++; else failed++; + +if (test('no stderr message for ls command', () => { + const input = JSON.stringify({ tool_input: { command: 'ls -la' } }); + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.strictEqual(result.stderr, '', 'stderr should be empty for non-build command'); +})) passed++; else failed++; + +if (test('no stderr message for git status command', () => { + const input = JSON.stringify({ tool_input: { command: 'git status' } }); + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.strictEqual(result.stderr, '', 'stderr should be empty for non-build command'); +})) passed++; else failed++; + +console.log('\nStdout pass-through:'); + +if (test('stdout passes through input for build command', () => { + const input = JSON.stringify({ tool_input: { command: 'npm run build' } }); + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.stdout, input, 'stdout should be the original input'); +})) passed++; else failed++; + +if (test('stdout passes through input for non-build command', () => { + const input = JSON.stringify({ tool_input: { command: 'npm test' } }); + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.stdout, input, 'stdout should be the original input'); +})) passed++; else failed++; + +if (test('stdout passes through input for invalid JSON', () => { + const input = 'not valid json'; + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.stdout, input, 'stdout should be the original input'); +})) passed++; else failed++; + +if (test('stdout passes through empty input', () => { + const input = ''; + const result = runScript(buildCompleteScript, input); + assert.strictEqual(result.stdout, input, 'stdout should be the original input'); +})) passed++; else failed++; + +// ── post-bash-pr-created.js ────────────────────────────────────── + +console.log('\n\nPost-Bash PR Created Hook Tests'); +console.log('================================\n'); + +console.log('PR creation detection:'); + +if (test('stderr contains PR URL when gh pr create output has PR URL', () => { + const input = JSON.stringify({ + tool_input: { command: 'gh pr create --title "Fix bug" --body "desc"' }, + tool_output: { output: 'https://github.com/owner/repo/pull/42\n' } + }); + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.ok(result.stderr.includes('https://github.com/owner/repo/pull/42'), `stderr should contain PR URL, got: ${result.stderr}`); + assert.ok(result.stderr.includes('[Hook] PR created:'), 'stderr should contain PR created message'); + assert.ok(result.stderr.includes('gh pr review 42'), 'stderr should contain review command'); +})) passed++; else failed++; + +if (test('stderr contains correct repo in review command', () => { + const input = JSON.stringify({ + tool_input: { command: 'gh pr create' }, + tool_output: { output: 'Created PR\nhttps://github.com/my-org/my-repo/pull/123\nDone' } + }); + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.ok(result.stderr.includes('--repo my-org/my-repo'), `stderr should contain correct repo, got: ${result.stderr}`); + assert.ok(result.stderr.includes('gh pr review 123'), 'stderr should contain correct PR number'); +})) passed++; else failed++; + +console.log('\nNon-PR command detection:'); + +if (test('no stderr about PR for non-gh command', () => { + const input = JSON.stringify({ tool_input: { command: 'npm test' } }); + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.strictEqual(result.stderr, '', 'stderr should be empty for non-PR command'); +})) passed++; else failed++; + +if (test('no stderr about PR for gh issue command', () => { + const input = JSON.stringify({ tool_input: { command: 'gh issue list' } }); + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.strictEqual(result.stderr, '', 'stderr should be empty for non-PR create command'); +})) passed++; else failed++; + +if (test('no stderr about PR for gh pr create without PR URL in output', () => { + const input = JSON.stringify({ + tool_input: { command: 'gh pr create' }, + tool_output: { output: 'Error: could not create PR' } + }); + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.strictEqual(result.stderr, '', 'stderr should be empty when no PR URL in output'); +})) passed++; else failed++; + +if (test('no stderr about PR for gh pr list command', () => { + const input = JSON.stringify({ tool_input: { command: 'gh pr list' } }); + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.status, 0, 'Should exit with code 0'); + assert.strictEqual(result.stderr, '', 'stderr should be empty for gh pr list'); +})) passed++; else failed++; + +console.log('\nStdout pass-through:'); + +if (test('stdout passes through input for PR create command', () => { + const input = JSON.stringify({ + tool_input: { command: 'gh pr create' }, + tool_output: { output: 'https://github.com/owner/repo/pull/1' } + }); + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.stdout, input, 'stdout should be the original input'); +})) passed++; else failed++; + +if (test('stdout passes through input for non-PR command', () => { + const input = JSON.stringify({ tool_input: { command: 'echo hello' } }); + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.stdout, input, 'stdout should be the original input'); +})) passed++; else failed++; + +if (test('stdout passes through input for invalid JSON', () => { + const input = 'not valid json'; + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.stdout, input, 'stdout should be the original input'); +})) passed++; else failed++; + +if (test('stdout passes through empty input', () => { + const input = ''; + const result = runScript(prCreatedScript, input); + assert.strictEqual(result.stdout, input, 'stdout should be the original input'); +})) passed++; else failed++; + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js new file mode 100644 index 00000000..7ec978dd --- /dev/null +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -0,0 +1,121 @@ +/** + * Tests for pre-bash-dev-server-block.js hook + * + * Run with: node tests/hooks/pre-bash-dev-server-block.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-dev-server-block.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runScript(command) { + const input = { tool_input: { command } }; + const result = spawnSync('node', [script], { + encoding: 'utf8', + input: JSON.stringify(input), + timeout: 10000, + }); + return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' }; +} + +function runTests() { + console.log('\n=== Testing pre-bash-dev-server-block.js ===\n'); + + let passed = 0; + let failed = 0; + + const isWindows = process.platform === 'win32'; + + // --- Blocking tests (non-Windows only) --- + + if (!isWindows) { + (test('blocks npm run dev (exit code 2, stderr contains BLOCKED)', () => { + const result = runScript('npm run dev'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + assert.ok(result.stderr.includes('BLOCKED'), `Expected stderr to contain BLOCKED, got: ${result.stderr}`); + }) ? passed++ : failed++); + + (test('blocks pnpm dev (exit code 2)', () => { + const result = runScript('pnpm dev'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks yarn dev (exit code 2)', () => { + const result = runScript('yarn dev'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + + (test('blocks bun run dev (exit code 2)', () => { + const result = runScript('bun run dev'); + assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); + }) ? passed++ : failed++); + } else { + console.log(' (skipping blocking tests on Windows)\n'); + } + + // --- Allow tests --- + + (test('allows tmux-wrapped npm run dev (exit code 0)', () => { + const result = runScript('tmux new-session -d -s dev "npm run dev"'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + + (test('allows npm install (exit code 0)', () => { + const result = runScript('npm install'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + + (test('allows npm test (exit code 0)', () => { + const result = runScript('npm test'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + + (test('allows npm run build (exit code 0)', () => { + const result = runScript('npm run build'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + }) ? passed++ : failed++); + + // --- Edge cases --- + + (test('empty/invalid input passes through (exit code 0)', () => { + const result = spawnSync('node', [script], { + encoding: 'utf8', + input: '', + timeout: 10000, + }); + assert.strictEqual(result.status || 0, 0, `Expected exit code 0, got ${result.status}`); + }) ? passed++ : failed++); + + (test('stdout contains original input on pass-through', () => { + const input = { tool_input: { command: 'npm install' } }; + const inputStr = JSON.stringify(input); + const result = spawnSync('node', [script], { + encoding: 'utf8', + input: inputStr, + timeout: 10000, + }); + assert.strictEqual(result.status || 0, 0); + assert.strictEqual(result.stdout.trim(), inputStr, `Expected stdout to contain original input`); + }) ? passed++ : failed++); + + // --- Summary --- + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/pre-bash-reminders.test.js b/tests/hooks/pre-bash-reminders.test.js new file mode 100644 index 00000000..507059a2 --- /dev/null +++ b/tests/hooks/pre-bash-reminders.test.js @@ -0,0 +1,104 @@ +/** + * Tests for pre-bash-git-push-reminder.js and pre-bash-tmux-reminder.js hooks + * + * Run with: node tests/hooks/pre-bash-reminders.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const gitPushScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-git-push-reminder.js'); +const tmuxScript = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-tmux-reminder.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runScript(scriptPath, command, envOverrides = {}) { + const input = { tool_input: { command } }; + const inputStr = JSON.stringify(input); + const result = spawnSync('node', [scriptPath], { + encoding: 'utf8', + input: inputStr, + timeout: 10000, + env: { ...process.env, ...envOverrides }, + }); + return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '', inputStr }; +} + +function runTests() { + console.log('\n=== Testing pre-bash-git-push-reminder.js & pre-bash-tmux-reminder.js ===\n'); + + let passed = 0; + let failed = 0; + + // --- git-push-reminder tests --- + + console.log(' git-push-reminder:'); + + (test('git push triggers stderr warning', () => { + const result = runScript(gitPushScript, 'git push origin main'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + assert.ok(result.stderr.includes('[Hook]'), `Expected stderr to contain [Hook], got: ${result.stderr}`); + assert.ok(result.stderr.includes('Review changes before push'), `Expected stderr to mention review`); + }) ? passed++ : failed++); + + (test('git status has no warning', () => { + const result = runScript(gitPushScript, 'git status'); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + assert.strictEqual(result.stderr, '', `Expected no stderr, got: ${result.stderr}`); + }) ? passed++ : failed++); + + (test('git push always passes through input on stdout', () => { + const result = runScript(gitPushScript, 'git push'); + assert.strictEqual(result.stdout, result.inputStr, 'Expected stdout to match original input'); + }) ? passed++ : failed++); + + // --- tmux-reminder tests (non-Windows only) --- + + const isWindows = process.platform === 'win32'; + + if (!isWindows) { + console.log('\n tmux-reminder:'); + + (test('npm install triggers tmux suggestion', () => { + const result = runScript(tmuxScript, 'npm install', { TMUX: '' }); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + assert.ok(result.stderr.includes('[Hook]'), `Expected stderr to contain [Hook], got: ${result.stderr}`); + assert.ok(result.stderr.includes('tmux'), `Expected stderr to mention tmux`); + }) ? passed++ : failed++); + + (test('npm test triggers tmux suggestion', () => { + const result = runScript(tmuxScript, 'npm test', { TMUX: '' }); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + assert.ok(result.stderr.includes('tmux'), `Expected stderr to mention tmux`); + }) ? passed++ : failed++); + + (test('regular command like ls has no tmux suggestion', () => { + const result = runScript(tmuxScript, 'ls -la', { TMUX: '' }); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + assert.strictEqual(result.stderr, '', `Expected no stderr for ls, got: ${result.stderr}`); + }) ? passed++ : failed++); + + (test('tmux reminder always passes through input on stdout', () => { + const result = runScript(tmuxScript, 'npm install', { TMUX: '' }); + assert.strictEqual(result.stdout, result.inputStr, 'Expected stdout to match original input'); + }) ? passed++ : failed++); + } else { + console.log('\n (skipping tmux-reminder tests on Windows)\n'); + } + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/quality-gate.test.js b/tests/hooks/quality-gate.test.js new file mode 100644 index 00000000..cd3a05c4 --- /dev/null +++ b/tests/hooks/quality-gate.test.js @@ -0,0 +1,159 @@ +/** + * Tests for scripts/hooks/quality-gate.js + * + * Run with: node tests/hooks/quality-gate.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const qualityGate = require('../../scripts/hooks/quality-gate'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +let passed = 0; +let failed = 0; + +console.log('\nQuality Gate Hook Tests'); +console.log('========================\n'); + +// --- run() returns original input for valid JSON --- + +console.log('run() pass-through behavior:'); + +if (test('returns original input for valid JSON with file_path', () => { + const input = JSON.stringify({ tool_input: { file_path: '/tmp/nonexistent-file.js' } }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +if (test('returns original input for valid JSON without file_path', () => { + const input = JSON.stringify({ tool_input: { command: 'ls' } }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +if (test('returns original input for valid JSON with nested structure', () => { + const input = JSON.stringify({ tool_input: { file_path: '/some/path.ts', content: 'hello' }, other: [1, 2, 3] }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +// --- run() returns original input for invalid JSON --- + +console.log('\nInvalid JSON handling:'); + +if (test('returns original input for invalid JSON (no crash)', () => { + const input = 'this is not json at all {{{'; + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +if (test('returns original input for partial JSON', () => { + const input = '{"tool_input": {'; + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +if (test('returns original input for JSON with trailing garbage', () => { + const input = '{"tool_input": {}}extra'; + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +// --- run() returns original input when file does not exist --- + +console.log('\nNon-existent file handling:'); + +if (test('returns original input when file_path points to non-existent file', () => { + const input = JSON.stringify({ tool_input: { file_path: '/tmp/does-not-exist-12345.js' } }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +if (test('returns original input when file_path is a non-existent .py file', () => { + const input = JSON.stringify({ tool_input: { file_path: '/tmp/does-not-exist-12345.py' } }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +if (test('returns original input when file_path is a non-existent .go file', () => { + const input = JSON.stringify({ tool_input: { file_path: '/tmp/does-not-exist-12345.go' } }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +// --- run() returns original input for empty input --- + +console.log('\nEmpty input handling:'); + +if (test('returns original input for empty string', () => { + const input = ''; + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return empty string unchanged'); +})) passed++; else failed++; + +if (test('returns original input for whitespace-only string', () => { + const input = ' '; + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return whitespace string unchanged'); +})) passed++; else failed++; + +// --- run() handles missing tool_input gracefully --- + +console.log('\nMissing tool_input handling:'); + +if (test('handles missing tool_input gracefully', () => { + const input = JSON.stringify({ something_else: 'value' }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +if (test('handles null tool_input gracefully', () => { + const input = JSON.stringify({ tool_input: null }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +if (test('handles tool_input with empty file_path', () => { + const input = JSON.stringify({ tool_input: { file_path: '' } }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +if (test('handles empty JSON object', () => { + const input = JSON.stringify({}); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); +})) passed++; else failed++; + +// --- run() with a real file (but no formatter installed) --- + +console.log('\nReal file without formatter:'); + +if (test('returns original input for existing file with no formatter configured', () => { + const tmpFile = path.join(os.tmpdir(), `quality-gate-test-${Date.now()}.js`); + fs.writeFileSync(tmpFile, 'const x = 1;\n'); + try { + const input = JSON.stringify({ tool_input: { file_path: tmpFile } }); + const result = qualityGate.run(input); + assert.strictEqual(result, input, 'Should return original input unchanged'); + } finally { + fs.unlinkSync(tmpFile); + } +})) passed++; else failed++; + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0);