diff --git a/.claude/guides/architecture.md b/.claude/guides/architecture.md index fe4ef4e..5f77b0f 100644 --- a/.claude/guides/architecture.md +++ b/.claude/guides/architecture.md @@ -1,7 +1,5 @@ # Architecture Guide -**Last Updated**: 2026-05-11 - Complete architecture documentation for merlin-commit, including folder structure, data flow, module relationships, and design patterns. --- @@ -212,7 +210,7 @@ type CommitAnswers = { issues?: string; // Issue references }; -// Configuration loaded from ~/.merlinrc.json +// Configuration merged from ~/.merlinrc.json and /.merlinrc.json type MerlinConfig = { types?: CommitType[]; maxSubjectLength?: number; @@ -291,8 +289,11 @@ External Dependencies: ✅ commands → types ✅ lib → utils ✅ lib → types +✅ lib → lib (acyclic) ✅ lib → external packages +✅ utils → utils (acyclic) ✅ utils → types +✅ utils → side-effect-free packages (chalk) ❌ lib → commands (library shouldn't know about orchestration) ❌ utils → commands (utils shouldn't know about orchestration) @@ -475,22 +476,13 @@ export function getMessages(repoRoot?: string | null): ThemeMessages { ## Module File Size Guidelines -| Category | Target Size | Current | -| ----------- | ----------- | --------------------------------------------------------------------------------------- | -| Commands | < 150 lines | commit: 176, config: 922, init: 410 | -| Lib modules | < 300 lines | git: 345, editor: 256, setup: 270, prompt: 223, cfg-ld: 213, cl-sync: 191, terminal: 63 | -| Utils | < 200 lines | config: 68, validators: 115, theme: 205, constants: 359 | - ---- - -## Code Statistics +| Category | Target size | +| ----------- | ----------- | +| Commands | < 150 lines | +| Lib modules | < 300 lines | +| Utils | < 200 lines | -| Category | Files | Total Lines | -| ------------- | ----- | ----------- | -| Source code | 24 | ~4,863 | -| Test files | 18 | ~7,152 | -| Config files | 9 | - | -| Documentation | 17 | - | +Current sizes and files over target are tracked in `.claude/maintenance/codebase-map.md`. --- @@ -552,13 +544,3 @@ export function getMessages(repoRoot?: string | null): ThemeMessages { Users can also build custom themes without code changes via `theme: "custom"` and `customTheme` in `.merlinrc.json` (see `utils/theme.ts`). Adding a template function to `ThemeMessages` requires an entry in `TEMPLATE_PARAMETER_NAMES`. - ---- - -## Resources - -- .claude/rules/commands.md - Command layer rules -- .claude/rules/lib.md - Library module rules -- .claude/rules/utils.md - Utility function rules -- .claude/guides/git-workflow.md - Git workflow -- .claude/guides/development.md - Development setup diff --git a/.claude/guides/development.md b/.claude/guides/development.md index fd5e49c..9150d0d 100644 --- a/.claude/guides/development.md +++ b/.claude/guides/development.md @@ -1,7 +1,5 @@ # Development Guide -**Last Updated**: 2026-02-07 - Complete development setup, commands, debugging tips, and common gotchas for merlin-commit. --- @@ -58,12 +56,12 @@ merlin --version ```json { - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - }, - "typescript.tsdk": "node_modules/typescript/lib" + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "typescript.tsdk": "node_modules/typescript/lib" } ``` @@ -212,8 +210,8 @@ Add debug logs (remove before committing): ```typescript // Temporary debugging -console.log('Debug:', { variable, otherVariable }); -console.error('Error:', error); +console.log("Debug:", { variable, otherVariable }); +console.error("Error:", error); console.dir(complexObject, { depth: null }); ``` @@ -297,13 +295,14 @@ git commit **Don't do**: ```javascript -const { commitCommand } = require('./commands/commit'); // ❌ +const { commitCommand } = require("./commands/commit"); // ❌ ``` **Do**: ```typescript -import { commitCommand } from './commands/commit.js'; // ✅ +import { commitCommand } from "./commands/commit.js"; // ✅ + // Note: .js extension required for ESM ``` @@ -314,13 +313,14 @@ import { commitCommand } from './commands/commit.js'; // ✅ **Don't do**: ```typescript -import { commit } from './lib/git'; // ❌ +import { commit } from "./lib/git"; // ❌ ``` **Do**: ```typescript -import { commit } from './lib/git.js'; // ✅ +import { commit } from "./lib/git.js"; // ✅ + // TypeScript compiler resolves .ts file ``` @@ -331,13 +331,13 @@ import { commit } from './lib/git.js'; // ✅ **Don't do**: ```typescript -import execa from 'execa'; // ❌ +import execa from "execa"; // ❌ ``` **Do**: ```typescript -import { execa } from 'execa'; // ✅ +import { execa } from "execa"; // ✅ ``` ### 4. Testing Async Functions @@ -347,37 +347,34 @@ import { execa } from 'execa'; // ✅ **Don't do**: ```typescript -it('should commit', () => { - commit('message'); // ❌ Missing await - expect(something).toBe(true); +it("should commit", () => { + commit("message"); // ❌ Missing await + expect(something).toBe(true); }); ``` **Do**: ```typescript -it('should commit', async () => { - await commit('message'); // ✅ - expect(something).toBe(true); +it("should commit", async () => { + await commit("message"); // ✅ + expect(something).toBe(true); }); ``` ### 5. Mock Placement -**Issue**: Mocks must be before imports - -**Don't do**: +**Issue**: A `vi.mock` factory throws because a variable it uses is undefined -```typescript -import { commit } from '../src/lib/git.js'; -vi.mock('../src/lib/git.js'); // ❌ Too late! -``` +**Reason**: Vitest hoists every `vi.mock` call above the imports, so its position in the file does +not matter, but the factory runs before ordinary top-level variables are initialized. -**Do**: +**Do**: Create anything the factory needs with `vi.hoisted`: ```typescript -vi.mock('../src/lib/git.js'); // ✅ Before imports -import { commit } from '../src/lib/git.js'; +const { mockCommit } = vi.hoisted(() => ({ mockCommit: vi.fn() })); + +vi.mock("../src/lib/git.js", () => ({ commit: mockCommit })); ``` ### 6. Chalk and Colors @@ -390,10 +387,10 @@ import { commit } from '../src/lib/git.js'; ```typescript // ❌ Don't test ANSI codes -expect(output).toContain('\x1b[32m'); +expect(output).toContain("\x1b[32m"); // ✅ Test actual content -expect(output).toContain('Success'); +expect(output).toContain("Success"); ``` ### 7. Path Separators @@ -403,14 +400,15 @@ expect(output).toContain('Success'); **Don't do**: ```typescript -const configPath = `${homeDir}/.merlinrc.json`; // ❌ Breaks on Windows +const configPath = `${homeDir}/.merlinrc.json`; // ❌ Breaks on Windows ``` **Do**: ```typescript -import path from 'path'; -const configPath = path.join(homeDir, '.merlinrc.json'); // ✅ +import path from "path"; + +const configPath = path.join(homeDir, ".merlinrc.json"); // ✅ ``` --- @@ -517,7 +515,7 @@ npx vitest run tests/unit/validators.test.ts npm run test # Runs tests in parallel # Skip slow tests during development -it.skip('slow test', () => { }); +it.skip("slow test", () => {}); ``` --- @@ -540,27 +538,4 @@ SKIP_HOOKS=1 merlin ## Release Checklist -Before releasing new version: - -- [ ] All tests pass -- [ ] No console.logs in code -- [ ] README.md updated -- [ ] CHANGELOG.md updated -- [ ] Version bumped in package.json -- [ ] npm audit passes -- [ ] Build succeeds -- [ ] CLI works locally (`npm link`) -- [ ] Documentation updated -- [ ] Examples tested - ---- - -## Resources - -- [Node.js Documentation](https://nodejs.org/docs/) -- [TypeScript Handbook](https://www.typescriptlang.org/docs/) -- [Vitest Documentation](https://vitest.dev/) -- [Commander.js Documentation](https://github.com/tj/commander.js) -- [Inquirer Documentation](https://github.com/SBoudrias/Inquirer.js) -- .claude/guides/architecture.md - Architecture overview -- .claude/guides/git-workflow.md - Git workflow +See `docs/development/releases.md`. diff --git a/.claude/guides/git-workflow.md b/.claude/guides/git-workflow.md index 49e60a9..0583cb7 100644 --- a/.claude/guides/git-workflow.md +++ b/.claude/guides/git-workflow.md @@ -1,7 +1,5 @@ # Git Workflow Guide -**Last Updated**: 2026-02-07 - Complete guide to the git workflow, branching strategy, commit conventions, and release process for merlin-commit. --- @@ -171,7 +169,7 @@ Merlin-commit follows [Conventional Commits](https://www.conventionalcommits.org | **perf** | Performance improvement | `perf(git): cache git repo check result` | | **test** | Adding/fixing tests | `test(validators): add edge case tests` | | **build** | Build system changes | `build: update rollup configuration` | -| **ci** | CI configuration | `ci: add Node.js 20 to test matrix` | +| **ci** | CI configuration | `ci: run knip in the ci job` | | **chore** | Maintenance | `chore(deps): upgrade inquirer to v10` | | **revert** | Revert previous commit | `revert: revert "feat: add custom types"` | @@ -623,13 +621,3 @@ merlin --amend # Force push if already pushed git push --force-with-lease origin feat/your-feature ``` - ---- - -## Resources - -- [Conventional Commits](https://www.conventionalcommits.org/) -- [GitHub Flow](https://guides.github.com/introduction/flow/) -- [Pro Git Book](https://git-scm.com/book/en/v2) -- .claude/guides/architecture.md - Architecture overview -- .claude/guides/development.md - Development setup diff --git a/.claude/maintenance/codebase-map.md b/.claude/maintenance/codebase-map.md index 87155a5..9e776bc 100644 --- a/.claude/maintenance/codebase-map.md +++ b/.claude/maintenance/codebase-map.md @@ -107,7 +107,7 @@ This file tracks all source files, their responsibilities, sizes, and maintenanc **Total**: 18 files, ~7,152 lines combined -**Coverage Target**: >80% overall, 100% for utils +**Coverage Target**: `vitest.config.ts` thresholds (80% lines/statements/functions, 75% branches) --- diff --git a/.claude/rules/clean-code.md b/.claude/rules/clean-code.md index 1847fe6..b67ff35 100644 --- a/.claude/rules/clean-code.md +++ b/.claude/rules/clean-code.md @@ -1,306 +1,14 @@ --- paths: - - "src/**/*.ts" + - "src/**/*.ts" --- # Clean Code Principles -**Last Updated**: 2026-02-07 - These principles apply to all source code in `src/` directory. --- -## DRY (Don't Repeat Yourself) - -Extract repeated code into reusable functions. If you copy-paste code, refactor it. - -**BAD:** - -```typescript -function validateFeatCommit(message: string): boolean { - if (message.length < 10) return false; - if (message.length > 72) return false; - if (!message.match(/^[a-z]/)) return false; - return true; -} - -function validateFixCommit(message: string): boolean { - if (message.length < 10) return false; - if (message.length > 72) return false; - if (!message.match(/^[a-z]/)) return false; - return true; -} -``` - -**GOOD:** - -```typescript -const MIN_MESSAGE_LENGTH = 10; -const MAX_MESSAGE_LENGTH = 72; - -function isValidMessageFormat(message: string): boolean { - if (message.length < MIN_MESSAGE_LENGTH) return false; - if (message.length > MAX_MESSAGE_LENGTH) return false; - if (!message.match(/^[a-z]/)) return false; - return true; -} -``` - ---- - -## KISS (Keep It Simple, Stupid) - -Write the simplest code that works. Avoid over-engineering. - -**BAD:** - -```typescript -function isEven(number: number): boolean { - return (number & 1) === 0 ? true : false; -} -``` - -**GOOD:** - -```typescript -function isEven(number: number): boolean { - return number % 2 === 0; -} -``` - -**Another example - BAD:** - -```typescript -class CommitMessageBuilder { - private type: string = ''; - private scope: string = ''; - private subject: string = ''; - - setType(type: string): this { - this.type = type; - return this; - } - - setScope(scope: string): this { - this.scope = scope; - return this; - } - - setSubject(subject: string): this { - this.subject = subject; - return this; - } - - build(): string { - return this.scope - ? `${this.type}(${this.scope}): ${this.subject}` - : `${this.type}: ${this.subject}`; - } -} - -const message = new CommitMessageBuilder() - .setType('feat') - .setScope('api') - .setSubject('add user endpoint') - .build(); -``` - -**GOOD:** - -```typescript -function buildCommitMessage(type: string, subject: string, scope?: string): string { - return scope - ? `${type}(${scope}): ${subject}` - : `${type}: ${subject}`; -} - -const message = buildCommitMessage('feat', 'add user endpoint', 'api'); -``` - ---- - -## SOLID Principles - -### S - Single Responsibility Principle (SRP) - -A class or module should have only ONE reason to change. - -**BAD:** - -```typescript -class CommitService { - createCommit(message: string): void { - /* ... */ - } - formatMessage(message: string): string { - /* ... */ - } - validateMessage(message: string): boolean { - /* ... */ - } - pushToRemote(): void { - /* ... */ - } - sendNotification(): void { - /* ... */ - } -} -``` - -**GOOD:** - -```typescript -// Each module has a single responsibility - -// lib/git.ts - Git operations only -export async function createCommit(message: string): Promise { - /* ... */ -} -export async function pushToRemote(): Promise { - /* ... */ -} - -// lib/message.ts - Message handling only -export function formatCommitMessage(answers: CommitAnswers): string { - /* ... */ -} - -// utils/validators.ts - Validation only -export function validateSubject(subject: string): ValidationResult { - /* ... */ -} -``` - -### O - Open/Closed Principle (OCP) - -Open for extension, closed for modification. - -**BAD:** - -```typescript -function formatCommitType(type: string): string { - if (type === 'feat') return '✨ feat'; - if (type === 'fix') return '🐛 fix'; - if (type === 'docs') return '📚 docs'; - // Must modify this function to add new types - return type; -} -``` - -**GOOD:** - -```typescript -// COMMIT_TYPES in constants.ts - extend by adding to array -const COMMIT_TYPES: CommitType[] = [ - { value: 'feat', label: '✨ feat', description: 'New feature' }, - { value: 'fix', label: '🐛 fix', description: 'Bug fix' }, - { value: 'docs', label: '📚 docs', description: 'Documentation' }, - // Add new types here without modifying formatting logic -]; - -function formatCommitType(type: CommitType): string { - return type.label; -} -``` - -### L - Liskov Substitution Principle (LSP) - -Subtypes must be substitutable for their base types. - -```typescript -// If MerlinConfig has a theme property, any valid theme should work -type MerlinConfig = { - theme: 'wizard' | 'standard'; -}; - -// Both themes must work identically in all contexts -const wizardConfig: MerlinConfig = { theme: 'wizard' }; -const standardConfig: MerlinConfig = { theme: 'standard' }; - -// This function works with any valid config -function getMessages(config: MerlinConfig): ThemeMessages { - return config.theme === 'wizard' ? WIZARD_MESSAGES : STANDARD_MESSAGES; -} -``` - -### I - Interface Segregation Principle (ISP) - -Don't force implementations to depend on methods they don't use. - -**BAD:** - -```typescript -interface CommitHandler { - createCommit(message: string): void; - amendCommit(message: string): void; - revertCommit(hash: string): void; - squashCommits(count: number): void; - rebaseInteractive(branch: string): void; -} -``` - -**GOOD:** - -```typescript -interface CommitCreator { - createCommit(message: string): void; -} - -interface CommitAmender { - amendCommit(message: string): void; -} - -// Implement only what's needed -class BasicCommitService implements CommitCreator { - createCommit(message: string): void { - /* ... */ - } -} -``` - -### D - Dependency Inversion Principle (DIP) - -High-level modules should depend on abstractions, not concrete implementations. - -**BAD:** - -```typescript -// High-level module depends on low-level implementation -class CommitCommand { - private git = new GitCliAdapter(); // Direct dependency - - async execute(message: string): Promise { - await this.git.commit(message); - } -} -``` - -**GOOD:** - -```typescript -// Both depend on abstraction -interface GitAdapter { - commit(message: string): Promise; -} - -class GitCliAdapter implements GitAdapter { - async commit(message: string): Promise { - await execa('git', ['commit', '-m', message]); - } -} - -class CommitCommand { - constructor(private git: GitAdapter) {} // Injected dependency - - async execute(message: string): Promise { - await this.git.commit(message); - } -} -``` - ---- - ## Merlin-Commit Architectural Patterns ### Commands Layer: Orchestration @@ -312,7 +20,7 @@ Commands coordinate workflows but don't implement domain logic. export async function commitCommand(options: CommitOptions): Promise { // 1. Validate preconditions const isRepo = await isGitRepo(); - if (!isRepo) throw new Error('Not a git repository'); + if (!isRepo) throw new Error("Not a git repository"); // 2. Gather user input const answers = await promptUser(); @@ -325,7 +33,7 @@ export async function commitCommand(options: CommitOptions): Promise { console.log(preview); // 5. Confirm - const confirmed = await confirm({ message: 'Create commit?' }); + const confirmed = await confirm({ message: "Create commit?" }); // 6. Execute (delegates to git.ts) if (confirmed) { @@ -341,15 +49,15 @@ Lib modules implement specific domain logic. ```typescript // src/lib/git.ts - GOOD export async function commit(message: string, options: CommitOptions): Promise { - const args = ['commit', '-m', message]; - if (options.noVerify) args.push('--no-verify'); - if (options.amend) args.push('--amend'); + const args = ["commit", "-m", message]; + if (options.noVerify) args.push("--no-verify"); + if (options.amend) args.push("--amend"); - const result = await execa('git', args); + const result = await execa("git", args); return { success: true, hash: parseCommitHash(result.stdout), - message + message, }; } ``` @@ -367,7 +75,7 @@ export function validateSubjectLength( if (subject.length > maxLength) { return { valid: false, - error: `Subject exceeds ${maxLength} characters` + error: `Subject exceeds ${maxLength} characters`, }; } return { valid: true }; @@ -404,16 +112,16 @@ function processCommit(message: string, config: MerlinConfig): CommitResult { // Main logic buried 4 levels deep! return createCommit(message); } else { - return { success: false, error: 'Auto-add disabled' }; + return { success: false, error: "Auto-add disabled" }; } } else { - return { success: false, error: 'Message too long' }; + return { success: false, error: "Message too long" }; } } else { - return { success: false, error: 'Empty message' }; + return { success: false, error: "Empty message" }; } } else { - return { success: false, error: 'No message provided' }; + return { success: false, error: "No message provided" }; } } ``` @@ -424,19 +132,19 @@ function processCommit(message: string, config: MerlinConfig): CommitResult { function processCommit(message: string, config: MerlinConfig): CommitResult { // Guard clauses - check error conditions first if (!message) { - return { success: false, error: 'No message provided' }; + return { success: false, error: "No message provided" }; } if (message.length === 0) { - return { success: false, error: 'Empty message' }; + return { success: false, error: "Empty message" }; } if (message.length > config.maxSubjectLength) { - return { success: false, error: 'Message too long' }; + return { success: false, error: "Message too long" }; } if (!config.autoAdd) { - return { success: false, error: 'Auto-add disabled' }; + return { success: false, error: "Auto-add disabled" }; } // Main logic at top level - easy to find and understand @@ -461,17 +169,17 @@ async function validateAndStage(): Promise { return false; } } else { - throw new Error('Not a git repository'); + throw new Error("Not a git repository"); } } // GOOD - early returns async function validateAndStage(): Promise { - if (!await isGitRepo()) { - throw new Error('Not a git repository'); + if (!(await isGitRepo())) { + throw new Error("Not a git repository"); } - if (!await hasUncommittedChanges()) { + if (!(await hasUncommittedChanges())) { return false; } @@ -497,7 +205,7 @@ function processCommit(answers: CommitAnswers): void { // Mixing high-level orchestration with low-level details const message = `${answers.type}(${answers.scope}): ${answers.subject}`; if (message.length > 72) { - throw new Error('Too long'); + throw new Error("Too long"); } execSync(`git commit -m "${message}"`); // Security issue too! } @@ -518,12 +226,12 @@ function buildCommitMessage(answers: CommitAnswers): string { function validateMessage(message: string): void { if (message.length > 72) { - throw new Error('Message too long'); + throw new Error("Message too long"); } } function createCommit(message: string): void { - execa('git', ['commit', '-m', message]); + execa("git", ["commit", "-m", message]); } ``` @@ -536,7 +244,7 @@ Functions should either DO something or RETURN something, not both. ```typescript function validateAndFormat(message: string): string { if (message.length > 72) { - console.error('Message too long'); // Side effect! + console.error("Message too long"); // Side effect! } return message.trim(); } @@ -547,7 +255,7 @@ function validateAndFormat(message: string): string { ```typescript function validateMessage(message: string): void { if (message.length > 72) { - console.error('Message too long'); + console.error("Message too long"); } } @@ -568,7 +276,7 @@ Always handle or propagate errors. ```typescript try { - await execa('git', ['commit', '-m', message]); + await execa("git", ["commit", "-m", message]); } catch { // Silently ignored! } @@ -578,9 +286,9 @@ try { ```typescript try { - await execa('git', ['commit', '-m', message]); + await execa("git", ["commit", "-m", message]); } catch (error) { - console.error('Failed to create commit:', error); + console.error("Failed to create commit:", error); throw error; // Re-throw for caller to handle } ``` @@ -591,15 +299,18 @@ Create meaningful error types for different failure modes. ```typescript class InvalidCommitMessageError extends Error { - constructor(message: string, public readonly details: string) { + constructor( + message: string, + public readonly details: string + ) { super(message); - this.name = 'InvalidCommitMessageError'; + this.name = "InvalidCommitMessageError"; } } throw new InvalidCommitMessageError( - 'Commit message validation failed', - 'Subject exceeds 72 characters' + "Commit message validation failed", + "Subject exceeds 72 characters" ); ``` @@ -616,11 +327,3 @@ Before committing, ask: - [ ] Am I using early returns instead of nested if/else blocks? - [ ] Is this testable without mocking everything? - [ ] Would a new developer understand this? - ---- - -## Resources - -- **Clean Code** by Robert C. Martin -- **Refactoring** by Martin Fowler -- **SOLID Principles**: [DigitalOcean Guide](https://www.digitalocean.com/community/conceptual_articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design) diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 7b0a617..42eaefa 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -5,112 +5,9 @@ paths: # TypeScript Code Style Rules -**Last Updated**: 2026-02-07 - -These rules apply to all TypeScript files in the project. - ---- - -## Formatting Rules - -### Indentation - -- Use **4 spaces** for indentation (no tabs) -- Consistent indentation required for readability - -```typescript -// GOOD -function example() { - const value = 1; - if (value > 0) { - console.log('positive'); - } -} - -// BAD -function example() { - const value = 1; - if (value > 0) { - console.log('positive'); - } -} -``` - -### Operators - -- Always put **spaces around operators**: `=`, `+`, `-`, `*`, `/` -- Add a **space after commas** in arrays and parameters - -```typescript -// GOOD -const fullPrice = price + price * tax; -const items = ["apple", "banana", "cherry"]; - -function calculateTotal(items: Item[]): number { - return items.reduce((sum, item) => sum + item.price, 0); -} - -// BAD -const fullPrice=price+price*tax; -const items=["apple","banana","cherry"]; - -function calculateTotal(items:Item[]):number{ - return items.reduce((sum,item)=>sum+item.price,0); -} -``` - -### Line Length - -- Keep lines under **100 characters** -- Break long lines logically - -### Semicolons - -- Always end statements with **semicolons** -- No exceptions - -```typescript -// GOOD -const message = "Hello"; -const count = 42; - -// BAD -const message = "Hello" -const count = 42 -``` - -### Braces - -- Opening brace on **same line** as statement -- Closing brace on new line - -```typescript -// GOOD -function example() { - return true; -} - -// BAD -function example() -{ - return true; -} -``` - -### Quotes - -- Use **double quotes** for strings (per Prettier config) -- Use template literals for interpolation - -```typescript -// GOOD -const name = "John"; -const greeting = `Hello, ${name}`; - -// BAD -const name = 'John'; -const greeting = "Hello, " + name; -``` +These rules apply to all TypeScript files in the project. Prettier (`.prettierrc`) and ESLint +already enforce formatting, `===`, `const` over `let`, no `var` and a `default` in every +`switch`, so they are not restated here. Run `npm run format`. --- @@ -146,17 +43,17 @@ interface ExtendedCommitAnswers extends CommitAnswers { } ``` -### Never Use any +### Never Use any in src/ -**Never use `any`** - always use `unknown` when the type is truly unknown, then narrow with type guards. +**Never use `any` in `src/`** - always use `unknown` when the type is truly unknown, then narrow with type guards. Tests may cast mocks `as any`; ESLint turns `no-explicit-any` off for `tests/**`. ```typescript // GOOD - unknown + type narrowing function parseInput(value: unknown): string { - if (typeof value === 'string') { + if (typeof value === "string") { return value; } - throw new Error('Expected a string'); + throw new Error("Expected a string"); } // BAD - any bypasses all type safety @@ -165,88 +62,25 @@ function parseInput(value: any): string { } ``` -### Strict Equality - -**Always use strict equality** - `===` and `!==`, never `==` or `!=`. - -```typescript -// GOOD -if (value === 0) { } -if (type !== 'feat') { } - -// BAD -if (value == 0) { } -if (type != 'feat') { } -``` - -### Variable Declaration - -1. **Declare variables at the top** of their scope -2. **Initialize variables** when declaring them -3. **Use `const` by default**, `let` only when reassignment is needed -4. **Never use `var`** - -```typescript -// GOOD -const userName = 'John'; -const isValid = true; -let counter = 0; - -if (isValid) { - counter++; -} - -// BAD -var userName = 'John'; -let isValid = true; // Should be const -let counter; // Should initialize -counter = 0; -``` - ### Default Parameters Use default parameters instead of checking for undefined. ```typescript // GOOD -function buildMessage(type: string, subject: string, scope: string = ''): string { +function buildMessage(type: string, subject: string, scope: string = ""): string { return scope ? `${type}(${scope}): ${subject}` : `${type}: ${subject}`; } // BAD function buildMessage(type: string, subject: string, scope?: string): string { if (scope === undefined) { - scope = ''; + scope = ""; } return scope ? `${type}(${scope}): ${subject}` : `${type}: ${subject}`; } ``` -### Switch Statements - -Always include `default` case in switch statements. - -```typescript -// GOOD -switch (commitType) { - case 'feat': - return 'Feature'; - case 'fix': - return 'Bug fix'; - default: - throw new Error(`Unknown type: ${commitType}`); -} - -// BAD -switch (commitType) { - case 'feat': - return 'Feature'; - case 'fix': - return 'Bug fix'; - // Missing default -} -``` - ### Literal Syntax Use literal syntax for objects and arrays. @@ -255,24 +89,20 @@ Use literal syntax for objects and arrays. // GOOD - Literal syntax const config: MerlinConfig = {}; const types: CommitType[] = []; -const message = ''; +const message = ""; const count = 0; const isValid = false; // BAD - Constructor syntax const config = new Object(); const types = new Array(); -const message = new String(''); +const message = new String(""); ``` --- ## Best Practices -### Avoid eval() - -**Never use eval()** - it's a security risk and bypasses type safety. - ### Return Early Use early returns to avoid deep nesting. @@ -334,15 +164,15 @@ Handle errors explicitly, don't swallow them. ```typescript // GOOD try { - await execa('git', ['commit', '-m', message]); + await execa("git", ["commit", "-m", message]); } catch (error) { - console.error('Failed to create commit:', error); + console.error("Failed to create commit:", error); throw error; } // BAD try { - await execa('git', ['commit', '-m', message]); + await execa("git", ["commit", "-m", message]); } catch { // Silently swallowed! } @@ -354,10 +184,10 @@ Always throw Error objects, not strings. ```typescript // GOOD -throw new Error('Invalid commit type'); +throw new Error("Invalid commit type"); // BAD -throw 'Invalid commit type'; +throw "Invalid commit type"; ``` --- @@ -405,20 +235,3 @@ export function buildCommitMessage(answers: CommitAnswers): string { // Implementation } ```` - ---- - -## Formatting Tools - -This project uses Prettier for automatic formatting. Run before committing: - -```bash -npm run format -``` - -Configuration is in `.prettierrc`: - -- Double quotes -- Trailing commas (ES5) -- 4 space indentation -- Line width: 90 diff --git a/.claude/rules/commands.md b/.claude/rules/commands.md index 675c33e..951eef3 100644 --- a/.claude/rules/commands.md +++ b/.claude/rules/commands.md @@ -5,8 +5,6 @@ paths: # Command Layer Rules -**Last Updated**: 2026-02-07 - These rules apply to all command files in `src/commands/` directory. --- @@ -188,8 +186,8 @@ if (!validateSubjectLength(subject, maxLength)) { ## Command Structure Template ```typescript -import {} from /* dependencies */ "../lib/..."; -import {} from /* types */ "../types"; +import { someLibFunction } from "../lib/some-module.js"; +import type { CommandOptions } from "../types/index.js"; /** * Brief description of what this command does. @@ -416,11 +414,3 @@ program .description("Description of new command") .action(newCommand); ``` - ---- - -## Resources - -- .claude/guides/architecture.md - Full architecture overview -- .claude/rules/lib.md - Library module rules -- .claude/rules/testing.md - Testing guidelines diff --git a/.claude/rules/lib.md b/.claude/rules/lib.md index a96d995..7c5c9cc 100644 --- a/.claude/rules/lib.md +++ b/.claude/rules/lib.md @@ -5,8 +5,6 @@ paths: # Library Module Rules -**Last Updated**: 2026-05-11 - These rules apply to all library modules in `src/lib/` directory. --- @@ -41,11 +39,24 @@ Library modules are **IMPLEMENTERS** - they implement specific domain logic with **Exports**: ```typescript -export type DetectedCommitlintConfig = { filePath: string; format: CommitlintConfigFormat }; -export type SyncResult = { success: boolean; filePath: string; appliedRuleNames: string[]; manualInstructions?: string }; -export async function detectCommitlintConfig(repoRoot: string): Promise; +export type DetectedCommitlintConfig = { + filePath: string; + format: CommitlintConfigFormat; +}; +export type SyncResult = { + success: boolean; + filePath: string; + appliedRuleNames: string[]; + manualInstructions?: string; +}; +export async function detectCommitlintConfig( + repoRoot: string +): Promise; export function buildSyncRules(config: Required): CommitlintRules; -export async function syncToCommitlintConfig(config: Required, repoRoot: string): Promise; +export async function syncToCommitlintConfig( + config: Required, + repoRoot: string +): Promise; ``` **Pattern**: Detects commitlint config format (JS/TS/JSON/YAML), builds rules from MerlinConfig, writes back with format preservation. Returns `manualInstructions` when auto-write is not possible (e.g. dynamic JS configs). @@ -88,9 +99,15 @@ export async function commit(message: string, noVerify?: boolean): Promise; export async function getGitDirectory(): Promise; export async function getRepoRoot(): Promise; -export type StagedFile = { status: "modified" | "new file" | "deleted" | "renamed" | "copied" | "typechange"; path: string }; +export type StagedFile = { + status: "modified" | "new file" | "deleted" | "renamed" | "copied" | "typechange"; + path: string; +}; export async function getStagedFilesWithStatus(): Promise; -export type StagingCandidate = { path: string; status: "modified" | "deleted" | "untracked" | "renamed" | "copied" }; +export type StagingCandidate = { + path: string; + status: "modified" | "deleted" | "untracked" | "renamed" | "copied"; +}; export async function getStagingCandidates(): Promise; ``` @@ -125,7 +142,10 @@ export type PromptDependencies = { editIssues: () => string; }; export async function promptUser(deps: PromptDependencies): Promise; -export async function promptFileSelection(candidates: StagingCandidate[], promptMessage: string): Promise; +export async function promptFileSelection( + candidates: StagingCandidate[], + promptMessage: string +): Promise; ``` **Pattern**: Single entry point with injected dependencies for testability. `promptFileSelection` is a standalone function for interactive staging candidate selection used by the auto-add flow. @@ -286,7 +306,7 @@ Prefer pure functions (no side effects, same input = same output): ```typescript // GOOD - pure function in message.ts export function buildCommitMessage(answers: CommitAnswers): string { - const scope = answers.scope ? `(${answers.scope})` : ''; + const scope = answers.scope ? `(${answers.scope})` : ""; return `${answers.type}${scope}: ${answers.subject}`; } @@ -613,20 +633,12 @@ async function process(input: InputType): Promise { - lib → types (type imports only) - lib → utils (for validation, formatting) - lib → external packages (execa, @inquirer/prompts, etc.) +- lib → lib (keep it acyclic; prefer `import type` where only types are needed) **Not Allowed**: - lib → commands (library shouldn't know about orchestration) -- lib → lib (minimize cross-dependencies) - lib → cli (library shouldn't know about CLI) -**If you need cross-module functionality**: Either extract to utils, or accept as parameter. - ---- - -## Resources - -- .claude/guides/architecture.md - Full architecture overview -- .claude/rules/commands.md - Command layer rules -- .claude/rules/utils.md - Utility function rules -- .claude/rules/testing.md - Testing guidelines +**If two lib modules would import each other**: Move the shared piece to `utils/` (if pure) or +pass it in as a parameter. diff --git a/.claude/rules/naming.md b/.claude/rules/naming.md index 675c3db..d35f2e2 100644 --- a/.claude/rules/naming.md +++ b/.claude/rules/naming.md @@ -1,331 +1,69 @@ -# Naming Conventions - -**Last Updated**: 2026-02-07 - -These naming rules apply to ALL code in the project, regardless of file type or location. - --- - -## Pre-Naming Requirement - -Before creating ANY variable, function, constant, class, interface, or type name, STOP and verify: - -1. **Clarity**: Can someone understand its purpose without reading the implementation? -2. **Accuracy**: Does the name accurately describe what it holds or does? -3. **Readability**: Is the name readable at a glance (not too long, not abbreviated)? -4. **Single Responsibility**: Does the name reflect a single, focused purpose? - +paths: + - "**/*.ts" --- -## Core Rules - -### Rule 1: No Single-Letter Variables - -Variables must have descriptive names. Single letters are forbidden except in very short lambdas. - -**BAD:** - -```typescript -const n = "John"; -const s = `; ${document.cookie}`.split("; "); -for (let c of cookies) { -} -``` +# Naming Conventions -**GOOD:** +## Rules -```typescript -const userName = "John"; -const cookieStrings = `; ${document.cookie}`.split("; "); -for (const cookie of cookies) { -} -``` +### 1. No single-letter variables -**Complete Example - BAD:** +Single letters are allowed only in very short lambdas. ```typescript -public static parseCookie(n: string): string | null { - const s = `; ${document.cookie}`.split('; '); - for (let c of s) { - if (c.indexOf(n) === 0) { - return c.substring(n.length + 1); - } - } - return null; +// BAD +for (const c of s) { } -``` - -**Complete Example - GOOD:** - -```typescript -public static parseCookie(cookieName: string): string | null { - const cookieStrings = `; ${document.cookie}`.split('; '); - for (const cookieString of cookieStrings) { - if (cookieString.indexOf(cookieName) === 0) { - return cookieString.substring(cookieName.length + 1); - } - } - return null; +// GOOD +for (const cookieString of cookieStrings) { } ``` -### Rule 2: Never Abbreviate - -Write out full words. Clarity always wins over brevity. - -**BAD:** +### 2. Never abbreviate ```typescript +// BAD const cfg = loadConfig(); -const msg = formatCommitMsg(); -const usr = getCurrentUsr(); const idx = items.findIndex((i) => i.active); -const btn = document.getElementById("btn"); -``` - -**GOOD:** - -```typescript +// GOOD const config = loadConfig(); -const message = formatCommitMessage(); -const user = getCurrentUser(); const activeItemIndex = items.findIndex((item) => item.active); -const button = document.getElementById("submitButton"); -``` - -**Complete Example - BAD:** - -```typescript -public calculateRelScore(m1: Mov, m2: Mov): number { - const GW = 0.4; - const YW = 0.1; - const DW = 0.2; - const WW = 0.3; - let s = 0; - if (m1.gen === m2.gen) s += GW; - if (m1.yr === m2.yr) s += YW; - if (m1.dir === m2.dir) s += DW; - for (const w of m1.writers) { - if (m2.writers.includes(w)) { - s += WW; - break; - } - } - return s; -} ``` -**Complete Example - GOOD:** - -```typescript -public calculateMovieRelationScore(movie1: Movie, movie2: Movie): number { - const GENRE_WEIGHT = 0.4; - const YEAR_WEIGHT = 0.1; - const DIRECTOR_WEIGHT = 0.2; - const WRITER_WEIGHT = 0.3; - - let score = 0; - - if (movie1.genre === movie2.genre) { - score += GENRE_WEIGHT; - } - if (movie1.year === movie2.year) { - score += YEAR_WEIGHT; - } - if (movie1.director === movie2.director) { - score += DIRECTOR_WEIGHT; - } - - const hasSharedWriter = movie1.writers.some( - writer => movie2.writers.includes(writer) - ); - if (hasSharedWriter) { - score += WRITER_WEIGHT; - } - - return score; -} -``` - -### Rule 3: Include Units in Variable Names - -When a variable represents a measurement, duration, or quantity with a unit, include the unit in the name. - -**BAD:** +### 3. Include units in names ```typescript +// BAD const timeout = 5000; -const delay = 30; -const size = 1024; const maxLength = 72; -``` - -**GOOD:** - -```typescript +// GOOD const timeoutMilliseconds = 5000; -const delaySeconds = 30; -const sizeBytes = 1024; const maxLengthCharacters = 72; ``` -### Rule 4: Functions Must Do Only What Their Name Says - -A function name must accurately reflect its SINGLE responsibility. If a function does more than its name suggests, it violates this rule. - -**BAD:** - -```typescript -// Function name says "parse" but it also starts session and fetches user -function parseCookies(cookieString: string): ParsedCookie[] { - const cookies = cookieString.split(";").map(parseSingleCookie); - startUserSession(cookies); // Hidden side effect! - fetchUserProfile(); // Another hidden side effect! - return cookies; -} - -// Vague name that doesn't explain what it does -function handleData(data: unknown): void { - /* ... */ -} - -// Name suggests one thing, does another -function validateCommit(message: string): void { - // Actually creates the commit, not just validates! - execSync(`git commit -m "${message}"`); -} -``` - -**GOOD:** - -```typescript -// Each function does exactly what its name says -function parseCookieString(cookieString: string): ParsedCookie[] { - return cookieString.split(";").map(parseSingleCookie); -} +### 4. A function does only what its name says -function startUserSession(cookies: ParsedCookie[]): void { - // Only starts session, nothing else -} +No hidden side effects. If `validateCommitMessage` also commits, split it into +`validateCommitMessage` and `createGitCommit`. Avoid vague names like `handleData` or +`doCommit`. -function fetchUserProfile(): Promise { - // Only fetches profile, nothing else -} - -// Clear, accurate names -function validateCommitMessage(message: string): ValidationResult { - /* ... */ -} -function createGitCommit(message: string): void { - /* ... */ -} -function formatCommitSubject(subject: string): string { - /* ... */ -} -``` - -### Rule 5: Boolean Variables Must Use is, has, or can Prefixes - -When naming Boolean variables, use prefixes such as `is`, `has`, or `can` to make their purpose clear. These prefixes instantly tell you that the variable holds a true or false value, even before you read the logic. Use `is` to describe the current state of something, `has` to indicate possession or completion, and `can` to express capability or permission. - -**BAD:** +### 5. Booleans start with `is`, `has` or `can` ```typescript -const valid = validateInput(message); +// BAD const staged = await checkStagedChanges(); -const admin = user.role === 'admin'; -const edit = checkPermissions(user, document); -const loading = true; -``` - -**GOOD:** - -```typescript -const isValid = validateInput(message); +// GOOD const hasStagedChanges = await checkStagedChanges(); -const isAdmin = user.role === 'admin'; -const canEdit = checkPermissions(user, document); -const isLoading = true; -``` - -**Complete Example - BAD:** - -```typescript -async function commitWorkflow(options: CommitOptions): Promise { - const repo = await isGitRepo(); - const staged = await hasStagedChanges(); - const empty = message.trim().length === 0; - const confirmed = await confirm({ message: 'Create commit?' }); - const dry = options.dryRun ?? false; - - if (!repo || !staged || empty || !confirmed || dry) { - return; - } -} -``` - -**Complete Example - GOOD:** - -```typescript -async function commitWorkflow(options: CommitOptions): Promise { - const isRepository = await isGitRepo(); - const hasStagedFiles = await hasStagedChanges(); - const isEmpty = message.trim().length === 0; - const isConfirmed = await confirm({ message: 'Create commit?' }); - const isDryRun = options.dryRun ?? false; - - if (!isRepository || !hasStagedFiles || isEmpty || !isConfirmed || isDryRun) { - return; - } -} +const isDryRun = options.dryRun ?? false; ``` ---- - -## Naming Convention Tables - -| Element | Convention | Example | -| ---------- | -------------------- | ----------------------------- | -| Variables | camelCase | `commitMessage`, `userConfig` | -| Functions | camelCase | `buildCommitMessage()` | -| Constants | SCREAMING_SNAKE_CASE | `MAX_SUBJECT_LENGTH` | -| Classes | PascalCase | `CommitBuilder` | -| Interfaces | PascalCase | `CommitAnswers` | -| Types | PascalCase | `CommitType` | -| Enums | PascalCase | `ThemeType` | -| File names | kebab-case | `commit-builder.ts` | - ---- - -## Merlin-Commit Specific Examples +## Casing -**BAD:** - -```typescript -// In lib/git.ts -async function doCommit(m: string, opts: any): Promise { - const r = await execa("git", ["status"]); - if (r.stdout.includes("nothing")) return; - await execa("git", ["add", "."]); - await execa("git", ["commit", "-m", m]); -} -``` - -**GOOD:** - -```typescript -// In lib/git.ts -async function createCommit(message: string, options: CommitOptions): Promise { - await execa("git", ["commit", "-m", message]); - return { success: true, message }; -} - -async function stageAllChanges(): Promise { - await execa("git", ["add", "."]); -} - -async function hasUncommittedChanges(): Promise { - const result = await execa("git", ["status", "--porcelain"]); - return result.stdout.trim().length > 0; -} -``` +| Element | Convention | Example | +| -------------------------- | -------------------- | -------------------- | +| Variables, functions | camelCase | `buildCommitMessage` | +| Constants | SCREAMING_SNAKE_CASE | `MAX_SUBJECT_LENGTH` | +| Classes, types, interfaces | PascalCase | `CommitAnswers` | +| Enums | PascalCase | `ThemeType` | +| File names | kebab-case | `commit-builder.ts` | diff --git a/.claude/rules/security.md b/.claude/rules/security.md index b05f541..4902ab2 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -1,236 +1,51 @@ -# Security Requirements - -**Last Updated**: 2026-02-07 - -These security requirements are **non-negotiable** and apply to ALL code in the project. - --- - -## MUST Follow Requirements - -### 1. No Network Calls - -Fully offline operation - never make HTTP requests. - -**Reason**: Merlin Commit is designed as a privacy-first tool. No telemetry, no analytics, no "calling home." - -**BAD:** - -```typescript -await fetch('https://api.example.com/log', { - method: 'POST', - body: JSON.stringify({ event: 'commit_created' }) -}); -``` - -**GOOD:** - -```typescript -// Simply don't make any network calls -// All functionality is local -``` - -### 2. No Data Collection - -Zero telemetry, analytics, or tracking. - -**MUST NOT**: - -- Send usage statistics -- Phone home for updates -- Track user behavior -- Collect any user data - -### 3. No eval() or Code Execution - -Never evaluate user-provided strings as code. - -**BAD:** - -```typescript -const userFunction = eval(userInput); -new Function(userInput)(); -``` - -**GOOD:** - -```typescript -// Use structured data and validation instead -const allowedTypes = ['feat', 'fix', 'docs']; -if (!allowedTypes.includes(userInput)) { - throw new Error('Invalid commit type'); -} -``` - -### 4. Safe Git Execution - -Always use `execa` with args array, never string interpolation. - -**CORRECT - Safe:** - -```typescript -await execa("git", ["commit", "-m", message]); -await execa("git", ["commit", "-m", message, "--amend"]); -await execa("git", ["push", "origin", branchName]); -``` - -**WRONG - Vulnerable to injection:** - -```typescript -execSync(`git commit -m "${message}"`); -execSync(`git push origin ${branchName}`); -exec(`git commit -m "${message}"`, callback); -``` - -**Why this matters**: - -```typescript -// If message contains: "; rm -rf /" -// String interpolation becomes: -execSync(`git commit -m "; rm -rf /"`); // DISASTER! - -// But with args array: -execa("git", ["commit", "-m", "; rm -rf /"]); // SAFE - treated as literal string -``` - -### 5. Config Validation - -All user configuration must be validated and sanitized. - -**GOOD:** - -```typescript -function validateConfig(config: unknown): MerlinConfig { - if (typeof config !== 'object' || config === null) { - throw new Error('Config must be an object'); - } - - const { theme, maxSubjectLength } = config as Record; - - // Validate theme - if (theme !== undefined && theme !== 'wizard' && theme !== 'standard') { - throw new Error('Invalid theme'); - } - - // Validate maxSubjectLength - if (maxSubjectLength !== undefined) { - if (typeof maxSubjectLength !== 'number' || maxSubjectLength < 1 || maxSubjectLength > 200) { - throw new Error('Invalid maxSubjectLength'); - } - } - - return { - theme: theme as 'wizard' | 'standard' || 'wizard', - maxSubjectLength: maxSubjectLength as number || 72, - }; -} -``` - -### 6. Fixed Config Path - -Config file path is `~/.merlinrc.json` - never use user input for file paths. - -**CORRECT:** - -```typescript -const configPath = path.join(os.homedir(), '.merlinrc.json'); -``` - -**WRONG:** - -```typescript -// Never construct paths from user input -const configPath = path.join(os.homedir(), userInput); // DANGEROUS! -``` - ---- - -## Security Checklist - -Before committing code, verify: - -- [ ] No network calls (fetch, axios, http, https) -- [ ] No telemetry or analytics -- [ ] No eval() or new Function() -- [ ] All git commands use execa with args array -- [ ] All user input is validated -- [ ] File paths are fixed, never from user input -- [ ] No sensitive data in logs -- [ ] No credentials in code (even examples) - +paths: + - "src/**/*.ts" --- -## Common Vulnerabilities to Avoid +# Security Details -### Command Injection +The six invariants live in AGENTS.md and load in every session. This file adds how to apply +them. -**Vulnerable:** +## Config validation -```typescript -const result = execSync(`git log --author="${authorName}"`); -``` - -**Safe:** +`validateConfig()` in `src/utils/config.ts` takes `unknown` and returns `Partial`. +It drops any field that fails a type check and never throws, so a tampered or stale +`.merlinrc.json` falls back to defaults instead of crashing. Follow that pattern for new fields: ```typescript -const result = await execa("git", ["log", `--author=${authorName}`]); -``` - -### Path Traversal - -**Vulnerable:** - -```typescript -const configFile = path.join(configDir, userInput); -fs.readFileSync(configFile); -``` - -**Safe:** - -```typescript -const allowedFiles = ['config.json', 'settings.json']; -if (!allowedFiles.includes(userInput)) { - throw new Error('Invalid file'); +if (typeof config.maxSubjectLength === "number" && config.maxSubjectLength > 0) { + validated.maxSubjectLength = config.maxSubjectLength; } -const configFile = path.join(configDir, userInput); ``` -### Arbitrary Code Execution +## File paths -**Vulnerable:** +Config is read from exactly two places: `~/.merlinrc.json` and `/.merlinrc.json`, +where the repo root comes from git. If a feature ever needs a user-chosen file, check it against +an allowlist before joining it into a path. -```typescript -const handler = new Function('return ' + userInput); -handler(); -``` +## Git arguments -**Safe:** +Interpolating inside one argument is fine; building the command line is not: ```typescript -// Use structured data and switch statements -const handlers = { - 'feat': handleFeat, - 'fix': handleFix, -}; -const handler = handlers[userInput]; -if (!handler) throw new Error('Invalid handler'); -handler(); +await execa("git", ["log", `--author=${authorName}`]); // safe +execSync(`git log --author="${authorName}"`); // injection ``` ---- +## Dispatch on user input -## Reporting Security Issues +Map user strings to handlers through a lookup object and reject unknown keys. Never turn them +into code. -If you discover a security vulnerability: +## Also -1. **DO NOT** open a public issue -2. Open a [private security advisory](https://github.com/mBukator/merlin-commit/security/advisories/new) on GitHub -3. Include: description, reproduction steps, potential impact - ---- +- Keep secrets and credentials out of logs and code, including examples. -## References +## Reporting a vulnerability -- [OWASP Top 10](https://owasp.org/www-project-top-ten/) -- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/) -- [Command Injection Prevention](https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html) +Do not open a public issue. Open a +[private security advisory](https://github.com/mBukator/merlin-commit/security/advisories/new) +with a description, reproduction steps and impact. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index b1d07a2..20ca570 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -1,12 +1,10 @@ --- paths: - - "tests/**/*.test.ts" + - "tests/**/*.test.ts" --- # Testing Guidelines -**Last Updated**: 2026-02-07 - These guidelines apply to all test files in the `tests/` directory. --- @@ -84,13 +82,13 @@ Examples: Use the **Arrange-Act-Assert** pattern with nested `describe` blocks: ```typescript -describe('ModuleName', () => { - describe('functionName', () => { - describe('when condition', () => { - it('should expected behavior', () => { +describe("ModuleName", () => { + describe("functionName", () => { + describe("when condition", () => { + it("should expected behavior", () => { // Arrange - Set up test data and dependencies - const input = 'test value'; - const expected = 'expected result'; + const input = "test value"; + const expected = "expected result"; // Act - Execute the function const result = functionName(input); @@ -106,37 +104,37 @@ describe('ModuleName', () => { **Example from merlin-commit:** ```typescript -describe('buildCommitMessage', () => { - describe('when scope is provided', () => { - it('should include scope in parentheses', () => { +describe("buildCommitMessage", () => { + describe("when scope is provided", () => { + it("should include scope in parentheses", () => { // Arrange const answers: CommitAnswers = { - type: 'feat', - scope: 'api', - subject: 'add user endpoint' + type: "feat", + scope: "api", + subject: "add user endpoint", }; // Act const message = buildCommitMessage(answers); // Assert - expect(message).toBe('feat(api): add user endpoint'); + expect(message).toBe("feat(api): add user endpoint"); }); }); - describe('when scope is not provided', () => { - it('should omit scope from message', () => { + describe("when scope is not provided", () => { + it("should omit scope from message", () => { // Arrange const answers: CommitAnswers = { - type: 'fix', - subject: 'resolve bug' + type: "fix", + subject: "resolve bug", }; // Act const message = buildCommitMessage(answers); // Assert - expect(message).toBe('fix: resolve bug'); + expect(message).toBe("fix: resolve bug"); }); }); }); @@ -161,18 +159,18 @@ export function validateSubjectLength( } // tests/unit/validators.test.ts -describe('validateSubjectLength', () => { - it('should return valid for short subjects', () => { - const result = validateSubjectLength('short', 72); +describe("validateSubjectLength", () => { + it("should return valid for short subjects", () => { + const result = validateSubjectLength("short", 72); expect(result.valid).toBe(true); }); - it('should return invalid for long subjects', () => { - const longSubject = 'a'.repeat(100); + it("should return invalid for long subjects", () => { + const longSubject = "a".repeat(100); const result = validateSubjectLength(longSubject, 72); expect(result.valid).toBe(false); - expect(result.error).toContain('exceeds 72 characters'); + expect(result.error).toContain("exceeds 72 characters"); }); }); ``` @@ -184,29 +182,30 @@ describe('validateSubjectLength', () => { ### Mocking Git Operations ```typescript -import { vi } from 'vitest'; -import { execa } from 'execa'; -import { commit } from '../src/lib/git'; +import { execa } from "execa"; +import { vi } from "vitest"; + +import { commit } from "../src/lib/git"; // Mock execa -vi.mock('execa'); +vi.mock("execa"); -describe('commit', () => { - it('should call git commit with correct arguments', async () => { +describe("commit", () => { + it("should call git commit with correct arguments", async () => { // Arrange - const message = 'feat: add feature'; + const message = "feat: add feature"; const mockExeca = vi.mocked(execa); mockExeca.mockResolvedValue({ - stdout: 'abc123 feat: add feature', - stderr: '', - exitCode: 0 + stdout: "abc123 feat: add feature", + stderr: "", + exitCode: 0, } as any); // Act await commit(message, { noVerify: false, amend: false }); // Assert - expect(mockExeca).toHaveBeenCalledWith('git', ['commit', '-m', message]); + expect(mockExeca).toHaveBeenCalledWith("git", ["commit", "-m", message]); }); }); ``` @@ -214,16 +213,17 @@ describe('commit', () => { ### Mocking File System ```typescript -import { vi } from 'vitest'; -import * as fs from 'fs'; -import { loadConfig } from '../src/utils/config'; +import * as fs from "fs"; +import { vi } from "vitest"; -vi.mock('fs'); +import { loadConfig } from "../src/utils/config"; -describe('loadConfig', () => { - it('should load config from file', () => { +vi.mock("fs"); + +describe("loadConfig", () => { + it("should load config from file", () => { // Arrange - const mockConfig = { theme: 'wizard', maxSubjectLength: 72 }; + const mockConfig = { theme: "wizard", maxSubjectLength: 72 }; vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockConfig)); // Act @@ -241,9 +241,8 @@ describe('loadConfig', () => { ### Target Coverage -- **Overall**: >80% -- **Critical paths**: 100% (security, git operations, validation) -- **Utils**: 100% (they're pure functions, easy to test) +The thresholds in `vitest.config.ts` are the rule: 80% lines, statements and functions, 75% +branches. `npm run test:coverage` fails below them; CI does not run coverage. ### Don't Chase 100% @@ -264,38 +263,38 @@ npm run test:coverage Commands coordinate workflows, so focus on testing the coordination logic. ```typescript -describe('commitCommand', () => { +describe("commitCommand", () => { beforeEach(() => { // Mock dependencies - vi.mock('../src/lib/git'); - vi.mock('../src/lib/prompt'); - vi.mock('../src/lib/message'); + vi.mock("../src/lib/git"); + vi.mock("../src/lib/prompt"); + vi.mock("../src/lib/message"); }); - it('should throw error when not in git repo', async () => { + it("should throw error when not in git repo", async () => { // Arrange vi.mocked(isGitRepo).mockResolvedValue(false); // Act & Assert - await expect(commitCommand({})).rejects.toThrow('Not a git repository'); + await expect(commitCommand({})).rejects.toThrow("Not a git repository"); }); - it('should create commit when user confirms', async () => { + it("should create commit when user confirms", async () => { // Arrange vi.mocked(isGitRepo).mockResolvedValue(true); vi.mocked(hasStagedChanges).mockResolvedValue(true); vi.mocked(promptUser).mockResolvedValue({ - type: 'feat', - subject: 'add feature' + type: "feat", + subject: "add feature", }); - vi.mocked(buildCommitMessage).mockReturnValue('feat: add feature'); + vi.mocked(buildCommitMessage).mockReturnValue("feat: add feature"); vi.mocked(confirm).mockResolvedValue(true); // Act await commitCommand({}); // Assert - expect(commit).toHaveBeenCalledWith('feat: add feature', {}); + expect(commit).toHaveBeenCalledWith("feat: add feature", {}); }); }); ``` @@ -307,23 +306,23 @@ describe('commitCommand', () => { Lib modules implement domain logic. Test the logic, mock external dependencies. ```typescript -describe('buildCommitMessage', () => { - it('should format message with all components', () => { +describe("buildCommitMessage", () => { + it("should format message with all components", () => { const answers: CommitAnswers = { - type: 'feat', - scope: 'api', - subject: 'add endpoint', - body: 'Detailed description', - breaking: 'BREAKING CHANGE: API changed', - issues: 'Closes #123' + type: "feat", + scope: "api", + subject: "add endpoint", + body: "Detailed description", + breaking: "BREAKING CHANGE: API changed", + issues: "Closes #123", }; const message = buildCommitMessage(answers); - expect(message).toContain('feat(api): add endpoint'); - expect(message).toContain('Detailed description'); - expect(message).toContain('BREAKING CHANGE: API changed'); - expect(message).toContain('Closes #123'); + expect(message).toContain("feat(api): add endpoint"); + expect(message).toContain("Detailed description"); + expect(message).toContain("BREAKING CHANGE: API changed"); + expect(message).toContain("Closes #123"); }); }); ``` @@ -335,14 +334,14 @@ describe('buildCommitMessage', () => { Utils must be pure functions, so tests are straightforward. ```typescript -describe('validators', () => { - describe('validateSubjectLength', () => { +describe("validators", () => { + describe("validateSubjectLength", () => { it.each([ - ['valid short subject', 'short', 72, true], - ['valid at max length', 'a'.repeat(72), 72, true], - ['invalid over max length', 'a'.repeat(73), 72, false], - ['valid empty', '', 72, true], - ])('%s', (_, subject, maxLength, expectedValid) => { + ["valid short subject", "short", 72, true], + ["valid at max length", "a".repeat(72), 72, true], + ["invalid over max length", "a".repeat(73), 72, false], + ["valid empty", "", 72, true], + ])("%s", (_, subject, maxLength, expectedValid) => { const result = validateSubjectLength(subject, maxLength); expect(result.valid).toBe(expectedValid); }); @@ -357,21 +356,21 @@ describe('validators', () => { ### Testing Async Functions ```typescript -it('should resolve with result', async () => { +it("should resolve with result", async () => { const result = await asyncFunction(); expect(result).toBe(expected); }); -it('should reject with error', async () => { - await expect(asyncFunction()).rejects.toThrow('Error message'); +it("should reject with error", async () => { + await expect(asyncFunction()).rejects.toThrow("Error message"); }); ``` ### Testing Error Conditions ```typescript -it('should throw error for invalid input', () => { - expect(() => functionName(invalidInput)).toThrow('Invalid input'); +it("should throw error for invalid input", () => { + expect(() => functionName(invalidInput)).toThrow("Invalid input"); }); ``` @@ -379,10 +378,10 @@ it('should throw error for invalid input', () => { ```typescript it.each([ - ['input1', 'output1'], - ['input2', 'output2'], - ['input3', 'output3'], -])('should handle %s correctly', (input, expected) => { + ["input1", "output1"], + ["input2", "output2"], + ["input3", "output3"], +])("should handle %s correctly", (input, expected) => { const result = functionName(input); expect(result).toBe(expected); }); @@ -391,7 +390,7 @@ it.each([ ### Setup and Teardown ```typescript -describe('functionName', () => { +describe("functionName", () => { beforeEach(() => { // Runs before each test }); @@ -490,16 +489,8 @@ npx vitest run -t "should format message" - Check mock is properly typed - Verify mock path is correct -### Coverage Not 100% +### Coverage Below Threshold -- Don't worry about it +- Run `npm run test:coverage` and cover the reported lines that matter - Focus on critical paths - Some code is hard to test meaningfully - ---- - -## Resources - -- [Vitest Documentation](https://vitest.dev/) -- [Testing JavaScript](https://testingjavascript.com/) -- [Test-Driven Development](https://martinfowler.com/bliki/TestDrivenDevelopment.html) diff --git a/.claude/rules/utils.md b/.claude/rules/utils.md index a25a5cb..4f12e01 100644 --- a/.claude/rules/utils.md +++ b/.claude/rules/utils.md @@ -5,8 +5,6 @@ paths: # Utility Function Rules -**Last Updated**: 2026-03-13 - These rules apply to all utility modules in `src/utils/` directory. --- @@ -131,10 +129,21 @@ export const MAX_CUSTOM_MESSAGE_LENGTH_CHARACTERS: number; ```typescript export type MessageTemplate = (...templateArguments: unknown[]) => string; -export function resolveBaseTheme(config: Pick, "theme" | "customTheme">): BaseThemeName; -export function validateThemeOverrides(rawOverrides: unknown, reference: ThemeMessages): ThemeMessageOverrides; -export function compileMessageTemplate(template: string, parameterNames: readonly string[]): MessageTemplate; -export function mergeThemeMessages(base: ThemeMessages, overrides: ThemeMessageOverrides): ThemeMessages; +export function resolveBaseTheme( + config: Pick, "theme" | "customTheme"> +): BaseThemeName; +export function validateThemeOverrides( + rawOverrides: unknown, + reference: ThemeMessages +): ThemeMessageOverrides; +export function compileMessageTemplate( + template: string, + parameterNames: readonly string[] +): MessageTemplate; +export function mergeThemeMessages( + base: ThemeMessages, + overrides: ThemeMessageOverrides +): ThemeMessages; ``` **Note**: Validation walks the built-in theme shape, so unknown keys are dropped without an allow-list. Template strings use `{placeholder}` substitution only - never evaluated. @@ -148,7 +157,10 @@ export function mergeThemeMessages(base: ThemeMessages, overrides: ThemeMessageO ```typescript export function createRangeValidator(min: number, max: number): Validator; export function createNonEmptyValidator(fieldName?: string): Validator; -export function createMaxLengthValidator(maxLength: number, fieldName?: string): Validator; +export function createMaxLengthValidator( + maxLength: number, + fieldName?: string +): Validator; export function createPatternValidator(pattern: RegExp, errorMessage: string): Validator; export function composeValidators(...validators: Validator[]): Validator; ``` @@ -249,8 +261,6 @@ describe("composeValidators", () => { }); ``` -**Target: 100% coverage for utils** - they're pure functions, so full coverage is achievable and valuable. - --- ## Util Function Template @@ -346,8 +356,8 @@ export const MAX_LENGTH = loadConfig().maxSubjectLength; // Side effect! **Allowed**: - utils → types (type imports) -- utils → other utils (sparingly) -- utils → external pure libraries (lodash, date-fns) +- utils → other utils (keep it acyclic) +- utils → side-effect-free packages (e.g. `chalk` in `constants.ts`) **Not Allowed**: @@ -498,25 +508,15 @@ const answer = await input({ message: "Subject:", validate: subjectValidator }); ## Testing Coverage -**Target: 100% coverage for utils** - -Since utils are pure functions with no side effects, achieving 100% coverage is straightforward and valuable. +Utils share the project thresholds in `vitest.config.ts`. They are pure, so gaps are cheap to +close: ```bash npm run test:coverage -- tests/unit/validators.test.ts ``` -If coverage is < 100%, ask: +If a util has uncovered lines, ask: - Are there untested edge cases? - Is the function too complex? (Should it be split?) - Are there unreachable code paths? (Dead code to remove?) - ---- - -## Resources - -- [Pure Functions (FP)](https://en.wikipedia.org/wiki/Pure_function) -- [Functional Programming in TypeScript](https://github.com/gcanti/fp-ts) -- .claude/rules/code-style.md - Code style rules -- .claude/rules/testing.md - Testing guidelines diff --git a/AGENTS.md b/AGENTS.md index 432d358..951d5e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,113 +1,63 @@ # AGENTS.md -Guidance for any coding agent working in this repository. This file is the tool-agnostic source -of truth; `CLAUDE.md` imports it and adds the Claude Code layer. +Merlin Commit (`merlin-commit`, command `merlin`) is an offline TypeScript CLI that walks users +through writing Conventional Commits with a wizard-themed interface. -## Project +## Every task -Merlin Commit is an interactive CLI for creating conventional commits with a wizard-themed -interface. - -| Property | Value | -| ---------- | -------------------------------------------- | -| Package | `merlin-commit` (command: `merlin`) | -| Node | >=22.13.0 | -| Language | TypeScript 5.7+, strict, ESM only | -| Tests | Vitest, `tests/unit/` + `tests/integration/` | -| License | MIT | -| Repository | github.com/mBukator/merlin-commit | -| User docs | `docs/` (reference, usage, guides, etc.) | - -## Architecture - -``` -src/ -├── commands/ # Orchestration: commit, config, init -├── lib/ # Implementation: git, message, prompt, config-loader, setup, ... -├── utils/ # Pure helpers: config validation, constants, theme, validators -└── types/ # Type definitions (barrel: src/types/index.ts) -``` - -Commands orchestrate, lib modules implement, utils provide pure functions. Imports point down -only: - -- `commands -> lib | utils | types` -- `lib -> utils | types | external packages` -- `utils -> types` -- Never `lib -> commands`, `utils -> lib` or `utils -> commands`. - -Full detail: `.claude/guides/architecture.md`. +- Node >=22.13, ESM only. Relative imports use the `.js` extension, even from `.ts` sources. +- Layers, top to bottom: `commands`, `lib`, `utils`, `types`. Import from a lower layer or the + same layer (acyclic), never upward. +- Never use `any` in `src/`; use `unknown` and narrow. +- In prose, comments, CLI strings, commits and PRs, use hyphens (`-`), not em or en dashes. +- A change to user-facing behavior updates the matching `docs/` page in the same branch. ## Security invariants (MUST hold) -1. Every git command runs through `execa` with an args array. Never build a command string. - ```typescript - await execa("git", ["commit", "-m", message]); // correct - execSync(`git commit -m "${message}"`); // never - ``` +1. Run every git command through `execa` with an args array. Never build a command string. 2. No network calls. Merlin works fully offline. -3. No `eval()` or `new Function()`. Custom theme templates use `{placeholder}` substitution only. +3. No `eval()` or `new Function()`. Theme templates use `{placeholder}` substitution only. 4. Validate and sanitize all user configuration (`src/utils/config.ts`, `src/utils/theme.ts`). -5. Fixed file paths only. Never build a path from user input. -6. No telemetry or analytics of any kind. - -Details and examples: `.claude/rules/security.md`. - -## Code conventions - -- Names are descriptive and unabbreviated: `config`, not `cfg`; no single-letter variables - outside very short lambdas. -- Include units in names: `timeoutMilliseconds`, `maxLengthCharacters`. -- Booleans start with `is`, `has` or `can`. -- A function does only what its name says. -- Prefer `type` over `interface`. Never use `any`; use `unknown` and narrow. -- 4-space indentation, double quotes, semicolons, `===`/`!==`, `const` by default, never `var`. -- ESM imports use the `.js` extension, even for `.ts` sources. -- In all prose, comments, CLI strings, commits and PRs, prefer hyphens (`-`) over em and en - dashes. - -Details: `.claude/rules/naming.md`, `.claude/rules/code-style.md`, -`.claude/rules/clean-code.md`. +5. Fixed file paths only (`~/.merlinrc.json`, `/.merlinrc.json`). Never build a path + from user input. +6. No telemetry or analytics. ## Commands ```bash -npm run build # Compile TypeScript to dist/ -npm run dev # Watch mode compilation -npm run lint # ESLint on src/ and tests/ -npm run format # Prettier --write on src, tests and docs -npm run type-check # tsc --noEmit -npm run knip # Unused exports, files and dependencies -npm run test # Vitest, all tests once -npx vitest run tests/unit/message.test.ts # One test file +npm run type-check # tsc --noEmit (hyphenated) +npm run knip # fails on unused exports, incl. unused barrel re-exports +npx vitest run tests/unit/message.test.ts # one test file ``` -### Before pushing - -These mirror the CI `ci` job in `.github/workflows/ci.yml`: +Before pushing, run the CI gate: ```bash npm run lint && npm run type-check && npm run knip && npm run test && npm run build npx prettier --check "src/**/*.ts" "tests/**/*.ts" "docs/**/*.md" ``` -`knip` fails on unused exports, for example a type re-exported from `src/types/index.ts` that -nothing imports through the barrel. The prettier check also covers `docs/`, so run it after -editing docs. Git hooks enforce part of this: `pre-commit` runs lint-staged and type-check, -`commit-msg` runs commitlint, `pre-push` runs knip, test and build. - -## Git workflow - -- `main` (releases) and `develop` (integration) are protected. Never commit or push to them - directly. -- Branch from `develop`: `feat/*`, `fix/*`, `docs/*`, `chore/*`, `refactor/*`, `test/*`. - `hotfix/*` branches from `main`. -- Open PRs against `develop` (`gh pr create --base develop`; the repo's default branch is - `main`). PRs are squash-merged and follow `.github/pull_request_template.md`. -- Releases go `develop -> release/vX.Y.Z -> main`, then get synced back to `develop`. -- Commits follow Conventional Commits: `(): `. commitlint enforces a - subject of at most 72 characters and a scope of at most 20. -- Do not add AI attribution to commits or PRs: no `Co-Authored-By` trailer for an AI and no - "Generated with" line. - -Full detail: `.claude/guides/git-workflow.md`. +## Git + +- `main` and `develop` are protected: never commit or push to them. Branch from `develop`. +- PRs target `develop`, not the repo default `main`: `gh pr create --base develop`. +- Conventional Commits; commitlint caps the subject at 72 characters and the scope at 20. +- No AI attribution in commits or PRs (no `Co-Authored-By` trailer, no "Generated with" line). + +## Read when relevant + +| Read when you are... | File | +| -------------------------------------------- | ----------------------------------------------------------------------------- | +| Naming a variable, function, type or file | [naming.md](.claude/rules/naming.md) | +| Writing TypeScript (types, errors, comments) | [code-style.md](.claude/rules/code-style.md) | +| Designing or refactoring functions in `src/` | [clean-code.md](.claude/rules/clean-code.md) | +| Working in `src/commands/` | [commands.md](.claude/rules/commands.md) | +| Working in `src/lib/` | [lib.md](.claude/rules/lib.md) | +| Working in `src/utils/` | [utils.md](.claude/rules/utils.md) | +| Writing or fixing tests | [testing.md](.claude/rules/testing.md) | +| Touching git calls, config or file I/O | [security.md](.claude/rules/security.md) | +| Changing user-facing behavior | [documentation.md](.claude/rules/documentation.md) | +| Adding a module, command or extension point | [architecture.md](.claude/guides/architecture.md) | +| Setting up, debugging, or hitting ESM issues | [development.md](.claude/guides/development.md) | +| Committing, opening a PR, releasing, hotfix | [git-workflow.md](.claude/guides/git-workflow.md) | +| Adding or removing a source file | [codebase-map.md](.claude/maintenance/codebase-map.md) (update it afterwards) | diff --git a/CLAUDE.md b/CLAUDE.md index 428aad4..b4a7b9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,13 +3,13 @@ # Claude Code layer Rules in `.claude/rules/` carry `paths:` frontmatter and load by themselves when you work on a -matching file. Rules without frontmatter load in every session. They are listed here by path, -not imported, so a session pays only for the rules it needs. +matching file. Every rule is path-scoped; what every session needs lives in AGENTS.md. Rules are +listed here by path, not imported, so a session pays only for the rules it needs. | Rule | Loads for | | -------------------------------- | ----------------------------- | -| `.claude/rules/naming.md` | always | -| `.claude/rules/security.md` | always | +| `.claude/rules/naming.md` | `**/*.ts` | +| `.claude/rules/security.md` | `src/**/*.ts` | | `.claude/rules/code-style.md` | `**/*.ts` | | `.claude/rules/clean-code.md` | `src/**/*.ts` | | `.claude/rules/commands.md` | `src/commands/*.ts` | @@ -18,11 +18,5 @@ not imported, so a session pays only for the rules it needs. | `.claude/rules/testing.md` | `tests/**/*.test.ts` | | `.claude/rules/documentation.md` | `src/**/*.ts`, `docs/**/*.md` | -Read on demand: - -- `.claude/guides/architecture.md`: folder structure, data flow, design patterns, extension - points. -- `.claude/guides/development.md`: setup, debugging and common ESM gotchas. -- `.claude/guides/git-workflow.md`: branching, commit format, PR and release process. -- `.claude/maintenance/codebase-map.md`: file inventory, sizes and technical debt. Update it - when you add a source file. +Guides in `.claude/guides/` and the codebase map never auto-load. Open them from the "Read when +relevant" table in AGENTS.md. diff --git a/docs/development/architecture.md b/docs/development/architecture.md index ebaf444..bba1ce0 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -40,8 +40,11 @@ The source code is organized into three layers. Each layer has a specific role a ✅ commands → types ✅ lib → utils ✅ lib → types +✅ lib → lib (acyclic) ✅ lib → external packages (execa, inquirer, chalk, ora) +✅ utils → utils (acyclic) ✅ utils → types +✅ utils → side-effect-free packages (chalk) ❌ lib → commands ❌ utils → commands @@ -85,7 +88,7 @@ Each library module implements one domain. It exports a focused set of functions - Single responsibility - one domain per module - Explicit error handling with descriptive `Error` objects - Pure functions where possible -- No cross-dependencies between library modules +- Imports between library modules stay acyclic ### Utilities (Pure Helpers) @@ -96,7 +99,7 @@ Utilities are pure, stateless functions. They have no side effects and produce t - No file I/O, network calls, or logging - No external state or mutations - Always return a value rather than throw (except for genuinely impossible states) -- 100% test coverage target - pure functions are trivial to test +- Pure functions are trivial to test, so keep them well covered --- diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 4d0087e..7675849 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -230,7 +230,7 @@ The `commit-msg` hook will reject messages that do not follow the format. The project enforces style automatically via ESLint and Prettier. Run `npm run format` to auto-fix formatting. Key rules: -- TypeScript: `type` preferred over `interface`, no `any`, strict equality (`===`) +- TypeScript: `type` preferred over `interface`, no `any` in `src/`, strict equality (`===`) - 4-space indentation, double quotes, semicolons - Named constants for all magic values - no inline strings or numbers - Boolean variable names use `is`, `has`, or `can` prefixes diff --git a/docs/development/testing.md b/docs/development/testing.md index 5f1af1a..2af17aa 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -48,7 +48,7 @@ Run coverage: npm run test:coverage ``` -Coverage thresholds are enforced in CI. A PR that drops below target will fail. +`npm run test:coverage` fails when coverage drops below these thresholds. CI does not run coverage, so run it locally before opening a PR. ---