├── reporters ├── go │ ├── go.sum │ ├── go.mod │ ├── internal │ │ ├── io │ │ │ ├── tee_reader.go │ │ │ └── tee_reader_test.go │ │ ├── storage │ │ │ └── storage.go │ │ └── parser │ │ │ └── mixed_reader.go │ └── README.md ├── pytest │ ├── tests │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_plugin_config.py │ │ ├── test_config_option.py │ │ └── test_path_validation.py │ ├── tdd_guard_pytest │ │ └── __init__.py │ ├── pytest.ini.example │ ├── pyproject.toml │ └── README.md ├── test │ ├── artifacts │ │ ├── go │ │ │ ├── import │ │ │ │ ├── go.mod │ │ │ │ └── single_import_error_test.go │ │ │ ├── failing │ │ │ │ ├── go.mod │ │ │ │ └── single_failing_test.go │ │ │ └── passing │ │ │ │ ├── go.mod │ │ │ │ └── single_passing_test.go │ │ ├── rust │ │ │ ├── failing │ │ │ │ ├── Cargo.toml │ │ │ │ ├── Cargo.lock │ │ │ │ └── src │ │ │ │ │ └── lib.rs │ │ │ ├── passing │ │ │ │ ├── Cargo.toml │ │ │ │ ├── Cargo.lock │ │ │ │ └── src │ │ │ │ │ └── lib.rs │ │ │ └── import │ │ │ │ ├── Cargo.toml │ │ │ │ ├── Cargo.lock │ │ │ │ └── src │ │ │ │ └── lib.rs │ │ ├── pytest │ │ │ ├── test_single_failing.py │ │ │ ├── test_single_passing.py │ │ │ └── test_single_import_error.py │ │ ├── storybook │ │ │ ├── Calculator.js │ │ │ ├── single-import-error.stories.js │ │ │ ├── single-passing.stories.js │ │ │ └── single-failing.stories.js │ │ ├── jest │ │ │ ├── single-failing.test.js │ │ │ ├── single-passing.test.js │ │ │ └── single-import-error.test.js │ │ ├── vitest │ │ │ ├── single-failing.test.js │ │ │ ├── single-passing.test.js │ │ │ └── single-import-error.test.js │ │ └── phpunit │ │ │ ├── SingleFailingTest.php │ │ │ ├── SinglePassingTest.php │ │ │ └── SingleImportErrorTest.php │ ├── factories │ │ ├── index.ts │ │ ├── pytest.ts │ │ ├── vitest.ts │ │ ├── helpers.ts │ │ ├── jest.ts │ │ ├── go.ts │ │ └── phpunit.ts │ └── types.ts ├── rspec │ └── Gemfile ├── jest │ ├── src │ │ ├── index.ts │ │ └── types.ts │ ├── .npmignore │ ├── tsconfig.json │ ├── package.json │ └── README.md ├── vitest │ ├── src │ │ ├── index.ts │ │ └── types.ts │ ├── .npmignore │ ├── tsconfig.json │ ├── package.json │ └── README.md ├── phpunit │ ├── .gitignore │ ├── phpunit.xml.dist │ ├── src │ │ ├── Storage.php │ │ ├── Event │ │ │ ├── TestRunnerFinishedSubscriber.php │ │ │ ├── PassedTestSubscriber.php │ │ │ ├── SkippedTestSubscriber.php │ │ │ ├── FailedTestSubscriber.php │ │ │ ├── ErroredTestSubscriber.php │ │ │ └── IncompleteTestSubscriber.php │ │ ├── TddGuardExtension.php │ │ ├── TddGuardSubscriber.php │ │ └── PathValidator.php │ ├── .php-cs-fixer.php │ ├── SYNC_README.md │ ├── psalm.xml │ ├── composer.json │ └── tests │ │ ├── TddGuardStorageLocationTest.php │ │ └── PathValidatorTest.php ├── storybook │ ├── src │ │ ├── index.ts │ │ ├── StorybookReporter.test-data.ts │ │ ├── types.ts │ │ └── StorybookReporter.ts │ ├── tsconfig.json │ └── package.json └── rust │ ├── Cargo.toml │ ├── Makefile.example │ └── README.md ├── .husky ├── pre-commit └── commit-msg ├── src ├── contracts │ ├── types │ │ ├── ClientType.ts │ │ ├── ModelClient.ts │ │ ├── ValidationResult.ts │ │ ├── ConfigOptions.ts │ │ └── Context.ts │ └── schemas │ │ ├── pytestSchemas.ts │ │ ├── guardSchemas.ts │ │ ├── pytestSchemas.test.ts │ │ ├── guardSchemas.test.ts │ │ ├── lintSchemas.ts │ │ └── reporterSchemas.ts ├── processors │ ├── index.ts │ └── lintProcessor.ts ├── linters │ ├── Linter.ts │ └── eslint │ │ └── ESLint.ts ├── validation │ ├── prompts │ │ ├── tools │ │ │ ├── todos.ts │ │ │ ├── test-output.ts │ │ │ └── lint-results.ts │ │ ├── system-prompt.ts │ │ ├── response.ts │ │ ├── operations │ │ │ ├── write.ts │ │ │ ├── multi-edit.ts │ │ │ └── edit.ts │ │ └── file-types.ts │ └── models │ │ ├── AnthropicApi.ts │ │ ├── ClaudeCli.ts │ │ └── ClaudeAgentSdk.ts ├── hooks │ ├── fileTypeDetection.ts │ ├── sessionHandler.ts │ ├── HookEvents.ts │ └── userPromptHandler.ts ├── providers │ ├── LinterProvider.ts │ ├── ModelClientProvider.ts │ ├── LinterProvider.test.ts │ └── ModelClientProvider.test.ts ├── storage │ ├── Storage.ts │ ├── MemoryStorage.ts │ └── FileStorage.test.ts ├── index.ts ├── cli │ ├── buildContext.ts │ └── tdd-guard.ts └── guard │ └── GuardManager.ts ├── test ├── artifacts │ ├── go │ │ ├── with-issues │ │ │ ├── go.mod │ │ │ └── file-with-issues.go │ │ ├── without-issues │ │ │ ├── go.mod │ │ │ └── file-without-issues.go │ │ └── .golangci.yml │ └── javascript │ │ ├── file-without-issues.js │ │ ├── file-with-issues.js │ │ └── eslint.config.js ├── utils │ ├── factories │ │ ├── scenarios │ │ │ ├── utils.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── modelClientProviderFactory.ts │ │ ├── contextFactory.ts │ │ ├── helpers.test.ts │ │ ├── sessionStartFactory.ts │ │ ├── operations.ts │ │ ├── userPromptSubmitFactory.ts │ │ ├── helpers.ts │ │ ├── reporterFactory.ts │ │ ├── testDefaults.ts │ │ ├── writeFactory.ts │ │ └── editFactory.ts │ └── assertions.ts ├── hooks │ ├── fileTypeDetection.test.ts │ ├── processHookData.fileType.test.ts │ └── processHookData.python.test.ts └── integration │ └── test-context.test.ts ├── docs ├── assets │ └── tdd-guard-demo-screenshot.gif ├── enforcement.md ├── quick-commands.md ├── custom-instructions.md ├── adr │ ├── 001-claude-session-subdirectory.md │ ├── 004-monorepo-architecture.md │ ├── 002-secure-claude-binary-path.md │ ├── 005-claude-project-dir-support.md │ └── 003-remove-configurable-data-directory.md ├── session-management.md ├── validation-model.md └── ignore-patterns.md ├── .prettierrc ├── tsconfig.node.json ├── .prettierignore ├── tsconfig.build.json ├── tsconfig.eslint.json ├── .commitlintrc.json ├── .npmignore ├── .env.example ├── .claudeignore ├── tsconfig.json ├── LICENSE ├── .gitignore ├── CONTRIBUTING.md ├── .github └── workflows │ └── security.yml ├── .devcontainer └── devcontainer.json └── vitest.config.ts /reporters/go/go.sum: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged --quiet -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 2 | -------------------------------------------------------------------------------- /reporters/pytest/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for TDD Guard pytest reporter.""" -------------------------------------------------------------------------------- /reporters/go/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nizos/tdd-guard/reporters/go 2 | 3 | go 1.24 4 | -------------------------------------------------------------------------------- /src/contracts/types/ClientType.ts: -------------------------------------------------------------------------------- 1 | export type ClientType = 'api' | 'cli' | 'sdk' 2 | -------------------------------------------------------------------------------- /reporters/test/artifacts/go/import/go.mod: -------------------------------------------------------------------------------- 1 | module missingImportModule 2 | 3 | go 1.24 4 | -------------------------------------------------------------------------------- /test/artifacts/go/with-issues/go.mod: -------------------------------------------------------------------------------- 1 | module tdd-guard-test/with-issues 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /test/artifacts/go/without-issues/go.mod: -------------------------------------------------------------------------------- 1 | module tdd-guard-test/without-issues 2 | 3 | go 1.19 -------------------------------------------------------------------------------- /reporters/rspec/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | -------------------------------------------------------------------------------- /reporters/test/artifacts/go/failing/go.mod: -------------------------------------------------------------------------------- 1 | module singleFailingTestModule 2 | 3 | go 1.24 4 | -------------------------------------------------------------------------------- /reporters/test/artifacts/go/passing/go.mod: -------------------------------------------------------------------------------- 1 | module singlePassingTestModule 2 | 3 | go 1.24 4 | -------------------------------------------------------------------------------- /reporters/pytest/tdd_guard_pytest/__init__.py: -------------------------------------------------------------------------------- 1 | """TDD Guard pytest plugin for capturing test results.""" -------------------------------------------------------------------------------- /src/processors/index.ts: -------------------------------------------------------------------------------- 1 | export { TestResultsProcessor } from './testResults/TestResultsProcessor' 2 | -------------------------------------------------------------------------------- /reporters/pytest/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration for integration tests.""" 2 | 3 | pytest_plugins = ["pytester"] -------------------------------------------------------------------------------- /src/contracts/types/ModelClient.ts: -------------------------------------------------------------------------------- 1 | export interface IModelClient { 2 | ask(prompt: string): Promise 3 | } 4 | -------------------------------------------------------------------------------- /reporters/test/artifacts/rust/failing/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "single_failing" 3 | version = "0.1.0" 4 | edition = "2021" -------------------------------------------------------------------------------- /reporters/test/artifacts/rust/passing/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "single_passing" 3 | version = "0.1.0" 4 | edition = "2021" -------------------------------------------------------------------------------- /test/artifacts/go/.golangci.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | linters: 3 | enable: 4 | - govet 5 | - errcheck 6 | - ineffassign -------------------------------------------------------------------------------- /docs/assets/tdd-guard-demo-screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nizos/tdd-guard/HEAD/docs/assets/tdd-guard-demo-screenshot.gif -------------------------------------------------------------------------------- /reporters/test/artifacts/rust/import/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "single_import_error" 3 | version = "0.1.0" 4 | edition = "2021" -------------------------------------------------------------------------------- /reporters/jest/src/index.ts: -------------------------------------------------------------------------------- 1 | import { JestReporter } from './JestReporter' 2 | 3 | export { JestReporter } 4 | export default JestReporter 5 | -------------------------------------------------------------------------------- /reporters/vitest/src/index.ts: -------------------------------------------------------------------------------- 1 | import { VitestReporter } from './VitestReporter' 2 | 3 | export { VitestReporter } 4 | export default VitestReporter 5 | -------------------------------------------------------------------------------- /reporters/test/artifacts/pytest/test_single_failing.py: -------------------------------------------------------------------------------- 1 | class TestCalculator: 2 | def test_should_add_numbers_correctly(self): 3 | assert 2 + 3 == 6 -------------------------------------------------------------------------------- /reporters/test/artifacts/pytest/test_single_passing.py: -------------------------------------------------------------------------------- 1 | class TestCalculator: 2 | def test_should_add_numbers_correctly(self): 3 | assert 2 + 3 == 5 -------------------------------------------------------------------------------- /reporters/test/artifacts/storybook/Calculator.js: -------------------------------------------------------------------------------- 1 | // Simple calculator module for testing 2 | export const Calculator = { 3 | add: (a, b) => a + b, 4 | } 5 | -------------------------------------------------------------------------------- /test/artifacts/go/without-issues/file-without-issues.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Println("Hello, World!") 7 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5", 6 | "printWidth": 80, 7 | "arrowParens": "always" 8 | } 9 | -------------------------------------------------------------------------------- /reporters/phpunit/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | /.phpunit.result.cache 4 | /.phpunit.cache/ 5 | /coverage/ 6 | /.claude/ 7 | *.log 8 | .php-cs-fixer.cache 9 | .psalm-cache/ -------------------------------------------------------------------------------- /reporters/test/artifacts/jest/single-failing.test.js: -------------------------------------------------------------------------------- 1 | describe('Calculator', () => { 2 | test('should add numbers correctly', () => { 3 | expect(2 + 3).toBe(6) 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /reporters/test/artifacts/jest/single-passing.test.js: -------------------------------------------------------------------------------- 1 | describe('Calculator', () => { 2 | test('should add numbers correctly', () => { 3 | expect(2 + 3).toBe(5) 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /src/contracts/types/ValidationResult.ts: -------------------------------------------------------------------------------- 1 | export type ValidationResult = { 2 | decision: 'approve' | 'block' | undefined 3 | reason: string 4 | continue?: boolean 5 | stopReason?: string 6 | } 7 | -------------------------------------------------------------------------------- /src/linters/Linter.ts: -------------------------------------------------------------------------------- 1 | import { LintResult } from '../contracts/schemas/lintSchemas' 2 | 3 | export interface Linter { 4 | lint(filePaths: string[], configPath?: string): Promise 5 | } 6 | -------------------------------------------------------------------------------- /reporters/test/artifacts/rust/failing/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "single_failing" 7 | version = "0.1.0" 8 | -------------------------------------------------------------------------------- /reporters/test/artifacts/rust/import/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "single_import_error" 7 | version = "0.1.0" 8 | -------------------------------------------------------------------------------- /reporters/test/artifacts/rust/passing/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "single_passing" 7 | version = "0.1.0" 8 | -------------------------------------------------------------------------------- /reporters/test/artifacts/vitest/single-failing.test.js: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | describe('Calculator', () => { 4 | test('should add numbers correctly', () => { 5 | expect(2 + 3).toBe(6) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /reporters/test/artifacts/vitest/single-passing.test.js: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | describe('Calculator', () => { 4 | test('should add numbers correctly', () => { 5 | expect(2 + 3).toBe(5) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /reporters/jest/.npmignore: -------------------------------------------------------------------------------- 1 | # TypeScript build info files 2 | *.tsbuildinfo 3 | 4 | # Source files (only ship dist) 5 | src/ 6 | *.ts 7 | !*.d.ts 8 | 9 | # Development files 10 | tsconfig.json 11 | *.test.* 12 | *.spec.* 13 | *.test-data.* -------------------------------------------------------------------------------- /reporters/vitest/.npmignore: -------------------------------------------------------------------------------- 1 | # TypeScript build info files 2 | *.tsbuildinfo 3 | 4 | # Source files (only ship dist) 5 | src/ 6 | *.ts 7 | !*.d.ts 8 | 9 | # Development files 10 | tsconfig.json 11 | *.test.* 12 | *.spec.* 13 | *.test-data.* -------------------------------------------------------------------------------- /reporters/test/artifacts/pytest/test_single_import_error.py: -------------------------------------------------------------------------------- 1 | from non_existent_module import non_existent_function 2 | 3 | class TestCalculator: 4 | def test_should_add_numbers_correctly(self): 5 | non_existent_module() 6 | assert 2 + 3 == 5 -------------------------------------------------------------------------------- /test/artifacts/javascript/file-without-issues.js: -------------------------------------------------------------------------------- 1 | // This file has no lint issues 2 | const greeting = 'hello'; 3 | console.log(greeting); 4 | 5 | function add(a, b) { 6 | return a + b; 7 | } 8 | 9 | const result = add(1, 2); 10 | console.log(result); -------------------------------------------------------------------------------- /src/contracts/schemas/pytestSchemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { TestModuleSchema } from './reporterSchemas' 3 | 4 | // Pytest uses same structure as Vitest 5 | export const PytestResultSchema = z.object({ 6 | testModules: z.array(TestModuleSchema), 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /reporters/test/artifacts/jest/single-import-error.test.js: -------------------------------------------------------------------------------- 1 | const { nonExistentFunction } = require('./non-existent-module') 2 | 3 | describe('Calculator', () => { 4 | test('should add numbers correctly', () => { 5 | nonExistentFunction() 6 | expect(2 + 3).toBe(5) 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/contracts/schemas/guardSchemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const GuardConfigSchema = z.object({ 4 | guardEnabled: z.boolean().optional(), 5 | ignorePatterns: z.array(z.string()).optional(), 6 | }) 7 | 8 | export type GuardConfig = z.infer 9 | -------------------------------------------------------------------------------- /reporters/test/artifacts/phpunit/SingleFailingTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(6, 2 + 3); 10 | } 11 | } -------------------------------------------------------------------------------- /reporters/test/artifacts/phpunit/SinglePassingTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(5, 2 + 3); 10 | } 11 | } -------------------------------------------------------------------------------- /test/artifacts/javascript/file-with-issues.js: -------------------------------------------------------------------------------- 1 | // This file has intentional lint issues for testing 2 | var unusedVariable = 5; 3 | console.log("hello") 4 | console.log("hello") 5 | console.log("hello") 6 | 7 | function tooManyParams(a, b, c, d, e, f) { 8 | return a + b; 9 | } 10 | 11 | var x = 1 12 | var y = 2 -------------------------------------------------------------------------------- /reporters/storybook/src/index.ts: -------------------------------------------------------------------------------- 1 | import { StorybookReporter } from './StorybookReporter' 2 | 3 | export { StorybookReporter } 4 | export default StorybookReporter 5 | export type { 6 | StorybookReporterOptions, 7 | StoryError, 8 | StoryTest, 9 | StoryModule, 10 | TestRunOutput, 11 | TestContext, 12 | } from './types' 13 | -------------------------------------------------------------------------------- /reporters/jest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" 8 | }, 9 | "include": ["src/**/*"], 10 | "exclude": ["**/*.test.ts", "**/*.spec.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /reporters/test/artifacts/vitest/single-import-error.test.js: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { nonExistentFunction } from './non-existent-module' 3 | 4 | describe('Calculator', () => { 5 | test('should add numbers correctly', () => { 6 | nonExistentFunction() 7 | expect(2 + 3).toBe(5) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /reporters/vitest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" 8 | }, 9 | "include": ["src/**/*"], 10 | "exclude": ["**/*.test.ts", "**/*.spec.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /reporters/storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" 8 | }, 9 | "include": ["src/**/*"], 10 | "exclude": ["**/*.test.ts", "**/*.spec.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /reporters/test/artifacts/rust/passing/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub fn add(left: u64, right: u64) -> u64 { 2 | left + right 3 | } 4 | 5 | #[cfg(test)] 6 | mod calculator_tests { 7 | use super::*; 8 | 9 | #[test] 10 | fn should_add_numbers_correctly() { 11 | let result = add(2, 3); 12 | assert_eq!(result, 5); 13 | } 14 | } -------------------------------------------------------------------------------- /test/artifacts/go/with-issues/file-with-issues.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | result := 42 7 | result = 24 // ineffassign: ineffectual assignment 8 | message := "Hello" 9 | 10 | fmt.Println(result) // use result to avoid unused var error 11 | fmt.Println(messag) // typecheck: undefined variable 12 | } -------------------------------------------------------------------------------- /reporters/pytest/pytest.ini.example: -------------------------------------------------------------------------------- 1 | # Example pytest configuration for TDD Guard development 2 | # Copy this file to pytest.ini and adjust the path to your project root 3 | 4 | [pytest] 5 | # Set this to the absolute path of your tdd-guard project root 6 | # This ensures test results are saved to the correct location 7 | tdd_guard_project_root = /path/to/tdd-guard -------------------------------------------------------------------------------- /src/contracts/types/ConfigOptions.ts: -------------------------------------------------------------------------------- 1 | import { ClientType } from './ClientType' 2 | 3 | export type ConfigOptions = { 4 | mode?: 'production' | 'test' 5 | projectRoot?: string 6 | useSystemClaude?: boolean 7 | anthropicApiKey?: string 8 | modelType?: string 9 | modelVersion?: string 10 | linterType?: string 11 | validationClient?: ClientType 12 | } 13 | -------------------------------------------------------------------------------- /reporters/test/artifacts/rust/failing/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub fn add(left: u64, right: u64) -> u64 { 2 | left + right 3 | } 4 | 5 | #[cfg(test)] 6 | mod calculator_tests { 7 | use super::*; 8 | 9 | #[test] 10 | fn should_add_numbers_correctly() { 11 | let result = add(2, 3); 12 | assert_eq!(result, 6); // This will fail: 2 + 3 != 6 13 | } 14 | } -------------------------------------------------------------------------------- /reporters/test/factories/index.ts: -------------------------------------------------------------------------------- 1 | export { createJestReporter } from './jest' 2 | export { createVitestReporter } from './vitest' 3 | export { createPhpunitReporter } from './phpunit' 4 | export { createPytestReporter } from './pytest' 5 | export { createGoReporter } from './go' 6 | export { createRustReporter } from './rust' 7 | export { createStorybookReporter } from './storybook' 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | package-lock.json 5 | .git/ 6 | **/*.min.js 7 | **/*.min.css 8 | build/ 9 | .next/ 10 | out/ 11 | .cache/ 12 | public/ 13 | hooks/ 14 | test/artifacts/ 15 | reporters/phpunit/ 16 | reporters/go/**/*.go 17 | reporters/go/**/*.mod 18 | reporters/go/**/*.sum 19 | reporters/go/**/*.tsbuildinfo 20 | reporters/test/factories/go.ts 21 | -------------------------------------------------------------------------------- /reporters/test/artifacts/go/failing/single_failing_test.go: -------------------------------------------------------------------------------- 1 | package singleFailingTestModule 2 | 3 | import "testing" 4 | 5 | func TestCalculator(t *testing.T) { 6 | t.Run("TestShouldAddNumbersCorrectly", func(t *testing.T) { 7 | result := 2 + 3 8 | expected := 6 9 | if result != expected { 10 | t.Errorf("Expected %d but got %d", expected, result) 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /reporters/test/artifacts/go/passing/single_passing_test.go: -------------------------------------------------------------------------------- 1 | package singlePassingTestModule 2 | 3 | import "testing" 4 | 5 | func TestCalculator(t *testing.T) { 6 | t.Run("TestShouldAddNumbersCorrectly", func(t *testing.T) { 7 | result := 2 + 3 8 | expected := 5 9 | if result != expected { 10 | t.Errorf("Expected %d but got %d", expected, result) 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /reporters/test/artifacts/rust/import/src/lib.rs: -------------------------------------------------------------------------------- 1 | use non_existent_module::Calculator; // This will fail to compile 2 | 3 | #[cfg(test)] 4 | mod calculator_tests { 5 | use super::*; 6 | 7 | #[test] 8 | fn should_add_numbers_correctly() { 9 | let calc = Calculator::new(); 10 | let result = calc.add(2, 3); 11 | assert_eq!(result, 5); 12 | } 13 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "rootDir": "./src", 6 | "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" 7 | }, 8 | "include": ["src/**/*"], 9 | "exclude": [ 10 | "dist", 11 | "node_modules", 12 | "src/**/*.test.ts", 13 | "src/**/*.test.tsx", 14 | "test" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/validation/prompts/tools/todos.ts: -------------------------------------------------------------------------------- 1 | export const TODOS = `### Todo List 2 | 3 | This section shows the developer's task list. Use this to understand: 4 | - What the developer is currently working on (in_progress) 5 | - What has been completed (completed) 6 | - What is planned next (pending) 7 | Note: Multiple pending "add test" todos don't justify adding multiple tests at once. 8 | 9 | ` 10 | -------------------------------------------------------------------------------- /src/contracts/types/Context.ts: -------------------------------------------------------------------------------- 1 | export interface ProcessedLintData { 2 | hasIssues: boolean 3 | summary: string 4 | issuesByFile: Map 5 | totalIssues: number 6 | errorCount: number 7 | warningCount: number 8 | } 9 | 10 | export type Context = { 11 | modifications: string 12 | todo?: string 13 | test?: string 14 | lint?: ProcessedLintData 15 | instructions?: string 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": [ 7 | "src/**/*", 8 | "test/**/*", 9 | "packages/*/src/**/*", 10 | "reporters/*/src/**/*", 11 | "reporters/*/test/**/*", 12 | "reporters/test/**/*", 13 | "vitest.config.ts", 14 | "eslint.config.mjs" 15 | ], 16 | "exclude": ["**/dist", "**/node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /reporters/test/artifacts/phpunit/SingleImportErrorTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(5, 2 + 3); 13 | } 14 | } -------------------------------------------------------------------------------- /reporters/test/artifacts/go/import/single_import_error_test.go: -------------------------------------------------------------------------------- 1 | package missingImportModule 2 | 3 | import ( 4 | "github.com/non-existent/module" 5 | "testing" 6 | ) 7 | 8 | func TestCalculator(t *testing.T) { 9 | t.Run("TestShouldAddNumbersCorrectly", func(t *testing.T) { 10 | module.NonExistentFunction() 11 | result := 2 + 3 12 | expected := 5 13 | if result != expected { 14 | t.Errorf("Expected %d but got %d", expected, result) 15 | } 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /reporters/test/artifacts/storybook/single-import-error.stories.js: -------------------------------------------------------------------------------- 1 | import { expect } from '@storybook/test' 2 | import { NonExistent } from './non-existent-module' // This module doesn't exist 3 | 4 | export default { 5 | title: 'Calculator', 6 | render: () => null, // No UI component, just testing logic 7 | } 8 | 9 | export const Primary = { 10 | name: 'should add numbers correctly', 11 | play: async () => { 12 | await expect(true).toBe(true) 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /reporters/test/artifacts/storybook/single-passing.stories.js: -------------------------------------------------------------------------------- 1 | import { expect } from '@storybook/test' 2 | import { Calculator } from './Calculator' 3 | 4 | export default { 5 | title: 'Calculator', 6 | render: () => null, // No UI component, just testing logic 7 | } 8 | 9 | export const Primary = { 10 | name: 'should add numbers correctly', 11 | play: async () => { 12 | const result = Calculator.add(2, 3) 13 | await expect(result).toBe(5) 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /reporters/test/artifacts/storybook/single-failing.stories.js: -------------------------------------------------------------------------------- 1 | import { expect } from '@storybook/test' 2 | import { Calculator } from './Calculator' 3 | 4 | export default { 5 | title: 'Calculator', 6 | render: () => null, // No UI component, just testing logic 7 | } 8 | 9 | export const Primary = { 10 | name: 'should add numbers correctly', 11 | play: async () => { 12 | const result = Calculator.add(2, 3) 13 | await expect(result).toBe(6) // Intentionally wrong - 2 + 3 = 5, not 6 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "type-enum": [ 5 | 2, 6 | "always", 7 | [ 8 | "feat", 9 | "fix", 10 | "docs", 11 | "style", 12 | "refactor", 13 | "test", 14 | "chore", 15 | "revert", 16 | "ci", 17 | "perf" 18 | ] 19 | ], 20 | "subject-case": [2, "never", ["upper-case", "pascal-case", "start-case"]], 21 | "subject-max-length": [2, "always", 100] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/artifacts/javascript/eslint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | files: ['**/*.js'], 4 | rules: { 5 | 'no-unused-vars': 'error', 6 | 'no-var': 'error', 7 | 'semi': ['error', 'always'], 8 | 'quotes': ['error', 'single'], 9 | 'no-duplicate-string': 'off', 10 | 'max-params': ['error', 5] 11 | }, 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | sourceType: 'module' 15 | } 16 | }, 17 | { 18 | ignores: ['node_modules/**', 'dist/**', 'coverage/**'] 19 | } 20 | ]; -------------------------------------------------------------------------------- /test/utils/factories/scenarios/utils.ts: -------------------------------------------------------------------------------- 1 | import { ValidationResult } from '../../../../src/contracts/types/ValidationResult' 2 | import { expect } from 'vitest' 3 | 4 | export function expectDecision( 5 | result: ValidationResult, 6 | expectedDecision: ValidationResult['decision'] 7 | ): void { 8 | if (result.decision !== expectedDecision) { 9 | console.error( 10 | `\nTest failed - expected decision: ${expectedDecision}, but got: ${result.decision}` 11 | ) 12 | console.error(`Reason: ${result.reason}\n`) 13 | } 14 | expect(result.decision).toBe(expectedDecision) 15 | } 16 | -------------------------------------------------------------------------------- /src/validation/prompts/system-prompt.ts: -------------------------------------------------------------------------------- 1 | export const SYSTEM_PROMPT = `# TDD-Guard 2 | 3 | ## Your Role 4 | You are a Test-Driven Development (TDD) Guard - a specialized code reviewer who ensures developers follow the strict discipline required for true test-driven development. 5 | 6 | Your purpose is to identify violations of TDD principles in real-time, helping agents maintain the Red-Green-Refactor cycle. 7 | 8 | ## What You're Reviewing 9 | You are analyzing a code change to determine if it violates TDD principles. Focus only on TDD compliance, not code quality, style, or best practices. 10 | ` 11 | -------------------------------------------------------------------------------- /reporters/go/internal/io/tee_reader.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import "io" 4 | 5 | // TeeReader reads from r and writes to w 6 | type TeeReader struct { 7 | reader io.Reader 8 | writer io.Writer 9 | } 10 | 11 | // NewTeeReader returns a Reader that writes to w what it reads from r 12 | func NewTeeReader(r io.Reader, w io.Writer) io.Reader { 13 | return &TeeReader{ 14 | reader: r, 15 | writer: w, 16 | } 17 | } 18 | 19 | // Read implements io.Reader 20 | func (t *TeeReader) Read(p []byte) (n int, err error) { 21 | n, err = t.reader.Read(p) 22 | if n > 0 { 23 | t.writer.Write(p[:n]) 24 | } 25 | return n, err 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/fileTypeDetection.ts: -------------------------------------------------------------------------------- 1 | export function detectFileType(hookData: unknown): 'python' | 'javascript' | 'php' { 2 | // Handle different tool operation types 3 | const toolInput = (hookData as { tool_input?: Record }).tool_input 4 | if (toolInput && typeof toolInput === 'object' && 'file_path' in toolInput) { 5 | const filePath = toolInput.file_path 6 | if (typeof filePath === 'string') { 7 | if (filePath.endsWith('.py')) { 8 | return 'python' 9 | } 10 | if (filePath.endsWith('.php')) { 11 | return 'php' 12 | } 13 | } 14 | } 15 | return 'javascript' 16 | } -------------------------------------------------------------------------------- /src/providers/LinterProvider.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from '../linters/Linter' 2 | import { Config } from '../config/Config' 3 | import { ESLint } from '../linters/eslint/ESLint' 4 | import { GolangciLint } from '../linters/golangci/GolangciLint' 5 | 6 | export class LinterProvider { 7 | getLinter(config?: Config): Linter | null { 8 | const actualConfig = config ?? new Config() 9 | 10 | switch (actualConfig.linterType) { 11 | case 'eslint': 12 | return new ESLint() 13 | case 'golangci-lint': 14 | return new GolangciLint() 15 | case undefined: 16 | default: 17 | return null 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /reporters/phpunit/phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | 15 | 16 | src 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/validation/prompts/tools/test-output.ts: -------------------------------------------------------------------------------- 1 | export const TEST_OUTPUT = `### Test Output 2 | 3 | This section shows the output from the most recent test run BEFORE this modification. 4 | 5 | IMPORTANT: This test output is from PREVIOUS work, not from the changes being reviewed. The modification has NOT been executed yet. 6 | 7 | Use this to understand: 8 | - Which tests are failing and why (from previous work) 9 | - What error messages indicate about missing implementation 10 | - Whether tests are passing (indicating refactor phase may be appropriate) 11 | 12 | Note: Test output may be from unrelated features. This does NOT prevent starting new test-driven work. 13 | 14 | ` 15 | -------------------------------------------------------------------------------- /reporters/phpunit/src/Storage.php: -------------------------------------------------------------------------------- 1 | projectRoot = PathValidator::resolveProjectRoot($projectRoot); 14 | } 15 | 16 | public function saveTest(string $content): void 17 | { 18 | $dataDir = $this->projectRoot . '/.claude/tdd-guard/data'; 19 | 20 | if (!is_dir($dataDir)) { 21 | mkdir($dataDir, 0755, true); 22 | } 23 | 24 | file_put_contents($dataDir . '/test.json', $content); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /reporters/phpunit/src/Event/TestRunnerFinishedSubscriber.php: -------------------------------------------------------------------------------- 1 | collector = $collector; 18 | } 19 | 20 | public function notify(Finished $event): void 21 | { 22 | $this->collector->saveResults(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /reporters/test/types.ts: -------------------------------------------------------------------------------- 1 | export interface TestScenarios { 2 | singlePassing: string 3 | singleFailing: string 4 | singleImportError: string 5 | } 6 | 7 | export interface ReporterConfig { 8 | name: string 9 | testScenarios: TestScenarios 10 | run: (tempDir: string, scenario: keyof TestScenarios) => void | Promise 11 | } 12 | 13 | export interface TestResultData { 14 | testModules: Array<{ 15 | moduleId: string 16 | tests: Array<{ 17 | name: string 18 | fullName: string 19 | state: string 20 | errors?: Array<{ 21 | message: string 22 | expected?: string 23 | actual?: string 24 | }> 25 | }> 26 | }> 27 | reason: string 28 | } 29 | -------------------------------------------------------------------------------- /reporters/phpunit/src/Event/PassedTestSubscriber.php: -------------------------------------------------------------------------------- 1 | collector = $collector; 18 | } 19 | 20 | public function notify(Passed $event): void 21 | { 22 | $this->collector->addTestResult( 23 | $event->test(), 24 | 'passed' 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/validation/prompts/tools/lint-results.ts: -------------------------------------------------------------------------------- 1 | export const LINT_RESULTS = `### Code Quality Status 2 | 3 | This section shows the current code quality status from static analysis. 4 | 5 | IMPORTANT: This lint output reflects the CURRENT state of the codebase BEFORE the proposed modification. 6 | 7 | Use this to understand: 8 | - Current code quality issues that need attention 9 | - Whether code quality should be addressed before new features 10 | - Patterns of issues that may indicate architectural concerns 11 | 12 | Note: During TDD red phase (failing tests), focus on making tests pass before addressing lint issues. 13 | During green phase (passing tests), lint issues should be addressed before proceeding to new features. 14 | 15 | ` 16 | -------------------------------------------------------------------------------- /src/storage/Storage.ts: -------------------------------------------------------------------------------- 1 | export const TRANSIENT_DATA = ['test', 'todo', 'modifications', 'lint'] as const 2 | 3 | export interface Storage { 4 | saveTest(content: string): Promise 5 | saveTodo(content: string): Promise 6 | saveModifications(content: string): Promise 7 | saveLint(content: string): Promise 8 | saveConfig(content: string): Promise 9 | saveInstructions(content: string): Promise 10 | getTest(): Promise 11 | getTodo(): Promise 12 | getModifications(): Promise 13 | getLint(): Promise 14 | getConfig(): Promise 15 | getInstructions(): Promise 16 | clearTransientData(): Promise 17 | } 18 | -------------------------------------------------------------------------------- /test/hooks/fileTypeDetection.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { detectFileType } from '../../src/hooks/fileTypeDetection' 3 | 4 | describe('detectFileType', () => { 5 | it('should detect Python files', () => { 6 | const hookData = { 7 | tool_input: { 8 | file_path: 'src/calculator.py' 9 | } 10 | } 11 | 12 | const result = detectFileType(hookData) 13 | expect(result).toBe('python') 14 | }) 15 | 16 | it('should detect JavaScript files', () => { 17 | const hookData = { 18 | tool_input: { 19 | file_path: 'src/calculator.js' 20 | } 21 | } 22 | 23 | const result = detectFileType(hookData) 24 | expect(result).toBe('javascript') 25 | }) 26 | }) -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files (only dist is needed) 2 | src/ 3 | *.ts 4 | !*.d.ts 5 | 6 | # Test files 7 | **/*.test.* 8 | **/*.spec.* 9 | coverage/ 10 | .vitest/ 11 | 12 | # Development files 13 | .env 14 | .env.* 15 | .git/ 16 | .gitignore 17 | .husky/ 18 | .claude/ 19 | docs/ 20 | hooks/ 21 | 22 | # Config files 23 | tsconfig.json 24 | tsconfig.*.json 25 | vitest.config.ts 26 | eslint.config.mjs 27 | prettier.config.* 28 | commitlint.config.* 29 | 30 | # IDE 31 | .vscode/ 32 | .idea/ 33 | *.swp 34 | *.swo 35 | 36 | # Logs 37 | *.log 38 | npm-debug.log* 39 | 40 | # OS 41 | .DS_Store 42 | Thumbs.db 43 | 44 | # Build artifacts we don't need 45 | *.tsbuildinfo 46 | *.map 47 | 48 | # Python publishing 49 | .venv/ 50 | *.egg-info/ 51 | __pycache__/ 52 | pyproject.toml -------------------------------------------------------------------------------- /src/contracts/schemas/pytestSchemas.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { PytestResultSchema } from './pytestSchemas' 3 | 4 | describe('PytestResultSchema', () => { 5 | it('should validate pytest test result format', () => { 6 | const pytestResult = { 7 | testModules: [ 8 | { 9 | moduleId: 'test_example.py', 10 | tests: [ 11 | { 12 | name: 'test_passing', 13 | fullName: 'test_example.py::test_passing', 14 | state: 'passed' as const, 15 | }, 16 | ], 17 | }, 18 | ], 19 | } 20 | 21 | const result = PytestResultSchema.safeParse(pytestResult) 22 | expect(result.success).toBe(true) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /reporters/storybook/src/StorybookReporter.test-data.ts: -------------------------------------------------------------------------------- 1 | import type { TestContext } from './types' 2 | 3 | const DEFAULT_STORY_ID = 'button--primary' 4 | const DEFAULT_STORY_TITLE = 'Button' 5 | const DEFAULT_STORY_NAME = 'Primary' 6 | 7 | export function createStoryContext( 8 | overrides?: Partial 9 | ): TestContext { 10 | return { 11 | id: DEFAULT_STORY_ID, 12 | title: DEFAULT_STORY_TITLE, 13 | name: DEFAULT_STORY_NAME, 14 | ...overrides, 15 | } 16 | } 17 | 18 | // Convenience aliases for createStoryContext 19 | export const passedStoryContext = createStoryContext 20 | export const failedStoryContext = createStoryContext 21 | export const skippedStoryContext = createStoryContext 22 | export const renderErrorContext = createStoryContext 23 | -------------------------------------------------------------------------------- /reporters/phpunit/src/Event/SkippedTestSubscriber.php: -------------------------------------------------------------------------------- 1 | collector = $collector; 18 | } 19 | 20 | public function notify(Skipped $event): void 21 | { 22 | $this->collector->addTestResult( 23 | $event->test(), 24 | 'skipped', 25 | $event->message() 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reporters/phpunit/src/Event/FailedTestSubscriber.php: -------------------------------------------------------------------------------- 1 | collector = $collector; 18 | } 19 | 20 | public function notify(Failed $event): void 21 | { 22 | $this->collector->addTestResult( 23 | $event->test(), 24 | 'failed', 25 | $event->throwable()->message() 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # TDD Guard Configuration 2 | 3 | # Validation client for TDD enforcement (optional) 4 | # Options: 'sdk' (default) or 'api' 5 | VALIDATION_CLIENT=sdk 6 | 7 | # Model version for validation (optional) 8 | # Default: claude-sonnet-4-0 9 | # See https://docs.anthropic.com/en/docs/about-claude/models/overview 10 | TDD_GUARD_MODEL_VERSION=claude-sonnet-4-0 11 | 12 | # Anthropic API Key for TDD Guard 13 | # Required when VALIDATION_CLIENT is set to 'api' 14 | # Get your API key from https://console.anthropic.com/ 15 | TDD_GUARD_ANTHROPIC_API_KEY= 16 | 17 | # Linter type for refactoring phase support (optional) 18 | # Options: 'eslint', 'golangci-lint' or unset (no linting) 19 | # Only set this if you want automatic code quality checks during refactoring 20 | # LINTER_TYPE=eslint 21 | -------------------------------------------------------------------------------- /reporters/phpunit/src/Event/ErroredTestSubscriber.php: -------------------------------------------------------------------------------- 1 | collector = $collector; 18 | } 19 | 20 | public function notify(Errored $event): void 21 | { 22 | $this->collector->addTestResult( 23 | $event->test(), 24 | 'errored', 25 | $event->throwable()->message() 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.claudeignore: -------------------------------------------------------------------------------- 1 | # Build outputs 2 | dist/ 3 | build/ 4 | out/ 5 | 6 | # Dependencies 7 | node_modules/ 8 | .pnp 9 | .pnp.js 10 | 11 | # Testing 12 | coverage/ 13 | .nyc_output/ 14 | 15 | # Environment files 16 | .env 17 | .env.local 18 | .env.*.local 19 | 20 | # Logs 21 | logs/ 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # OS files 28 | .DS_Store 29 | Thumbs.db 30 | 31 | # IDE files 32 | .idea/ 33 | .vscode/ 34 | *.swp 35 | *.swo 36 | 37 | # Temporary files 38 | tmp/ 39 | temp/ 40 | *.tmp 41 | 42 | # Cache 43 | .cache/ 44 | .parcel-cache/ 45 | .next/ 46 | .nuxt/ 47 | 48 | # Production builds 49 | *.production 50 | 51 | # Misc 52 | *.lock 53 | package-lock.json 54 | yarn.lock 55 | pnpm-lock.yaml 56 | 57 | # Python 58 | .venv/ 59 | __pycache__/ 60 | *.egg-info/ -------------------------------------------------------------------------------- /test/utils/factories/modelClientProviderFactory.ts: -------------------------------------------------------------------------------- 1 | import { ModelClientProvider } from '../../../src/providers/ModelClientProvider' 2 | import { IModelClient } from '../../../src/contracts/types/ModelClient' 3 | import { Config } from '../../../src/config/Config' 4 | 5 | export function modelClientProvider(): ModelClientProvider { 6 | return new MockModelClientProvider() 7 | } 8 | 9 | class MockModelClientProvider extends ModelClientProvider { 10 | getModelClient(config?: Config): IModelClient { 11 | const actualConfig = config ?? new Config() 12 | 13 | return { 14 | ask: async () => 15 | JSON.stringify({ 16 | decision: undefined, 17 | reason: `Using mock model client with modelType: ${actualConfig.modelType}`, 18 | }), 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/utils/factories/scenarios/index.ts: -------------------------------------------------------------------------------- 1 | import * as typescriptScenarios from './languages/typescript' 2 | import * as pythonScenarios from './languages/python' 3 | import { LanguageScenario } from './types' 4 | 5 | export { LanguageScenario } from './types' 6 | export { expectDecision } from './utils' 7 | 8 | export const typescript: LanguageScenario = { 9 | language: 'typescript', 10 | testFile: 'src/Calculator/Calculator.test.ts', 11 | implementationFile: 'src/Calculator/Calculator.ts', 12 | ...typescriptScenarios, 13 | } 14 | 15 | export const python: LanguageScenario = { 16 | language: 'python', 17 | testFile: 'src/calculator/test_calculator.py', 18 | implementationFile: 'src/calculator/calculator.py', 19 | ...pythonScenarios, 20 | } 21 | 22 | export const languages: LanguageScenario[] = [typescript, python] 23 | -------------------------------------------------------------------------------- /reporters/phpunit/src/Event/IncompleteTestSubscriber.php: -------------------------------------------------------------------------------- 1 | collector = $collector; 18 | } 19 | 20 | public function notify(MarkedIncomplete $event): void 21 | { 22 | $this->collector->addTestResult( 23 | $event->test(), 24 | 'skipped', 25 | 'Incomplete: ' . $event->throwable()->message() 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/utils/assertions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test assertion helpers for linter tests 3 | */ 4 | 5 | import type { LintIssue } from '../../src/contracts/schemas/lintSchemas' 6 | 7 | /** 8 | * Check if issues contain a specific rule 9 | */ 10 | export const hasRule = (issues: LintIssue[], rule: string): boolean => { 11 | return issues.some((issue) => issue.rule === rule) 12 | } 13 | 14 | /** 15 | * Check if issues contain all specified rules 16 | */ 17 | export const hasRules = (issues: LintIssue[], rules: string[]): boolean[] => { 18 | return rules.map((rule) => hasRule(issues, rule)) 19 | } 20 | 21 | /** 22 | * Filter issues from a specific file 23 | */ 24 | export const issuesFromFile = ( 25 | issues: LintIssue[], 26 | filename: string 27 | ): LintIssue[] => { 28 | return issues.filter((issue) => issue.file.includes(filename)) 29 | } 30 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Storage exports 2 | export { Storage } from './storage/Storage' 3 | export { FileStorage } from './storage/FileStorage' 4 | export { MemoryStorage } from './storage/MemoryStorage' 5 | 6 | // Config exports 7 | export { Config, DEFAULT_DATA_DIR } from './config/Config' 8 | export type { ConfigOptions } from './contracts/types/ConfigOptions' 9 | 10 | // Contract exports - Types 11 | export type { Context } from './contracts/types/Context' 12 | export type { IModelClient } from './contracts/types/ModelClient' 13 | export type { ValidationResult } from './contracts/types/ValidationResult' 14 | 15 | // Contract exports - Schemas 16 | export * from './contracts/schemas/toolSchemas' 17 | export * from './contracts/schemas/reporterSchemas' 18 | export * from './contracts/schemas/pytestSchemas' 19 | export * from './contracts/schemas/lintSchemas' 20 | -------------------------------------------------------------------------------- /src/providers/ModelClientProvider.ts: -------------------------------------------------------------------------------- 1 | import { IModelClient } from '../contracts/types/ModelClient' 2 | import { Config } from '../config/Config' 3 | import { ClaudeCli } from '../validation/models/ClaudeCli' 4 | import { AnthropicApi } from '../validation/models/AnthropicApi' 5 | import { ClaudeAgentSdk } from '../validation/models/ClaudeAgentSdk' 6 | 7 | export class ModelClientProvider { 8 | getModelClient(config?: Config): IModelClient { 9 | const actualConfig = config ?? new Config() 10 | 11 | switch (actualConfig.validationClient) { 12 | case 'sdk': 13 | return new ClaudeAgentSdk(actualConfig) 14 | case 'api': 15 | return new AnthropicApi(actualConfig) 16 | case 'cli': 17 | return new ClaudeCli(actualConfig) 18 | default: 19 | return new ClaudeCli(actualConfig) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "commonjs", 5 | "lib": ["ES2022"], 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "allowSyntheticDefaultImports": true, 13 | "outDir": "./dist", 14 | "rootDir": ".", 15 | "baseUrl": ".", 16 | "paths": { 17 | "@testUtils": ["./test/utils/index.ts"] 18 | }, 19 | "types": ["node", "vitest/globals"], 20 | "sourceMap": false, 21 | "declaration": true, 22 | "declarationMap": true 23 | }, 24 | "include": ["src/**/*", "test/utils/**/*"], 25 | "exclude": [ 26 | "dist", 27 | "node_modules", 28 | "src/**/*.test.ts", 29 | "src/**/*.test.tsx", 30 | "test/**/*.test.ts" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /test/utils/factories/contextFactory.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '../../../src/contracts/types/Context' 2 | import { TEST_DEFAULTS } from './testDefaults' 3 | 4 | /** 5 | * Creates a test Context object with sensible defaults 6 | */ 7 | export function context(overrides?: Partial): Context { 8 | return { 9 | modifications: TEST_DEFAULTS.modifications, 10 | todo: JSON.stringify([TEST_DEFAULTS.todo]), 11 | test: TEST_DEFAULTS.test, 12 | ...overrides, 13 | } 14 | } 15 | 16 | /** 17 | * Creates a test Context object without specific fields 18 | */ 19 | export function contextWithout( 20 | ...omitFields: K[] 21 | ): Omit { 22 | const fullContext = context() 23 | const result = { ...fullContext } 24 | 25 | for (const field of omitFields) { 26 | delete result[field] 27 | } 28 | 29 | return result as Omit 30 | } 31 | -------------------------------------------------------------------------------- /reporters/storybook/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Storage } from 'tdd-guard' 2 | 3 | export interface StorybookReporterOptions { 4 | storage?: Storage 5 | projectRoot?: string 6 | } 7 | 8 | export interface StoryError { 9 | message: string 10 | stack?: string 11 | expected?: unknown 12 | actual?: unknown 13 | } 14 | 15 | export interface StoryTest { 16 | name: string 17 | fullName: string 18 | state: 'passed' | 'failed' | 'skipped' 19 | errors?: StoryError[] 20 | } 21 | 22 | export interface StoryModule { 23 | moduleId: string 24 | tests: StoryTest[] 25 | } 26 | 27 | export interface TestRunOutput { 28 | testModules: StoryModule[] 29 | unhandledErrors: unknown[] 30 | reason?: 'passed' | 'failed' | 'interrupted' 31 | } 32 | 33 | export interface TestContext { 34 | id: string 35 | title: string 36 | name: string // Story name comes directly from context, not nested in storyExport 37 | } 38 | -------------------------------------------------------------------------------- /reporters/vitest/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TestState, 3 | TestRunEndReason, 4 | TestModule, 5 | TestCase, 6 | } from 'vitest/node' 7 | import type { SerializedError } from '@vitest/utils' 8 | 9 | export type ModuleDataMap = Map 10 | 11 | export type CollectedModuleData = { 12 | module: TestModule 13 | tests: TestCase[] 14 | } 15 | 16 | export type FormattedError = { 17 | message: string 18 | stack?: string 19 | expected?: unknown 20 | actual?: unknown 21 | } 22 | 23 | export type FormattedTest = { 24 | name: string 25 | fullName: string 26 | state: TestState 27 | errors?: FormattedError[] 28 | } 29 | 30 | export type ModuleResult = { 31 | moduleId: string 32 | tests: FormattedTest[] 33 | } 34 | 35 | export type TestRunOutput = { 36 | testModules: ModuleResult[] 37 | unhandledErrors: readonly SerializedError[] 38 | reason?: TestRunEndReason 39 | } 40 | -------------------------------------------------------------------------------- /reporters/vitest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tdd-guard-vitest", 3 | "version": "0.1.6", 4 | "description": "Vitest reporter for TDD Guard", 5 | "author": "Nizar Selander", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/nizos/tdd-guard.git", 10 | "directory": "reporters/vitest" 11 | }, 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "files": [ 15 | "dist", 16 | "!dist/*.tsbuildinfo", 17 | "!dist/*.test-data.*" 18 | ], 19 | "scripts": { 20 | "build": "tsc --build", 21 | "test": "vitest run", 22 | "typecheck": "tsc --noEmit" 23 | }, 24 | "dependencies": { 25 | "tdd-guard": "^1.1.0" 26 | }, 27 | "peerDependencies": { 28 | "vitest": ">=3.2.4" 29 | }, 30 | "devDependencies": { 31 | "@types/node": "^24.1.0", 32 | "typescript": "^5.8.3", 33 | "vitest": "^3.2.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /reporters/go/internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/nizos/tdd-guard/reporters/go/internal/transformer" 9 | ) 10 | 11 | var ( 12 | // Path components for cross-platform compatibility 13 | TestResultsPath = []string{".claude", "tdd-guard", "data", "test.json"} 14 | ) 15 | 16 | type Storage struct { 17 | basePath string 18 | } 19 | 20 | func NewStorage(projectRoot string) *Storage { 21 | return &Storage{basePath: projectRoot} 22 | } 23 | 24 | func (s *Storage) Save(results *transformer.TestResult) error { 25 | parts := append([]string{s.basePath}, TestResultsPath...) 26 | filePath := filepath.Join(parts...) 27 | 28 | // Ensure directory exists 29 | dir := filepath.Dir(filePath) 30 | os.MkdirAll(dir, 0755) 31 | 32 | // Marshal to JSON 33 | data, _ := json.Marshal(results) 34 | return os.WriteFile(filePath, data, 0644) 35 | } 36 | -------------------------------------------------------------------------------- /reporters/go/internal/io/tee_reader_test.go: -------------------------------------------------------------------------------- 1 | package io 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestTeeReader(t *testing.T) { 11 | input := "test data" 12 | 13 | t.Run("reads input", func(t *testing.T) { 14 | result, _ := readThroughTeeReader(input) 15 | 16 | if string(result) != input { 17 | t.Errorf("Expected to read '%s', got '%s'", input, string(result)) 18 | } 19 | }) 20 | 21 | t.Run("writes to output", func(t *testing.T) { 22 | _, output := readThroughTeeReader(input) 23 | 24 | if output.String() != input { 25 | t.Errorf("Expected output to contain '%s', got '%s'", input, output.String()) 26 | } 27 | }) 28 | } 29 | 30 | // Test helpers 31 | func readThroughTeeReader(input string) ([]byte, *bytes.Buffer) { 32 | reader := strings.NewReader(input) 33 | output := &bytes.Buffer{} 34 | teeReader := NewTeeReader(reader, output) 35 | result, _ := io.ReadAll(teeReader) 36 | return result, output 37 | } 38 | -------------------------------------------------------------------------------- /reporters/jest/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Storage } from 'tdd-guard' 2 | 3 | export interface TDDGuardReporterOptions { 4 | storage?: Storage 5 | projectRoot?: string 6 | } 7 | 8 | export interface CapturedError { 9 | message: string 10 | actual?: string 11 | expected?: string 12 | showDiff?: boolean 13 | operator?: string 14 | diff?: string 15 | name?: string 16 | ok?: boolean 17 | stack?: string 18 | } 19 | 20 | export interface CapturedTest { 21 | name: string 22 | fullName: string 23 | state: string 24 | errors?: CapturedError[] 25 | } 26 | 27 | export interface CapturedModule { 28 | moduleId: string 29 | tests: CapturedTest[] 30 | } 31 | 32 | export interface CapturedUnhandledError { 33 | message: string 34 | name: string 35 | stack?: string 36 | } 37 | 38 | export interface CapturedTestRun { 39 | testModules: CapturedModule[] 40 | unhandledErrors?: CapturedUnhandledError[] 41 | reason?: 'passed' | 'failed' | 'interrupted' 42 | } 43 | -------------------------------------------------------------------------------- /reporters/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tdd-guard-rust" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["SvO <104hp6u@gmail.com>"] 6 | description = "Rust test reporter for TDD Guard validation" 7 | license = "MIT" 8 | repository = "https://github.com/nizos/tdd-guard" 9 | homepage = "https://github.com/nizos/tdd-guard" 10 | documentation = "https://github.com/nizos/tdd-guard/tree/main/reporters/rust" 11 | readme = "README.md" 12 | keywords = ["testing", "tdd", "tdd-guard", "reporter", "cargo-test"] 13 | categories = ["development-tools::testing", "command-line-utilities"] 14 | 15 | [[bin]] 16 | name = "tdd-guard-rust" 17 | path = "src/main.rs" 18 | 19 | [dependencies] 20 | clap = { version = "4.5", features = ["derive"] } 21 | lazy_static = "1.5" 22 | regex = "1.11" 23 | serde = { version = "1.0", features = ["derive"] } 24 | serde_json = "1.0" 25 | 26 | [dev-dependencies] 27 | tempfile = "3.0" 28 | 29 | [profile.release] 30 | lto = true 31 | codegen-units = 1 32 | strip = true 33 | opt-level = "z" 34 | -------------------------------------------------------------------------------- /reporters/jest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tdd-guard-jest", 3 | "version": "0.1.4", 4 | "description": "Jest reporter for TDD Guard", 5 | "author": "Nizar Selander", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/nizos/tdd-guard.git", 10 | "directory": "reporters/jest" 11 | }, 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "files": [ 15 | "dist", 16 | "!dist/*.tsbuildinfo", 17 | "!dist/*.test-data.*" 18 | ], 19 | "scripts": { 20 | "build": "tsc --build", 21 | "test": "vitest run", 22 | "typecheck": "tsc --noEmit" 23 | }, 24 | "dependencies": { 25 | "tdd-guard": "^1.1.0" 26 | }, 27 | "peerDependencies": { 28 | "jest": ">=30.0.5" 29 | }, 30 | "devDependencies": { 31 | "@jest/reporters": "^30.0.5", 32 | "@jest/test-result": "^30.0.5", 33 | "@jest/types": "^30.0.5", 34 | "@types/node": "^24.1.0", 35 | "typescript": "^5.8.3", 36 | "vitest": "^3.2.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /reporters/phpunit/.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__ . '/src') 5 | ->in(__DIR__ . '/tests') 6 | ->exclude('vendor'); 7 | 8 | $config = new PhpCsFixer\Config(); 9 | return $config->setRules([ 10 | '@PSR12' => true, 11 | 'array_syntax' => ['syntax' => 'short'], 12 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 13 | 'no_unused_imports' => true, 14 | 'single_quote' => true, 15 | 'trailing_comma_in_multiline' => true, 16 | 'phpdoc_scalar' => true, 17 | 'unary_operator_spaces' => true, 18 | 'binary_operator_spaces' => true, 19 | 'blank_line_before_statement' => [ 20 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 21 | ], 22 | 'phpdoc_single_line_var_spacing' => true, 23 | 'phpdoc_var_without_name' => true, 24 | 'method_argument_space' => [ 25 | 'on_multiline' => 'ensure_fully_multiline', 26 | ], 27 | ]) 28 | ->setFinder($finder); -------------------------------------------------------------------------------- /src/hooks/sessionHandler.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from '../storage/Storage' 2 | import { FileStorage } from '../storage/FileStorage' 3 | import { SessionStartSchema } from '../contracts/schemas/toolSchemas' 4 | import { RULES } from '../validation/prompts/rules' 5 | 6 | export class SessionHandler { 7 | private readonly storage: Storage 8 | 9 | constructor(storage?: Storage) { 10 | this.storage = storage ?? new FileStorage() 11 | } 12 | 13 | async processSessionStart(hookData: string): Promise { 14 | const parsedData = JSON.parse(hookData) 15 | const sessionStartResult = SessionStartSchema.safeParse(parsedData) 16 | 17 | if (!sessionStartResult.success) { 18 | return 19 | } 20 | 21 | await this.ensureInstructionsExist() 22 | await this.storage.clearTransientData() 23 | } 24 | 25 | private async ensureInstructionsExist(): Promise { 26 | const existingInstructions = await this.storage.getInstructions() 27 | if (!existingInstructions) { 28 | await this.storage.saveInstructions(RULES) 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /reporters/phpunit/SYNC_README.md: -------------------------------------------------------------------------------- 1 | # TDD Guard PHPUnit Reporter 2 | 3 | This repository is automatically synchronized from the main [TDD Guard monorepo](https://github.com/nizos/tdd-guard). 4 | 5 | ## Important Notice 6 | 7 | **This is a read-only mirror.** Please do not submit pull requests here. 8 | 9 | - **Source code**: https://github.com/nizos/tdd-guard/tree/main/reporters/phpunit 10 | - **Issues**: https://github.com/nizos/tdd-guard/issues 11 | - **Pull requests**: https://github.com/nizos/tdd-guard/pulls 12 | 13 | ## Installation 14 | 15 | ```bash 16 | composer require --dev tdd-guard/phpunit 17 | ``` 18 | 19 | ## Why a Separate Repository? 20 | 21 | Packagist requires composer.json to be at the root of the repository. Since TDD Guard is a monorepo containing multiple packages (npm, Python, PHP), we maintain this synchronized copy specifically for Packagist distribution. 22 | 23 | ## Synchronization 24 | 25 | This repository is automatically updated whenever changes are pushed to the `reporters/phpunit` directory in the main repository. 26 | 27 | ## License 28 | 29 | MIT - See the main repository for details. -------------------------------------------------------------------------------- /reporters/phpunit/src/TddGuardExtension.php: -------------------------------------------------------------------------------- 1 | getProjectRoot($parameters); 20 | 21 | $subscriber = new TddGuardSubscriber($projectRoot); 22 | foreach ($subscriber->getSubscribers() as $eventSubscriber) { 23 | $facade->registerSubscriber($eventSubscriber); 24 | } 25 | } 26 | 27 | private function getProjectRoot(ParameterCollection $parameters): string 28 | { 29 | $configuredRoot = $parameters->has('projectRoot') ? $parameters->get('projectRoot') : ''; 30 | 31 | return PathValidator::resolveProjectRoot($configuredRoot); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Nizar Selander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/hooks/HookEvents.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HookData, 3 | HookDataSchema, 4 | isTodoWriteOperation, 5 | ToolOperation, 6 | ToolOperationSchema, 7 | } from '../contracts/schemas/toolSchemas' 8 | import { Storage } from '../storage/Storage' 9 | 10 | export type { HookData } 11 | 12 | export class HookEvents { 13 | constructor(private readonly storage: Storage) {} 14 | 15 | async processEvent(event: unknown): Promise { 16 | const hookResult = HookDataSchema.safeParse(event) 17 | if (!hookResult.success) return 18 | 19 | const operation = this.extractToolOperation(hookResult.data) 20 | if (!operation) return 21 | 22 | await this.persistOperation(operation) 23 | } 24 | 25 | private extractToolOperation(hook: HookData): ToolOperation | null { 26 | const result = ToolOperationSchema.safeParse(hook) 27 | return result.success ? result.data : null 28 | } 29 | 30 | private async persistOperation(operation: ToolOperation): Promise { 31 | const content = JSON.stringify(operation, null, 2) 32 | 33 | if (isTodoWriteOperation(operation)) { 34 | await this.storage.saveTodo(content) 35 | } else { 36 | await this.storage.saveModifications(content) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /reporters/storybook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tdd-guard-storybook", 3 | "version": "0.1.0", 4 | "description": "Storybook test-runner reporter for TDD Guard", 5 | "author": "Tony Kornmeier", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/nizos/tdd-guard.git", 10 | "directory": "reporters/storybook" 11 | }, 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "files": [ 15 | "dist", 16 | "!dist/*.tsbuildinfo", 17 | "!dist/*.test-data.*" 18 | ], 19 | "scripts": { 20 | "build": "tsc --build", 21 | "test": "vitest run", 22 | "typecheck": "tsc --noEmit" 23 | }, 24 | "dependencies": { 25 | "tdd-guard": "^1.1.0" 26 | }, 27 | "peerDependencies": { 28 | "@storybook/test-runner": ">=0.19.0" 29 | }, 30 | "devDependencies": { 31 | "@storybook/react-vite": "^8.4.7", 32 | "@storybook/test": "^8.4.7", 33 | "@storybook/test-runner": "^0.19.1", 34 | "@types/node": "^24.1.0", 35 | "@types/react": "^18.3.18", 36 | "react": "^18.3.1", 37 | "react-dom": "^18.3.1", 38 | "storybook": "^8.4.7", 39 | "typescript": "^5.8.3", 40 | "vite": "^6.0.11", 41 | "vitest": "^3.2.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /reporters/phpunit/src/TddGuardSubscriber.php: -------------------------------------------------------------------------------- 1 | collector = new TestResultCollector($storage, $projectRoot); 22 | } 23 | 24 | public function getSubscribers(): array 25 | { 26 | return [ 27 | new PassedTestSubscriber($this->collector), 28 | new FailedTestSubscriber($this->collector), 29 | new ErroredTestSubscriber($this->collector), 30 | new SkippedTestSubscriber($this->collector), 31 | new IncompleteTestSubscriber($this->collector), 32 | new TestRunnerFinishedSubscriber($this->collector), 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /reporters/pytest/tests/test_plugin_config.py: -------------------------------------------------------------------------------- 1 | """Test plugin configuration functionality""" 2 | from pathlib import Path 3 | from tdd_guard_pytest.pytest_reporter import TDDGuardPytestPlugin, DEFAULT_DATA_DIR 4 | from .helpers import create_config 5 | 6 | 7 | def test_plugin_accepts_config_parameter(): 8 | """Test that TDDGuardPytestPlugin can be initialized with a config parameter""" 9 | # Create a minimal config object 10 | class MinimalConfig: 11 | pass 12 | 13 | config = MinimalConfig() 14 | 15 | # Plugin should accept config parameter without error 16 | plugin = TDDGuardPytestPlugin(config) 17 | 18 | # Default behavior - should still use default storage dir 19 | assert plugin.storage_dir == DEFAULT_DATA_DIR 20 | 21 | 22 | def test_plugin_uses_configured_project_root(): 23 | """Test that plugin uses tdd_guard_project_root from config""" 24 | project_root = Path("/test/project") 25 | cwd = project_root / "subdir" 26 | 27 | config = create_config(str(project_root)) 28 | plugin = TDDGuardPytestPlugin(config, cwd=cwd) 29 | 30 | # Plugin should use the configured directory 31 | expected_storage = project_root / DEFAULT_DATA_DIR 32 | assert plugin.storage_dir == expected_storage -------------------------------------------------------------------------------- /test/utils/factories/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { pick, omit } from './helpers' 3 | 4 | describe('Helper functions', () => { 5 | describe('pick', () => { 6 | const testObject = { 7 | a: 1, 8 | b: 'two', 9 | c: true, 10 | d: { nested: 'value' }, 11 | } 12 | 13 | test('picks single property', () => { 14 | const result = pick(testObject, ['a']) 15 | 16 | expect(result).toEqual({ a: 1 }) 17 | }) 18 | 19 | test('picks multiple properties', () => { 20 | const result = pick(testObject, ['a', 'c']) 21 | 22 | expect(result).toEqual({ a: 1, c: true }) 23 | }) 24 | }) 25 | 26 | describe('omit', () => { 27 | const testObject = { 28 | a: 1, 29 | b: 'two', 30 | c: true, 31 | d: { nested: 'value' }, 32 | } 33 | 34 | test('omits single property', () => { 35 | const result = omit(testObject, ['a']) 36 | 37 | expect(result).toEqual({ b: 'two', c: true, d: { nested: 'value' } }) 38 | }) 39 | 40 | test('omits multiple properties', () => { 41 | const result = omit(testObject, ['a', 'c']) 42 | 43 | expect(result).toEqual({ b: 'two', d: { nested: 'value' } }) 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /test/utils/factories/sessionStartFactory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Factory functions for creating SessionStart test data 3 | */ 4 | 5 | import type { SessionStart } from '../../../src/contracts/schemas/toolSchemas' 6 | import { omit } from './helpers' 7 | import { TEST_DEFAULTS } from './testDefaults' 8 | 9 | /** 10 | * Creates a SessionStart object 11 | * @param params - Optional parameters for the SessionStart 12 | */ 13 | export const sessionStart = (params?: Partial): SessionStart => { 14 | const defaults = TEST_DEFAULTS.sessionStart 15 | const base = params ?? {} 16 | 17 | return { 18 | session_id: base.session_id ?? defaults.session_id, 19 | transcript_path: base.transcript_path ?? defaults.transcript_path, 20 | hook_event_name: base.hook_event_name ?? defaults.hook_event_name, 21 | source: base.source ?? defaults.source, 22 | } 23 | } 24 | 25 | /** 26 | * Creates a SessionStart object with specified properties omitted 27 | * @param keys - Array of property keys to omit 28 | * @param params - Optional parameters for the SessionStart 29 | */ 30 | export const sessionStartWithout = ( 31 | keys: K[], 32 | params?: Partial 33 | ): Omit => { 34 | const fullSessionStart = sessionStart(params) 35 | return omit(fullSessionStart, keys) 36 | } 37 | -------------------------------------------------------------------------------- /test/utils/factories/operations.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Write, 3 | Edit, 4 | MultiEdit, 5 | TodoWrite, 6 | } from '../../../src/contracts/schemas/toolSchemas' 7 | 8 | type ToolInput = Write | Edit | MultiEdit | TodoWrite 9 | 10 | export function createOperation( 11 | toolName: string, 12 | toolInput: ToolInput 13 | ): string { 14 | return JSON.stringify({ 15 | tool_name: toolName, 16 | session_id: 'test-session', 17 | transcript_path: '/test/transcript', 18 | hook_event_name: 'tool_use', 19 | tool_input: toolInput, 20 | }) 21 | } 22 | 23 | export function createWriteOperation( 24 | filePath: string, 25 | content: string 26 | ): string { 27 | return createOperation('Write', { 28 | file_path: filePath, 29 | content, 30 | }) 31 | } 32 | 33 | export function createEditOperation( 34 | filePath: string, 35 | oldString: string, 36 | newString: string 37 | ): string { 38 | return createOperation('Edit', { 39 | file_path: filePath, 40 | old_string: oldString, 41 | new_string: newString, 42 | }) 43 | } 44 | 45 | export function createMultiEditOperation( 46 | filePath: string, 47 | edits: Array<{ 48 | old_string: string 49 | new_string: string 50 | replace_all?: boolean 51 | }> 52 | ): string { 53 | return createOperation('MultiEdit', { 54 | file_path: filePath, 55 | edits, 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Testing 27 | coverage 28 | .nyc_output 29 | playwright-report 30 | test-results 31 | 32 | # TypeScript 33 | *.tsbuildinfo 34 | 35 | # Environment 36 | .env 37 | .env.local 38 | .env.*.local 39 | 40 | # Other 41 | TODO.md 42 | 43 | # TDD Guard data directory 44 | .claude/tdd-guard/ 45 | 46 | # Ignore .claude directories in subdirectories (not at project root) 47 | **/.claude/ 48 | 49 | # Python virtual environment 50 | .venv/ 51 | 52 | # Debug directory 53 | debug/ 54 | 55 | # Python 56 | __pycache__/ 57 | *.py[cod] 58 | *$py.class 59 | *.so 60 | .Python 61 | build/ 62 | develop-eggs/ 63 | dist/ 64 | downloads/ 65 | eggs/ 66 | .eggs/ 67 | lib/ 68 | lib64/ 69 | parts/ 70 | sdist/ 71 | var/ 72 | wheels/ 73 | *.egg-info/ 74 | .installed.cfg 75 | *.egg 76 | 77 | # Pytest 78 | .pytest_cache/ 79 | .coverage 80 | htmlcov/ 81 | .tox/ 82 | reporters/pytest/pytest.ini 83 | 84 | # Claude indexer 85 | .claude-indexer/ 86 | 87 | # Internal documentation 88 | docs/internal/ 89 | 90 | # Rust build artifacts 91 | target/ 92 | **/*.rs.bk 93 | *.pdb 94 | -------------------------------------------------------------------------------- /reporters/test/factories/pytest.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'node:child_process' 2 | import { writeFileSync } from 'node:fs' 3 | import { join } from 'node:path' 4 | import type { ReporterConfig, TestScenarios } from '../types' 5 | import { copyTestArtifacts } from './helpers' 6 | 7 | export function createPytestReporter(): ReporterConfig { 8 | const artifactDir = 'pytest' 9 | const testScenarios = { 10 | singlePassing: 'test_single_passing.py', 11 | singleFailing: 'test_single_failing.py', 12 | singleImportError: 'test_single_import_error.py', 13 | } 14 | 15 | return { 16 | name: 'PytestReporter', 17 | testScenarios, 18 | run: (tempDir, scenario: keyof TestScenarios) => { 19 | // Copy test file 20 | copyTestArtifacts(artifactDir, testScenarios, scenario, tempDir) 21 | 22 | // Write pytest config 23 | writeFileSync(join(tempDir, 'pytest.ini'), createPytestConfig(tempDir)) 24 | 25 | // Run pytest 26 | const pytestPath = join(__dirname, '../../pytest/.venv/bin/pytest') 27 | const testFile = testScenarios[scenario] 28 | spawnSync(pytestPath, [testFile, '-c', 'pytest.ini'], { 29 | cwd: tempDir, 30 | stdio: 'pipe', 31 | encoding: 'utf8', 32 | }) 33 | }, 34 | } 35 | } 36 | 37 | function createPytestConfig(tempDir: string): string { 38 | return `[pytest] 39 | tdd_guard_project_root = ${tempDir} 40 | ` 41 | } 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Core Requirements 4 | 5 | Implementation must be test driven with all relevant and affected tests passing. Run linting and formatting (`npm run checks`) and ensure the build succeeds (`npm run build`). 6 | 7 | ## Pull Requests 8 | 9 | Create focused PRs with meaningful titles that describe what the change accomplishes. The description must explain what the PR introduces and why it's needed. Document any important design decisions or architectural choices. Keep PRs small and focused for easier review and incremental feedback. 10 | 11 | ## Commit Messages 12 | 13 | Use conventional commits and communicate the why, not just what. Focus on the reasoning behind changes rather than describing what was changed. 14 | 15 | ## Reporter Contributions 16 | 17 | Project root path can be specified so that tests can be run from any directory in the project. For security, validate that the project root path is absolute and that it is the current working directory or an ancestor of it. Relevant cases must be added to reporter integration tests. 18 | 19 | ## Style Guidelines 20 | 21 | No emojis in code or documentation. Avoid generic or boilerplate content. Be deliberate and intentional. Keep it clean and concise. 22 | 23 | ## Development 24 | 25 | - [Development Guide](DEVELOPMENT.md) - Setup instructions and testing 26 | - [Dev Container setup](.devcontainer/README.md) - Consistent development environment 27 | -------------------------------------------------------------------------------- /test/utils/factories/userPromptSubmitFactory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Factory functions for creating UserPromptSubmit test data 3 | */ 4 | 5 | import type { UserPromptSubmit } from '../../../src/contracts/schemas/toolSchemas' 6 | import { omit } from './helpers' 7 | import { TEST_DEFAULTS } from './testDefaults' 8 | 9 | /** 10 | * Creates a UserPromptSubmit object 11 | * @param params - Optional parameters for the UserPromptSubmit 12 | */ 13 | export const userPromptSubmit = ( 14 | params?: Partial 15 | ): UserPromptSubmit => { 16 | const defaults = TEST_DEFAULTS.userPromptSubmit 17 | const base = params ?? {} 18 | 19 | return { 20 | session_id: base.session_id ?? defaults.session_id, 21 | transcript_path: base.transcript_path ?? defaults.transcript_path, 22 | hook_event_name: base.hook_event_name ?? defaults.hook_event_name, 23 | prompt: base.prompt ?? defaults.prompt, 24 | cwd: base.cwd ?? defaults.cwd, 25 | } 26 | } 27 | 28 | /** 29 | * Creates a UserPromptSubmit object with specified properties omitted 30 | * @param keys - Array of property keys to omit 31 | * @param params - Optional parameters for the UserPromptSubmit 32 | */ 33 | export const userPromptSubmitWithout = ( 34 | keys: K[], 35 | params?: Partial 36 | ): Omit => { 37 | const fullUserPromptSubmit = userPromptSubmit(params) 38 | return omit(fullUserPromptSubmit, keys) 39 | } 40 | -------------------------------------------------------------------------------- /docs/enforcement.md: -------------------------------------------------------------------------------- 1 | # Strengthening TDD Enforcement 2 | 3 | Ensure consistent TDD validation by preventing agents from modifying guard settings or bypassing file operation hooks. 4 | 5 | ## Protect Guard Settings 6 | 7 | Prevent agents from accessing TDD Guard's configuration and state: 8 | 9 | ```json 10 | { 11 | "permissions": { 12 | "deny": ["Read(.claude/tdd-guard/**)"] 13 | } 14 | } 15 | ``` 16 | 17 | This protects your custom instructions, guard state, and test results from unintended changes. 18 | 19 | ## Block File Operation Bypass 20 | 21 | If your settings allow shell commands without approval, agents can modify files without triggering TDD validation. Block these commands to maintain enforcement: 22 | 23 | ```json 24 | { 25 | "permissions": { 26 | "deny": [ 27 | "Bash(echo:*)", 28 | "Bash(printf:*)", 29 | "Bash(sed:*)", 30 | "Bash(awk:*)", 31 | "Bash(perl:*)" 32 | ] 33 | } 34 | } 35 | ``` 36 | 37 | **Note:** Only needed if you've configured auto-approval for shell commands. May limit some agent capabilities. 38 | 39 | ## Where to Apply 40 | 41 | Add these settings to your chosen settings file. See [Settings File Locations](configuration.md#settings-file-locations) to choose the appropriate location. 42 | 43 | ## Need Help? 44 | 45 | - Report bypass methods: [GitHub Issues](https://github.com/nizos/tdd-guard/issues) 46 | - Share strategies: [GitHub Discussions](https://github.com/nizos/tdd-guard/discussions) 47 | -------------------------------------------------------------------------------- /test/utils/factories/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions for object manipulation in test factories 3 | */ 4 | 5 | import { TEST_DEFAULTS } from './testDefaults' 6 | 7 | /** 8 | * Default hook data fields for all operations 9 | */ 10 | export const hookDataDefaults = (): { 11 | session_id: string 12 | transcript_path: string 13 | hook_event_name: string 14 | } => ({ 15 | session_id: TEST_DEFAULTS.hookData.session_id, 16 | transcript_path: TEST_DEFAULTS.hookData.transcript_path, 17 | hook_event_name: TEST_DEFAULTS.hookData.hook_event_name, 18 | }) 19 | 20 | /** 21 | * Creates a new object with only the specified properties 22 | * @param obj - The source object 23 | * @param keys - Array of property keys to include 24 | * @returns A new object with only the specified properties 25 | */ 26 | export const pick = (obj: T, keys: K[]): Pick => { 27 | const result = {} as Pick 28 | keys.forEach((key) => { 29 | result[key] = obj[key] 30 | }) 31 | return result 32 | } 33 | 34 | /** 35 | * Creates a new object with specified properties omitted 36 | * @param obj - The source object 37 | * @param keys - Array of property keys to omit 38 | * @returns A new object without the specified properties 39 | */ 40 | export const omit = (obj: T, keys: K[]): Omit => { 41 | const result = { ...obj } 42 | keys.forEach((key) => { 43 | delete result[key] 44 | }) 45 | return result as Omit 46 | } 47 | -------------------------------------------------------------------------------- /reporters/pytest/tests/test_config_option.py: -------------------------------------------------------------------------------- 1 | """Test configuration option for project root""" 2 | from .helpers import check_pytest_accepts_config 3 | 4 | 5 | def test_pytest_ini_accepts_project_root(): 6 | """Test that pytest.ini accepts tdd_guard_project_root configuration""" 7 | result = check_pytest_accepts_config(""" 8 | [pytest] 9 | tdd_guard_project_root = /some/path 10 | """, "pytest.ini") 11 | 12 | # Should succeed without "unknown config option" error 13 | assert result.returncode == 0 14 | assert "unknown config option" not in result.stderr.lower() 15 | 16 | 17 | def test_pyproject_toml_accepts_project_root(): 18 | """Test that pyproject.toml accepts tdd_guard_project_root configuration""" 19 | result = check_pytest_accepts_config(""" 20 | [tool.pytest.ini_options] 21 | tdd_guard_project_root = "/some/path" 22 | """, "pyproject.toml") 23 | 24 | # Should succeed without "unknown config option" error 25 | assert result.returncode == 0 26 | assert "unknown config option" not in result.stderr.lower() 27 | 28 | 29 | def test_setup_cfg_accepts_project_root(): 30 | """Test that setup.cfg accepts tdd_guard_project_root configuration""" 31 | result = check_pytest_accepts_config(""" 32 | [tool:pytest] 33 | tdd_guard_project_root = /some/path 34 | """, "setup.cfg") 35 | 36 | # Should succeed without "unknown config option" error 37 | assert result.returncode == 0 38 | assert "unknown config option" not in result.stderr.lower() -------------------------------------------------------------------------------- /src/cli/buildContext.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from '../storage/Storage' 2 | import { LintDataSchema } from '../contracts/schemas/lintSchemas' 3 | import { Context } from '../contracts/types/Context' 4 | import { processLintData } from '../processors/lintProcessor' 5 | 6 | export async function buildContext(storage: Storage): Promise { 7 | const [modifications, rawTest, todo, lint, instructions] = await Promise.all([ 8 | storage.getModifications(), 9 | storage.getTest(), 10 | storage.getTodo(), 11 | storage.getLint(), 12 | storage.getInstructions(), 13 | ]) 14 | 15 | let processedLintData 16 | try { 17 | if (lint) { 18 | const rawLintData = LintDataSchema.parse(JSON.parse(lint)) 19 | processedLintData = processLintData(rawLintData) 20 | } else { 21 | processedLintData = processLintData() 22 | } 23 | } catch { 24 | processedLintData = processLintData() 25 | } 26 | 27 | return { 28 | modifications: formatModifications(modifications ?? ''), 29 | test: rawTest ?? '', 30 | todo: todo ?? '', 31 | lint: processedLintData, 32 | instructions: instructions ?? undefined, 33 | } 34 | } 35 | 36 | function formatModifications(modifications: string): string { 37 | if (!modifications) { 38 | return '' 39 | } 40 | 41 | try { 42 | const parsed = JSON.parse(modifications) 43 | return JSON.stringify(parsed, null, 2) 44 | } catch { 45 | // If it's not valid JSON, leave it as is 46 | return modifications 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/validation/models/AnthropicApi.ts: -------------------------------------------------------------------------------- 1 | import Anthropic from '@anthropic-ai/sdk' 2 | import { Config } from '../../config/Config' 3 | import { IModelClient } from '../../contracts/types/ModelClient' 4 | import { SYSTEM_PROMPT } from '../prompts/system-prompt' 5 | 6 | export class AnthropicApi implements IModelClient { 7 | private readonly config: Config 8 | private readonly client: Anthropic 9 | 10 | constructor(config?: Config) { 11 | this.config = config ?? new Config() 12 | this.client = new Anthropic({ 13 | apiKey: this.config.anthropicApiKey, 14 | }) 15 | } 16 | 17 | async ask(prompt: string): Promise { 18 | const response = await this.client.messages.create({ 19 | model: this.config.modelVersion, 20 | system: SYSTEM_PROMPT, 21 | max_tokens: 1024, 22 | messages: [ 23 | { 24 | role: 'user', 25 | content: prompt, 26 | }, 27 | ], 28 | }) 29 | 30 | return extractTextFromResponse(response) 31 | } 32 | } 33 | 34 | interface MessageResponse { 35 | content: Array<{ text?: string; type?: string }> 36 | } 37 | 38 | function extractTextFromResponse(response: MessageResponse): string { 39 | if (response.content.length === 0) { 40 | throw new Error('No content in response') 41 | } 42 | 43 | const firstContent = response.content[0] 44 | if (!('text' in firstContent) || !firstContent.text) { 45 | throw new Error('Response content does not contain text') 46 | } 47 | 48 | return firstContent.text 49 | } 50 | -------------------------------------------------------------------------------- /src/providers/LinterProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { LinterProvider } from './LinterProvider' 3 | import { Config } from '../config/Config' 4 | import { ESLint } from '../linters/eslint/ESLint' 5 | import { GolangciLint } from '../linters/golangci/GolangciLint' 6 | 7 | describe('LinterProvider', () => { 8 | test('returns ESLint when config linterType is eslint', () => { 9 | const config = new Config({ linterType: 'eslint' }) 10 | 11 | const provider = new LinterProvider() 12 | const linter = provider.getLinter(config) 13 | 14 | expect(linter).toBeInstanceOf(ESLint) 15 | }) 16 | 17 | test('returns GolangciLint when config linterType is golangci-lint', () => { 18 | const config = new Config({ linterType: 'golangci-lint' }) 19 | 20 | const provider = new LinterProvider() 21 | const linter = provider.getLinter(config) 22 | 23 | expect(linter).toBeInstanceOf(GolangciLint) 24 | }) 25 | 26 | test('returns null when config linterType is explicitly undefined', () => { 27 | const config = new Config({ linterType: undefined }) 28 | 29 | const provider = new LinterProvider() 30 | const linter = provider.getLinter(config) 31 | 32 | expect(linter).toBeNull() 33 | }) 34 | 35 | test('returns null when config linterType is unknown value', () => { 36 | const config = new Config({ linterType: 'unknown-linter' }) 37 | 38 | const provider = new LinterProvider() 39 | const linter = provider.getLinter(config) 40 | 41 | expect(linter).toBeNull() 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /reporters/test/factories/vitest.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'node:child_process' 2 | import { writeFileSync } from 'node:fs' 3 | import { join } from 'node:path' 4 | import type { ReporterConfig, TestScenarios } from '../types' 5 | import { copyTestArtifacts, getReporterPath } from './helpers' 6 | 7 | export function createVitestReporter(): ReporterConfig { 8 | const artifactDir = 'vitest' 9 | const testScenarios = { 10 | singlePassing: 'single-passing.test.js', 11 | singleFailing: 'single-failing.test.js', 12 | singleImportError: 'single-import-error.test.js', 13 | } 14 | 15 | return { 16 | name: 'VitestReporter', 17 | testScenarios, 18 | run: (tempDir, scenario: keyof TestScenarios) => { 19 | // Copy test file 20 | copyTestArtifacts(artifactDir, testScenarios, scenario, tempDir) 21 | 22 | // Write Vitest config 23 | writeFileSync( 24 | join(tempDir, 'vitest.config.js'), 25 | createVitestConfig(tempDir) 26 | ) 27 | 28 | // Run Vitest 29 | const vitestPath = require.resolve('vitest/vitest.mjs') 30 | spawnSync(process.execPath, [vitestPath, 'run', '--no-coverage'], { 31 | cwd: tempDir, 32 | env: { ...process.env, CI: 'true' }, 33 | stdio: 'pipe', 34 | }) 35 | }, 36 | } 37 | } 38 | 39 | function createVitestConfig(tempDir: string): string { 40 | const reporterPath = getReporterPath('vitest/dist/index.js') 41 | return ` 42 | export default { 43 | test: { 44 | reporters: [ 45 | 'default', 46 | ['${reporterPath}', '${tempDir}'] 47 | ] 48 | } 49 | }; 50 | ` 51 | } 52 | -------------------------------------------------------------------------------- /src/cli/tdd-guard.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import 'dotenv/config' 4 | import { processHookData } from '../hooks/processHookData' 5 | import { Storage } from '../storage/Storage' 6 | import { FileStorage } from '../storage/FileStorage' 7 | import { validator } from '../validation/validator' 8 | import { Config } from '../config/Config' 9 | import { ModelClientProvider } from '../providers/ModelClientProvider' 10 | import { ValidationResult } from '../contracts/types/ValidationResult' 11 | 12 | export async function run( 13 | input: string, 14 | config?: Config, 15 | storage?: Storage, 16 | provider?: ModelClientProvider 17 | ): Promise { 18 | const appConfig = config ?? new Config() 19 | const actualStorage = storage ?? new FileStorage(appConfig) 20 | const modelProvider = provider ?? new ModelClientProvider() 21 | const modelClient = modelProvider.getModelClient(appConfig) 22 | 23 | return processHookData(input, { 24 | storage: actualStorage, 25 | validator: (context) => validator(context, modelClient), 26 | }) 27 | } 28 | 29 | // Only run if this is the main module 30 | if (require.main === module) { 31 | let inputData = '' 32 | process.stdin.setEncoding('utf8') 33 | 34 | process.stdin.on('data', (chunk) => { 35 | inputData += chunk 36 | }) 37 | 38 | process.stdin.on('end', async () => { 39 | try { 40 | const result = await run(inputData) 41 | console.log(JSON.stringify(result)) 42 | } catch (error) { 43 | console.error('Failed to parse hook data:', error) 44 | } finally { 45 | process.exit(0) 46 | } 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /reporters/phpunit/psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /reporters/test/factories/helpers.ts: -------------------------------------------------------------------------------- 1 | import { copyFileSync, mkdirSync, cpSync, statSync } from 'node:fs' 2 | import { join } from 'node:path' 3 | import type { TestScenarios } from '../types' 4 | 5 | /** 6 | * Copy test artifacts from the artifacts directory to the temp directory 7 | * Handles both files and directories automatically 8 | */ 9 | export function copyTestArtifacts( 10 | artifactDir: string, 11 | testScenarios: TestScenarios, 12 | scenario: keyof TestScenarios, 13 | tempDir: string, 14 | options?: { 15 | targetSubdir?: string // For PHPUnit which needs files in 'tests/' 16 | } 17 | ): void { 18 | const artifactName = testScenarios[scenario] 19 | const sourcePath = join(__dirname, '../artifacts', artifactDir, artifactName) 20 | 21 | // Check if source is a directory or file 22 | const stats = statSync(sourcePath) 23 | 24 | if (stats.isDirectory()) { 25 | // Copy entire directory contents 26 | cpSync(sourcePath, tempDir, { recursive: true }) 27 | } else { 28 | // Copy single file 29 | const targetDir = options?.targetSubdir 30 | ? join(tempDir, options.targetSubdir) 31 | : tempDir 32 | 33 | // Create target directory if needed 34 | if (options?.targetSubdir) { 35 | mkdirSync(targetDir, { recursive: true }) 36 | } 37 | 38 | const destPath = join(targetDir, artifactName) 39 | copyFileSync(sourcePath, destPath) 40 | } 41 | } 42 | 43 | /** 44 | * Get the path to a reporter module 45 | */ 46 | export function getReporterPath(reporterModule: string): string { 47 | return join(__dirname, '../..', reporterModule).replace(/\\/g, '/') 48 | } 49 | -------------------------------------------------------------------------------- /reporters/test/factories/jest.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'node:child_process' 2 | import { writeFileSync } from 'node:fs' 3 | import { join } from 'node:path' 4 | import type { ReporterConfig, TestScenarios } from '../types' 5 | import { copyTestArtifacts, getReporterPath } from './helpers' 6 | 7 | export function createJestReporter(): ReporterConfig { 8 | const artifactDir = 'jest' 9 | const testScenarios = { 10 | singlePassing: 'single-passing.test.js', 11 | singleFailing: 'single-failing.test.js', 12 | singleImportError: 'single-import-error.test.js', 13 | } 14 | 15 | return { 16 | name: 'JestReporter', 17 | testScenarios, 18 | run: (tempDir, scenario: keyof TestScenarios) => { 19 | // Copy test file 20 | copyTestArtifacts(artifactDir, testScenarios, scenario, tempDir) 21 | 22 | // Write Jest config 23 | writeFileSync(join(tempDir, 'jest.config.js'), createJestConfig(tempDir)) 24 | 25 | // Run Jest 26 | const jestCliPath = require.resolve('jest-cli/bin/jest') 27 | spawnSync(process.execPath, [jestCliPath, '--no-cache'], { 28 | cwd: tempDir, 29 | env: { ...process.env, CI: 'true' }, 30 | stdio: 'pipe', 31 | }) 32 | }, 33 | } 34 | } 35 | 36 | function createJestConfig(tempDir: string): string { 37 | const reporterPath = getReporterPath('jest/dist/index.js') 38 | return ` 39 | const path = require('path'); 40 | 41 | module.exports = { 42 | testMatch: ['**/*.test.js'], 43 | reporters: [ 44 | 'default', 45 | ['${reporterPath}', { 46 | projectRoot: '${tempDir}' 47 | }] 48 | ] 49 | }; 50 | ` 51 | } 52 | -------------------------------------------------------------------------------- /test/utils/factories/reporterFactory.ts: -------------------------------------------------------------------------------- 1 | import type { TestModule, TestCase, TestResult } from 'vitest/node' 2 | import type { SerializedError } from '@vitest/utils' 3 | 4 | // Minimal module - VitestReporter only uses moduleId and errors() 5 | const defaultModule: Partial = { 6 | moduleId: '/test/example.test.ts', 7 | errors: () => [] as SerializedError[], 8 | } 9 | 10 | // Minimal test case base 11 | const defaultTestCase: TestCase = { 12 | name: 'should pass', 13 | fullName: 'Example Suite > should pass', 14 | module: defaultModule, 15 | result: () => 16 | ({ 17 | state: 'passed', 18 | errors: [], 19 | }) as TestResult, 20 | } as TestCase 21 | 22 | export function testModule(overrides?: Partial): TestModule { 23 | return { 24 | ...defaultModule, 25 | ...overrides, 26 | } as TestModule 27 | } 28 | 29 | export function passedTestCase(overrides?: Partial): TestCase { 30 | return { 31 | ...defaultTestCase, 32 | ...overrides, 33 | } as TestCase 34 | } 35 | 36 | export function failedTestCase(overrides?: Partial): TestCase { 37 | return { 38 | ...defaultTestCase, 39 | name: 'should fail', 40 | fullName: 'Example Suite > should fail', 41 | result: () => 42 | ({ 43 | state: 'failed', 44 | errors: [ 45 | { 46 | message: 'expected 2 to be 3', 47 | stack: 'Error: expected 2 to be 3\n at test.ts:7:19', 48 | expected: '3', 49 | actual: '2', 50 | }, 51 | ], 52 | }) as TestResult, 53 | ...overrides, 54 | } as TestCase 55 | } 56 | -------------------------------------------------------------------------------- /reporters/pytest/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tdd-guard-pytest" 3 | version = "0.1.2" 4 | description = "Pytest plugin for TDD Guard - enforces Test-Driven Development principles" 5 | authors = [{name = "Nizar Selander"}, {name = "Durafen"}] 6 | license = "MIT" 7 | readme = "README.md" 8 | keywords = ["tdd", "test-driven-development", "testing", "pytest", "claude"] 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Framework :: Pytest", 12 | "Intended Audience :: Developers", 13 | "Programming Language :: Python :: 3", 14 | "Programming Language :: Python :: 3.8", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Topic :: Software Development :: Testing", 21 | "Topic :: Software Development :: Quality Assurance", 22 | ] 23 | requires-python = ">=3.8" 24 | dependencies = [ 25 | "pytest>=6.0", 26 | ] 27 | 28 | [project.urls] 29 | "Homepage" = "https://github.com/nizos/tdd-guard" 30 | "Repository" = "https://github.com/nizos/tdd-guard" 31 | "Bug Tracker" = "https://github.com/nizos/tdd-guard/issues" 32 | "Documentation" = "https://github.com/nizos/tdd-guard/tree/main/reporters/pytest" 33 | 34 | [project.entry-points."pytest11"] 35 | tdd-guard = "tdd_guard_pytest.pytest_reporter" 36 | 37 | [build-system] 38 | requires = ["setuptools>=45", "wheel"] 39 | build-backend = "setuptools.build_meta" 40 | 41 | [tool.pytest.ini_options] 42 | testpaths = ["tests"] 43 | python_files = ["test_*.py", "*_test.py"] -------------------------------------------------------------------------------- /src/validation/models/ClaudeCli.ts: -------------------------------------------------------------------------------- 1 | import { execFileSync } from 'child_process' 2 | import { join } from 'path' 3 | import { homedir } from 'os' 4 | import { existsSync, mkdirSync } from 'fs' 5 | import { IModelClient } from '../../contracts/types/ModelClient' 6 | import { Config } from '../../config/Config' 7 | 8 | export class ClaudeCli implements IModelClient { 9 | private readonly config: Config 10 | 11 | constructor(config?: Config) { 12 | this.config = config ?? new Config() 13 | } 14 | 15 | async ask(prompt: string): Promise { 16 | const claudeBinary = this.getClaudeBinary() 17 | 18 | const args = [ 19 | '-', 20 | '--output-format', 21 | 'json', 22 | '--max-turns', 23 | '5', 24 | '--model', 25 | 'sonnet', 26 | '--disallowed-tools', 27 | 'TodoWrite', 28 | '--strict-mcp-config', 29 | ] 30 | const claudeDir = join(process.cwd(), '.claude') 31 | 32 | if (!existsSync(claudeDir)) { 33 | mkdirSync(claudeDir, { recursive: true }) 34 | } 35 | 36 | const output = execFileSync(claudeBinary, args, { 37 | encoding: 'utf-8', 38 | timeout: 60000, 39 | input: prompt, 40 | cwd: claudeDir, 41 | shell: process.platform === 'win32', 42 | }) 43 | 44 | // Parse the Claude CLI response and extract the result field 45 | const response = JSON.parse(output) 46 | 47 | return response.result 48 | } 49 | 50 | private getClaudeBinary(): string { 51 | if (this.config.useSystemClaude) { 52 | return 'claude' 53 | } 54 | 55 | return join(homedir(), '.claude', 'local', 'claude') 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/validation/models/ClaudeAgentSdk.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../../config/Config' 2 | import { query, type Options } from '@anthropic-ai/claude-agent-sdk' 3 | import { IModelClient } from '../../contracts/types/ModelClient' 4 | import { SYSTEM_PROMPT } from '../prompts/system-prompt' 5 | 6 | export class ClaudeAgentSdk implements IModelClient { 7 | constructor( 8 | private readonly config: Config = new Config(), 9 | private readonly queryFn: typeof query = query 10 | ) {} 11 | 12 | async ask(prompt: string): Promise { 13 | const queryResult = this.queryFn({ 14 | prompt, 15 | options: this.getQueryOptions(), 16 | }) 17 | 18 | for await (const message of queryResult) { 19 | if (message.type !== 'result') continue 20 | 21 | if (message.subtype === 'success') { 22 | return message.result 23 | } 24 | throw new Error(`Claude Agent SDK error: ${message.subtype}`) 25 | } 26 | 27 | throw new Error('Claude Agent SDK error: No result message received') 28 | } 29 | 30 | private getQueryOptions(): Options { 31 | return { 32 | maxTurns: 1, 33 | systemPrompt: SYSTEM_PROMPT, 34 | allowedTools: [], 35 | disallowedTools: [ 36 | 'Read', 37 | 'Edit', 38 | 'MultiEdit', 39 | 'Write', 40 | 'Grep', 41 | 'Glob', 42 | 'Bash', 43 | 'WebFetch', 44 | 'WebSearch', 45 | 'Task', 46 | 'TodoWrite', 47 | ], 48 | maxThinkingTokens: 0, 49 | model: this.config.modelVersion, 50 | strictMcpConfig: true, 51 | cwd: this.config.dataDir, 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /reporters/vitest/README.md: -------------------------------------------------------------------------------- 1 | # TDD Guard Vitest Reporter 2 | 3 | Vitest reporter that captures test results for TDD Guard validation. 4 | 5 | ## Requirements 6 | 7 | - Node.js 18+ 8 | - Vitest 3.2.0+ 9 | - [TDD Guard](https://github.com/nizos/tdd-guard) installed globally 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install --save-dev tdd-guard-vitest 15 | ``` 16 | 17 | ## Configuration 18 | 19 | ### Vitest Configuration 20 | 21 | Add the reporter to your `vitest.config.ts`: 22 | 23 | ```typescript 24 | import { defineConfig } from 'vitest/config' 25 | import { VitestReporter } from 'tdd-guard-vitest' 26 | 27 | export default defineConfig({ 28 | test: { 29 | reporters: ['default', new VitestReporter()], 30 | }, 31 | }) 32 | ``` 33 | 34 | ### Workspace/Monorepo Configuration 35 | 36 | For workspaces or monorepos, pass the project root path to the reporter: 37 | 38 | ```typescript 39 | // vitest.config.ts in project root 40 | import { defineConfig } from 'vitest/config' 41 | import { VitestReporter } from 'tdd-guard-vitest' 42 | import path from 'path' 43 | 44 | export default defineConfig({ 45 | test: { 46 | reporters: ['default', new VitestReporter(path.resolve(__dirname))], 47 | }, 48 | }) 49 | ``` 50 | 51 | If your vitest config is in a workspace subdirectory, pass the absolute path to your project root: 52 | 53 | ```typescript 54 | new VitestReporter('/Users/username/projects/my-app') 55 | ``` 56 | 57 | ## More Information 58 | 59 | - Test results are saved to `.claude/tdd-guard/data/test.json` 60 | - See [TDD Guard documentation](https://github.com/nizos/tdd-guard) for complete setup 61 | 62 | ## License 63 | 64 | MIT 65 | -------------------------------------------------------------------------------- /test/hooks/processHookData.fileType.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { processHookData } from '../../src/hooks/processHookData' 3 | import { MemoryStorage } from '../../src/storage/MemoryStorage' 4 | 5 | describe('processHookData file type integration', () => { 6 | it('should use pytest schema for Python files', async () => { 7 | const storage = new MemoryStorage() 8 | const pytestResults = { 9 | testModules: [{ 10 | moduleId: 'test_example.py', 11 | tests: [{ 12 | name: 'test_passing', 13 | fullName: 'test_example.py::test_passing', 14 | state: 'passed' 15 | }] 16 | }] 17 | } 18 | 19 | await storage.saveTest(JSON.stringify(pytestResults)) 20 | 21 | const hookData = { 22 | hook_event_name: 'PreToolUse', 23 | tool_input: { file_path: 'test_example.py' } 24 | } 25 | 26 | const result = await processHookData(JSON.stringify(hookData), { storage }) 27 | 28 | // Should not block when pytest results are valid for Python file 29 | expect(result.decision).toBeUndefined() 30 | }) 31 | 32 | it('should handle Python file when no test results exist', async () => { 33 | const storage = new MemoryStorage() 34 | // No test results stored 35 | 36 | const hookData = { 37 | hook_event_name: 'PreToolUse', 38 | tool_input: { file_path: 'calculator.py' } 39 | } 40 | 41 | const result = await processHookData(JSON.stringify(hookData), { storage }) 42 | 43 | // Should not block when no test results (allows initial implementation) 44 | expect(result.decision).toBeUndefined() 45 | }) 46 | }) -------------------------------------------------------------------------------- /src/storage/MemoryStorage.ts: -------------------------------------------------------------------------------- 1 | import { Storage, TRANSIENT_DATA } from './Storage' 2 | 3 | export class MemoryStorage implements Storage { 4 | private readonly store = new Map() 5 | 6 | async saveTest(content: string): Promise { 7 | this.store.set('test', content) 8 | } 9 | 10 | async saveTodo(content: string): Promise { 11 | this.store.set('todo', content) 12 | } 13 | 14 | async saveModifications(content: string): Promise { 15 | this.store.set('modifications', content) 16 | } 17 | 18 | async saveLint(content: string): Promise { 19 | this.store.set('lint', content) 20 | } 21 | 22 | async saveConfig(content: string): Promise { 23 | this.store.set('config', content) 24 | } 25 | 26 | async saveInstructions(content: string): Promise { 27 | this.store.set('instructions', content) 28 | } 29 | 30 | async getTest(): Promise { 31 | return this.store.get('test') ?? null 32 | } 33 | 34 | async getTodo(): Promise { 35 | return this.store.get('todo') ?? null 36 | } 37 | 38 | async getModifications(): Promise { 39 | return this.store.get('modifications') ?? null 40 | } 41 | 42 | async getLint(): Promise { 43 | return this.store.get('lint') ?? null 44 | } 45 | 46 | async getConfig(): Promise { 47 | return this.store.get('config') ?? null 48 | } 49 | 50 | async getInstructions(): Promise { 51 | return this.store.get('instructions') ?? null 52 | } 53 | 54 | async clearTransientData(): Promise { 55 | TRANSIENT_DATA.forEach((key) => this.store.delete(key)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/contracts/schemas/guardSchemas.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { GuardConfigSchema } from './guardSchemas' 3 | 4 | describe('GuardConfigSchema', () => { 5 | test.each([ 6 | { 7 | description: 'valid config with all fields', 8 | config: { 9 | guardEnabled: true, 10 | ignorePatterns: ['*.md', '**/*.test.ts', 'dist/**'], 11 | }, 12 | expectedSuccess: true, 13 | }, 14 | { 15 | description: 'config with only guardEnabled', 16 | config: { 17 | guardEnabled: false, 18 | }, 19 | expectedSuccess: true, 20 | }, 21 | { 22 | description: 'config with only ignorePatterns', 23 | config: { 24 | ignorePatterns: ['*.log', 'node_modules/**'], 25 | }, 26 | expectedSuccess: true, 27 | }, 28 | { 29 | description: 'empty config object', 30 | config: {}, 31 | expectedSuccess: true, 32 | }, 33 | { 34 | description: 'invalid guardEnabled type (string)', 35 | config: { 36 | guardEnabled: 'true', 37 | }, 38 | expectedSuccess: false, 39 | }, 40 | { 41 | description: 'invalid ignorePatterns type (string)', 42 | config: { 43 | ignorePatterns: '*.md', 44 | }, 45 | expectedSuccess: false, 46 | }, 47 | { 48 | description: 'non-string items in ignorePatterns array', 49 | config: { 50 | ignorePatterns: ['*.md', 123, true], 51 | }, 52 | expectedSuccess: false, 53 | }, 54 | ])('$description', ({ config, expectedSuccess }) => { 55 | const result = GuardConfigSchema.safeParse(config) 56 | expect(result.success).toBe(expectedSuccess) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /src/hooks/userPromptHandler.ts: -------------------------------------------------------------------------------- 1 | import { GuardManager } from '../guard/GuardManager' 2 | import { ValidationResult } from '../contracts/types/ValidationResult' 3 | 4 | export class UserPromptHandler { 5 | private readonly guardManager: GuardManager 6 | private readonly GUARD_COMMANDS = { 7 | ON: 'tdd-guard on', 8 | OFF: 'tdd-guard off' 9 | } as const 10 | 11 | constructor(guardManager?: GuardManager) { 12 | this.guardManager = guardManager ?? new GuardManager() 13 | } 14 | 15 | async processUserCommand(hookData: string): Promise { 16 | const data = JSON.parse(hookData) 17 | 18 | // Only process UserPromptSubmit events 19 | if (data.hook_event_name !== 'UserPromptSubmit') { 20 | return undefined 21 | } 22 | 23 | const command = data.prompt?.toLowerCase() 24 | 25 | switch (command) { 26 | case this.GUARD_COMMANDS.ON: 27 | await this.guardManager.enable() 28 | return this.createBlockResult('TDD Guard enabled') 29 | 30 | case this.GUARD_COMMANDS.OFF: 31 | await this.guardManager.disable() 32 | return this.createBlockResult('TDD Guard disabled') 33 | 34 | default: 35 | return undefined 36 | } 37 | } 38 | 39 | private createBlockResult(message: string): ValidationResult { 40 | return { 41 | decision: undefined, 42 | reason: message, 43 | continue: false, 44 | stopReason: message 45 | } 46 | } 47 | 48 | async getDisabledResult(): Promise { 49 | const isEnabled = await this.guardManager.isEnabled() 50 | if (!isEnabled) { 51 | return { decision: undefined, reason: '' } 52 | } 53 | return undefined 54 | } 55 | } -------------------------------------------------------------------------------- /reporters/phpunit/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tdd-guard/phpunit", 3 | "description": "PHPUnit reporter for TDD Guard", 4 | "version": "0.1.3", 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Wolfgang Klinger", 10 | "email": "hello@wolfgang-klinger.dev" 11 | } 12 | ], 13 | "keywords": [ 14 | "tdd", 15 | "test-driven-development", 16 | "testing", 17 | "phpunit", 18 | "claude" 19 | ], 20 | "homepage": "https://github.com/nizos/tdd-guard", 21 | "support": { 22 | "issues": "https://github.com/nizos/tdd-guard/issues", 23 | "source": "https://github.com/nizos/tdd-guard/tree/main/reporters/phpunit" 24 | }, 25 | "require": { 26 | "php": ">=8.1", 27 | "phpunit/phpunit": "^9.0 || ^10.0 || ^11.0 || ^12.0" 28 | }, 29 | "require-dev": { 30 | "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", 31 | "friendsofphp/php-cs-fixer": "^3.40", 32 | "vimeo/psalm": "^5.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "TddGuard\\PHPUnit\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "TddGuard\\PHPUnit\\Tests\\": "tests/" 42 | } 43 | }, 44 | "scripts": { 45 | "test": "vendor/bin/phpunit", 46 | "test:coverage": "vendor/bin/phpunit --coverage-text", 47 | "format": "vendor/bin/php-cs-fixer fix", 48 | "format:check": "vendor/bin/php-cs-fixer fix --dry-run --diff", 49 | "lint": "vendor/bin/psalm", 50 | "lint:check": "vendor/bin/psalm --no-progress" 51 | }, 52 | "config": { 53 | "sort-packages": true 54 | } 55 | } -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | # Run security scans every Monday at 09:00 UTC 10 | - cron: '0 9 * * 1' 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | codeql: 18 | name: CodeQL Analysis 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 10 21 | permissions: 22 | actions: read 23 | contents: read 24 | security-events: write 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: typescript 33 | queries: security-extended 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v3 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v3 40 | 41 | npm-audit: 42 | name: NPM Audit 43 | runs-on: ubuntu-latest 44 | timeout-minutes: 5 45 | steps: 46 | - name: Checkout code 47 | uses: actions/checkout@v4 48 | 49 | - name: Setup Node.js 50 | uses: actions/setup-node@v4 51 | with: 52 | node-version: 22 53 | cache: npm 54 | 55 | - name: Install dependencies 56 | run: npm ci 57 | 58 | - name: Run npm audit 59 | run: npm audit --audit-level=moderate 60 | 61 | all-security-checks-pass: 62 | name: All Security Checks Pass 63 | runs-on: ubuntu-latest 64 | needs: [codeql, npm-audit] 65 | steps: 66 | - name: All security checks passed 67 | run: echo "All security checks passed successfully!" 68 | -------------------------------------------------------------------------------- /reporters/go/README.md: -------------------------------------------------------------------------------- 1 | # TDD Guard Go Reporter 2 | 3 | Go test reporter that captures test results for TDD Guard validation. 4 | 5 | ## Requirements 6 | 7 | - Go 1.24+ 8 | - [TDD Guard](https://github.com/nizos/tdd-guard) installed globally 9 | 10 | ## Installation 11 | 12 | ```bash 13 | go install github.com/nizos/tdd-guard/reporters/go/cmd/tdd-guard-go@latest 14 | ``` 15 | 16 | ## Configuration 17 | 18 | ### Basic Usage 19 | 20 | Pipe `go test -json` output to the reporter: 21 | 22 | ```bash 23 | go test -json ./... 2>&1 | tdd-guard-go 24 | ``` 25 | 26 | ### Project Root Configuration 27 | 28 | For projects where tests run in subdirectories, specify the project root: 29 | 30 | ```bash 31 | go test -json ./... 2>&1 | tdd-guard-go -project-root /absolute/path/to/project/root 32 | ``` 33 | 34 | ### Configuration Rules 35 | 36 | - Path must be absolute when using `-project-root` flag 37 | - Current directory must be within the configured project root 38 | - Falls back to current directory if not specified 39 | 40 | ### Makefile Integration 41 | 42 | Add to your `Makefile`: 43 | 44 | ```makefile 45 | test: 46 | go test -json ./... 2>&1 | tdd-guard-go -project-root /absolute/path/to/project/root 47 | ``` 48 | 49 | ## How It Works 50 | 51 | The reporter acts as a filter that: 52 | 53 | 1. Reads `go test -json` output from stdin 54 | 2. Passes the output through to stdout unchanged 55 | 3. Parses test results and transforms them to TDD Guard format 56 | 4. Saves results to `.claude/tdd-guard/data/test.json` 57 | 58 | This design allows it to be inserted into existing test pipelines without disrupting output. 59 | 60 | ## More Information 61 | 62 | - Test results are saved to `.claude/tdd-guard/data/test.json` 63 | - See [TDD Guard documentation](https://github.com/nizos/tdd-guard) for complete setup 64 | 65 | ## License 66 | 67 | MIT 68 | -------------------------------------------------------------------------------- /docs/quick-commands.md: -------------------------------------------------------------------------------- 1 | # TDD Guard Quick Commands 2 | 3 | TDD Guard can be quickly enabled or disabled using simple commands in your Claude Code session. 4 | This is particularly useful when you need to temporarily disable TDD enforcement during prototyping or exploration phases. 5 | 6 | ## Usage 7 | 8 | Simply type one of these commands in your Claude Code prompt: 9 | 10 | - `tdd-guard on` - Enables TDD Guard enforcement 11 | - `tdd-guard off` - Disables TDD Guard enforcement 12 | 13 | The commands are case-insensitive, so `TDD-Guard OFF`, `tdd-guard off`, and `Tdd-Guard Off` all work the same way. 14 | 15 | ## Setup 16 | 17 | To enable the quick commands feature, you need to add the UserPromptSubmit hook to your Claude Code configuration. 18 | You can set this up either through the interactive `/hooks` command or by manually editing your settings file. See [Settings File Locations](configuration.md#settings-file-locations) to choose the appropriate location. 19 | 20 | ### Using Interactive Setup (Recommended) 21 | 22 | 1. Type `/hooks` in Claude Code 23 | 2. Select `UserPromptSubmit - When the user submits a prompt` 24 | 3. Select `+ Add new hook...` 25 | 4. Enter command: `tdd-guard` 26 | 5. Choose where to save 27 | 28 | ### Manual Configuration (Alternative) 29 | 30 | Add the following to your chosen settings file: 31 | 32 | ```json 33 | { 34 | "hooks": { 35 | "userpromptsubmit": [ 36 | { 37 | "hooks": [ 38 | { 39 | "type": "command", 40 | "command": "tdd-guard" 41 | } 42 | ] 43 | } 44 | ] 45 | } 46 | } 47 | ``` 48 | 49 | Note: Your configuration file may already have other hooks configured. 50 | Simply add the `userpromptsubmit` section to your existing hooks object. 51 | 52 | **Tip**: To prevent agents from modifying the TDD Guard state, see [Strengthening TDD Enforcement](enforcement.md). 53 | -------------------------------------------------------------------------------- /docs/custom-instructions.md: -------------------------------------------------------------------------------- 1 | # Custom TDD Instructions 2 | 3 | Customize TDD Guard's validation rules to match your specific TDD practices. 4 | 5 | ## How It Works 6 | 7 | TDD Guard uses validation rules to enforce TDD principles. You can override these default rules by creating a custom instructions file at `.claude/tdd-guard/data/instructions.md`. 8 | 9 | ## Automatic Setup 10 | 11 | If you have the [SessionStart hook](session-management.md) configured, the instructions file is created automatically with default rules when: 12 | 13 | - Starting a new Claude Code session 14 | - Resuming a session 15 | - Using the `/clear` command 16 | 17 | Your custom instructions are never overwritten - once created, the file remains under your control. 18 | 19 | ## Creating Custom Instructions 20 | 21 | 1. Edit `.claude/tdd-guard/data/instructions.md` 22 | 2. Adjust or replace the default rules with your TDD requirements 23 | 3. Changes take effect immediately - no restart needed 24 | 25 | ## Updating to Latest Defaults 26 | 27 | When updating TDD Guard, you may want the latest default instructions: 28 | 29 | 1. Delete `.claude/tdd-guard/data/instructions.md` 30 | 2. Trigger the SessionStart hook (start new session or use `/clear`) 31 | 3. The latest defaults will be created automatically 32 | 33 | Alternatively, you can manually copy the default rules from [`src/validation/prompts/rules.ts`](../src/validation/prompts/rules.ts). 34 | 35 | ## Protecting Your Instructions 36 | 37 | Prevent agents from modifying your custom instructions by denying access to TDD Guard data. See [Strengthening TDD Enforcement](enforcement.md) for details. 38 | 39 | ## Tips 40 | 41 | - Start with the default instructions and modify incrementally 42 | - Keep rules clear and actionable for consistent validation 43 | - Share effective customizations with the TDD Guard community in [GitHub Discussions](https://github.com/nizos/tdd-guard/discussions) 44 | -------------------------------------------------------------------------------- /reporters/pytest/README.md: -------------------------------------------------------------------------------- 1 | # TDD Guard Pytest Reporter 2 | 3 | Pytest plugin that captures test results for TDD Guard validation. 4 | 5 | ## Requirements 6 | 7 | - Python 3.8+ 8 | - pytest 6.0+ 9 | - [TDD Guard](https://github.com/nizos/tdd-guard) installed globally 10 | 11 | ## Installation 12 | 13 | ```bash 14 | pip install tdd-guard-pytest 15 | ``` 16 | 17 | The plugin activates automatically when installed. 18 | 19 | ## Configuration 20 | 21 | ### Project Root Configuration 22 | 23 | Set `tdd_guard_project_root` to your project root using any ONE of these methods: 24 | 25 | **Option 1: pyproject.toml** 26 | 27 | ```toml 28 | [tool.pytest.ini_options] 29 | tdd_guard_project_root = "/absolute/path/to/project/root" 30 | ``` 31 | 32 | **Option 2: pytest.ini** 33 | 34 | ```ini 35 | [pytest] 36 | tdd_guard_project_root = /absolute/path/to/project/root 37 | ``` 38 | 39 | **Option 3: setup.cfg** 40 | 41 | ```ini 42 | [tool:pytest] 43 | tdd_guard_project_root = /absolute/path/to/project/root 44 | ``` 45 | 46 | ### Configuration Rules 47 | 48 | - Path must be absolute 49 | - Current directory must be within the configured project root 50 | - Falls back to current directory if configuration is invalid 51 | 52 | ## Development 53 | 54 | When developing the pytest reporter, you need to configure the project root to ensure test results are saved to the correct location: 55 | 56 | 1. Copy the example configuration: 57 | 58 | ```bash 59 | cp pytest.ini.example pytest.ini 60 | ``` 61 | 62 | 2. Edit `pytest.ini` and set the absolute path to your TDD Guard project root: 63 | ```ini 64 | [pytest] 65 | tdd_guard_project_root = /absolute/path/to/tdd-guard 66 | ``` 67 | 68 | **Note:** `pytest.ini` is gitignored to avoid committing machine-specific paths. 69 | 70 | ## More Information 71 | 72 | - Test results are saved to `.claude/tdd-guard/data/test.json` 73 | - See [TDD Guard documentation](https://github.com/nizos/tdd-guard) for complete setup 74 | 75 | ## License 76 | 77 | MIT 78 | -------------------------------------------------------------------------------- /docs/adr/001-claude-session-subdirectory.md: -------------------------------------------------------------------------------- 1 | # ADR-001: Execute Claude CLI from Subdirectory for Session Management 2 | 3 | ## Status 4 | 5 | Accepted 6 | 7 | ## Context 8 | 9 | The TDD Guard validation system creates a new Claude session for each validation operation when running `claude` commands. This results in session list clutter and makes it difficult to track the actual development sessions. 10 | 11 | We considered two approaches: 12 | 13 | 1. Clear the context before each validation using `/clear` command 14 | 2. Run the Claude CLI from a subdirectory to isolate validation sessions 15 | 16 | The first approach has limitations: 17 | 18 | - Cannot clear and ask a question in the same command 19 | - Would require multiple command executions 20 | - Still shows all sessions in the same directory listing 21 | 22 | ## Decision 23 | 24 | We will execute all Claude validation commands from a `.claude` subdirectory within the project root. 25 | 26 | Implementation details: 27 | 28 | - Create `.claude` directory if it doesn't exist 29 | - Set the `cwd` option in `execSync` to the subdirectory path 30 | - The `.claude` directory itself is not ignored (contains settings.json which should be tracked) 31 | - User-specific files like `.claude/settings.local.json` should already be in `.gitignore` 32 | 33 | ## Consequences 34 | 35 | ### Positive 36 | 37 | - Validation sessions are isolated from development sessions 38 | - No cluttering of the main project's session list 39 | - Automatic trust inheritance from parent directory 40 | - Simple implementation with minimal code changes 41 | - No impact on validation functionality 42 | 43 | ### Negative 44 | 45 | - Creates an additional directory in the project 46 | - Slightly increases complexity in the model client 47 | - Sessions are less visible (though this is mostly a benefit) 48 | 49 | ### Neutral 50 | 51 | - All validation sessions will appear in the `.claude` subdirectory listing 52 | - Developers need to know to look in `.claude` for validation session history 53 | -------------------------------------------------------------------------------- /src/contracts/schemas/lintSchemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const ESLintMessageSchema = z.object({ 4 | line: z.number().optional(), 5 | column: z.number().optional(), 6 | severity: z.number(), 7 | message: z.string(), 8 | ruleId: z.string().optional(), 9 | }) 10 | 11 | export const ESLintResultSchema = z.object({ 12 | filePath: z.string(), 13 | messages: z.array(ESLintMessageSchema).optional(), 14 | }) 15 | 16 | export const GolangciLintPositionSchema = z.object({ 17 | Filename: z.string(), 18 | Line: z.number(), 19 | Column: z.number(), 20 | }) 21 | 22 | export const GolangciLintIssueSchema = z.object({ 23 | FromLinter: z.string(), 24 | Text: z.string(), 25 | Severity: z.string(), 26 | Pos: GolangciLintPositionSchema, 27 | }) 28 | 29 | export const GolangciLintResultSchema = z.object({ 30 | Issues: z.array(GolangciLintIssueSchema).optional(), 31 | }) 32 | 33 | export const LintIssueSchema = z.object({ 34 | file: z.string(), 35 | line: z.number(), 36 | column: z.number(), 37 | severity: z.enum(['error', 'warning']), 38 | message: z.string(), 39 | rule: z.string().optional(), 40 | }) 41 | 42 | export const LintResultSchema = z.object({ 43 | timestamp: z.string(), 44 | files: z.array(z.string()), 45 | issues: z.array(LintIssueSchema), 46 | errorCount: z.number(), 47 | warningCount: z.number(), 48 | }) 49 | 50 | export const LintDataSchema = LintResultSchema.extend({ 51 | hasNotifiedAboutLintIssues: z.boolean(), 52 | }) 53 | 54 | export type ESLintMessage = z.infer 55 | export type ESLintResult = z.infer 56 | export type GolangciLintPosition = z.infer 57 | export type GolangciLintIssue = z.infer 58 | export type GolangciLintResult = z.infer 59 | export type LintData = z.infer 60 | export type LintIssue = z.infer 61 | export type LintResult = z.infer 62 | -------------------------------------------------------------------------------- /src/validation/prompts/response.ts: -------------------------------------------------------------------------------- 1 | export const RESPONSE = `## Your Response 2 | 3 | ### Format 4 | Respond with a JSON object: 5 | \`\`\`json 6 | { 7 | "decision": "block" | null, 8 | "reason": "Clear explanation with actionable next steps" 9 | } 10 | \`\`\` 11 | 12 | ### Decision Values 13 | - **"block"**: Clear TDD principle violation detected 14 | - **null**: Changes follow TDD principles OR insufficient information to determine 15 | 16 | ### Writing Effective Reasons 17 | 18 | When blocking, your reason must: 19 | 1. **Identify the specific violation** (e.g., "Multiple test addition") 20 | 2. **Explain why it violates TDD** (e.g., "Adding 2 tests at once") 21 | 3. **Provide the correct next step** (e.g., "Add only one test first") 22 | 23 | #### Example Block Reasons: 24 | - "Multiple test addition violation - adding 2 new tests simultaneously. Write and run only ONE test at a time to maintain TDD discipline." 25 | - "Over-implementation violation. Test fails with 'Calculator is not defined' but implementation adds both class AND method. Create only an empty class first, then run test again." 26 | - "Refactoring without passing tests. Test output shows failures. Fix failing tests first, ensure all pass, then refactor." 27 | - "Premature implementation - implementing without a failing test. Write the test first, run it to see the specific failure, then implement only what's needed to address that failure." 28 | - "No test output captured. Cannot validate TDD compliance without test results. Run tests using standard commands (npm test, pytest) without output filtering or redirection that may prevent the test reporter from capturing results." 29 | 30 | #### Example Approval Reasons: 31 | - "Adding single test to test file - follows TDD red phase" 32 | - "Minimal implementation addressing specific test failure" 33 | - "Refactoring with evidence of passing tests" 34 | 35 | ### Focus 36 | Remember: You are ONLY evaluating TDD compliance, not: 37 | - Code quality or style 38 | - Performance or optimization 39 | - Design patterns or architecture 40 | - Variable names or formatting` 41 | -------------------------------------------------------------------------------- /src/validation/prompts/operations/write.ts: -------------------------------------------------------------------------------- 1 | export const WRITE = `## Analyzing Write Operations 2 | 3 | This section shows the new file being created. Analyze the content to determine if it follows TDD principles for new file creation. 4 | 5 | ### Your Task 6 | You are reviewing a Write operation where a new file is being created. Determine if this violates TDD principles. 7 | 8 | **FIRST**: Check the file path to identify if this is a test file (\`.test.\`, \`.spec.\`, or \`test/\`) or implementation file. 9 | 10 | ### Write Operation Rules 11 | 12 | 1. **Creating a test file:** 13 | - Usually the first step in TDD (Red phase) 14 | - Should contain only ONE test initially 15 | - Multiple tests in new test file = Violation 16 | - Exception: Test utilities or setup files 17 | 18 | 2. **Creating an implementation file:** 19 | - Must have evidence of a failing test 20 | - Check test output for justification 21 | - Implementation must match test failure type 22 | - No test output = Likely violation 23 | 24 | 3. **Special considerations:** 25 | - Configuration files: Generally allowed 26 | - Test helpers/utilities: Allowed if supporting TDD 27 | - Empty stubs: Allowed if addressing test failure 28 | 29 | ### Common Write Scenarios 30 | 31 | **Scenario 1**: Writing first test file 32 | - Allowed: File with one test 33 | - Violation: File with multiple tests 34 | - Reason: TDD requires one test at a time 35 | 36 | **Scenario 2**: Writing implementation without test 37 | - Check for test output 38 | - No output = "Premature implementation" 39 | - With output = Verify it matches implementation 40 | 41 | **Scenario 3**: Writing full implementation 42 | - Test shows "not defined" 43 | - Writing complete class with methods = Violation 44 | - Should write minimal stub first 45 | 46 | ### Key Questions for Write Operations 47 | 48 | 1. Is this creating a test or implementation file? 49 | 2. If test: Does it contain only one test? 50 | 3. If implementation: Is there a failing test? 51 | 4. Does the implementation match the test failure? 52 | 53 | ## Changes to Review 54 | ` 55 | -------------------------------------------------------------------------------- /test/utils/factories/testDefaults.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default values for test data used across factories 3 | */ 4 | 5 | const session_id = 'test-session-id' 6 | const transcript_path = '/path/to/transcript.jsonl' 7 | const hook_event_name = 'PreToolUse' 8 | 9 | const todo = { 10 | content: 'Implement feature', 11 | status: 'pending' as const, 12 | priority: 'high' as const, 13 | id: '123', 14 | } 15 | const write = { 16 | file_path: '/test/file.ts', 17 | content: 'file content to write', 18 | } 19 | 20 | const edit = { 21 | file_path: '/test/file.ts', 22 | old_string: 'old content;', 23 | new_string: 'old content; new content', 24 | } 25 | 26 | const multiEdit = { 27 | file_path: '/test/file.ts', 28 | edits: [ 29 | { 30 | old_string: 'first old content', 31 | new_string: 'first new content', 32 | }, 33 | { 34 | old_string: 'second old content', 35 | new_string: 'second new content', 36 | }, 37 | ], 38 | } 39 | 40 | export const TEST_DEFAULTS = { 41 | hookData: { 42 | session_id, 43 | transcript_path, 44 | hook_event_name, 45 | }, 46 | todo, 47 | todoWriteOperation: { 48 | tool_name: 'TodoWrite', 49 | tool_input: { 50 | todos: [todo], 51 | }, 52 | }, 53 | write, 54 | writeOperation: { 55 | tool_name: 'Write', 56 | tool_input: { 57 | ...write, 58 | }, 59 | }, 60 | edit, 61 | editOperation: { 62 | tool_name: 'Edit', 63 | tool_input: { 64 | ...edit, 65 | }, 66 | }, 67 | multiEdit, 68 | multiEditOperation: { 69 | tool_name: 'MultiEdit', 70 | tool_input: { 71 | ...multiEdit, 72 | }, 73 | }, 74 | userPromptSubmit: { 75 | session_id, 76 | transcript_path, 77 | hook_event_name: 'UserPromptSubmit', 78 | prompt: 'tdd-guard on', 79 | cwd: '/current/working/directory', 80 | }, 81 | sessionStart: { 82 | session_id, 83 | transcript_path, 84 | hook_event_name: 'SessionStart', 85 | source: 'startup' as const, 86 | }, 87 | // Context defaults 88 | modifications: 'Test modifications', 89 | test: 'Test results', 90 | } as const 91 | -------------------------------------------------------------------------------- /reporters/test/factories/go.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync, execFileSync } from 'node:child_process' 2 | import { join } from 'node:path' 3 | import { existsSync } from 'node:fs' 4 | import type { ReporterConfig, TestScenarios } from '../types' 5 | import { copyTestArtifacts } from './helpers' 6 | 7 | export function createGoReporter(): ReporterConfig { 8 | // Use hardcoded absolute path for security when available, fall back to PATH for CI environments 9 | const goBinary = existsSync('/usr/local/go/bin/go') ? '/usr/local/go/bin/go' : 'go' 10 | const artifactDir = 'go' 11 | const testScenarios = { 12 | singlePassing: 'passing', 13 | singleFailing: 'failing', 14 | singleImportError: 'import', 15 | } 16 | 17 | return { 18 | name: 'GoReporter', 19 | testScenarios, 20 | run: (tempDir, scenario: keyof TestScenarios) => { 21 | // Copy the test module directory to temp 22 | copyTestArtifacts(artifactDir, testScenarios, scenario, tempDir) 23 | 24 | const reporterPath = join(__dirname, '../../go') 25 | const binaryPath = join(tempDir, 'tdd-guard-go') 26 | 27 | // Build the reporter binary, output to temp directory 28 | spawnSync(goBinary, ['build', '-o', binaryPath, './cmd/tdd-guard-go'], { 29 | cwd: reporterPath, 30 | stdio: 'pipe', 31 | }) 32 | 33 | // Run go test with JSON output 34 | const goTestResult = spawnSync(goBinary, ['test', '-json', '.'], { 35 | cwd: tempDir, 36 | stdio: 'pipe', 37 | encoding: 'utf8', 38 | }) 39 | 40 | // Combine stdout and stderr for processing 41 | const testOutput = (goTestResult.stdout || '') + (goTestResult.stderr || '') 42 | 43 | // Pipe test output to our reporter 44 | try { 45 | execFileSync(binaryPath, ['-project-root', tempDir], { 46 | cwd: tempDir, 47 | input: testOutput, 48 | stdio: 'pipe', 49 | encoding: 'utf8', 50 | }) 51 | } catch (error) { 52 | // Return the error for test verification 53 | return error as ReturnType 54 | } 55 | 56 | return goTestResult 57 | }, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /reporters/jest/README.md: -------------------------------------------------------------------------------- 1 | # TDD Guard Jest Reporter 2 | 3 | Jest reporter that captures test results for TDD Guard validation. 4 | 5 | ## Requirements 6 | 7 | - Node.js 18+ 8 | - Jest 30.0.5+ 9 | - [TDD Guard](https://github.com/nizos/tdd-guard) installed globally 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install --save-dev tdd-guard-jest 15 | ``` 16 | 17 | ## Configuration 18 | 19 | ### Jest Configuration 20 | 21 | Add the reporter to your `jest.config.js`: 22 | 23 | ```javascript 24 | module.exports = { 25 | reporters: [ 26 | 'default', 27 | [ 28 | 'tdd-guard-jest', 29 | { 30 | projectRoot: __dirname, 31 | }, 32 | ], 33 | ], 34 | } 35 | ``` 36 | 37 | Or in `jest.config.ts`: 38 | 39 | ```typescript 40 | import type { Config } from 'jest' 41 | import path from 'path' 42 | 43 | const config: Config = { 44 | reporters: [ 45 | 'default', 46 | [ 47 | 'tdd-guard-jest', 48 | { 49 | projectRoot: path.resolve(__dirname), 50 | }, 51 | ], 52 | ], 53 | } 54 | 55 | export default config 56 | ``` 57 | 58 | ### Workspace/Monorepo Configuration 59 | 60 | For workspaces or monorepos, pass the project root path to the reporter: 61 | 62 | ```javascript 63 | // jest.config.js in project root 64 | const path = require('path') 65 | 66 | module.exports = { 67 | reporters: [ 68 | 'default', 69 | [ 70 | 'tdd-guard-jest', 71 | { 72 | projectRoot: path.resolve(__dirname), 73 | }, 74 | ], 75 | ], 76 | } 77 | ``` 78 | 79 | If your jest config is in a workspace subdirectory, pass the absolute path to your project root: 80 | 81 | ```javascript 82 | module.exports = { 83 | reporters: [ 84 | 'default', 85 | [ 86 | 'tdd-guard-jest', 87 | { 88 | projectRoot: '/Users/username/projects/my-app', 89 | }, 90 | ], 91 | ], 92 | } 93 | ``` 94 | 95 | ## More Information 96 | 97 | - Test results are saved to `.claude/tdd-guard/data/test.json` 98 | - See [TDD Guard documentation](https://github.com/nizos/tdd-guard) for complete setup 99 | 100 | ## License 101 | 102 | MIT 103 | -------------------------------------------------------------------------------- /test/hooks/processHookData.python.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { processHookData } from '../../src/hooks/processHookData' 3 | import { MemoryStorage } from '../../src/storage/MemoryStorage' 4 | 5 | describe('processHookData python support', () => { 6 | it('should handle pytest test results format', async () => { 7 | const storage = new MemoryStorage() 8 | const pytestResults = { 9 | testModules: [ 10 | { 11 | moduleId: 'test_example.py', 12 | tests: [ 13 | { 14 | name: 'test_passing', 15 | fullName: 'test_example.py::test_passing', 16 | state: 'passed' 17 | } 18 | ] 19 | } 20 | ] 21 | } 22 | 23 | await storage.saveTest(JSON.stringify(pytestResults)) 24 | 25 | const result = await processHookData('{"hook_event_name": "PreToolUse"}', { storage }) 26 | 27 | expect(result.decision).toBeUndefined() 28 | }) 29 | 30 | it('should handle pytest failing test results', async () => { 31 | const storage = new MemoryStorage() 32 | const pytestResults = { 33 | testModules: [ 34 | { 35 | moduleId: 'test_example.py', 36 | tests: [ 37 | { 38 | name: 'test_failing', 39 | fullName: 'test_example.py::test_failing', 40 | state: 'failed', 41 | errors: [{ message: 'AssertionError: 1 != 2' }] 42 | } 43 | ] 44 | } 45 | ] 46 | } 47 | 48 | await storage.saveTest(JSON.stringify(pytestResults)) 49 | 50 | const result = await processHookData('{"hook_event_name": "PreToolUse"}', { storage }) 51 | 52 | expect(result.decision).toBeUndefined() 53 | }) 54 | 55 | it('should detect Python files from hook data', async () => { 56 | const storage = new MemoryStorage() 57 | const hookData = { 58 | hook_event_name: 'PreToolUse', 59 | tool_input: { 60 | file_path: 'src/calculator.py' 61 | } 62 | } 63 | 64 | const result = await processHookData(JSON.stringify(hookData), { storage }) 65 | 66 | expect(result.decision).toBeUndefined() 67 | }) 68 | }) -------------------------------------------------------------------------------- /src/validation/prompts/file-types.ts: -------------------------------------------------------------------------------- 1 | export const FILE_TYPES = `## File Type Specific Rules 2 | 3 | ### Identifying File Types 4 | - **Test files**: Contain \`.test.\`, \`.spec.\`, or \`test/\` in the path 5 | - **Implementation files**: All other source files 6 | 7 | ### Test File Rules 8 | 9 | #### Always Allowed: 10 | - **Adding ONE new test** - This is ALWAYS allowed regardless of test output (foundation of TDD cycle) 11 | - Modifying existing tests without adding new ones 12 | - Setting up test infrastructure and utilities 13 | 14 | **CRITICAL**: Adding a single test to a test file does NOT require prior test output. Writing the first failing test is the start of the TDD cycle. 15 | 16 | #### Violations: 17 | - Adding multiple new tests simultaneously 18 | - Refactoring tests without running them first 19 | 20 | #### Refactoring Tests: 21 | - ONLY allowed when relevant tests are passing 22 | - Moving test setup to beforeEach: Requires passing tests 23 | - Extracting test helpers: Requires passing tests 24 | - Blocked if tests are failing, no test output, or only irrelevant test output 25 | 26 | **For test refactoring**: "Relevant tests" are the tests in the file being refactored 27 | 28 | ### Implementation File Rules 29 | 30 | #### Creation Rules by Test Failure Type: 31 | 32 | | Test Failure | Allowed Implementation | 33 | |-------------|----------------------| 34 | | "X is not defined" | Create empty class/function stub only | 35 | | "X is not a constructor" | Create empty class only | 36 | | "X is not a function" | Add method stub only | 37 | | Assertion error (e.g., "expected X to be Y") | Implement logic to pass assertion | 38 | | No test output | Nothing - must run test first | 39 | | Irrelevant test output | Nothing - must run relevant test | 40 | 41 | #### Refactoring Implementation: 42 | - ONLY allowed when relevant tests are passing 43 | - Blocked if tests are failing 44 | - Blocked if no test output 45 | - Blocked if test output is for unrelated code 46 | 47 | **What are "relevant tests"?** 48 | - Tests that exercise the code being refactored 49 | - Tests that would fail if the refactored code was broken 50 | - Tests that import or depend on the module being changed 51 | - Key principle: The test output must show tests for the code you're changing 52 | ` 53 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TDD Guard Development", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "args": { 6 | "TZ": "${localEnv:TZ:Europe/Stockholm}", 7 | "CLAUDE_CODE_VERSION": "latest", 8 | "COMPOSER_VERSION": "2.8.10", 9 | "GIT_DELTA_VERSION": "0.18.2", 10 | "ZSH_IN_DOCKER_VERSION": "1.2.1", 11 | "GO_VERSION": "1.24.0", 12 | "RUST_VERSION": "1.89.0" 13 | } 14 | }, 15 | "runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"], 16 | "customizations": { 17 | "vscode": { 18 | "extensions": [ 19 | "dbaeumer.vscode-eslint", 20 | "esbenp.prettier-vscode", 21 | "eamodio.gitlens", 22 | "vitest.explorer", 23 | "ms-python.python", 24 | "Anthropic.claude-code", 25 | "bmewburn.vscode-intelephense-client", 26 | "golang.go", 27 | "Shopify.ruby-lsp", 28 | "rust-lang.rust-analyzer" 29 | ], 30 | "settings": { 31 | "editor.formatOnSave": true, 32 | "editor.defaultFormatter": "esbenp.prettier-vscode", 33 | "editor.codeActionsOnSave": { 34 | "source.fixAll.eslint": "explicit" 35 | }, 36 | "terminal.integrated.defaultProfile.linux": "zsh", 37 | "terminal.integrated.profiles.linux": { 38 | "bash": { 39 | "path": "bash", 40 | "icon": "terminal-bash" 41 | }, 42 | "zsh": { 43 | "path": "zsh" 44 | } 45 | } 46 | } 47 | } 48 | }, 49 | "remoteUser": "node", 50 | "mounts": [ 51 | "source=claude-code-bashhistory-${devcontainerId},target=/commandhistory,type=volume", 52 | "source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume" 53 | ], 54 | "containerEnv": { 55 | "NODE_OPTIONS": "--max-old-space-size=4096", 56 | "CLAUDE_CONFIG_DIR": "/home/node/.claude", 57 | "POWERLEVEL9K_DISABLE_GITSTATUS": "true" 58 | }, 59 | "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated", 60 | "workspaceFolder": "/workspace", 61 | "postCreateCommand": "/usr/local/bin/setup-dev-environment.sh", 62 | "postStartCommand": "npx playwright install chromium --only-shell 2>/dev/null || echo 'Playwright already installed'" 63 | } 64 | -------------------------------------------------------------------------------- /test/utils/factories/scenarios/types.ts: -------------------------------------------------------------------------------- 1 | // Shared types for scenario data structures 2 | import type { Todo } from '../../../../src/contracts/schemas/toolSchemas' 3 | 4 | export interface TestData { 5 | description: string 6 | content: T 7 | } 8 | 9 | // Test result types 10 | export interface TestResults { 11 | notDefined: TestData 12 | notAConstructor: TestData 13 | notAFunction: TestData 14 | assertionError: TestData 15 | passing: TestData 16 | irrelevant: TestData 17 | empty: TestData 18 | } 19 | 20 | // Test modification types 21 | export interface TestModifications { 22 | singleTest: TestData 23 | multipleTests: TestData 24 | multipleTestsWithImports: TestData 25 | singleTestWithContainer: TestData 26 | singleTestComplete: TestData 27 | emptyTestContainer: TestData 28 | emptyTestContainerWithImports: TestData 29 | refactoredTests: TestData 30 | } 31 | 32 | // Implementation modification types 33 | export interface ImplementationModifications { 34 | empty: TestData 35 | classStub: TestData 36 | methodStub: TestData 37 | methodStubReturning0: TestData 38 | methodImplementation: TestData 39 | overEngineered: TestData 40 | completeClass: TestData 41 | } 42 | 43 | // Todo state types 44 | export interface TodoStates { 45 | empty: TestData 46 | irrelevantCompleted: TestData 47 | irrelevantInProgress: TestData 48 | classInProgress: TestData 49 | methodInProgress: TestData 50 | allCompleted: TestData 51 | refactoring: TestData 52 | } 53 | 54 | // Refactoring types 55 | export interface RefactoringScenarios { 56 | beforeRefactor: TestData 57 | afterRefactor: TestData 58 | } 59 | 60 | export interface RefactoringTestResults { 61 | failing: TestData 62 | passing: TestData 63 | } 64 | 65 | // Language scenario interface 66 | export interface LanguageScenario { 67 | language: 'typescript' | 'python' 68 | testFile: string 69 | implementationFile: string 70 | testResults: TestResults 71 | testModifications: TestModifications 72 | implementationModifications: ImplementationModifications 73 | todos: TodoStates 74 | refactoringImplementation: RefactoringScenarios 75 | refactoringTests: RefactoringScenarios 76 | refactoringTestResults: RefactoringTestResults 77 | } 78 | -------------------------------------------------------------------------------- /test/utils/factories/writeFactory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Factory functions for creating Write and WriteOperation test data 3 | */ 4 | 5 | import type { 6 | Write, 7 | WriteOperation, 8 | } from '../../../src/contracts/schemas/toolSchemas' 9 | import { hookDataDefaults, omit } from './helpers' 10 | import { TEST_DEFAULTS } from './testDefaults' 11 | 12 | /** 13 | * Creates a single write object 14 | * @param params - Optional parameters for the write 15 | */ 16 | export const write = (params?: Partial): Write => ({ 17 | file_path: params?.file_path ?? TEST_DEFAULTS.write.file_path, 18 | content: params?.content ?? TEST_DEFAULTS.write.content, 19 | }) 20 | 21 | /** 22 | * Creates a write object with specified properties omitted 23 | * @param keys - Array of property keys to omit 24 | * @param params - Optional parameters for the write 25 | */ 26 | export const writeWithout = ( 27 | keys: K[], 28 | params?: Partial 29 | ): Omit => { 30 | const fullWrite = write(params) 31 | return omit(fullWrite, keys) 32 | } 33 | 34 | /** 35 | * Creates a single write operation object 36 | * @param params - Optional parameters for the write operation 37 | */ 38 | export const writeOperation = ( 39 | params?: Partial 40 | ): WriteOperation => ({ 41 | ...hookDataDefaults(), 42 | tool_name: 'Write', 43 | tool_input: params?.tool_input ?? write(), 44 | }) 45 | 46 | /** 47 | * Creates a write operation object with specified properties omitted 48 | * @param keys - Array of property keys to omit 49 | * @param params - Optional parameters for the write operation 50 | */ 51 | export const writeOperationWithout = ( 52 | keys: K[], 53 | params?: Partial 54 | ): Omit => { 55 | const fullWriteOperation = writeOperation(params) 56 | return omit(fullWriteOperation, keys) 57 | } 58 | 59 | /** 60 | * Creates an invalid write operation object for testing 61 | * @param params - Parameters including invalid values 62 | */ 63 | export const invalidWriteOperation = (params: { 64 | tool_name?: string 65 | tool_input?: unknown 66 | }): Record => ({ 67 | ...hookDataDefaults(), 68 | tool_name: params.tool_name ?? 'Write', 69 | tool_input: params.tool_input ?? write(), 70 | }) 71 | -------------------------------------------------------------------------------- /reporters/test/factories/phpunit.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'node:child_process' 2 | import { symlinkSync, writeFileSync } from 'node:fs' 3 | import { join } from 'node:path' 4 | import type { ReporterConfig, TestScenarios } from '../types' 5 | import { copyTestArtifacts } from './helpers' 6 | 7 | export function createPhpunitReporter(): ReporterConfig { 8 | const artifactDir = 'phpunit' 9 | const testScenarios = { 10 | singlePassing: 'SinglePassingTest.php', 11 | singleFailing: 'SingleFailingTest.php', 12 | singleImportError: 'SingleImportErrorTest.php', 13 | } 14 | 15 | return { 16 | name: 'PHPUnitReporter', 17 | testScenarios, 18 | run: (tempDir, scenario: keyof TestScenarios) => { 19 | // Copy test file to tests subdirectory 20 | copyTestArtifacts(artifactDir, testScenarios, scenario, tempDir, { 21 | targetSubdir: 'tests', 22 | }) 23 | 24 | // Write PHPUnit config 25 | writeFileSync(join(tempDir, 'phpunit.xml'), createPhpunitConfig(tempDir)) 26 | 27 | // Create symlink to vendor directory 28 | const reporterVendorPath = join(__dirname, '../../phpunit/vendor') 29 | const tempVendorPath = join(tempDir, 'vendor') 30 | symlinkSync(reporterVendorPath, tempVendorPath) 31 | 32 | // Run PHPUnit 33 | const phpunitPath = join(__dirname, '../../phpunit/vendor/bin/phpunit') 34 | spawnSync(phpunitPath, ['-c', 'phpunit.xml'], { 35 | cwd: tempDir, 36 | env: { ...process.env }, 37 | stdio: 'pipe', 38 | }) 39 | }, 40 | } 41 | } 42 | 43 | function createPhpunitConfig(tempDir: string): string { 44 | return ` 45 | 50 | 51 | 52 | tests 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ` 63 | } 64 | -------------------------------------------------------------------------------- /src/validation/prompts/operations/multi-edit.ts: -------------------------------------------------------------------------------- 1 | export const MULTI_EDIT = `## Analyzing MultiEdit Operations 2 | 3 | This section shows the code changes being proposed. Compare the old content with the new content to identify what's being added, removed, or modified. 4 | 5 | ### Your Task 6 | You are reviewing a MultiEdit operation where multiple edits are being applied to the same file. Each edit must be evaluated for TDD compliance. 7 | 8 | **FIRST**: Check the file path to identify if this is a test file (\`.test.\`, \`.spec.\`, or \`test/\`) or implementation file. 9 | 10 | ### How to Analyze Multiple Edits 11 | 12 | 1. **Process edits sequentially** 13 | - Each edit builds on the previous one 14 | - Track cumulative changes across all edits 15 | - Count total new tests across ALL edits 16 | 17 | 2. **Counting new tests across edits:** 18 | - Start with the original file content 19 | - Apply each edit in sequence 20 | - Count tests that appear in final result but not in original 21 | - Multiple new tests across all edits = Violation 22 | 23 | 3. **Common patterns to watch for:** 24 | - Edit 1: Adds one test (OK) 25 | - Edit 2: Adds another test (VIOLATION - 2 total new tests) 26 | 27 | ### Test File Changes 28 | 29 | **For test files**: Adding ONE new test total across all edits is allowed - no test output required. Multiple new tests = violation. 30 | 31 | ### Implementation Changes in MultiEdit 32 | 33 | 1. **Each edit must be justified** 34 | - Check if test output supports the change 35 | - Verify incremental implementation 36 | - No edit should over-implement 37 | 38 | 2. **Sequential dependency** 39 | - Later edits may depend on earlier ones 40 | - But this doesn't justify multiple new tests 41 | - Each edit should still follow minimal implementation 42 | 43 | ### Example MultiEdit Analysis 44 | 45 | **Edit 1**: Adds empty Calculator class 46 | - Test output: "Calculator is not defined" 47 | - Analysis: Appropriate minimal fix 48 | 49 | **Edit 2**: Adds both add() and subtract() methods 50 | - Test output: "calculator.add is not a function" 51 | - Analysis: VIOLATION - Should only add add() method 52 | 53 | **Reason**: "Over-implementation in Edit 2. Test only requires add() method but edit adds both add() and subtract(). Implement only the method causing the test failure." 54 | ` 55 | -------------------------------------------------------------------------------- /src/contracts/schemas/reporterSchemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const TestErrorSchema = z.object({ 4 | message: z.string(), 5 | stack: z.string().optional(), 6 | }) 7 | 8 | export const UnhandledErrorSchema = z.object({ 9 | name: z.string(), 10 | message: z.string(), 11 | stack: z.string().optional(), 12 | }) 13 | 14 | export const TestSchema = z.object({ 15 | name: z.string(), 16 | fullName: z.string(), 17 | state: z.enum(['passed', 'failed', 'skipped']), 18 | errors: z.array(TestErrorSchema).optional(), 19 | }) 20 | 21 | export const TestModuleSchema = z.object({ 22 | moduleId: z.string(), 23 | tests: z.array(TestSchema), 24 | }) 25 | 26 | export const TestResultSchema = z.object({ 27 | testModules: z.array(TestModuleSchema), 28 | unhandledErrors: z.array(UnhandledErrorSchema).optional(), 29 | reason: z.enum(['passed', 'failed', 'interrupted']).optional(), 30 | }) 31 | 32 | export type TestError = z.infer 33 | export type UnhandledError = z.infer 34 | export type Test = z.infer 35 | export type TestModule = z.infer 36 | export type TestResult = z.infer 37 | 38 | export function isTestModule(value: unknown): value is TestModule { 39 | return TestModuleSchema.safeParse(value).success 40 | } 41 | 42 | export function isTestCase(value: unknown): value is Test { 43 | return TestSchema.safeParse(value).success 44 | } 45 | 46 | export function isFailingTest( 47 | value: unknown 48 | ): value is Test & { state: 'failed' } { 49 | return isTestCase(value) && value.state === 'failed' 50 | } 51 | 52 | export function isPassingTest( 53 | value: unknown 54 | ): value is Test & { state: 'passed' } { 55 | return isTestCase(value) && value.state === 'passed' 56 | } 57 | 58 | export function isTestPassing(testResult: TestResult): boolean { 59 | // No tests means the test suite is not passing 60 | if (testResult.testModules.length === 0) { 61 | return false 62 | } 63 | 64 | // Check if any tests exist 65 | const hasTests = testResult.testModules.some( 66 | (module) => module.tests.length > 0 67 | ) 68 | if (!hasTests) { 69 | return false 70 | } 71 | 72 | return testResult.testModules.every((module) => 73 | module.tests.every((test) => test.state !== 'failed') 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /test/integration/test-context.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeEach } from 'vitest' 2 | import { VitestReporter } from 'tdd-guard-vitest' 3 | import { TestResultsProcessor } from '../../src/processors' 4 | import { MemoryStorage } from '../../src/storage/MemoryStorage' 5 | import { testData } from '../utils' 6 | 7 | describe('Test Context', () => { 8 | describe('reporter and processor handle module import errors correctly', () => { 9 | let storage: MemoryStorage 10 | let reporter: VitestReporter 11 | let processor: TestResultsProcessor 12 | let result: string 13 | 14 | beforeEach(async () => { 15 | // Setup 16 | storage = new MemoryStorage() 17 | reporter = new VitestReporter(storage) 18 | processor = new TestResultsProcessor() 19 | 20 | // Given a module with import error 21 | const moduleWithError = testData.testModule({ 22 | moduleId: '/src/utils/helpers.test.ts', 23 | }) 24 | 25 | // And an import error 26 | const importError = testData.createUnhandledError() 27 | 28 | // When the reporter processes the module with error 29 | reporter.onTestModuleCollected(moduleWithError) 30 | await reporter.onTestRunEnd([], [importError]) 31 | 32 | // And the processor formats the stored data 33 | const storedData = await storage.getTest() 34 | expect(storedData).toBeTruthy() 35 | result = processor.process(storedData!) 36 | }) 37 | 38 | test('shows the module as passed with 0 tests', () => { 39 | expect(result).toContain('✓ /src/utils/helpers.test.ts (0 tests)') 40 | }) 41 | 42 | test('displays unhandled errors section', () => { 43 | expect(result).toContain('Unhandled Errors:') 44 | }) 45 | 46 | test('shows error name and message', () => { 47 | expect(result).toContain('× Error: Cannot find module "./helpers"') 48 | }) 49 | 50 | test('includes stack trace header', () => { 51 | expect(result).toContain('Stack:') 52 | }) 53 | 54 | test('shows import location in stack trace', () => { 55 | expect(result).toContain('imported from') 56 | }) 57 | 58 | test('summary shows 1 passed test file', () => { 59 | expect(result).toContain('Test Files 1 passed (1)') 60 | }) 61 | 62 | test('summary shows 0 tests', () => { 63 | expect(result).toContain('Tests 0 passed (0)') 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /reporters/pytest/tests/test_path_validation.py: -------------------------------------------------------------------------------- 1 | """Test path validation for project root configuration""" 2 | from pathlib import Path 3 | from tdd_guard_pytest.pytest_reporter import TDDGuardPytestPlugin, DEFAULT_DATA_DIR 4 | from .helpers import create_config 5 | 6 | 7 | def test_plugin_only_accepts_absolute_paths(): 8 | """Test that plugin ignores relative paths""" 9 | config = create_config("../some/relative/path") 10 | plugin = TDDGuardPytestPlugin(config) 11 | 12 | # Should fall back to default when relative path is provided 13 | assert plugin.storage_dir == DEFAULT_DATA_DIR 14 | 15 | 16 | def test_plugin_rejects_project_root_when_cwd_is_outside(): 17 | """Test that plugin rejects project root if cwd is not within it""" 18 | project_root = Path("/other/project") 19 | cwd = Path("/test/current") 20 | 21 | config = create_config(str(project_root)) 22 | plugin = TDDGuardPytestPlugin(config, cwd=cwd) 23 | 24 | # Should fall back to default since cwd is not within project_root 25 | assert plugin.storage_dir == DEFAULT_DATA_DIR 26 | 27 | 28 | def test_plugin_accepts_project_root_same_as_cwd(): 29 | """Test that plugin accepts project root when it equals cwd""" 30 | cwd = Path("/test/project") 31 | 32 | config = create_config(str(cwd)) 33 | plugin = TDDGuardPytestPlugin(config, cwd=cwd) 34 | 35 | # Should use the configured directory since cwd == project root 36 | expected_storage = cwd / DEFAULT_DATA_DIR 37 | assert plugin.storage_dir == expected_storage 38 | 39 | 40 | def test_plugin_accepts_project_root_when_cwd_is_child(): 41 | """Test that plugin accepts project root when cwd is a child of it""" 42 | project_root = Path("/test/project") 43 | cwd = project_root / "subdir" 44 | 45 | config = create_config(str(project_root)) 46 | plugin = TDDGuardPytestPlugin(config, cwd=cwd) 47 | 48 | # Should use the configured directory since cwd is within parent 49 | expected_storage = project_root / DEFAULT_DATA_DIR 50 | assert plugin.storage_dir == expected_storage 51 | 52 | 53 | def test_plugin_handles_empty_config_value(): 54 | """Test that plugin uses default when config returns empty string""" 55 | config = create_config("") 56 | plugin = TDDGuardPytestPlugin(config) 57 | 58 | # Should fall back to default when config is empty 59 | assert plugin.storage_dir == DEFAULT_DATA_DIR -------------------------------------------------------------------------------- /docs/adr/004-monorepo-architecture.md: -------------------------------------------------------------------------------- 1 | # ADR-004: Monorepo Architecture for Multi-Language Support 2 | 3 | ## Status 4 | 5 | Accepted 6 | 7 | ## Context 8 | 9 | TDD Guard originally published a single package to both npm and PyPI, with all test framework reporters mixed together in the src directory. This created several problems: 10 | 11 | - **Language mixing** - JavaScript and Python code in the same package 12 | - **Publishing complexity** - Single codebase published to multiple package registries 13 | - **Package bloat** - Users installed code for all languages even if using only one 14 | - **Contribution barriers** - Adding new reporters required navigating the entire codebase 15 | 16 | We considered several approaches: 17 | 18 | 1. **Keep monolithic structure** - Continue with mixed languages in one package 19 | 2. **Separate repositories** - Create individual repos for each reporter 20 | 3. **Monorepo with workspaces** - Keep one repo but separate packages 21 | 22 | ## Decision 23 | 24 | We will restructure TDD Guard as a monorepo using npm workspaces, with each reporter as a separate package. 25 | 26 | The new structure: 27 | 28 | ``` 29 | tdd-guard/ # Main CLI package (npm) 30 | ├── src/ # Core functionality and shared code 31 | └── package.json 32 | 33 | reporters/ 34 | ├── vitest/ # tdd-guard-vitest package (npm) 35 | │ └── package.json 36 | └── pytest/ # tdd-guard-pytest package (PyPI) 37 | └── pyproject.toml 38 | ``` 39 | 40 | Implementation details: 41 | 42 | - Main package exports shared functionality (Storage, Config, contracts) 43 | - Each reporter is a standalone package with its own version 44 | - Vitest reporter imports shared code from 'tdd-guard' package 45 | - Python reporter is self-contained (no JavaScript dependencies) 46 | 47 | ## Consequences 48 | 49 | ### Positive 50 | 51 | - **Clean separation** - Each language has its own package and tooling 52 | - **Smaller packages** - Users only install what they need 53 | - **Independent releases** - Can update reporters without touching others 54 | - **Easier contributions** - Clear boundaries for adding new reporters 55 | 56 | ### Negative 57 | 58 | - **Multiple packages to maintain** - More release overhead 59 | - **Build complexity** - Must ensure correct build order during development 60 | 61 | ### Neutral 62 | 63 | - Users now install two packages (CLI + reporter) instead of one 64 | - Each package has its own documentation and version number 65 | -------------------------------------------------------------------------------- /src/guard/GuardManager.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from '../storage/Storage' 2 | import { FileStorage } from '../storage/FileStorage' 3 | import { minimatch } from 'minimatch' 4 | import { 5 | GuardConfig, 6 | GuardConfigSchema, 7 | } from '../contracts/schemas/guardSchemas' 8 | 9 | export class GuardManager { 10 | private readonly storage: Storage 11 | private readonly minimatchOptions = { 12 | matchBase: true, // allows *.ext to match in any directory 13 | nobrace: false, // enables brace expansion {a,b} 14 | dot: true, // allows patterns to match files/dirs starting with . 15 | } as const 16 | 17 | static readonly DEFAULT_IGNORE_PATTERNS = [ 18 | '*.md', 19 | '*.txt', 20 | '*.log', 21 | '*.json', 22 | '*.yml', 23 | '*.yaml', 24 | '*.xml', 25 | '*.html', 26 | '*.css', 27 | '*.rst', 28 | ] 29 | 30 | constructor(storage?: Storage) { 31 | this.storage = storage ?? new FileStorage() 32 | } 33 | 34 | async isEnabled(): Promise { 35 | const config = await this.getConfig() 36 | return config?.guardEnabled ?? true 37 | } 38 | 39 | async enable(): Promise { 40 | await this.setGuardEnabled(true) 41 | } 42 | 43 | async disable(): Promise { 44 | await this.setGuardEnabled(false) 45 | } 46 | 47 | async getIgnorePatterns(): Promise { 48 | const config = await this.getConfig() 49 | return config?.ignorePatterns ?? GuardManager.DEFAULT_IGNORE_PATTERNS 50 | } 51 | 52 | async shouldIgnoreFile(filePath: string): Promise { 53 | const patterns = await this.getIgnorePatterns() 54 | 55 | return patterns.some((pattern) => 56 | minimatch(filePath, pattern, this.minimatchOptions) 57 | ) 58 | } 59 | 60 | private async setGuardEnabled(enabled: boolean): Promise { 61 | const existingConfig = await this.getConfig() 62 | const config: GuardConfig = { 63 | ...existingConfig, 64 | guardEnabled: enabled, 65 | } 66 | await this.storage.saveConfig(JSON.stringify(config)) 67 | } 68 | 69 | private async getConfig(): Promise { 70 | const configString = await this.storage.getConfig() 71 | if (!configString) { 72 | return null 73 | } 74 | 75 | try { 76 | const parsed = JSON.parse(configString) 77 | return GuardConfigSchema.parse(parsed) 78 | } catch { 79 | // Return null for invalid JSON or schema validation errors 80 | return null 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /docs/session-management.md: -------------------------------------------------------------------------------- 1 | # TDD Guard Session Management 2 | 3 | The SessionStart hook manages TDD Guard's session data and ensures a clean slate for each Claude Code session. 4 | 5 | ## What It Does 6 | 7 | ### Clears Transient Data 8 | 9 | - Test results from previous sessions 10 | - Lint reports and code quality checks 11 | - Other temporary validation data 12 | 13 | ### Sets Up Validation Rules 14 | 15 | - Creates the customizable instructions file if it doesn't exist 16 | - Preserves your custom rules if already configured 17 | - See [Custom Instructions](custom-instructions.md) for details 18 | 19 | **Note:** The guard's enabled/disabled state is preserved across sessions. 20 | 21 | ## Setup 22 | 23 | To enable session management, you need to add the SessionStart hook to your Claude Code configuration. 24 | You can set this up either through the interactive `/hooks` command or by manually editing your settings file. See [Settings File Locations](configuration.md#settings-file-locations) to choose the appropriate location. 25 | 26 | ### Using Interactive Setup (Recommended) 27 | 28 | 1. Type `/hooks` in Claude Code 29 | 2. Select `SessionStart - When a new session is started` 30 | 3. Select `+ Add new matcher…` 31 | 4. Enter matcher: `startup|resume|clear` 32 | 5. Select `+ Add new hook…` 33 | 6. Enter command: `tdd-guard` 34 | 7. Choose where to save 35 | 36 | ### Manual Configuration (Alternative) 37 | 38 | Add the following to your chosen settings file: 39 | 40 | ```json 41 | { 42 | "hooks": { 43 | "SessionStart": [ 44 | { 45 | "matcher": "startup|resume|clear", 46 | "hooks": [ 47 | { 48 | "type": "command", 49 | "command": "tdd-guard" 50 | } 51 | ] 52 | } 53 | ] 54 | } 55 | } 56 | ``` 57 | 58 | Note: Your configuration file may already have other hooks configured. 59 | Simply add the `SessionStart` section to your existing hooks object. 60 | 61 | ## How It Works 62 | 63 | The SessionStart hook triggers when: 64 | 65 | - Claude Code starts up (`startup`) 66 | - A session is resumed (`resume`) 67 | - The `/clear` command is used (`clear`) 68 | 69 | When triggered, TDD Guard clears all transient data while preserving the guard state and your custom validation rules. 70 | 71 | ## Tips 72 | 73 | - No manual intervention needed - clearing happens automatically 74 | - To toggle the guard on/off, use the [quick commands](quick-commands.md) 75 | - For debugging, check `.claude/tdd-guard/` to see stored data 76 | -------------------------------------------------------------------------------- /docs/adr/002-secure-claude-binary-path.md: -------------------------------------------------------------------------------- 1 | # ADR-002: Secure Claude Binary Path Configuration 2 | 3 | ## Status 4 | 5 | Accepted 6 | 7 | ## Context 8 | 9 | CodeQL security scanning identified a potential command injection vulnerability in `ClaudeModelClient` where the Claude binary path is taken from an environment variable (`CLAUDE_BINARY_PATH`) and interpolated into a shell command executed via `execSync`. 10 | 11 | The vulnerability occurs because: 12 | 13 | - Environment variables can be manipulated by attackers 14 | - Shell metacharacters in the path could be interpreted, allowing arbitrary command execution 15 | - For example, setting `CLAUDE_BINARY_PATH="claude; rm -rf /"` would execute both commands 16 | 17 | We considered several approaches: 18 | 19 | 1. **Use execFileSync instead of execSync** - Avoids shell interpretation entirely 20 | 2. **Validate/sanitize the binary path** - Check for allowed characters only 21 | 3. **Use shell-quote library** - Properly escape shell metacharacters 22 | 4. **Boolean flag for predefined paths** - Switch between hardcoded safe paths 23 | 24 | ## Decision 25 | 26 | We will use a boolean environment variable `USE_LOCAL_CLAUDE` to switch between two hardcoded, safe paths: 27 | 28 | - When `USE_LOCAL_CLAUDE=true`: Use `$HOME/.claude/local/claude` 29 | - Otherwise: Use system `claude` command 30 | 31 | Additionally, we will: 32 | 33 | - Implement the path logic in `ClaudeModelClient` rather than `Config` class 34 | - Use `execFileSync` instead of `execSync` to prevent shell interpretation 35 | - Keep the Config class focused on just providing the boolean flag 36 | 37 | ## Consequences 38 | 39 | ### Positive 40 | 41 | - **Eliminates injection risk** - No user-controlled input in command construction 42 | - **Simple and secure** - Only two possible paths, both hardcoded 43 | - **Clear intent** - Boolean flag clearly indicates local vs system Claude 44 | - **Separation of concerns** - Config provides settings, ModelClient handles implementation 45 | - **Future flexibility** - ModelClient can handle OS-specific paths internally 46 | 47 | ### Negative 48 | 49 | - **Less flexible** - Users cannot specify custom installation paths 50 | - **Requires code changes** - Adding new paths requires updating the code 51 | - **Platform-specific paths** - May need adjustment for different operating systems 52 | 53 | ### Neutral 54 | 55 | - Migration from `CLAUDE_BINARY_PATH` to `USE_LOCAL_CLAUDE` for existing users 56 | - Documentation needs to be updated to reflect the new configuration approach 57 | -------------------------------------------------------------------------------- /src/providers/ModelClientProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { ModelClientProvider } from './ModelClientProvider' 3 | import { Config } from '../config/Config' 4 | import { ClaudeCli } from '../validation/models/ClaudeCli' 5 | import { AnthropicApi } from '../validation/models/AnthropicApi' 6 | import { ClaudeAgentSdk } from '../validation/models/ClaudeAgentSdk' 7 | 8 | describe('ModelClientProvider', () => { 9 | test('uses default config when no config is provided', () => { 10 | const provider = new ModelClientProvider() 11 | const client = provider.getModelClient() 12 | 13 | expect(client['config']).toBeDefined() 14 | }) 15 | 16 | test('returns ClaudeCli when config validationClient is cli', () => { 17 | const config = new Config({ validationClient: 'cli' }) 18 | 19 | const provider = new ModelClientProvider() 20 | const client = provider.getModelClient(config) 21 | 22 | expect(client).toBeInstanceOf(ClaudeCli) 23 | }) 24 | 25 | test('returns AnthropicApi when config validationClient is api', () => { 26 | const config = new Config({ validationClient: 'api' }) 27 | 28 | const provider = new ModelClientProvider() 29 | const client = provider.getModelClient(config) 30 | 31 | expect(client).toBeInstanceOf(AnthropicApi) 32 | }) 33 | 34 | test('returns ClaudeAgentSdk when config validationClient is sdk', () => { 35 | const config = new Config({ validationClient: 'sdk' }) 36 | 37 | const provider = new ModelClientProvider() 38 | const client = provider.getModelClient(config) 39 | 40 | expect(client).toBeInstanceOf(ClaudeAgentSdk) 41 | }) 42 | 43 | test('passes config with API key to AnthropicApi client', () => { 44 | const config = new Config({ 45 | validationClient: 'api', 46 | anthropicApiKey: 'test-api-key-123', 47 | }) 48 | 49 | const provider = new ModelClientProvider() 50 | const client = provider.getModelClient(config) 51 | 52 | expect(client).toBeInstanceOf(AnthropicApi) 53 | expect(client['config'].anthropicApiKey).toBe('test-api-key-123') 54 | }) 55 | 56 | test('passes config with useSystemClaude to ClaudeCli client', () => { 57 | const config = new Config({ 58 | validationClient: 'cli', 59 | useSystemClaude: true, 60 | }) 61 | 62 | const provider = new ModelClientProvider() 63 | const client = provider.getModelClient(config) 64 | 65 | expect(client).toBeInstanceOf(ClaudeCli) 66 | expect(client['config'].useSystemClaude).toBe(true) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /docs/validation-model.md: -------------------------------------------------------------------------------- 1 | # Validation Model Configuration 2 | 3 | TDD Guard validates changes using AI. Configure both the validation client (SDK or API) and the Claude model version. 4 | 5 | ## Claude Agent SDK (Default) 6 | 7 | The recommended approach. Uses the Claude Agent SDK to communicate with Claude directly. 8 | 9 | ```bash 10 | VALIDATION_CLIENT=sdk # Default, can be omitted 11 | ``` 12 | 13 | **Features:** 14 | 15 | - Works automatically with your Claude Code installation 16 | - Uses your Claude Code subscription (no extra charges) 17 | - Requires Claude Code to be installed and authenticated 18 | 19 | **Important:** If you have `ANTHROPIC_API_KEY` set in your environment, Claude Code may use it for billing instead of your subscription. To avoid unexpected charges: 20 | 21 | ```bash 22 | # Check if API key is set 23 | echo $ANTHROPIC_API_KEY 24 | 25 | # Unset it if present 26 | unset ANTHROPIC_API_KEY 27 | ``` 28 | 29 | If you've never created an API key, you can ignore this warning. 30 | 31 | ## Anthropic API 32 | 33 | For CI/CD environments or when you need faster validation. Requires separate billing from Claude Code. 34 | 35 | ```bash 36 | VALIDATION_CLIENT=api 37 | TDD_GUARD_ANTHROPIC_API_KEY=your_api_key_here 38 | ``` 39 | 40 | Get your API key from [console.anthropic.com](https://console.anthropic.com/) 41 | 42 | **Notes:** 43 | 44 | - Charges separately from your Claude Code subscription ([pricing](https://www.anthropic.com/pricing)) 45 | - We use `TDD_GUARD_ANTHROPIC_API_KEY` (not `ANTHROPIC_API_KEY`) to prevent accidental charges. If you used the regular `ANTHROPIC_API_KEY`, Claude Code might use it for all your normal coding tasks, charging your API account instead of using your subscription. 46 | 47 | ## Model Selection 48 | 49 | Configure which Claude model to use for validation (default: `claude-sonnet-4-0`): 50 | 51 | ```bash 52 | # Fastest but unreliable results 53 | TDD_GUARD_MODEL_VERSION=claude-3-5-haiku-20241022 54 | 55 | # Best results but slowest 56 | TDD_GUARD_MODEL_VERSION=claude-opus-4-1 57 | ``` 58 | 59 | See [Claude model overview](https://docs.anthropic.com/en/docs/about-claude/models/overview) for available models and pricing. Note: pricing only applies to API users - SDK uses your Claude Code subscription by default. Balance model capability with [custom instructions](custom-instructions.md) to optimize for your needs. 60 | 61 | ## Migration from Legacy Configuration 62 | 63 | If you're using the old `MODEL_TYPE` configuration, see the [Configuration Migration Guide](config-migration.md) for detailed instructions. 64 | -------------------------------------------------------------------------------- /src/processors/lintProcessor.ts: -------------------------------------------------------------------------------- 1 | import { ProcessedLintData } from '../contracts/types/Context' 2 | import { LintData } from '../contracts/schemas/lintSchemas' 3 | 4 | /** 5 | * Processes lint data into a presentable format for validation context 6 | */ 7 | export function processLintData(lintData?: LintData): ProcessedLintData { 8 | if (!lintData) { 9 | return { 10 | hasIssues: false, 11 | summary: 'No lint data available', 12 | issuesByFile: new Map(), 13 | totalIssues: 0, 14 | errorCount: 0, 15 | warningCount: 0, 16 | } 17 | } 18 | 19 | const hasIssues = lintData.errorCount > 0 || lintData.warningCount > 0 20 | const totalIssues = lintData.errorCount + lintData.warningCount 21 | 22 | if (!hasIssues) { 23 | return { 24 | hasIssues: false, 25 | summary: 'No lint issues found', 26 | issuesByFile: new Map(), 27 | totalIssues: 0, 28 | errorCount: 0, 29 | warningCount: 0, 30 | } 31 | } 32 | 33 | // Group issues by file 34 | const issuesByFile = new Map() 35 | for (const issue of lintData.issues) { 36 | if (!issuesByFile.has(issue.file)) { 37 | issuesByFile.set(issue.file, []) 38 | } 39 | 40 | const ruleInfo = issue.rule ? ` (${issue.rule})` : '' 41 | const formattedIssue = ` Line ${issue.line}:${issue.column} - ${issue.severity}: ${issue.message}${ruleInfo}` 42 | issuesByFile.get(issue.file)!.push(formattedIssue) 43 | } 44 | 45 | const summary = `${totalIssues} lint ${totalIssues === 1 ? 'issue' : 'issues'} found (${lintData.errorCount} ${lintData.errorCount === 1 ? 'error' : 'errors'}, ${lintData.warningCount} ${lintData.warningCount === 1 ? 'warning' : 'warnings'})` 46 | 47 | return { 48 | hasIssues: true, 49 | summary, 50 | issuesByFile, 51 | totalIssues, 52 | errorCount: lintData.errorCount, 53 | warningCount: lintData.warningCount, 54 | } 55 | } 56 | 57 | /** 58 | * Formats processed lint data as a readable string for AI context 59 | */ 60 | export function formatLintDataForContext( 61 | processedLint: ProcessedLintData 62 | ): string { 63 | if (!processedLint.hasIssues) { 64 | return processedLint.summary 65 | } 66 | 67 | let formatted = `Code Quality Status: ${processedLint.summary}\n` 68 | 69 | if (processedLint.issuesByFile.size > 0) { 70 | formatted += '\nLint Issues by File:\n' 71 | for (const [file, issues] of processedLint.issuesByFile) { 72 | formatted += `\n${file}:\n${issues.join('\n')}\n` 73 | } 74 | } 75 | 76 | return formatted.trim() 77 | } 78 | -------------------------------------------------------------------------------- /reporters/phpunit/tests/TddGuardStorageLocationTest.php: -------------------------------------------------------------------------------- 1 | filesystem = new Filesystem(); 20 | $this->tempDir = sys_get_temp_dir() . '/tdd-guard-test-' . uniqid(); 21 | $this->filesystem->mkdir($this->tempDir); 22 | $this->originalCwd = getcwd(); 23 | chdir($this->tempDir); 24 | } 25 | 26 | protected function tearDown(): void 27 | { 28 | chdir($this->originalCwd); 29 | $this->filesystem->remove($this->tempDir); 30 | } 31 | 32 | public function testStorageSavesToCorrectLocation(): void 33 | { 34 | // Given: A storage configured with a specific project root 35 | $storage = new Storage($this->tempDir); 36 | 37 | // When: We save test results 38 | $testData = '{"testModules": []}'; 39 | $storage->saveTest($testData); 40 | 41 | // Then: The file should be saved in the correct TDD Guard location 42 | $expectedPath = $this->tempDir . '/.claude/tdd-guard/data/test.json'; 43 | $this->assertFileExists($expectedPath); 44 | $this->assertEquals($testData, file_get_contents($expectedPath)); 45 | } 46 | 47 | public function testStorageRespectsEnvironmentVariable(): void 48 | { 49 | // Given: Environment variable is set 50 | $originalEnv = getenv('TDD_GUARD_PROJECT_ROOT'); 51 | putenv('TDD_GUARD_PROJECT_ROOT=' . $this->tempDir); 52 | 53 | try { 54 | // When: Storage is created without explicit project root 55 | $storage = new Storage(''); 56 | $testData = '{"testModules": []}'; 57 | $storage->saveTest($testData); 58 | 59 | // Then: It should use the environment variable location 60 | $expectedPath = $this->tempDir . '/.claude/tdd-guard/data/test.json'; 61 | $this->assertFileExists($expectedPath); 62 | 63 | } finally { 64 | if ($originalEnv !== false) { 65 | putenv('TDD_GUARD_PROJECT_ROOT=' . $originalEnv); 66 | } else { 67 | putenv('TDD_GUARD_PROJECT_ROOT'); 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /reporters/rust/Makefile.example: -------------------------------------------------------------------------------- 1 | # Example Makefile for Rust projects with TDD Guard integration 2 | 3 | # Variables 4 | PROJECT_ROOT := $(shell pwd) 5 | CARGO := cargo 6 | NEXTEST := cargo nextest 7 | TDD_GUARD := tdd-guard-rust 8 | 9 | # Check if nextest is available 10 | HAS_NEXTEST := $(shell command -v cargo-nextest 2> /dev/null) 11 | 12 | # Default target 13 | .PHONY: all 14 | all: build test 15 | 16 | # Build the project 17 | .PHONY: build 18 | build: 19 | $(CARGO) build --release 20 | 21 | # Run tests with TDD Guard 22 | .PHONY: test 23 | test: 24 | ifdef HAS_NEXTEST 25 | $(NEXTEST) run 2>&1 | $(TDD_GUARD) --project-root $(PROJECT_ROOT) --passthrough 26 | else 27 | $(CARGO) test -- -Z unstable-options --format json 2>&1 | $(TDD_GUARD) --project-root $(PROJECT_ROOT) --passthrough 28 | endif 29 | 30 | # Run tests with TDD enforcement 31 | .PHONY: test-tdd 32 | test-tdd: 33 | tdd-guard on && $(MAKE) test 34 | 35 | # Run tests without TDD Guard (for debugging) 36 | .PHONY: test-plain 37 | test-plain: 38 | ifdef HAS_NEXTEST 39 | $(NEXTEST) run 40 | else 41 | $(CARGO) test 42 | endif 43 | 44 | # Install the TDD Guard Rust reporter 45 | .PHONY: install-reporter 46 | install-reporter: 47 | $(CARGO) install --path . 48 | 49 | # Clean build artifacts 50 | .PHONY: clean 51 | clean: 52 | $(CARGO) clean 53 | rm -rf .claude/tdd-guard/data/test.json 54 | 55 | # Run clippy for linting 56 | .PHONY: lint 57 | lint: 58 | $(CARGO) clippy -- -D warnings 59 | 60 | # Format code 61 | .PHONY: fmt 62 | fmt: 63 | $(CARGO) fmt 64 | 65 | # Check formatting 66 | .PHONY: fmt-check 67 | fmt-check: 68 | $(CARGO) fmt -- --check 69 | 70 | # Run all checks (format, lint, test) 71 | .PHONY: check 72 | check: fmt-check lint test 73 | 74 | # Install development dependencies 75 | .PHONY: dev-setup 76 | dev-setup: 77 | $(CARGO) install cargo-nextest 78 | $(CARGO) install tdd-guard-rust 79 | 80 | # Help target 81 | .PHONY: help 82 | help: 83 | @echo "Available targets:" 84 | @echo " all - Build and test (default)" 85 | @echo " build - Build the project" 86 | @echo " test - Run tests with TDD Guard" 87 | @echo " test-tdd - Run tests with TDD enforcement" 88 | @echo " test-plain - Run tests without TDD Guard" 89 | @echo " lint - Run clippy linting" 90 | @echo " fmt - Format code" 91 | @echo " fmt-check - Check code formatting" 92 | @echo " check - Run all checks" 93 | @echo " clean - Clean build artifacts" 94 | @echo " dev-setup - Install development dependencies" 95 | @echo " help - Show this help message" -------------------------------------------------------------------------------- /src/linters/eslint/ESLint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LintResult, 3 | LintIssue, 4 | ESLintResult, 5 | ESLintMessage, 6 | } from '../../contracts/schemas/lintSchemas' 7 | import { execFile } from 'child_process' 8 | import { promisify } from 'util' 9 | import { Linter } from '../Linter' 10 | 11 | const execFileAsync = promisify(execFile) 12 | 13 | export class ESLint implements Linter { 14 | async lint(filePaths: string[], configPath?: string): Promise { 15 | const timestamp = new Date().toISOString() 16 | const args = buildArgs(filePaths, configPath) 17 | 18 | try { 19 | await execFileAsync('npx', args, { shell: process.platform === 'win32' }) 20 | return createLintData(timestamp, filePaths, []) 21 | } catch (error) { 22 | if (!isExecError(error)) throw error 23 | 24 | const results = parseResults(error.stdout) 25 | return createLintData(timestamp, filePaths, results) 26 | } 27 | } 28 | } 29 | 30 | // Helper functions 31 | const buildArgs = (files: string[], configPath?: string): string[] => { 32 | const args = ['eslint', ...files, '--format', 'json'] 33 | if (configPath) { 34 | args.push('-c', configPath) 35 | } 36 | return args 37 | } 38 | 39 | const isExecError = (error: unknown): error is Error & { stdout?: string } => 40 | error !== null && typeof error === 'object' && 'stdout' in error 41 | 42 | const parseResults = (stdout?: string): ESLintResult[] => { 43 | try { 44 | return JSON.parse(stdout ?? '[]') 45 | } catch { 46 | return [] 47 | } 48 | } 49 | 50 | const createLintData = ( 51 | timestamp: string, 52 | files: string[], 53 | results: ESLintResult[] 54 | ): LintResult => { 55 | const issues = extractIssues(results) 56 | return { 57 | timestamp, 58 | files, 59 | issues, 60 | errorCount: countBySeverity(issues, 'error'), 61 | warningCount: countBySeverity(issues, 'warning'), 62 | } 63 | } 64 | 65 | const extractIssues = (results: ESLintResult[]): LintIssue[] => 66 | results.flatMap((file) => (file.messages ?? []).map(toIssue(file.filePath))) 67 | 68 | const toIssue = 69 | (filePath: string) => 70 | (msg: ESLintMessage): LintIssue => ({ 71 | file: filePath, 72 | line: msg.line ?? 0, 73 | column: msg.column ?? 0, 74 | severity: (msg.severity === 2 ? 'error' : 'warning') as 'error' | 'warning', 75 | message: msg.message, 76 | rule: msg.ruleId, 77 | }) 78 | 79 | const countBySeverity = ( 80 | issues: LintIssue[], 81 | severity: 'error' | 'warning' 82 | ): number => issues.filter((i) => i.severity === severity).length 83 | -------------------------------------------------------------------------------- /test/utils/factories/editFactory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Factory functions for creating Edit and EditOperation test data 3 | */ 4 | 5 | import type { 6 | Edit, 7 | EditOperation, 8 | } from '../../../src/contracts/schemas/toolSchemas' 9 | import { hookDataDefaults, omit } from './helpers' 10 | import { TEST_DEFAULTS } from './testDefaults' 11 | 12 | /** 13 | * Creates a single edit object 14 | * @param params - Optional parameters for the edit 15 | */ 16 | export const edit = (params?: Partial): Edit => { 17 | const defaults = TEST_DEFAULTS.edit 18 | const base = params ?? {} 19 | 20 | const result: Edit = { 21 | file_path: base.file_path ?? defaults.file_path, 22 | old_string: base.old_string ?? defaults.old_string, 23 | new_string: base.new_string ?? defaults.new_string, 24 | } 25 | 26 | if (base.replace_all !== undefined) { 27 | result.replace_all = base.replace_all 28 | } 29 | 30 | return result 31 | } 32 | 33 | /** 34 | * Creates an edit object with specified properties omitted 35 | * @param keys - Array of property keys to omit 36 | * @param params - Optional parameters for the edit 37 | */ 38 | export const editWithout = ( 39 | keys: K[], 40 | params?: Partial 41 | ): Omit => { 42 | const fullEdit = edit(params) 43 | return omit(fullEdit, keys) 44 | } 45 | 46 | /** 47 | * Creates a single edit operation object 48 | * @param params - Optional parameters for the edit operation 49 | */ 50 | export const editOperation = ( 51 | params?: Partial 52 | ): EditOperation => ({ 53 | ...hookDataDefaults(), 54 | tool_name: 'Edit', 55 | tool_input: params?.tool_input ?? edit(), 56 | }) 57 | 58 | /** 59 | * Creates an edit operation object with specified properties omitted 60 | * @param keys - Array of property keys to omit 61 | * @param params - Optional parameters for the edit operation 62 | */ 63 | export const editOperationWithout = ( 64 | keys: K[], 65 | params?: Partial 66 | ): Omit => { 67 | const fullEditOperation = editOperation(params) 68 | return omit(fullEditOperation, keys) 69 | } 70 | 71 | /** 72 | * Creates an invalid edit operation object for testing 73 | * @param params - Parameters including invalid values 74 | */ 75 | export const invalidEditOperation = (params: { 76 | tool_name?: string 77 | tool_input?: unknown 78 | }): Record => ({ 79 | ...hookDataDefaults(), 80 | tool_name: params.tool_name ?? 'Edit', 81 | tool_input: params.tool_input ?? edit(), 82 | }) 83 | -------------------------------------------------------------------------------- /reporters/rust/README.md: -------------------------------------------------------------------------------- 1 | # TDD Guard Rust Reporter 2 | 3 | Rust test reporter that captures test results for TDD Guard validation. 4 | 5 | **Note:** This reporter is part of the [TDD Guard](https://github.com/nizos/tdd-guard) project, which ensures Claude Code follows Test-Driven Development principles. 6 | 7 | ## Requirements 8 | 9 | - Rust 1.70+ 10 | - TDD Guard installed globally 11 | - `cargo-nextest` (recommended) or `cargo test` with JSON output support 12 | 13 | ## Installation 14 | 15 | ### Step 1: Install TDD Guard 16 | 17 | ```bash 18 | npm install -g tdd-guard 19 | ``` 20 | 21 | ### Step 2: Install the Rust reporter 22 | 23 | ```bash 24 | cargo install tdd-guard-rust 25 | ``` 26 | 27 | ## Usage 28 | 29 | The reporter works as a filter that processes test output while passing it through unchanged. 30 | 31 | ### With cargo-nextest (Recommended) 32 | 33 | ```bash 34 | cargo nextest run 2>&1 | tdd-guard-rust --project-root /absolute/path/to/project 35 | ``` 36 | 37 | ### With cargo test 38 | 39 | ```bash 40 | cargo test -- -Z unstable-options --format json 2>&1 | tdd-guard-rust --project-root /absolute/path/to/project 41 | ``` 42 | 43 | ### Direct Execution 44 | 45 | The reporter can also execute tests directly: 46 | 47 | ```bash 48 | # Auto-detect runner (prefers nextest) 49 | tdd-guard-rust --project-root /absolute/path/to/project 50 | 51 | # Force specific runner 52 | tdd-guard-rust --project-root /absolute/path/to/project --runner nextest 53 | tdd-guard-rust --project-root /absolute/path/to/project --runner cargo 54 | ``` 55 | 56 | ## Makefile Integration 57 | 58 | Add to your `Makefile`: 59 | 60 | ```makefile 61 | .PHONY: test 62 | test: 63 | cargo nextest run 2>&1 | tdd-guard-rust --project-root $(PWD) --passthrough 64 | 65 | .PHONY: test-tdd 66 | test-tdd: 67 | tdd-guard on && $(MAKE) test 68 | ``` 69 | 70 | ## Configuration 71 | 72 | ### Project Root 73 | 74 | The `--project-root` flag must be an absolute path to your project directory. This is where the `.claude/tdd-guard/data/test.json` file will be written. 75 | 76 | ### Flags 77 | 78 | - `--passthrough`: Force passthrough mode even if stdin is a terminal 79 | - `--runner [auto|nextest|cargo]`: Choose test runner for direct execution (default: auto) 80 | - `--project-root`: Absolute path to project directory (required) 81 | 82 | ## How It Works 83 | 84 | The reporter captures JSON-formatted test output, passes it through unchanged to stdout, and saves TDD Guard-formatted results to `.claude/tdd-guard/data/test.json`. 85 | 86 | ## License 87 | 88 | MIT - See LICENSE file in the repository root. 89 | -------------------------------------------------------------------------------- /reporters/phpunit/src/PathValidator.php: -------------------------------------------------------------------------------- 1 | filesystem = new Filesystem(); 20 | $this->tempDir = sys_get_temp_dir() . '/tdd-guard-path-test-' . uniqid(); 21 | $this->filesystem->mkdir($this->tempDir); 22 | $this->originalCwd = getcwd(); 23 | } 24 | 25 | protected function tearDown(): void 26 | { 27 | chdir($this->originalCwd); 28 | $this->filesystem->remove($this->tempDir); 29 | } 30 | 31 | public function testRejectsPathTraversal(): void 32 | { 33 | // Given: A path with directory traversal 34 | $pathWithTraversal = $this->tempDir . '/../dangerous'; 35 | 36 | // Then: Should throw exception 37 | $this->expectException(\InvalidArgumentException::class); 38 | $this->expectExceptionMessage('Configured project root is invalid'); 39 | 40 | // When: Validating the path 41 | PathValidator::resolveProjectRoot($pathWithTraversal); 42 | } 43 | 44 | public function testAllowsAncestorOfCurrentDirectory(): void 45 | { 46 | // Given: Working in a subdirectory 47 | $subDir = $this->tempDir . '/subdir'; 48 | $this->filesystem->mkdir($subDir); 49 | chdir($subDir); 50 | 51 | // When: Validating the parent directory 52 | $result = PathValidator::resolveProjectRoot($this->tempDir); 53 | 54 | // Then: Should return the validated path 55 | $this->assertEquals(realpath($this->tempDir), $result); 56 | } 57 | 58 | public function testRejectsNonAncestorDirectory(): void 59 | { 60 | // Given: Two sibling directories 61 | $dir1 = $this->tempDir . '/dir1'; 62 | $dir2 = $this->tempDir . '/dir2'; 63 | $this->filesystem->mkdir($dir1); 64 | $this->filesystem->mkdir($dir2); 65 | chdir($dir1); 66 | 67 | // Then: Should throw exception 68 | $this->expectException(\InvalidArgumentException::class); 69 | $this->expectExceptionMessage('Configured project root is invalid'); 70 | 71 | // When: Trying to use sibling directory as project root 72 | PathValidator::resolveProjectRoot($dir2); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import { VitestReporter } from 'tdd-guard-vitest' 3 | import path from 'path' 4 | 5 | const root = path.resolve(__dirname) 6 | 7 | // Shared test configuration 8 | const baseTestConfig = { 9 | globals: true, 10 | environment: 'node', 11 | testTimeout: 120000, 12 | exclude: ['**/node_modules/**', '**/dist/**', '**/artifacts/**'], 13 | } 14 | 15 | // Shared resolve configuration 16 | const baseResolveConfig = { 17 | alias: { 18 | '@testUtils': path.resolve(__dirname, './test/utils/index.ts'), 19 | }, 20 | } 21 | 22 | export default defineConfig({ 23 | test: { 24 | reporters: ['default', new VitestReporter(root)], 25 | projects: [ 26 | { 27 | test: { 28 | ...baseTestConfig, 29 | name: 'golangci-lint', 30 | include: ['src/linters/golangci/**/*.test.ts'], 31 | pool: 'forks', // Use forks for tests that need process.chdir 32 | }, 33 | resolve: baseResolveConfig, 34 | }, 35 | { 36 | test: { 37 | ...baseTestConfig, 38 | name: 'unit', 39 | include: ['**/*.test.ts'], 40 | exclude: [ 41 | ...baseTestConfig.exclude, 42 | 'src/linters/golangci/**/*.test.ts', // Handled by golangci-lint project 43 | '**/test/integration/**', 44 | '**/reporters/**', 45 | ], 46 | pool: 'threads', 47 | }, 48 | resolve: baseResolveConfig, 49 | }, 50 | { 51 | test: { 52 | ...baseTestConfig, 53 | name: 'integration', 54 | include: ['test/integration/**/*.test.ts'], 55 | pool: 'threads', 56 | }, 57 | resolve: baseResolveConfig, 58 | }, 59 | { 60 | test: { 61 | ...baseTestConfig, 62 | name: 'reporters', 63 | include: ['reporters/**/*.test.ts'], 64 | pool: 'threads', 65 | }, 66 | resolve: baseResolveConfig, 67 | }, 68 | { 69 | test: { 70 | ...baseTestConfig, 71 | name: 'default', 72 | include: ['**/*.test.ts'], 73 | exclude: [ 74 | ...baseTestConfig.exclude, 75 | 'src/linters/golangci/**/*.test.ts', // Handled by golangci-lint project 76 | '**/test/integration/validator.scenarios.test.ts', // Extensive test suite - run separately for faster feedback 77 | ], 78 | pool: 'threads', 79 | }, 80 | resolve: baseResolveConfig, 81 | }, 82 | ], 83 | }, 84 | resolve: baseResolveConfig, 85 | }) 86 | -------------------------------------------------------------------------------- /docs/adr/005-claude-project-dir-support.md: -------------------------------------------------------------------------------- 1 | # ADR-005: Support CLAUDE_PROJECT_DIR for Consistent Data Storage 2 | 3 | ## Status 4 | 5 | Accepted 6 | 7 | ## Context 8 | 9 | TDD Guard stores data in `.claude/tdd-guard/data` relative to the current working directory. This creates issues when: 10 | 11 | - Users run commands from subdirectories (e.g., `cd src && npm test`) 12 | - Claude Code executes commands from different locations within a project 13 | - Multiple `.claude` directories are created at different levels 14 | 15 | Previously, users had to configure `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR` in Claude Code to ensure commands always run from the project root. This required additional configuration and restricted how developers could use Claude Code. 16 | 17 | Claude Code provides the `CLAUDE_PROJECT_DIR` environment variable that always points to the project root, regardless of where commands are executed. This is part of Claude Code's [security best practices](https://docs.anthropic.com/en/docs/claude-code/hooks#security-best-practices). 18 | 19 | ## Decision 20 | 21 | We will use `CLAUDE_PROJECT_DIR` when available to determine the base path for TDD Guard's data directory. 22 | 23 | The implementation: 24 | 25 | - Check if `CLAUDE_PROJECT_DIR` is set and valid 26 | - Use it as the base path for `.claude/tdd-guard/data` if available 27 | - Fall back to current working directory if not set 28 | - Apply security validations to prevent path traversal attacks 29 | - Reporter-provided `projectRoot` takes precedence over `CLAUDE_PROJECT_DIR` 30 | 31 | Security validations include: 32 | 33 | - Validate `CLAUDE_PROJECT_DIR` is an absolute path 34 | - Prevent path traversal by checking for `..` sequences 35 | - Ensure current working directory is within `CLAUDE_PROJECT_DIR` 36 | 37 | When validation fails, TDD Guard throws a descriptive error and the operation is blocked, preventing any file system access with invalid paths. 38 | 39 | ## Consequences 40 | 41 | ### Positive 42 | 43 | - **Consistent data location** - Data is always stored at the project root 44 | - **No user configuration needed** - Works automatically with Claude Code 45 | - **Better developer experience** - Can run commands from any project subdirectory 46 | - **Maintains security** - Path validation prevents directory traversal attacks 47 | 48 | ### Negative 49 | 50 | - **Additional validation code** - Security checks add complexity, but this is centralized in the Config class 51 | 52 | ### Neutral 53 | 54 | - Falls back gracefully when environment variable is not present 55 | - Replaces the need for `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR` configuration 56 | -------------------------------------------------------------------------------- /src/validation/prompts/operations/edit.ts: -------------------------------------------------------------------------------- 1 | export const EDIT = `## Analyzing Edit Operations 2 | 3 | This section shows the code changes being proposed. Compare the old content with the new content to identify what's being added, removed, or modified. 4 | 5 | ### Your Task 6 | You are reviewing an Edit operation where existing code is being modified. You must determine if this edit violates TDD principles. 7 | 8 | **IMPORTANT**: First identify if this is a test file or implementation file by checking the file path for \`.test.\`, \`.spec.\`, or \`test/\`. 9 | 10 | ### How to Count New Tests 11 | **CRITICAL**: A test is only "new" if it doesn't exist in the old content. 12 | 13 | 1. **Compare old content vs new content character by character** 14 | - Find test declarations: \`test(\`, \`it(\`, \`describe(\` 15 | - A test that exists in both old and new is NOT new 16 | - Only count tests that appear in new but not in old 17 | - Count the NUMBER of new tests added, not the total tests in the file 18 | 19 | 2. **What counts as a new test:** 20 | - A test block that wasn't in the old content 21 | - NOT: Moving an existing test to a different location 22 | - NOT: Renaming an existing test 23 | - NOT: Reformatting or refactoring existing tests 24 | 25 | 3. **Multiple test check:** 26 | - One new test = Allowed (part of TDD cycle) 27 | - Two or more new tests = Violation 28 | 29 | **Example**: If old content has 1 test and new content has 2 tests, that's adding 1 new test (allowed), NOT 2 tests total. 30 | 31 | ### Analyzing Test File Changes 32 | 33 | **For test files**: Adding ONE new test is ALWAYS allowed - no test output required. This is the foundation of TDD. 34 | 35 | ### Analyzing Implementation File Changes 36 | 37 | **For implementation files**: 38 | 39 | 1. **Check the test output** to understand the current failure 40 | 2. **Match implementation to failure type:** 41 | - "not defined" → Only create empty class/function 42 | - "not a constructor" → Only create empty class 43 | - "not a function" → Only add method stub 44 | - Assertion error (e.g., "expected 0 to be 4") → Implement minimal logic to make it pass 45 | 46 | 3. **Verify minimal implementation:** 47 | - Don't add extra methods 48 | - Don't add error handling unless tested 49 | - Don't implement features beyond current test 50 | 51 | ### Example Analysis 52 | 53 | **Scenario**: Test fails with "Calculator is not defined" 54 | - Allowed: Add \`export class Calculator {}\` 55 | - Violation: Add \`export class Calculator { add(a, b) { return a + b; } }\` 56 | - **Reason**: Should only fix "not defined", not implement methods 57 | 58 | ` 59 | -------------------------------------------------------------------------------- /docs/adr/003-remove-configurable-data-directory.md: -------------------------------------------------------------------------------- 1 | # ADR-003: Remove Configurable Data Directory 2 | 3 | ## Status 4 | 5 | Accepted 6 | 7 | ## Context 8 | 9 | A security review identified a potential path traversal vulnerability in TDD Guard where the data directory path is taken from an environment variable (`TDD_DATA_DIR`) and used directly for file system operations without validation. 10 | 11 | The vulnerability occurs because: 12 | 13 | - Environment variables can be manipulated by attackers 14 | - Path traversal sequences (`../`) in the path could escape the intended directory 15 | - For example, setting `TDD_DATA_DIR="../../../../etc"` would write files to system directories 16 | - The application writes files like `test.txt`, `todo.json`, and `modifications.json` to this directory 17 | 18 | We considered several approaches: 19 | 20 | 1. **Validate and sanitize the path** - Check for `../` sequences and resolve to absolute paths 21 | 2. **Restrict to project subdirectories** - Ensure the path stays within the project root 22 | 3. **Use a whitelist of allowed paths** - Only allow specific predefined directories 23 | 4. **Remove the configuration entirely** - Hardcode the data directory path 24 | 25 | ## Decision 26 | 27 | We will remove the `TDD_DATA_DIR` environment variable and hardcode the data directory path to `.claude/tdd-guard/data` in the Config class. 28 | 29 | The implementation will: 30 | 31 | - Remove `TDD_DATA_DIR` from environment variable processing 32 | - Hardcode `dataDir` to `.claude/tdd-guard/data` in the Config constructor 33 | - Keep the existing Config class interface unchanged for dependent code 34 | - Remove documentation about `TDD_DATA_DIR` from `.env.example`, README, and CLAUDE.md 35 | 36 | ## Consequences 37 | 38 | ### Positive 39 | 40 | - **Eliminates path traversal risk** - No user-controlled input for file paths 41 | - **Simpler implementation** - No validation or sanitization code needed 42 | - **Consistent data location** - All TDD Guard data in a predictable location 43 | - **Better security posture** - Follows principle of least privilege 44 | - **No breaking changes for code** - Config class interface remains the same 45 | 46 | ### Negative 47 | 48 | - **Less flexible** - Users cannot customize where TDD Guard stores its data 49 | - **Potential disk space issues** - Users cannot redirect to different drives/partitions 50 | - **Testing limitations** - Integration tests cannot use isolated data directories 51 | 52 | ### Neutral 53 | 54 | - The data stored (test results, todos, modifications) is operational/temporary 55 | - Most users likely never customized this path anyway 56 | - Follows the same security-first approach as ADR-002 57 | -------------------------------------------------------------------------------- /docs/ignore-patterns.md: -------------------------------------------------------------------------------- 1 | # Ignore Patterns Guide 2 | 3 | Configure TDD Guard to skip validation for specific files using glob patterns. 4 | 5 | ## Why Use Ignore Patterns? 6 | 7 | Control exactly which files TDD Guard validates. Useful for monorepos, rapid prototyping, or when different parts of your codebase need different validation rules. 8 | 9 | ## Default Ignore Patterns 10 | 11 | By default, TDD Guard ignores files with these extensions: 12 | 13 | - `*.md` - Markdown documentation 14 | - `*.txt` - Text files 15 | - `*.log` - Log files 16 | - `*.json` - JSON configuration files 17 | - `*.yml` / `*.yaml` - YAML configuration files 18 | - `*.xml` - XML files 19 | - `*.html` - HTML files 20 | - `*.css` - Stylesheets 21 | - `*.rst` - reStructuredText documentation 22 | 23 | ## Custom Ignore Patterns 24 | 25 | You can configure custom ignore patterns by creating a `config.json` file in the TDD Guard data directory (`.claude/tdd-guard/data/`): 26 | 27 | ```json 28 | { 29 | "guardEnabled": true, 30 | "ignorePatterns": [ 31 | "*.md", 32 | "*.css", 33 | "*.json", 34 | "*.yml", 35 | "**/*.generated.ts", 36 | "**/public/**", 37 | "*.config.*" 38 | ] 39 | } 40 | ``` 41 | 42 | **Note**: Custom patterns replace the default patterns entirely. If you want to keep some defaults (like `*.md` or `*.json`), include them in your custom list. 43 | 44 | ## Pattern Syntax 45 | 46 | Patterns use minimatch syntax (similar to `.gitignore`): 47 | 48 | - `*.ext` - Match files with extension (e.g., `*.md`) 49 | - `dir/**` - Match all files in directory (e.g., `dist/**`) 50 | - `**/*.ext` - Match extension anywhere (e.g., `**/*.test.ts`) 51 | - `*.{js,ts}` - Match multiple extensions (e.g., `*.{yml,yaml}`) 52 | - `path/**/*.ext` - Match in specific path (e.g., `src/**/*.spec.js`) 53 | 54 | ## Managing Patterns 55 | 56 | ### Viewing Current Patterns 57 | 58 | To see which patterns are currently active, check your `config.json` file: 59 | 60 | ```bash 61 | cat .claude/tdd-guard/data/config.json 62 | ``` 63 | 64 | If no custom patterns are configured, the default patterns listed above are used. 65 | 66 | ### Updating Patterns 67 | 68 | 1. Create or edit `.claude/tdd-guard/data/config.json` 69 | 2. Add your `ignorePatterns` array 70 | 3. The changes take effect immediately 71 | 72 | ### Testing Patterns 73 | 74 | To verify your patterns work as expected: 75 | 76 | 1. Edit a file that should be ignored 77 | 2. TDD Guard should skip validation immediately 78 | 79 | ## Summary 80 | 81 | Ignore patterns provide the flexibility to apply TDD validation exactly where you want it in your codebase. Start with the defaults, then customize as your project's needs evolve. 82 | -------------------------------------------------------------------------------- /reporters/go/internal/parser/mixed_reader.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | // MixedReader handles both JSON and plain text input from go test 11 | type MixedReader struct { 12 | JSONLines []string 13 | NonJSONLines []string 14 | CompilationError *CompilationError 15 | } 16 | 17 | // CompilationError represents a compilation error if one occurred 18 | type CompilationError struct { 19 | Package string 20 | Messages []string 21 | } 22 | 23 | // NewMixedReader creates a new MixedReader and processes the input 24 | func NewMixedReader(reader io.Reader) *MixedReader { 25 | mr := &MixedReader{} 26 | scanner := bufio.NewScanner(reader) 27 | foundErrorHeader := false 28 | var errorPkg string 29 | 30 | for scanner.Scan() { 31 | line := scanner.Text() 32 | 33 | // Try to parse as JSON 34 | if isJSON(line) { 35 | mr.JSONLines = append(mr.JSONLines, line) 36 | continue 37 | } 38 | 39 | // Not JSON - store it 40 | mr.NonJSONLines = append(mr.NonJSONLines, line) 41 | 42 | // Check for compilation error pattern 43 | if isErrorHeader(line) { 44 | foundErrorHeader = true 45 | errorPkg = extractPackageName(line) 46 | continue 47 | } 48 | 49 | // Capture error messages (all non-FAIL lines after header) 50 | if foundErrorHeader && isErrorMessage(line) { 51 | if mr.CompilationError == nil { 52 | mr.CompilationError = &CompilationError{ 53 | Package: errorPkg, 54 | Messages: []string{}, 55 | } 56 | } 57 | mr.CompilationError.Messages = append(mr.CompilationError.Messages, line) 58 | } 59 | } 60 | 61 | // If we found error header but no error message, still create CompilationError 62 | if foundErrorHeader && mr.CompilationError == nil { 63 | mr.CompilationError = &CompilationError{ 64 | Package: errorPkg, 65 | Messages: []string{}, 66 | } 67 | } 68 | 69 | return mr 70 | } 71 | 72 | // isJSON checks if a line is valid JSON 73 | func isJSON(line string) bool { 74 | var event TestEvent 75 | return json.Unmarshal([]byte(line), &event) == nil 76 | } 77 | 78 | // isErrorHeader checks if line starts with # (compilation error header) 79 | func isErrorHeader(line string) bool { 80 | return len(line) > 0 && line[0] == '#' 81 | } 82 | 83 | // extractPackageName extracts package name from error header line 84 | func extractPackageName(line string) string { 85 | if len(line) > 2 { 86 | return line[2:] // Everything after "# " 87 | } 88 | return "" 89 | } 90 | 91 | // isErrorMessage checks if line is a valid error message 92 | func isErrorMessage(line string) bool { 93 | return line != "" && !strings.HasPrefix(line, "FAIL") 94 | } 95 | -------------------------------------------------------------------------------- /src/storage/FileStorage.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest' 2 | import { FileStorage } from './FileStorage' 3 | import { Config, DEFAULT_DATA_DIR } from '../config/Config' 4 | import fs from 'fs/promises' 5 | import path from 'path' 6 | import os from 'os' 7 | 8 | describe('FileStorage', () => { 9 | let projectRoot: string 10 | let config: Config 11 | let storage: FileStorage 12 | 13 | beforeEach(async () => { 14 | projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'filestorage-test-')) 15 | config = new Config({ projectRoot }) 16 | storage = new FileStorage(config) 17 | }) 18 | 19 | afterEach(async () => { 20 | await fs.rm(projectRoot, { recursive: true, force: true }) 21 | }) 22 | 23 | describe('constructor', () => { 24 | it('initializes with default configuration when instantiated without parameters', () => { 25 | const defaultStorage = new FileStorage() 26 | 27 | expect(defaultStorage).toBeDefined() 28 | expect(defaultStorage).toBeInstanceOf(FileStorage) 29 | }) 30 | 31 | it('should accept a Config instance', async () => { 32 | await storage.saveTest('test content') 33 | const retrieved = await storage.getTest() 34 | expect(retrieved).toBe('test content') 35 | }) 36 | }) 37 | 38 | it('creates directory if it does not exist', async () => { 39 | const newProjectRoot = path.join(projectRoot, 'new-project') 40 | const nonExistentPath = path.join( 41 | newProjectRoot, 42 | ...DEFAULT_DATA_DIR.split('/') 43 | ) 44 | const customConfig = new Config({ projectRoot: newProjectRoot }) 45 | const customStorage = new FileStorage(customConfig) 46 | 47 | await expect(fs.access(nonExistentPath)).rejects.toThrow() 48 | 49 | await customStorage.saveTest('content') 50 | 51 | await expect(fs.access(nonExistentPath)).resolves.toBeUndefined() 52 | }) 53 | 54 | describe('save and get operations', () => { 55 | it('saves and retrieves test content', async () => { 56 | await storage.saveTest('test content') 57 | 58 | const retrieved = await storage.getTest() 59 | expect(retrieved).toBe('test content') 60 | }) 61 | 62 | it('saves and retrieves todo content', async () => { 63 | await storage.saveTodo('todo content') 64 | 65 | const retrieved = await storage.getTodo() 66 | expect(retrieved).toBe('todo content') 67 | }) 68 | 69 | it('saves and retrieves modifications content', async () => { 70 | await storage.saveModifications('modifications content') 71 | 72 | const retrieved = await storage.getModifications() 73 | expect(retrieved).toBe('modifications content') 74 | }) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /reporters/storybook/src/StorybookReporter.ts: -------------------------------------------------------------------------------- 1 | import { Storage, FileStorage, Config } from 'tdd-guard' 2 | import type { 3 | TestContext, 4 | TestRunOutput, 5 | StoryTest, 6 | StoryModule, 7 | StoryError, 8 | } from './types' 9 | 10 | export class StorybookReporter { 11 | private readonly storage: Storage 12 | private readonly collectedTests: Map = new Map() 13 | 14 | constructor(storageOrRoot?: Storage | string) { 15 | this.storage = this.initializeStorage(storageOrRoot) 16 | } 17 | 18 | private initializeStorage(storageOrRoot?: Storage | string): Storage { 19 | if (!storageOrRoot) { 20 | return new FileStorage() 21 | } 22 | 23 | if (typeof storageOrRoot === 'string') { 24 | const config = new Config({ projectRoot: storageOrRoot }) 25 | return new FileStorage(config) 26 | } 27 | 28 | return storageOrRoot 29 | } 30 | 31 | async onStoryResult( 32 | context: TestContext, 33 | status: 'passed' | 'failed' | 'skipped' = 'passed', 34 | errors?: unknown[] 35 | ): Promise { 36 | const moduleId = context.id 37 | const test: StoryTest = { 38 | name: context.name, 39 | fullName: `${context.title} > ${context.name}`, 40 | state: status, 41 | } 42 | 43 | // Add errors if present 44 | if (errors && errors.length > 0) { 45 | test.errors = errors.map((err: unknown): StoryError => { 46 | const errorObj = err as Record 47 | const message = errorObj.message 48 | return { 49 | message: typeof message === 'string' ? message : String(err), 50 | stack: errorObj.stack as string | undefined, 51 | } 52 | }) 53 | } 54 | 55 | if (!this.collectedTests.has(moduleId)) { 56 | this.collectedTests.set(moduleId, []) 57 | } 58 | this.collectedTests.get(moduleId)!.push(test) 59 | } 60 | 61 | async onComplete(): Promise { 62 | const testModules: StoryModule[] = Array.from( 63 | this.collectedTests.entries() 64 | ).map(([moduleId, tests]) => ({ 65 | moduleId, 66 | tests, 67 | })) 68 | 69 | const output: TestRunOutput = { 70 | testModules, 71 | unhandledErrors: [], 72 | reason: this.determineReason(testModules), 73 | } 74 | 75 | await this.storage.saveTest(JSON.stringify(output, null, 2)) 76 | } 77 | 78 | private determineReason( 79 | testModules: StoryModule[] 80 | ): 'passed' | 'failed' | undefined { 81 | const allTests = testModules.flatMap((m) => m.tests) 82 | if (allTests.length === 0) { 83 | return undefined 84 | } 85 | const hasFailures = allTests.some((t) => t.state === 'failed') 86 | return hasFailures ? 'failed' : 'passed' 87 | } 88 | } 89 | --------------------------------------------------------------------------------