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.
This commit is contained in:
Affaan Mustafa
2026-03-16 14:31:49 -07:00
committed by GitHub
parent f12bb90924
commit b6595974c2
19 changed files with 2471 additions and 0 deletions

View File

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

72
agents/cpp-reviewer.md Normal file
View File

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

173
commands/cpp-build.md Normal file
View File

@@ -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<User> 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/`

132
commands/cpp-review.md Normal file
View File

@@ -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<Session>(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/`

251
commands/cpp-test.md Normal file
View File

@@ -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 <string>
#include <expected>
enum class EmailError {
Empty,
InvalidFormat
};
std::expected<void, EmailError> validate_email(const std::string& email);
```
## Step 2: Write Tests (RED)
```cpp
// validator/email_test.cpp
#include <gtest/gtest.h>
#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 <regex>
std::expected<void, EmailError> 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<Database> db_;
};
TEST_F(DatabaseTest, InsertsRecord) {
db_->insert("key", "value");
EXPECT_EQ(db_->get("key"), "value");
}
```
### Parameterized Tests
```cpp
class PrimeTest : public ::testing::TestWithParam<std::pair<int, bool>> {};
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/`

44
rules/cpp/coding-style.md Normal file
View File

@@ -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 <file>` before committing
## Reference
See skill: `cpp-coding-standards` for comprehensive C++ coding standards and guidelines.

39
rules/cpp/hooks.md Normal file
View File

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

51
rules/cpp/patterns.md Normal file
View File

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

51
rules/cpp/security.md Normal file
View File

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

44
rules/cpp/testing.md Normal file
View File

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

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

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

View File

@@ -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();

View File

@@ -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();

View File

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