diff --git a/.claude/guides/architecture.md b/.claude/guides/architecture.md new file mode 100644 index 0000000..fe4ef4e --- /dev/null +++ b/.claude/guides/architecture.md @@ -0,0 +1,564 @@ +# Architecture Guide + +**Last Updated**: 2026-05-11 + +Complete architecture documentation for merlin-commit, including folder structure, data flow, module relationships, and design patterns. + +--- + +## Table of Contents + +- [Folder Structure](#folder-structure) +- [Module Organization](#module-organization) +- [Data Flow Architecture](#data-flow-architecture) +- [Module Relationships](#module-relationships) +- [Design Patterns](#design-patterns) +- [Tech Stack](#tech-stack) + +--- + +## Folder Structure + +``` +merlin-commit/ +├── .github/ # GitHub configuration +│ ├── ISSUE_TEMPLATE/ # Bug report and feature request templates +│ └── pull_request_template.md # PR template +├── .husky/ # Git hooks (pre-commit, commit-msg) +├── bin/ +│ └── merlin.js # CLI entry point (shebang wrapper) +├── src/ +│ ├── cli.ts # Commander.js program setup +│ ├── commands/ # Orchestration layer +│ │ ├── commit.ts # Default commit workflow command +│ │ ├── config.ts # Configuration management command +│ │ └── init.ts # Project initialization wizard +│ ├── lib/ # Implementation layer +│ │ ├── commitlint-sync.ts # Commitlint config detection and sync +│ │ ├── config-loader.ts # Config loading, saving, theme resolution +│ │ ├── editor-wrapper.ts # External editor integration +│ │ ├── git.ts # Git operations via execa +│ │ ├── message.ts # Commit message building and preview +│ │ ├── prompt.ts # Interactive prompts via @inquirer/prompts +│ │ ├── setup.ts # Project setup utilities (husky, commitlint) +│ │ ├── sigint.ts # Graceful SIGINT/Ctrl+C handling +│ │ ├── terminal.ts # VS16 emoji width normalization +│ │ └── transformers.ts # Inquirer input transformers (char counters) +│ ├── types/ # Type definitions +│ │ ├── commit.ts # CommitType, CommitAnswers, CommitOptions +│ │ ├── config.ts # MerlinConfig, ConfigOptions, ConfigMenuAction +│ │ ├── theme.ts # ThemeMessages +│ │ ├── common.ts # Validator +│ │ ├── init.ts # InitOptions +│ │ └── index.ts # Barrel re-exports +│ └── utils/ # Pure helpers +│ ├── config.ts # Config validation (pure function) +│ ├── constants.ts # Themes, commit types, and defaults +│ ├── theme.ts # Custom theme resolve/validate/merge helpers +│ └── validators.ts # Inquirer input validators (range, etc.) +├── tests/ +│ └── unit/ # Unit tests (80% coverage target) +│ ├── cli.test.ts # CLI setup tests +│ ├── commit-command.test.ts # Command orchestration tests +│ ├── commitlint-sync.test.ts # Commitlint sync tests +│ ├── config.test.ts # Config validation tests +│ ├── config-command.test.ts # Config command tests +│ ├── config-loader.test.ts # Config loader tests +│ ├── constants.test.ts # Constants tests +│ ├── editor-wrapper.test.ts # Editor wrapper tests +│ ├── git.test.ts # Git operations tests +│ ├── init-command.test.ts # Init command tests +│ ├── message.test.ts # Message building tests +│ ├── prompt.test.ts # Prompt tests +│ ├── setup.test.ts # Setup utilities tests +│ ├── sigint.test.ts # Signal handling tests +│ ├── terminal.test.ts # Terminal detection tests +│ ├── theme.test.ts # Custom theme helper tests +│ ├── transformers.test.ts # Input transformer tests +│ └── validators.test.ts # Validator tests +├── dist/ # Compiled output (git-ignored) +├── Configuration Files +│ ├── package.json # npm package configuration +│ ├── tsconfig.json # TypeScript configuration +│ ├── tsconfig.base.json # Shared TypeScript base config +│ ├── vitest.config.ts # Vitest testing configuration +│ ├── eslint.config.mjs # ESLint flat config +│ ├── .prettierrc # Prettier formatting config +│ └── commitlint.config.js # Conventional commit linting +├── docs/ # User docs: getting-started, usage, reference, guides, development +│ └── GLOSSARY.md # Wizard theme vocabulary glossary +└── Documentation + ├── AGENTS.md # Tool-agnostic agent guidance (source of truth) + ├── CLAUDE.md # Imports AGENTS.md, indexes Claude Code rules + ├── .claude/ # Claude Code layer + │ ├── settings.json # Shared agent permissions + │ ├── rules/ # Path-scoped rules + │ ├── guides/ # Reference documentation + │ └── maintenance/ # Codebase map + ├── CHANGELOG.md # Release notes + └── CODE_OF_CONDUCT.md # Community guidelines +``` + +--- + +## Module Organization + +### Three-Layer Architecture + +Merlin-commit follows a **three-layer architecture** pattern: + +``` +┌─────────────────────────────────────┐ +│ Commands Layer │ ← Orchestration +│ (src/commands/*.ts) │ +│ - commit.ts │ +│ - config.ts │ +│ - init.ts │ +└──────────────┬──────────────────────┘ + │ delegates to + ↓ +┌─────────────────────────────────────┐ +│ Library Layer │ ← Implementation +│ (src/lib/*.ts) │ +│ - commitlint-sync.ts (lint sync) │ +│ - config-loader.ts (config I/O) │ +│ - git.ts (git operations) │ +│ - message.ts (formatting) │ +│ - prompt.ts (user input) │ +│ - setup.ts (initialization) │ +│ - editor-wrapper.ts (editing) │ +│ - sigint.ts (signal handling) │ +│ - terminal.ts (emoji normalization)│ +│ - transformers.ts (UI helpers) │ +└──────────────┬──────────────────────┘ + │ uses + ↓ +┌─────────────────────────────────────┐ +│ Utilities Layer │ ← Pure Helpers +│ (src/utils/*.ts) │ +│ - config.ts (config validation) │ +│ - constants.ts (data) │ +│ - theme.ts (custom themes) │ +│ - validators.ts (validation) │ +└─────────────────────────────────────┘ +``` + +### Layer Responsibilities + +| Layer | Responsibility | Examples | +| ------------- | ------------------------------------------------------------------ | ----------------------------------------------------- | +| **Commands** | Orchestrate workflows, coordinate modules, handle user interaction | `commitCommand()`, `configCommand()`, `initCommand()` | +| **Library** | Implement domain logic, single-purpose modules | `commit()`, `buildCommitMessage()`, `promptUser()` | +| **Utilities** | Pure functions, stateless helpers, shared logic | `validateConfig()`, `DEFAULT_CONFIG`, `COMMIT_TYPES` | + +--- + +## Data Flow Architecture + +### Commit Workflow Data Flow + +``` +User runs `merlin` command + ↓ + cli.ts parses arguments + ↓ + commands/commit.ts (orchestrator) + ↓ +┌────────────────────────────────────────┐ +│ 1. Validate preconditions │ +│ - isGitRepo() → lib/git.ts │ +│ - hasStagedChanges() → lib/git.ts │ +└────────────┬───────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ 2. Gather user input │ +│ - promptUser() → lib/prompt.ts │ +│ → Returns CommitAnswers │ +└────────────┬───────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ 3. Build commit message │ +│ - buildCommitMessage() → lib/message│ +│ → Returns formatted string │ +└────────────┬───────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ 4. Preview and confirm │ +│ - formatPreview() → lib/message │ +│ - confirm() → @inquirer/prompts │ +└────────────┬───────────────────────────┘ + ↓ +┌────────────────────────────────────────┐ +│ 5. Create commit │ +│ - commit() → lib/git.ts │ +│ → Returns CommitResult │ +└────────────┬───────────────────────────┘ + ↓ + Display success message + ↓ + Exit with code 0 +``` + +### Key Data Types + +```typescript +// User input collected from prompts +type CommitAnswers = { + type: string; // feat, fix, docs, etc. + scope?: string; // Affected area + subject: string; // Short description + body?: string; // Detailed explanation + breaking?: string; // Breaking changes + issues?: string; // Issue references +}; + +// Configuration loaded from ~/.merlinrc.json +type MerlinConfig = { + types?: CommitType[]; + maxSubjectLength?: number; + maxScopeLength?: number; + editor?: string; + autoAdd?: boolean; + theme?: "wizard" | "standard" | "custom"; + customTheme?: CustomThemeConfig; // { extends, messages } + showCharacterCounter?: boolean; +}; + +// Result from git operations +type CommitResult = { + success: boolean; + hash: string; + message: string; +}; + +// Commit type definition +type CommitType = { + value: string; // "feat", "fix", etc. + name: string; // Display name with emoji + description: string; // Usage explanation + emoji: string; // Icon representation +}; +``` + +--- + +## Module Relationships + +### Dependency Graph + +``` +┌─────────┐ +│ cli.ts │ +└────┬────┘ + │ imports + ↓ +┌─────────────────────────────────┐ +│ commands/ │ +│ ├─ commit.ts │ +│ ├─ config.ts │ +│ └─ init.ts │ +└────┬─────────┬──────────────────┘ + │ │ + │ └────────────┐ + ↓ ↓ +┌──────────────┐ ┌──────────────┐ +│ lib/ │ │ utils/ │ +│ ├─ cl-sync │ │ ├─ config │ +│ ├─ cfg-load │ │ ├─ constants│ +│ ├─ git │ │ └─ validator│ +│ ├─ message │ └──────────────┘ +│ ├─ prompt │ ↑ +│ ├─ setup │ │ +│ ├─ editor │ │ +│ ├─ sigint │ │ +│ ├─ terminal │ │ +│ └─ transf. │────────────┘ +└──────────────┘ + +External Dependencies: +- execa (git operations) +- @inquirer/prompts (user input) +- chalk (colors) +- ora (spinners) +- commander (CLI) +``` + +### Allowed Dependencies + +``` +✅ commands → lib +✅ commands → utils +✅ commands → types +✅ lib → utils +✅ lib → types +✅ lib → external packages +✅ utils → types + +❌ lib → commands (library shouldn't know about orchestration) +❌ utils → commands (utils shouldn't know about orchestration) +❌ utils → lib (utils should be pure/independent) +``` + +--- + +## Design Patterns + +### 1. Command Pattern (Orchestration) + +Commands are stateless functions that coordinate workflows: + +```typescript +// Pattern: Validate → Gather → Process → Confirm → Execute +export async function commitCommand(options: CommitOptions): Promise { + await validate(); + const input = await gather(); + const processed = process(input); + const confirmed = await confirm(processed); + if (confirmed) await execute(processed, options); +} +``` + +### 2. Delegation Pattern + +Commands delegate to specialized modules: + +```typescript +// Command delegates, doesn't implement +export async function commitCommand(options: CommitOptions): Promise { + const isRepo = await isGitRepo(); // Delegates to lib/git.ts + const answers = await promptUser(); // Delegates to lib/prompt.ts + const message = buildCommitMessage(answers); // Delegates to lib/message.ts + await commit(message, options); // Delegates to lib/git.ts +} +``` + +### 3. Pure Function Pattern (Utilities) + +Utils are pure, stateless, deterministic. Validators use a factory pattern: + +```typescript +// Pure factory function - returns an Inquirer-compatible validator +export function createMaxLengthValidator( + maxLength: number, + fieldName?: string +): Validator { + return (value: string) => { + if (value.length > maxLength) { + return `${fieldName ?? "Value"} exceeds ${maxLength} characters`; + } + return true; + }; +} +``` + +### 4. Dependency Injection Pattern + +Functions accept dependencies as parameters: + +```typescript +// git.ts can accept custom executor for testing +export async function commit( + message: string, + options: CommitOptions, + executor: typeof execa = execa +): Promise { + await executor("git", ["commit", "-m", message]); +} +``` + +### 5. Error Handling Pattern + +Explicit error handling at appropriate layers: + +```typescript +// Library layer handles domain errors +export async function commit(message: string): Promise { + try { + const result = await execa("git", ["commit", "-m", message]); + return { success: true, hash: parseHash(result.stdout) }; + } catch (error) { + if (error.stderr.includes("nothing to commit")) { + throw new Error("No changes to commit"); + } + throw new Error(`Git commit failed: ${error.message}`); + } +} + +// Command layer handles orchestration errors +export async function commitCommand(options: CommitOptions): Promise { + try { + await commit(message, options); + console.log("✓ Commit created successfully"); + } catch (error) { + console.error("Failed to create commit:", error.message); + process.exit(1); + } +} +``` + +### 6. Theme Pattern + +Messages are theme-aware and organized into domain sub-groups: + +```typescript +// ThemeMessages is structured by command domain with cross-cutting categories +type ThemeMessages = { + commit: { intro, exit, dryRunExit, previewHeader }; + config: { intro, exit, ... }; + init: { intro, exit, ... }; + prompts: { selectType, enterScope, enterSubject, ... }; + errors: { commit: { notGitRepo, ... }, config: { ... }, init: { ... } }; + warnings: { commit: { ... }, config: { ... } }; + success: { commit: { ... }, config: { ... }, init: { ... } }; + tips: { commit: { ... }, init: { ... } }; +}; + +// utils/constants.ts defines two complete message sets +export const WIZARD_MESSAGES: ThemeMessages = { /* wizard-themed text */ }; +export const STANDARD_MESSAGES: ThemeMessages = { /* plain text */ }; + +// lib/config-loader.ts resolves messages with VS16 emoji normalization +export function getMessages(repoRoot?: string | null): ThemeMessages { + const config = loadConfig(repoRoot); + if (config.theme !== "wizard") return STANDARD_MESSAGES; + return normalizeThemeStrings(WIZARD_MESSAGES); +} +``` + +--- + +## Tech Stack + +### Core Dependencies + +| Package | Version | Purpose | +| --------------------- | --------- | ------------------------- | +| **TypeScript** | 5.7+ | Language | +| **Node.js** | >=22.13.0 | Runtime | +| **Commander.js** | ^14.0.2 | CLI framework | +| **@inquirer/prompts** | ^8.2.0 | Interactive prompts | +| **execa** | ^9.6.1 | Safe subprocess execution | +| **chalk** | ^5.6.2 | Terminal colors | +| **ora** | ^9.0.0 | Loading spinners | + +### Dev Dependencies + +| Package | Purpose | +| -------------- | ---------------------- | +| **Vitest** | Testing framework | +| **ESLint** | Code linting | +| **Prettier** | Code formatting | +| **Commitlint** | Commit message linting | +| **Husky** | Git hooks | + +### Why These Choices? + +**execa over child_process**: + +- Promise-based API +- Better error handling +- Args array prevents injection + +**@inquirer/prompts over inquirer**: + +- Modular, tree-shakeable +- Modern Promise API +- Better TypeScript support + +**Commander.js over yargs**: + +- Simpler API +- Better for command-based CLIs +- Smaller bundle size + +--- + +## 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 | Files | Total Lines | +| ------------- | ----- | ----------- | +| Source code | 24 | ~4,863 | +| Test files | 18 | ~7,152 | +| Config files | 9 | - | +| Documentation | 17 | - | + +--- + +## Key Architectural Decisions + +### 1. **ES Modules Only** + +- No CommonJS (`require`) +- Use `import/export` +- Target: ES2022 + +### 2. **TypeScript Strict Mode** + +- `noUnusedLocals`: true +- `noUnusedParameters`: true +- `strict`: true + +### 3. **Offline-First** + +- No network calls +- No telemetry +- Privacy-focused + +### 4. **Convention Over Configuration** + +- Sensible defaults +- Optional configuration +- Works zero-config + +### 5. **Safety First** + +- execa args array (not string interpolation) +- Config validation +- No eval() +- Fixed file paths + +--- + +## Extension Points + +### Adding New Commands + +1. Create `src/commands/new-command.ts` +2. Follow orchestration pattern +3. Register in `src/cli.ts` +4. Add tests in `tests/unit/new-command-command.test.ts` + +### Adding New Commit Types + +1. Update `COMMIT_TYPES` in `utils/constants.ts` +2. No code changes needed (data-driven) + +### Adding New Themes + +1. Add new message set in `utils/constants.ts` +2. Update `BaseThemeName` in `types/theme.ts` +3. Update `getMessages()` in `lib/config-loader.ts` + +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 new file mode 100644 index 0000000..fd5e49c --- /dev/null +++ b/.claude/guides/development.md @@ -0,0 +1,566 @@ +# Development Guide + +**Last Updated**: 2026-02-07 + +Complete development setup, commands, debugging tips, and common gotchas for merlin-commit. + +--- + +## Table of Contents + +- [Development Setup](#development-setup) +- [NPM Commands](#npm-commands) +- [CLI Usage](#cli-usage) +- [Debugging](#debugging) +- [Common Gotchas](#common-gotchas) +- [Dependency Management](#dependency-management) + +--- + +## Development Setup + +### Prerequisites + +- **Node.js** >= 22.13.0 + +- **npm** >= 9.0.0 +- **Git** >= 2.0.0 + +### Initial Setup + +```bash +# 1. Clone the repository +git clone https://github.com/mBukator/merlin-commit.git +cd merlin-commit + +# 2. Install dependencies +npm install + +# 3. Build the project +npm run build + +# 4. Link for local development +npm link + +# 5. Verify installation +merlin --version +``` + +### IDE Setup (VS Code) + +**Recommended Extensions**: + +- ESLint (`dbaeumer.vscode-eslint`) +- Prettier (`esbenp.prettier-vscode`) +- TypeScript (`ms-vscode.vscode-typescript-next`) + +**Workspace Settings** (`.vscode/settings.json`): + +```json +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "typescript.tsdk": "node_modules/typescript/lib" +} +``` + +--- + +## NPM Commands + +### Build & Development + +| Command | Description | When to Use | +| --------------- | ----------------------------- | -------------------------------------- | +| `npm run build` | Compile TypeScript to `dist/` | Before testing locally, before commits | +| `npm run dev` | Watch mode compilation | During active development | +| `npm run clean` | Remove `dist/` directory | When build artifacts cause issues | + +**Examples**: + +```bash +# Start watch mode during development +npm run dev + +# Then in another terminal, test changes +merlin + +# Build for production +npm run build +``` + +### Quality Checks + +| Command | Description | When to Use | +| -------------------- | ------------------------------------- | ------------------------------- | +| `npm run lint` | Run ESLint on `src/**/*.ts` | Before commits, in CI | +| `npm run format` | Format code with Prettier | Before commits | +| `npm run type-check` | TypeScript type checking without emit | Before commits, when types fail | + +**Full quality check**: + +```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" +``` + +### Testing + +| Command | Description | When to Use | +| ----------------------- | ------------------------------ | ----------------------------- | +| `npm run test` | Run all tests once | Before commits, in CI | +| `npm run test:watch` | Run tests in watch mode | During test development | +| `npm run test:coverage` | Run tests with coverage report | Before PRs, checking coverage | + +**Run single test file**: + +```bash +npx vitest run tests/unit/message.test.ts +``` + +**Run single test**: + +```bash +npx vitest run -t "should format message with scope" +``` + +**Debug test**: + +```bash +npx vitest run --inspect-brk tests/unit/message.test.ts +``` + +--- + +## CLI Usage + +### Basic Commands + +```bash +# Interactive commit workflow +merlin +merlin commit +merlin c # Alias + +# Configuration management +merlin config +merlin config --show +merlin config --reset + +# Project initialization +merlin init +merlin init --husky-only +merlin init --commitlint-only + +# Help and version +merlin --help +merlin --version +``` + +### Commit Options + +| Option | Description | Example | +| ------------- | ---------------------------------- | -------------------- | +| `--dry-run` | Preview commit without creating it | `merlin --dry-run` | +| `--no-verify` | Skip git hooks (commitlint, husky) | `merlin --no-verify` | +| `--amend` | Amend previous commit | `merlin --amend` | + +**Examples**: + +```bash +# Preview what commit would look like +merlin --dry-run + +# Create commit skipping hooks (emergency only!) +merlin --no-verify + +# Amend last commit +merlin --amend +``` + +### Config Options + +| Option | Description | Example | +| --------- | ----------------------------- | ----------------------- | +| `--show` | Display current configuration | `merlin config --show` | +| `--reset` | Reset to defaults | `merlin config --reset` | + +--- + +## Debugging + +### Debug Mode + +Enable Node.js debugging: + +```bash +# Debug the CLI +node --inspect-brk bin/merlin.js + +# Or use npm script +NODE_OPTIONS='--inspect-brk' merlin +``` + +Then open `chrome://inspect` in Chrome. + +### Console Logging + +Add debug logs (remove before committing): + +```typescript +// Temporary debugging +console.log('Debug:', { variable, otherVariable }); +console.error('Error:', error); +console.dir(complexObject, { depth: null }); +``` + +### TypeScript Errors + +**Problem**: Type errors during development + +**Solution**: + +```bash +# Check types explicitly +npm run type-check + +# Or in watch mode +npx tsc --noEmit --watch +``` + +### Test Failures + +**Problem**: Tests fail unexpectedly + +**Solutions**: + +```bash +# Run single test file +npx vitest run tests/unit/problematic.test.ts + +# Run with verbose output +npx vitest run --reporter=verbose + +# Run in watch mode to see failures immediately +npm run test:watch +``` + +### Build Issues + +**Problem**: Build fails or produces unexpected output + +**Solutions**: + +```bash +# Clean and rebuild +npm run clean +npm run build + +# Check for TypeScript errors +npm run type-check + +# Verify tsconfig.json is correct +cat tsconfig.json +``` + +### Git Hooks Failing + +**Problem**: Pre-commit hook blocks commit + +**Solutions**: + +```bash +# Run checks manually to see what's failing +npm run lint +npm run format +npm run type-check + +# Fix issues +npm run format # Auto-fix formatting +npm run lint -- --fix # Auto-fix linting + +# Then try commit again +git commit +``` + +--- + +## Common Gotchas + +### 1. ESM vs CommonJS + +**Issue**: Merlin uses ES Modules, not CommonJS + +**Don't do**: + +```javascript +const { commitCommand } = require('./commands/commit'); // ❌ +``` + +**Do**: + +```typescript +import { commitCommand } from './commands/commit.js'; // ✅ +// Note: .js extension required for ESM +``` + +### 2. File Extensions in Imports + +**Issue**: ESM requires `.js` extension in imports (even for `.ts` files) + +**Don't do**: + +```typescript +import { commit } from './lib/git'; // ❌ +``` + +**Do**: + +```typescript +import { commit } from './lib/git.js'; // ✅ +// TypeScript compiler resolves .ts file +``` + +### 3. execa Import + +**Issue**: execa v9 is ESM-only + +**Don't do**: + +```typescript +import execa from 'execa'; // ❌ +``` + +**Do**: + +```typescript +import { execa } from 'execa'; // ✅ +``` + +### 4. Testing Async Functions + +**Issue**: Forgetting to await in tests + +**Don't do**: + +```typescript +it('should commit', () => { + commit('message'); // ❌ Missing await + expect(something).toBe(true); +}); +``` + +**Do**: + +```typescript +it('should commit', async () => { + await commit('message'); // ✅ + expect(something).toBe(true); +}); +``` + +### 5. Mock Placement + +**Issue**: Mocks must be before imports + +**Don't do**: + +```typescript +import { commit } from '../src/lib/git.js'; +vi.mock('../src/lib/git.js'); // ❌ Too late! +``` + +**Do**: + +```typescript +vi.mock('../src/lib/git.js'); // ✅ Before imports +import { commit } from '../src/lib/git.js'; +``` + +### 6. Chalk and Colors + +**Issue**: Colors don't show in tests + +**Reason**: Chalk detects non-TTY and disables colors + +**Solution**: Don't test color codes, test content: + +```typescript +// ❌ Don't test ANSI codes +expect(output).toContain('\x1b[32m'); + +// ✅ Test actual content +expect(output).toContain('Success'); +``` + +### 7. Path Separators + +**Issue**: Windows uses `\`, Linux/Mac use `/` + +**Don't do**: + +```typescript +const configPath = `${homeDir}/.merlinrc.json`; // ❌ Breaks on Windows +``` + +**Do**: + +```typescript +import path from 'path'; +const configPath = path.join(homeDir, '.merlinrc.json'); // ✅ +``` + +--- + +## Dependency Management + +### Adding Dependencies + +```bash +# Production dependency +npm install package-name + +# Dev dependency +npm install --save-dev package-name +``` + +**Before adding**: + +1. Check if really needed +2. Review package size (use [bundlephobia](https://bundlephobia.com/)) +3. Check last update date +4. Review security (npm audit) +5. Prefer well-maintained packages + +### Updating Dependencies + +```bash +# Check for updates +npm outdated + +# Update specific package +npm update package-name + +# Update all packages (carefully!) +npm update + +# Major version updates +npm install package-name@latest +``` + +**Update checklist**: + +1. Read CHANGELOG for breaking changes +2. Update code if needed +3. Run full test suite +4. Test CLI manually +5. Commit with `chore(deps): update package-name to vX.X.X` + +### Security Audits + +```bash +# Check for vulnerabilities +npm audit + +# Fix automatically (if possible) +npm audit fix + +# Fix with breaking changes +npm audit fix --force +``` + +**Process**: + +1. Run `npm audit` monthly +2. Review vulnerabilities +3. Update affected packages +4. Test thoroughly +5. Document breaking changes + +### Package Policy + +**Allowed**: + +- CLI tools (commander, inquirer, chalk, ora) +- Git utilities (execa) +- Dev tools (typescript, eslint, prettier, vitest) + +**Not Allowed**: + +- Network libraries (no network calls) +- Analytics/telemetry +- Large bundles (>1MB) +- Unmaintained packages (>1 year) + +--- + +## Performance Tips + +### Build Performance + +```bash +# Faster builds with swc (future consideration) +# Current: tsc takes ~2-3 seconds +# Target: <1 second with swc +``` + +### Test Performance + +```bash +# Run specific tests only +npx vitest run tests/unit/validators.test.ts + +# Parallel testing (default in vitest) +npm run test # Runs tests in parallel + +# Skip slow tests during development +it.skip('slow test', () => { }); +``` + +--- + +## Environment Variables + +Merlin doesn't use environment variables by design (offline-first). + +But for development: + +```bash +# Debug mode +DEBUG=merlin:* merlin + +# Skip hooks +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 diff --git a/.claude/guides/git-workflow.md b/.claude/guides/git-workflow.md new file mode 100644 index 0000000..49e60a9 --- /dev/null +++ b/.claude/guides/git-workflow.md @@ -0,0 +1,635 @@ +# Git Workflow Guide + +**Last Updated**: 2026-02-07 + +Complete guide to the git workflow, branching strategy, commit conventions, and release process for merlin-commit. + +--- + +## Table of Contents + +- [Branching Strategy](#branching-strategy) +- [Workflow Steps](#workflow-steps) +- [Commit Message Format](#commit-message-format) +- [Pull Request Process](#pull-request-process) +- [Release Process](#release-process) +- [Git Hooks](#git-hooks) + +--- + +## Branching Strategy + +### Protected Branches + +| Branch | Purpose | Protection Rules | +| ----------- | -------------------------------------- | ---------------------------------------------------- | +| **main** | Production-ready code, tagged releases | No direct commits, PR required, all checks must pass | +| **develop** | Integration branch for features | No direct commits, PR required, all checks must pass | + +### Branch Types + +| Pattern | Purpose | Created From | Merges Into | Lifetime | +| ------------ | -------------------------------- | ------------ | -------------------- | ------------------------- | +| `feat/*` | New features | `develop` | `develop` via PR | Until merged or abandoned | +| `fix/*` | Bug fixes | `develop` | `develop` via PR | Until merged | +| `docs/*` | Documentation changes | `develop` | `develop` via PR | Until merged | +| `chore/*` | Maintenance tasks (deps, config) | `develop` | `develop` via PR | Until merged | +| `refactor/*` | Code refactoring | `develop` | `develop` via PR | Until merged | +| `test/*` | Adding/fixing tests | `develop` | `develop` via PR | Until merged | +| `release/v*` | Release preparation | `develop` | `main` and `develop` | Until released | +| `hotfix/*` | Urgent production fixes | `main` | `main` and `develop` | Until fixed | + +### Branch Naming Examples + +**Good**: + +- `feat/add-custom-types` +- `fix/scope-validation` +- `docs/update-readme` +- `chore/upgrade-dependencies` +- `release/v1.1.0` +- `hotfix/critical-security-fix` + +**Bad**: + +- `feature-1` (not descriptive) +- `johns-branch` (personal, not descriptive) +- `fix` (too generic) +- `WIP-stuff` (work-in-progress should be descriptive) + +--- + +## Workflow Steps + +### Starting New Work + +```bash +# 1. Ensure you're on develop and up to date +git checkout develop +git pull origin develop + +# 2. Create feature branch +git checkout -b feat/your-feature-name + +# 3. Make changes +# ... edit files ... + +# 4. Run quality checks +npm run lint +npm run format +npm run type-check +npm run test +npm run build + +# 5. Stage and commit +git add +merlin # Use merlin to create conventional commit + +# 6. Push to remote +git push -u origin feat/your-feature-name + +# 7. Create PR on GitHub +gh pr create --base develop --title "feat: your feature title" --body "..." +``` + +### Working on Feature Branch + +```bash +# Regularly pull from develop to stay updated +git checkout develop +git pull origin develop +git checkout feat/your-feature + +# Rebase on develop (preferred over merge) +git rebase develop + +# Or merge if you prefer +git merge develop + +# Continue work +# ... make changes ... + +# Commit changes +merlin + +# Push updates +git push origin feat/your-feature + +# If you rebased, force push (carefully!) +git push --force-with-lease origin feat/your-feature +``` + +### Before Creating PR + +```bash +# 1. Ensure all tests pass +npm run test + +# 2. Run all quality checks +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" + +# 3. Review your changes +git diff develop...HEAD + +# 4. Check commit history +git log develop..HEAD --oneline + +# 5. Ensure branch is up to date with develop +git pull origin develop +# Or: git rebase develop + +# 6. Push final changes +git push origin feat/your-feature +``` + +--- + +## Commit Message Format + +Merlin-commit follows [Conventional Commits](https://www.conventionalcommits.org/) specification. + +### Format + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +### Commit Types + +| Type | Description | Example | +| ------------ | ----------------------------- | ------------------------------------------------------- | +| **feat** | New feature | `feat(prompts): add custom commit type configuration` | +| **fix** | Bug fix | `fix(git): resolve staging detection for renamed files` | +| **docs** | Documentation only | `docs(readme): update installation instructions` | +| **style** | Code style (formatting, etc.) | `style: fix indentation in config module` | +| **refactor** | Code refactoring | `refactor(message): simplify formatting logic` | +| **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` | +| **chore** | Maintenance | `chore(deps): upgrade inquirer to v10` | +| **revert** | Revert previous commit | `revert: revert "feat: add custom types"` | + +### Scope Examples + +Scope indicates which part of codebase is affected: + +- `prompts` - Interactive prompt changes +- `git` - Git operation changes +- `message` - Commit message formatting +- `config` - Configuration handling +- `cli` - CLI interface changes +- `validators` - Validation logic +- `types` - TypeScript types +- `tests` - Test files + +### Subject Guidelines + +- Use imperative mood ("add" not "added" or "adds") +- Don't capitalize first letter +- No period at the end +- Maximum 72 characters +- Be concise but descriptive + +**Good**: + +- `feat(git): add support for signed commits` +- `fix(prompt): handle EOF in interactive mode` +- `docs: clarify configuration options` + +**Bad**: + +- `feat: Added some stuff` (past tense, vague) +- `fix: Fixed it.` (period, vague) +- `Update README` (not conventional format) + +### Body Guidelines + +- Separate from subject with blank line +- Wrap at 72 characters +- Explain **what** and **why**, not **how** +- Use bullet points if multiple points + +Example: + +``` +feat(config): add config validation + +- Validate all user configuration before saving +- Throw clear errors for invalid values +- Support migration from old config format + +This prevents runtime errors from malformed config files +and provides better user experience with actionable error messages. +``` + +### Footer Guidelines + +Use for breaking changes and issue references: + +**Breaking Changes**: + +``` +feat(api): change config file format + +BREAKING CHANGE: config file format changed from JSON to YAML. +Migration: run `merlin migrate-config` to update your config. +``` + +**Issue References**: + +``` +fix(git): resolve merge conflict detection + +Closes #123 +Fixes #456 +Resolves #789 +``` + +### AI Attribution + +Do not add AI attribution to commits or PRs: no `Co-Authored-By` trailer for an AI assistant +and no "Generated with" line in commit bodies or PR descriptions. + +BAD EXAMPLE: + +``` +feat(prompts): add new prompt type + +Co-Authored-By: +``` + +GOOD EXAMPLE: + +``` +feat(prompts): add new prompt type +``` + +--- + +## Pull Request Process + +### Creating PR + +1. **Push your branch**: + + ```bash + git push -u origin feat/your-feature + ``` + +2. **Create PR using GitHub CLI**: + + ```bash + gh pr create \ + --base develop \ + --title "feat(scope): brief description" \ + --body "$(cat <<'EOF' + ## Summary + - Change 1 + - Change 2 + - Change 3 + + ## Test Plan + - [ ] Unit tests added/updated + - [ ] Manual testing performed + - [ ] No regressions + EOF + )" + ``` + +3. **Or create via GitHub web interface** + +### PR Title + +Follow conventional commit format: + +- `feat(scope): add feature description` +- `fix(scope): resolve bug description` +- `docs: update documentation` + +### PR Description Template + +To find description template for pull request go to .github/pull_request_template.md + +### PR Review Process + +**As Author**: + +1. Ensure all CI checks pass +2. Respond to review comments promptly +3. Make requested changes +4. Request re-review after changes + +**As Reviewer**: + +1. Review code logic and architecture +2. Check for security issues +3. Verify tests cover changes +4. Ensure code follows style guide +5. Approve when satisfied + +### Merging PR + +**Merge Strategy**: **Squash and Merge** (default) + +Why squash? + +- Clean, linear history on develop +- One commit per feature/fix +- Easier to revert if needed +- Clear changelog generation + +**Process**: + +1. All checks must pass +2. At least one approval required +3. Resolve any merge conflicts +4. Squash and merge to develop +5. Delete feature branch + +--- + +## Release Process + +### Preparation + +1. **Create release branch from develop**: + + ```bash + git checkout develop + git pull origin develop + git checkout -b release/v1.1.0 + ``` + +2. **Update version in package.json**: + + ```bash + npm version minor # or major/patch + ``` + +3. **Update CHANGELOG.md**: + + ```markdown + ## [1.1.0] - 2026-02-07 + + ### Added + + - New feature 1 + - New feature 2 + + ### Fixed + + - Bug fix 1 + - Bug fix 2 + + ### Changed + + - Breaking change 1 + ``` + +4. **Run final checks**: + + ```bash + npm run lint + npm run test + npm run build + ``` + +5. **Commit version bump**: + ```bash + git add package.json CHANGELOG.md package-lock.json + git commit -m "chore(release): bump version to 1.1.0" + git push origin release/v1.1.0 + ``` + +### Releasing + +1. **Create PR from release branch to main**: + + ```bash + gh pr create \ + --base main \ + --title "release: v1.1.0" \ + --body "Release version 1.1.0" + ``` + +2. **Review and merge to main** + +3. **Tag the release**: + + ```bash + git checkout main + git pull origin main + git tag -a v1.1.0 -m "Release version 1.1.0" + git push origin v1.1.0 + ``` + +4. **Merge release branch back to develop**: + + ```bash + gh pr create \ + --base develop \ + --head release/v1.1.0 \ + --title "chore: sync release v1.1.0 to develop" \ + --body "Sync version bump from release" + ``` + +5. **Delete release branch**: + + ```bash + git branch -d release/v1.1.0 + git push origin --delete release/v1.1.0 + ``` + +6. **Publish to npm** (if applicable): + ```bash + npm publish + ``` + +### Hotfix Process + +For urgent fixes to production: + +1. **Create hotfix branch from main**: + + ```bash + git checkout main + git pull origin main + git checkout -b hotfix/critical-bug + ``` + +2. **Fix the issue and test** + +3. **Bump patch version**: + + ```bash + npm version patch + ``` + +4. **Create PR to main**: + + ```bash + gh pr create --base main --title "hotfix: critical bug" + ``` + +5. **After merge, tag the hotfix**: + + ```bash + git tag -a v1.0.1 -m "Hotfix version 1.0.1" + git push origin v1.0.1 + ``` + +6. **Merge hotfix to develop**: + ```bash + gh pr create --base develop --title "chore: sync hotfix v1.0.1" + ``` + +--- + +## Git Hooks + +Merlin-commit uses Husky for git hooks. + +### Pre-Commit Hook + +**Location**: `.husky/pre-commit` + +**Actions**: + +1. Run `npx lint-staged` - ESLint `--fix` and Prettier on staged `src/` and `tests/` files +2. Run `npm run type-check` - TypeScript validation + +**If fails**: Commit is blocked, fix issues and try again + +### Commit-Msg Hook + +**Location**: `.husky/commit-msg` + +**Actions**: + +1. Run `commitlint` to validate commit message format +2. Ensure message follows conventional commits spec + +**If fails**: Commit is blocked with error explaining issue + +### Pre-Push Hook + +**Location**: `.husky/pre-push` + +**Actions**: + +1. Run `npm run knip` - unused exports, files and dependencies +2. Run `npm run test` - full test suite +3. Run `npm run build` - TypeScript compilation + +**If fails**: Push is blocked, fix issues and try again + +### Bypassing Hooks + +Coding agents must never bypass hooks (`.claude/settings.json` denies `--no-verify`). For +humans, it is **not recommended**, but sometimes necessary: + +```bash +git commit --no-verify -m "message" +merlin --no-verify +``` + +**Only use when**: + +- Emergencies +- CI is broken and needs urgent fix +- Hook has a bug (then fix the hook!) + +--- + +## Best Practices + +### Do + +- ✅ Keep commits focused and atomic +- ✅ Write descriptive commit messages +- ✅ Rebase feature branches regularly +- ✅ Run tests before pushing +- ✅ Use conventional commit format +- ✅ Review your own PR before requesting review +- ✅ Keep branches up to date with develop + +### Don't + +- ❌ Commit directly to main or develop +- ❌ Force push to main or develop +- ❌ Create vague commit messages ("fix stuff", "WIP") +- ❌ Leave failing tests +- ❌ Skip pre-commit hooks without good reason +- ❌ Let branches get too far behind develop +- ❌ Commit sensitive data (.env, credentials) + +--- + +## Troubleshooting + +### Merge Conflicts + +```bash +# Update your branch +git checkout feat/your-feature +git pull origin develop + +# Fix conflicts in editor +# ... resolve conflicts ... + +# Mark as resolved +git add + +# Continue +git rebase --continue +# Or if merging: +git commit +``` + +### Accidentally Committed to Wrong Branch + +```bash +# Save your changes +git stash + +# Switch to correct branch +git checkout -b correct-branch + +# Apply changes +git stash pop + +# Commit +merlin +``` + +### Need to Amend Last Commit + +```bash +# Make changes +# ... edit files ... + +# Stage changes +git add + +# Amend (using merlin) +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 new file mode 100644 index 0000000..87155a5 --- /dev/null +++ b/.claude/maintenance/codebase-map.md @@ -0,0 +1,310 @@ +# Codebase Map + +**Last Updated**: 2026-05-17 + +File inventory with ownership and last-updated tracking for merlin-commit. + +--- + +## Purpose + +This file tracks all source files, their responsibilities, sizes, and maintenance status. Use this to: + +- Identify owners for specific modules +- Track when files were last significantly changed +- Plan refactoring efforts +- Onboard new contributors + +--- + +## Source Files Inventory + +### Commands (src/commands/) + +| File | Lines | Purpose | Last Major Change | Owner/Contact | +| --------- | ----- | ----------------------------------------- | ----------------- | ------------- | +| commit.ts | 176 | Interactive commit workflow orchestration | 2026-05-11 | Core team | +| config.ts | 922 | User and project configuration management | 2026-05-11 | Core team | +| init.ts | 410 | Project initialization wizard | 2026-03-21 | Core team | + +**Total**: 3 files, ~1,508 lines + +### Library Modules (src/lib/) + +| File | Lines | Purpose | Last Major Change | Owner/Contact | +| ------------------ | ----- | ------------------------------------------- | ----------------- | ------------- | +| commitlint-sync.ts | 191 | Commitlint config detection and sync | 2026-05-11 | Core team | +| config-loader.ts | 213 | Config loading, saving, theme resolution | 2026-04-19 | Core team | +| editor-wrapper.ts | 256 | External editor integration | 2026-03-21 | Core team | +| git.ts | 345 | Git operations via execa | 2026-05-11 | Core team | +| message.ts | 130 | Commit message building and preview | 2026-03-08 | Core team | +| prompt.ts | 223 | Interactive prompts via @inquirer/prompts | 2026-05-11 | Core team | +| setup.ts | 270 | Project setup utilities (husky, commitlint) | 2026-04-19 | Core team | +| sigint.ts | 29 | Graceful SIGINT/Ctrl+C handling | 2026-03-08 | Core team | +| terminal.ts | 63 | VS16 emoji width normalization | 2026-05-11 | Core team | +| transformers.ts | 91 | Inquirer input transformers | 2026-03-21 | Core team | + +**Total**: 10 files, ~1,709 lines + +### Utilities (src/utils/) + +| File | Lines | Purpose | Last Major Change | Owner/Contact | +| ------------- | ----- | ------------------------------------------- | ----------------- | ------------- | +| config.ts | 68 | Config validation (pure) | 2026-05-11 | Core team | +| constants.ts | 359 | Themes, commit types, defaults | 2026-10-18 | Core team | +| theme.ts | 205 | Custom theme resolve/validate/merge helpers | 2026-10-18 | Core team | +| validators.ts | 115 | Inquirer input validators | 2026-03-08 | Core team | + +**Total**: 4 files, ~747 lines + +### Types (src/types/) + +| File | Lines | Purpose | Last Major Change | Owner/Contact | +| --------- | ----- | ---------------------------------------------- | ----------------- | ------------- | +| commit.ts | 102 | CommitType, CommitAnswers, CommitOptions | 2026-03-14 | Core team | +| config.ts | 98 | MerlinConfig, ConfigOptions, ConfigurableField | 2026-05-11 | Core team | +| theme.ts | 354 | ThemeMessages (domain sub-grouped) | 2026-05-11 | Core team | +| common.ts | 5 | Validator | 2026-03-14 | Core team | +| init.ts | 26 | InitOptions | 2026-03-14 | Core team | +| index.ts | 10 | Barrel re-exports (incl. ConfigurableField) | 2026-04-19 | Core team | + +**Total**: 6 files, ~595 lines + +### CLI Entry + +| File | Lines | Purpose | Last Major Change | Owner/Contact | +| ------ | ----- | -------------------------- | ----------------- | ------------- | +| cli.ts | 59 | Commander.js program setup | 2026-05-11 | Core team | + +**Total**: 1 file, 59 lines + +--- + +## Test Files Inventory + +### Unit Tests (tests/unit/) + +| File | Purpose | Last Major Change | Coverage | +| ----------------------- | ---------------------------- | ----------------- | -------- | +| cli.test.ts | CLI setup tests | 2026-03-08 | Good | +| commit-command.test.ts | Command orchestration tests | 2026-05-11 | Good | +| commitlint-sync.test.ts | Commitlint sync tests | 2026-05-11 | Good | +| config.test.ts | Config validation tests | 2026-03-08 | Good | +| config-command.test.ts | Config command tests | 2026-04-19 | Good | +| config-loader.test.ts | Config loader tests | 2026-04-19 | Good | +| constants.test.ts | Constants tests | 2026-03-21 | Good | +| editor-wrapper.test.ts | Editor wrapper tests | 2026-03-08 | Good | +| git.test.ts | Git operations tests | 2026-03-08 | Good | +| init-command.test.ts | Init command tests (largest) | 2026-04-19 | Good | +| message.test.ts | Message building tests | 2026-03-08 | Good | +| prompt.test.ts | Prompt tests | 2026-04-19 | Good | +| setup.test.ts | Setup utilities tests | 2026-04-19 | Good | +| sigint.test.ts | Signal handling tests | 2026-03-08 | Good | +| terminal.test.ts | Terminal detection tests | 2026-03-21 | Good | +| theme.test.ts | Custom theme helper tests | 2026-10-18 | Good | +| transformers.test.ts | Input transformer tests | 2026-03-21 | Good | +| validators.test.ts | Validator tests | 2026-03-08 | Good | + +**Total**: 18 files, ~7,152 lines combined + +**Coverage Target**: >80% overall, 100% for utils + +--- + +## Configuration Files + +| File | Purpose | Last Major Change | Owner | +| -------------------- | --------------------------- | ----------------- | --------- | +| package.json | npm package configuration | 2026-02-07 | Core team | +| tsconfig.json | TypeScript configuration | 2026-02-07 | Core team | +| vitest.config.ts | Testing configuration | 2026-02-07 | Core team | +| eslint.config.mjs | ESLint flat config | 2026-02-07 | Core team | +| .prettierrc | Prettier formatting | 2026-02-07 | Core team | +| commitlint.config.js | Conventional commit linting | 2026-02-07 | Core team | + +--- + +## Documentation Files + +| File | Purpose | Last Major Change | Owner | +| ------------------------- | --------------------------------- | ----------------- | --------- | +| AGENTS.md | Tool-agnostic agent guidance | 2026-10-22 | Core team | +| CLAUDE.md | Imports AGENTS.md + rule index | 2026-10-22 | Core team | +| .claude/rules/\*.md | Path-scoped rules (9 files) | 2026-10-22 | Core team | +| .claude/guides/\*.md | Reference documentation (3 files) | 2026-10-22 | Core team | +| .claude/maintenance/\*.md | This codebase map | 2026-10-22 | Core team | +| .claude/settings.json | Shared agent permissions | 2026-10-22 | Core team | +| README.md | Project README | 2026-05-17 | Core team | +| CODE_OF_CONDUCT.md | Community guidelines | 2026-01-XX | Core team | + +### User-Facing Documentation (docs/) + +Organized into five subdirectories added in PR #76. + +| Directory | Files | Purpose | +| ---------------- | -------------------------------------------------------------- | -------------------------------- | +| docs/index.md | index.md | Navigation hub by audience | +| docs/GLOSSARY.md | GLOSSARY.md | Wizard theme vocabulary | +| getting-started/ | installation.md, quickstart.md | First-run setup and first commit | +| usage/ | commit-types.md, themes.md, editor-integration.md, auto-add.md | Feature guides | +| reference/ | cli-reference.md, configuration.md, config-schema.md | Complete reference material | +| guides/ | team-setup.md, commitlint-integration.md | Team rollout and integrations | +| development/ | contributing.md, architecture.md, testing.md, releases.md | Contributor documentation | + +**Total**: 17 doc files (16 added 2026-05-17 + GLOSSARY pre-existing) + +--- + +## Module Size Guidelines + +### Current Status + +| Category | Target | Current Average | Status | +| ----------- | ----------- | --------------- | --------------------------------- | +| Commands | < 150 lines | ~442 lines | ⚠️ config.ts, init.ts over target | +| Lib modules | < 300 lines | ~168 lines | ✅ All within target | +| Utils | < 200 lines | ~172 lines | ✅ All within target | + +### Files Needing Attention + +**Over Size Target**: + +- `src/commands/config.ts` (922 lines) - Significantly over target, grew via PRs #64/#66/#67/#70/#71/#73 (project config, ThemeMessages sub-groups, back navigation, showCharacterCounter, scoped --show, syncCommitlint) +- `src/commands/init.ts` (410 lines) - Over target +- `src/utils/constants.ts` (337 lines) - Over 200-line target but acceptable (mostly data) + +--- + +## Ownership and Responsibilities + +### Core Team Responsibilities + +**Commands Layer**: + +- commit.ts - Commit workflow +- config.ts - Configuration +- init.ts - Project initialization + +**Library Layer**: + +- commitlint-sync.ts - Commitlint config detection and sync +- config-loader.ts - Config loading, saving, theme resolution +- editor-wrapper.ts - Editor integration +- git.ts - All git operations +- message.ts - Message formatting +- prompt.ts - User input +- setup.ts - Tool setup +- sigint.ts - Signal handling +- terminal.ts - VS16 emoji width normalization +- transformers.ts - UI helpers + +**Utils Layer**: + +- config.ts - Config validation (pure) +- constants.ts - Data definitions +- validators.ts - Input validation (factory pattern) + +### External Contributors + +No external maintainers currently. Open to contributions via PRs. + +--- + +## Review Schedule + +### Monthly Review + +- [ ] Check for new files not in this map +- [ ] Update line counts if significant changes +- [ ] Verify ownership is still accurate +- [ ] Check for files that have grown too large + +### Quarterly Review + +- [ ] Full inventory review +- [ ] Update all "Last Major Change" dates +- [ ] Identify technical debt +- [ ] Plan refactoring efforts +- [ ] Archive obsolete files + +### On Every Release + +- [ ] Update all line counts +- [ ] Verify test coverage percentages +- [ ] Check for dead code +- [ ] Update documentation status + +--- + +## Technical Debt Tracking + +### Current Technical Debt + +- `src/commands/config.ts` (922 lines) far exceeds 150-line command target - strong candidate for splitting +- `src/commands/init.ts` (410 lines) exceeds 150-line command target + +### Completed Refactors + +- Config file I/O moved from `utils/config.ts` to `lib/config-loader.ts` (2026-03-08) - utils layer is now purely functional + +### Future Considerations + +- Split large command files (config.ts, init.ts) if they grow further +- Evaluate constants.ts organization if it grows beyond 400 lines + +--- + +## Quick Stats + +| Metric | Count | Target | +| ------------------- | ---------- | ------ | +| Total source files | 24 | - | +| Total source lines | ~4,863 | - | +| Total test files | 18 | - | +| Test file lines | ~7,152 | - | +| Config files | 9 | - | +| Documentation files | 17 | - | +| Average file size | ~181 lines | < 300 | + +--- + +## How to Update This File + +### Adding New Files + +When adding a new source file: + +1. Add entry to appropriate table +2. Include: file name, lines, purpose, date, owner +3. Update totals +4. Update quick stats +5. Commit with message: `docs(maintenance): add NewFile.ts to codebase map` + +### Updating Existing Files + +When making major changes: + +1. Update "Last Major Change" date +2. Update line count if significantly changed (+/- 20%) +3. If file grew too large, add to "Files Needing Attention" +4. Commit with message: `docs(maintenance): update codebase map for File.ts` + +### Monthly Update + +```bash +# Get line counts +find src -name "*.ts" -exec wc -l {} \; + +# Update this file +# Commit +git add .claude/maintenance/codebase-map.md +git commit -m "docs(maintenance): monthly codebase map update" +``` + +--- + +## Last Updated + +**Date**: 2026-10-22 +**Updated By**: Agent layer reorganization +**Changes**: Added utils/theme.ts and theme.test.ts (custom themes, PR #122). Refreshed source and test totals. Documentation table now reflects the AGENTS.md / CLAUDE.md split, the shared settings.json and the single maintenance file. diff --git a/.claude/rules/clean-code.md b/.claude/rules/clean-code.md new file mode 100644 index 0000000..1847fe6 --- /dev/null +++ b/.claude/rules/clean-code.md @@ -0,0 +1,626 @@ +--- +paths: + - "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 + +Commands coordinate workflows but don't implement domain logic. + +```typescript +// src/commands/commit.ts - GOOD +export async function commitCommand(options: CommitOptions): Promise { + // 1. Validate preconditions + const isRepo = await isGitRepo(); + if (!isRepo) throw new Error('Not a git repository'); + + // 2. Gather user input + const answers = await promptUser(); + + // 3. Build message (delegates to message.ts) + const message = buildCommitMessage(answers); + + // 4. Preview + const preview = formatPreview(message); + console.log(preview); + + // 5. Confirm + const confirmed = await confirm({ message: 'Create commit?' }); + + // 6. Execute (delegates to git.ts) + if (confirmed) { + await commit(message, options); + } +} +``` + +### Library Layer: Implementation + +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 result = await execa('git', args); + return { + success: true, + hash: parseCommitHash(result.stdout), + message + }; +} +``` + +### Utils Layer: Pure Functions + +Utils must be pure, stateless, and reusable. + +```typescript +// src/utils/validators.ts - GOOD +export function validateSubjectLength( + subject: string, + maxLength: number +): ValidationResult { + if (subject.length > maxLength) { + return { + valid: false, + error: `Subject exceeds ${maxLength} characters` + }; + } + return { valid: true }; +} +``` + +--- + +## Function Design + +### Keep Functions Small + +Target: Under 50 lines per function. If longer, break it down. + +### Prefer Early Returns (Guard Clauses) + +**ALWAYS use early returns to avoid deep nesting.** Check for error conditions or edge cases first and return immediately. This improves readability and reduces cognitive load. + +**Why Early Returns?** + +- Reduces nesting levels (flatter code is easier to read) +- Makes error conditions explicit and obvious +- Separates validation from main logic +- Eliminates else clauses + +**BAD - Deep Nesting:** + +```typescript +function processCommit(message: string, config: MerlinConfig): CommitResult { + if (message) { + if (message.length > 0) { + if (message.length <= config.maxSubjectLength) { + if (config.autoAdd) { + // Main logic buried 4 levels deep! + return createCommit(message); + } else { + return { success: false, error: 'Auto-add disabled' }; + } + } else { + return { success: false, error: 'Message too long' }; + } + } else { + return { success: false, error: 'Empty message' }; + } + } else { + return { success: false, error: 'No message provided' }; + } +} +``` + +**GOOD - Early Returns:** + +```typescript +function processCommit(message: string, config: MerlinConfig): CommitResult { + // Guard clauses - check error conditions first + if (!message) { + return { success: false, error: 'No message provided' }; + } + + if (message.length === 0) { + return { success: false, error: 'Empty message' }; + } + + if (message.length > config.maxSubjectLength) { + return { success: false, error: 'Message too long' }; + } + + if (!config.autoAdd) { + return { success: false, error: 'Auto-add disabled' }; + } + + // Main logic at top level - easy to find and understand + return createCommit(message); +} +``` + +**More Examples:** + +```typescript +// BAD - nested conditions +async function validateAndStage(): Promise { + if (await isGitRepo()) { + if (await hasUncommittedChanges()) { + if (await hasStagedChanges()) { + return true; + } else { + await stageAllChanges(); + return true; + } + } else { + return false; + } + } else { + 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 hasUncommittedChanges()) { + return false; + } + + if (await hasStagedChanges()) { + return true; + } + + await stageAllChanges(); + return true; +} +``` + +**Rule of Thumb:** If you find yourself writing `else` blocks, consider if early returns would be clearer. + +### One Level of Abstraction + +Functions should operate at one level of abstraction. + +**BAD:** + +```typescript +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'); + } + execSync(`git commit -m "${message}"`); // Security issue too! +} +``` + +**GOOD:** + +```typescript +function processCommit(answers: CommitAnswers): void { + const message = buildCommitMessage(answers); + validateMessage(message); + createCommit(message); +} + +function buildCommitMessage(answers: CommitAnswers): string { + return `${answers.type}(${answers.scope}): ${answers.subject}`; +} + +function validateMessage(message: string): void { + if (message.length > 72) { + throw new Error('Message too long'); + } +} + +function createCommit(message: string): void { + execa('git', ['commit', '-m', message]); +} +``` + +### Avoid Side Effects + +Functions should either DO something or RETURN something, not both. + +**BAD:** + +```typescript +function validateAndFormat(message: string): string { + if (message.length > 72) { + console.error('Message too long'); // Side effect! + } + return message.trim(); +} +``` + +**GOOD:** + +```typescript +function validateMessage(message: string): void { + if (message.length > 72) { + console.error('Message too long'); + } +} + +function formatMessage(message: string): string { + return message.trim(); +} +``` + +--- + +## Error Handling + +### Don't Swallow Errors + +Always handle or propagate errors. + +**BAD:** + +```typescript +try { + await execa('git', ['commit', '-m', message]); +} catch { + // Silently ignored! +} +``` + +**GOOD:** + +```typescript +try { + await execa('git', ['commit', '-m', message]); +} catch (error) { + console.error('Failed to create commit:', error); + throw error; // Re-throw for caller to handle +} +``` + +### Use Custom Error Types + +Create meaningful error types for different failure modes. + +```typescript +class InvalidCommitMessageError extends Error { + constructor(message: string, public readonly details: string) { + super(message); + this.name = 'InvalidCommitMessageError'; + } +} + +throw new InvalidCommitMessageError( + 'Commit message validation failed', + 'Subject exceeds 72 characters' +); +``` + +--- + +## Refactoring Checklist + +Before committing, ask: + +- [ ] Is this function doing ONE thing? +- [ ] Are there any repeated code blocks? +- [ ] Can I simplify this logic? +- [ ] Are my abstractions at the right level? +- [ ] 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 new file mode 100644 index 0000000..7b0a617 --- /dev/null +++ b/.claude/rules/code-style.md @@ -0,0 +1,424 @@ +--- +paths: + - "**/*.ts" +--- + +# 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; +``` + +--- + +## TypeScript-Specific Conventions + +### Type vs Interface + +**Prefer `type` over `interface`** - use type aliases by default; only use interface when declaration merging or extends is needed. + +```typescript +// GOOD - type alias (preferred) +type CommitAnswers = { + type: string; + scope?: string; + subject: string; +}; + +type CommitResult = { + success: boolean; + hash: string; +}; + +// AVOID - interface when type suffices +interface CommitAnswers { + type: string; + scope?: string; + subject: string; +} + +// ACCEPTABLE - interface when extending +interface ExtendedCommitAnswers extends CommitAnswers { + timestamp: number; +} +``` + +### Never Use any + +**Never use `any`** - always use `unknown` when the type is truly unknown, then narrow with type guards. + +```typescript +// GOOD - unknown + type narrowing +function parseInput(value: unknown): string { + if (typeof value === 'string') { + return value; + } + throw new Error('Expected a string'); +} + +// BAD - any bypasses all type safety +function parseInput(value: any): string { + return value; +} +``` + +### 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 { + return scope ? `${type}(${scope}): ${subject}` : `${type}: ${subject}`; +} + +// BAD +function buildMessage(type: string, subject: string, scope?: string): string { + if (scope === undefined) { + 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. + +```typescript +// GOOD - Literal syntax +const config: MerlinConfig = {}; +const types: CommitType[] = []; +const message = ''; +const count = 0; +const isValid = false; + +// BAD - Constructor syntax +const config = new Object(); +const types = new Array(); +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. + +```typescript +// GOOD +function validate(message: string): boolean { + if (!message) return false; + if (message.length < 10) return false; + if (message.length > 72) return false; + return true; +} + +// BAD +function validate(message: string): boolean { + if (message) { + if (message.length >= 10) { + if (message.length <= 72) { + return true; + } + } + } + return false; +} +``` + +### Optional Chaining + +Use optional chaining for safer property access. + +```typescript +// GOOD +const scope = config?.types?.[0]?.value; + +// BAD +const scope = config && config.types && config.types[0] && config.types[0].value; +``` + +### Nullish Coalescing + +Use `??` for default values (not `||`). + +```typescript +// GOOD - handles 0, '', false correctly +const maxLength = config.maxSubjectLength ?? 72; + +// BAD - 0 would be replaced with 72 +const maxLength = config.maxSubjectLength || 72; +``` + +--- + +## Error Handling + +### Explicit Error Handling + +Handle errors explicitly, don't swallow them. + +```typescript +// GOOD +try { + await execa('git', ['commit', '-m', message]); +} catch (error) { + console.error('Failed to create commit:', error); + throw error; +} + +// BAD +try { + await execa('git', ['commit', '-m', message]); +} catch { + // Silently swallowed! +} +``` + +### Use Error Objects + +Always throw Error objects, not strings. + +```typescript +// GOOD +throw new Error('Invalid commit type'); + +// BAD +throw 'Invalid commit type'; +``` + +--- + +## Comments + +### When to Comment + +- **DO**: Explain WHY, not WHAT +- **DON'T**: State the obvious +- **DO**: Document complex algorithms +- **DON'T**: Comment bad code - refactor it + +```typescript +// GOOD +// Git requires commit messages to be under 72 characters for proper display in logs +const MAX_SUBJECT_LENGTH = 72; + +// BAD +// Set max length to 72 +const MAX_SUBJECT_LENGTH = 72; +``` + +### JSDoc + +Public functions should have JSDoc comments. + +````typescript +/** + * Builds a conventional commit message from user answers. + * + * @param answers - User's commit information + * @returns Formatted commit message following conventional commits spec + * @example + * ```typescript + * const message = buildCommitMessage({ + * type: 'feat', + * scope: 'api', + * subject: 'add user endpoint' + * }); + * // Returns: "feat(api): add user endpoint" + * ``` + */ +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 new file mode 100644 index 0000000..675c33e --- /dev/null +++ b/.claude/rules/commands.md @@ -0,0 +1,426 @@ +--- +paths: + - "src/commands/*.ts" +--- + +# Command Layer Rules + +**Last Updated**: 2026-02-07 + +These rules apply to all command files in `src/commands/` directory. + +--- + +## Command Responsibilities + +Commands are **ORCHESTRATORS** - they coordinate workflows but don't implement domain logic. + +### What Commands DO: + +- Validate preconditions (is git repo? are there staged changes?) +- Coordinate the sequence of operations +- Handle conditional logic (if user confirms, if file exists, etc.) +- Manage error states and user feedback +- Delegate actual work to lib modules + +### What Commands DON'T DO: + +- Implement git operations (that's `lib/git.ts`) +- Build commit messages (that's `lib/message.ts`) +- Create prompts (that's `lib/prompt.ts`) +- Validate input formats (that's `utils/validators.ts`) + +--- + +## Orchestration Pattern + +A well-designed command follows this pattern: + +```typescript +export async function commandName(options: CommandOptions): Promise { + // 1. Validate preconditions + await validatePreconditions(); + + // 2. Gather user input (delegate to lib/prompt.ts) + const userInput = await gatherUserInput(); + + // 3. Process data (delegate to lib modules) + const processedData = processUserInput(userInput); + + // 4. Preview/confirm with user + const confirmed = await confirmAction(processedData); + + // 5. Execute action (delegate to lib modules) + if (confirmed) { + await executeAction(processedData, options); + } + + // 6. Provide feedback + displayResult(); +} +``` + +--- + +## Example: commit.ts + +This is the ideal structure for a command: + +```typescript +// src/commands/commit.ts + +export async function commitCommand(options: CommitOptions): Promise { + // 1. Validate environment + const isRepo = await isGitRepo(); // Delegates to lib/git.ts + if (!isRepo) { + throw new Error("Not a git repository"); + } + + // 2. Check state + const hasStaged = await hasStagedChanges(); // Delegates to lib/git.ts + if (!hasStaged && !options.autoAdd) { + console.log("No staged changes. Use git add or --auto-add"); + return; + } + + // 3. Auto-add if requested + if (options.autoAdd) { + await stageAllChanges(); // Delegates to lib/git.ts + } + + // 4. Gather user input + const answers = await promptUser(); // Delegates to lib/prompt.ts + + // 5. Build message + const message = buildCommitMessage(answers); // Delegates to lib/message.ts + + // 6. Preview + if (!options.dryRun) { + const preview = formatPreview(message); // Delegates to lib/message.ts + console.log(preview); + + // 7. Confirm + const confirmed = await confirm({ + message: "Create this commit?", + default: true, + }); + + if (!confirmed) { + console.log("Commit cancelled"); + return; + } + } + + // 8. Execute + if (options.dryRun) { + console.log("Dry run - would create commit"); + return; + } + + try { + const result = await commit(message, options); // Delegates to lib/git.ts + console.log(`✓ Commit created: ${result.hash.substring(0, 7)}`); + } catch (error) { + console.error("Failed to create commit:", error); + throw error; + } +} +``` + +--- + +## Command Guidelines + +### Keep Commands Thin + +Target: Under 150 lines per command file. + +If longer, extract logic to lib modules. + +### Error Handling + +Commands handle errors at the orchestration level: + +```typescript +try { + await executeOperation(); +} catch (error) { + if (error instanceof SpecificError) { + console.error("Specific error occurred:", error.message); + // Handle gracefully + } else { + console.error("Unexpected error:", error); + throw error; // Re-throw for process exit + } +} +``` + +### User Feedback + +Commands are responsible for user-facing messages: + +```typescript +// GOOD - Clear, actionable feedback +console.log("No staged changes. Use git add to stage files."); + +// BAD - Technical jargon +console.error("ERR_NO_STAGED: staging area empty"); +``` + +### Validation + +Commands validate at high level, utils validate details: + +```typescript +// Command validates business rules +if (!hasStaged && !options.autoAdd) { + throw new Error("Cannot commit without staged changes"); +} + +// Utils validate format/constraints +if (!validateSubjectLength(subject, maxLength)) { + throw new Error("Subject too long"); +} +``` + +--- + +## Command Structure Template + +```typescript +import {} from /* dependencies */ "../lib/..."; +import {} from /* types */ "../types"; + +/** + * Brief description of what this command does. + * + * @param options - Command options + * @throws {Error} When preconditions not met + */ +export async function commandName(options: CommandOptions): Promise { + // Step 1: Validate preconditions + await validatePreconditions(); + + // Step 2: Gather input + const input = await gatherInput(); + + // Step 3: Process + const processed = processInput(input); + + // Step 4: Confirm + const confirmed = await confirmAction(processed); + if (!confirmed) { + console.log("Action cancelled"); + return; + } + + // Step 5: Execute + try { + const result = await executeAction(processed, options); + console.log("Success:", result); + } catch (error) { + console.error("Failed:", error); + throw error; + } +} + +// Helper functions for this command (if needed) +async function validatePreconditions(): Promise { + // Validation logic +} + +async function gatherInput(): Promise { + // Delegate to lib/prompt.ts +} + +function processInput(input: InputType): ProcessedType { + // Delegate to lib modules +} + +async function confirmAction(data: ProcessedType): Promise { + // Confirmation logic +} + +async function executeAction( + data: ProcessedType, + options: CommandOptions +): Promise { + // Delegate to lib modules +} +``` + +--- + +## Command Options + +Commands accept options from CLI arguments: + +```typescript +type CommitOptions = { + dryRun?: boolean; // Preview without executing + noVerify?: boolean; // Skip git hooks + amend?: boolean; // Amend previous commit + autoAdd?: boolean; // Auto-stage changes +}; + +export async function commitCommand(options: CommitOptions): Promise { + // Use options to control behavior + if (options.dryRun) { + // Preview mode + } + + if (options.autoAdd) { + await stageAllChanges(); + } + + // Pass options to lib modules + await commit(message, { + noVerify: options.noVerify, + amend: options.amend, + }); +} +``` + +--- + +## Testing Commands + +Focus on testing orchestration logic, not implementation details: + +```typescript +describe("commitCommand", () => { + beforeEach(() => { + // Mock all lib dependencies + vi.mock("../lib/git"); + vi.mock("../lib/prompt"); + vi.mock("../lib/message"); + }); + + it("should throw error when not in git repo", async () => { + vi.mocked(isGitRepo).mockResolvedValue(false); + + await expect(commitCommand({})).rejects.toThrow("Not a git repository"); + }); + + it("should create commit when all conditions met", async () => { + // Arrange - mock happy path + vi.mocked(isGitRepo).mockResolvedValue(true); + vi.mocked(hasStagedChanges).mockResolvedValue(true); + vi.mocked(promptUser).mockResolvedValue({ type: "feat", subject: "test" }); + vi.mocked(buildCommitMessage).mockReturnValue("feat: test"); + vi.mocked(confirm).mockResolvedValue(true); + vi.mocked(commit).mockResolvedValue({ success: true, hash: "abc123" }); + + // Act + await commitCommand({}); + + // Assert - verify orchestration + expect(commit).toHaveBeenCalledWith("feat: test", {}); + }); +}); +``` + +--- + +## Anti-Patterns to Avoid + +### ❌ Implementing Domain Logic in Commands + +```typescript +// BAD - git logic in command +export async function commitCommand(options: CommitOptions): Promise { + const args = ["commit", "-m", message]; + if (options.amend) args.push("--amend"); + await execa("git", args); // Git logic belongs in lib/git.ts! +} +``` + +### ❌ Complex Transformations in Commands + +```typescript +// BAD - message building in command +export async function commitCommand(options: CommitOptions): Promise { + const message = answers.scope + ? `${answers.type}(${answers.scope}): ${answers.subject}` + : `${answers.type}: ${answers.subject}`; + // Message building belongs in lib/message.ts! +} +``` + +### ❌ Validation Logic in Commands + +```typescript +// BAD - validation in command +export async function commitCommand(options: CommitOptions): Promise { + if (subject.length > 72) { + throw new Error("Subject too long"); + } + // Validation belongs in utils/validators.ts! +} +``` + +--- + +## Current Commands + +### commit.ts + +- **Purpose**: Interactive commit workflow +- **Dependencies**: git.ts, message.ts, prompt.ts, sigint.ts +- **Flow**: Validate → Prompt → Build → Preview → Confirm → Commit + +### config.ts + +- **Purpose**: User configuration management +- **Dependencies**: config.ts (utils), constants.ts +- **Flow**: Show menu → Get selection → Update config → Save + +### init.ts + +- **Purpose**: Project initialization wizard +- **Dependencies**: setup.ts, git.ts +- **Flow**: Detect project → Prompt options → Setup tools → Verify + +--- + +## Adding New Commands + +When adding a new command: + +1. **Define the workflow** - what steps are needed? +2. **Identify dependencies** - which lib modules to use? +3. **Follow the pattern** - validate, gather, process, confirm, execute +4. **Keep it thin** - delegate all domain logic +5. **Add tests** - test orchestration, mock dependencies +6. **Update CLI** - register command in `cli.ts` + +Example: + +```typescript +// src/commands/new-command.ts + +export async function newCommand(options: NewCommandOptions): Promise { + // Follow the pattern + await validatePreconditions(); + const input = await gatherInput(); + const processed = processInput(input); + await executeAction(processed, options); +} +``` + +Then register in `cli.ts`: + +```typescript +program + .command("new-command") + .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/documentation.md b/.claude/rules/documentation.md new file mode 100644 index 0000000..1b2518a --- /dev/null +++ b/.claude/rules/documentation.md @@ -0,0 +1,22 @@ +--- +paths: + - "src/**/*.ts" + - "docs/**/*.md" +--- + +# Documentation Sync + +When adding or changing user-facing behavior (new flags, new config fields, new commands, +changed defaults, changed UX), you MUST update the relevant `docs/` file in the same branch and +commit. Never leave `docs/` out of sync with `src/`. + +Mapping: + +- New CLI flags → `docs/reference/cli-reference.md` +- New config fields → `docs/reference/config-schema.md` + `docs/reference/configuration.md` +- New commands → `docs/reference/cli-reference.md` + `docs/index.md` +- New features → the matching guide under `docs/usage/` or `docs/guides/` +- Behavior changes → whichever doc describes that behavior +- Contributor-facing changes (architecture, testing, releases) → `docs/development/` + +CI runs `npx prettier --check "docs/**/*.md"`, so format any doc you touch. diff --git a/.claude/rules/lib.md b/.claude/rules/lib.md new file mode 100644 index 0000000..a96d995 --- /dev/null +++ b/.claude/rules/lib.md @@ -0,0 +1,632 @@ +--- +paths: + - "src/lib/*.ts" +--- + +# Library Module Rules + +**Last Updated**: 2026-05-11 + +These rules apply to all library modules in `src/lib/` directory. + +--- + +## Library Responsibilities + +Library modules are **IMPLEMENTERS** - they implement specific domain logic with single responsibility. + +### What Library Modules DO: + +- Implement domain-specific logic (git operations, message building, prompts) +- Handle their specific concerns completely +- Export focused, reusable functions +- Handle errors explicitly within their domain +- Provide clear, well-typed interfaces + +### What Library Modules DON'T DO: + +- Orchestrate workflows (that's commands) +- Know about other lib modules' internals +- Handle CLI argument parsing +- Provide user-facing feedback (commands do this) + +--- + +## Current Library Modules + +### commitlint-sync.ts (191 lines) - Commitlint Config Sync + +**Purpose**: Detect existing commitlint config files and sync MerlinConfig rules into them + +**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 function buildSyncRules(config: Required): CommitlintRules; +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). + +### config-loader.ts (213 lines) - Configuration I/O + +**Purpose**: Load, save, merge, and reset user and project configuration + +**Exports**: + +```typescript +export function loadUserConfig(): Partial; +export function loadProjectConfig(repoRoot: string): Partial; +export function loadConfig(repoRoot?: string | null): Required; +export function saveConfig(config: Partial): void; +export function resetConfig(): void; +export function saveProjectConfig(config: Partial, repoRoot: string): void; +export function removeProjectConfigField( + field: keyof MerlinConfig, + repoRoot: string +): void; +export function resetProjectConfig(repoRoot: string): void; +export function getMessages(repoRoot?: string | null): ThemeMessages; +``` + +**Pattern**: File I/O with 3-tier config merging (defaults < user < project) and validation. Uses `validateConfig()` from utils/config.ts for input sanitization. VS16 emoji spacing normalized via `normalizeThemeStrings()` using `lib/terminal.ts`. Custom themes are merged over their base via `mergeThemeMessages()` from `utils/theme.ts`. + +### git.ts (345 lines) - Git Operations + +**Purpose**: All git command execution via execa + +**Exports**: + +```typescript +export async function isGitRepo(): Promise; +export async function hasStagedChanges(): Promise; +export async function getUnstagedFiles(): Promise; +export async function addFiles(files: string[]): Promise; +export async function commit(message: string, noVerify?: boolean): Promise; +export async function amendCommit(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 async function getStagedFilesWithStatus(): Promise; +export type StagingCandidate = { path: string; status: "modified" | "deleted" | "untracked" | "renamed" | "copied" }; +export async function getStagingCandidates(): Promise; +``` + +**Pattern**: Each function does ONE git operation safely + +### message.ts (130 lines) - Commit Message Building + +**Purpose**: Format and preview commit messages + +**Exports**: + +```typescript +export function buildCommitMessage(answers: CommitAnswers): string; +export function formatPreview(message: string): string; +``` + +**Pattern**: Pure string transformations + +### prompt.ts (223 lines) - Interactive Prompts + +**Purpose**: Gather user input using @inquirer/prompts with dependency injection + +**Exports**: + +```typescript +export type PromptDependencies = { + config: Required; + messages: ThemeMessages; + getStagedFiles: () => Promise; + editBody: (context: CommitEditorContext) => string; + editBreaking: () => string; + editIssues: () => string; +}; +export async function promptUser(deps: PromptDependencies): 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. + +### setup.ts (270 lines) - Project Setup + +**Purpose**: Initialize husky, commitlint, git alias, and project config + +**Exports**: + +```typescript +export const INIT_DEPENDENCIES: string[]; +export async function hasPackageJson(): Promise; +export async function detectExistingSetup(): Promise; +export async function isPackageInstalled(packageName: string): Promise; +export async function installDependencies(deps: string[]): Promise; +export async function initializeHusky(): Promise; +export async function createCommitMsgHook(): Promise; +export async function createCommitlintConfig(): Promise; +export async function checkGitAlias(): Promise; +export async function setupGitAlias(scope: "global" | "local"): Promise; +export async function createProjectConfig(): Promise; +``` + +**Pattern**: Setup operations with detection and verification + +### editor-wrapper.ts (256 lines) - External Editor Integration + +**Purpose**: Launch external editor for long-form input using COMMIT_EDITMSG + +**Exports**: + +```typescript +export function validateEditorAvailable(editorCommand: string): boolean; +export type CommitEditorContext = { + type: string; + scope?: string; + subject: string; + stagedFiles: StagedFile[]; +}; +export function buildBreakingChangeTemplate(): string; +export function buildIssueReferenceTemplate(): string; +export async function editWithCommitEditMsg( + template: string, + editorCommand: string, + gitDir: string +): Promise; +export async function editWithGitCommitMessage( + context: CommitEditorContext, + editorCommand: string, + gitDir: string +): Promise; +``` + +**Pattern**: All editor fields write to `.git/COMMIT_EDITMSG`, strip comment lines on read-back + +### sigint.ts (29 lines) - Signal Handling + +**Purpose**: Graceful Ctrl+C exit + +**Exports**: + +```typescript +export function setupSigintHandler( + messages: ThemeMessages, + onExit?: () => void +): () => void; +``` + +**Pattern**: Process signal management, returns cleanup function + +### terminal.ts (63 lines) - Terminal Detection + +**Purpose**: Runtime terminal detection for VS16 emoji width normalization + +**Exports**: + +```typescript +export function isWideEmojiTerminal(): boolean; +export function normalizeEmojiSpacing(text: string): string; +export function resetTerminalCache(): void; +``` + +**Pattern**: Cached detection with lazy initialization. Detects whether the terminal renders VS16 (U+FE0F) emojis as 2 columns wide (modern terminals) or 1 column (VS Code/xterm.js). Used by `config-loader.ts` to normalize theme message spacing. + +### transformers.ts (91 lines) - Inquirer Transformers + +**Purpose**: Real-time input transformation for prompts + +**Exports**: + +```typescript +export type Transformer = (value: string, options: { isFinal: boolean }) => string; +export function createCharacterCounterTransformer(maxLength: number): Transformer; +export function createOptionalCharacterCounterTransformer(maxLength: number): Transformer; +``` + +**Pattern**: Factory functions returning Inquirer-compatible transformers + +--- + +## Module Design Principles + +### Single Responsibility + +Each module has ONE domain: + +```typescript +// GOOD - git.ts only does git operations +export async function commit(message: string): Promise { + await execa("git", ["commit", "-m", message]); +} + +// BAD - git.ts shouldn't build messages +export async function commit(answers: CommitAnswers): Promise { + const message = `${answers.type}: ${answers.subject}`; // Wrong layer! + await execa("git", ["commit", "-m", message]); +} +``` + +### Explicit Error Handling + +Handle errors within the module's domain: + +```typescript +// GOOD - git.ts handles git-specific errors +export async function commit(message: string): Promise { + try { + const result = await execa("git", ["commit", "-m", message]); + return { + success: true, + hash: parseCommitHash(result.stdout), + message, + }; + } catch (error) { + if (error.exitCode === 1 && error.stderr.includes("nothing to commit")) { + throw new Error("No changes to commit"); + } + throw new Error(`Git commit failed: ${error.message}`); + } +} + +// BAD - silently swallowing errors +export async function commit(message: string): Promise { + try { + const result = await execa("git", ["commit", "-m", message]); + return { success: true, hash: parseCommitHash(result.stdout) }; + } catch { + return null; // Caller doesn't know what went wrong! + } +} +``` + +### Pure Functions When Possible + +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})` : ''; + return `${answers.type}${scope}: ${answers.subject}`; +} + +// BAD - side effects +export function buildCommitMessage(answers: CommitAnswers): string { + console.log('Building message...'); // Side effect! + globalState.lastMessage = ...; // Side effect! + return message; +} +``` + +### Dependency Injection + +Don't hard-code dependencies: + +```typescript +// GOOD - dependency injected +export async function commit( + message: string, + options: CommitOptions, + executor: GitExecutor = execa +): Promise { + await executor("git", ["commit", "-m", message]); +} + +// ACCEPTABLE - default to execa but testable +export async function commit( + message: string, + options: CommitOptions +): Promise { + await execa("git", ["commit", "-m", message]); +} +``` + +--- + +## Function Signatures + +### Type Everything + +```typescript +// GOOD - fully typed +export async function commit( + message: string, + options: CommitOptions +): Promise { + // ... +} + +// BAD - any or missing types +export async function commit(message: any, options?: any): Promise { + // ... +} +``` + +### Use Specific Return Types + +```typescript +// GOOD - specific result type +type CommitResult = { + success: boolean; + hash: string; + message: string; +}; + +export async function commit(message: string): Promise { + // ... +} + +// BAD - generic object +export async function commit(message: string): Promise { + // ... +} +``` + +### Options as Single Object + +```typescript +// GOOD - options object +type CommitOptions = { + noVerify?: boolean; + amend?: boolean; + signoff?: boolean; +}; + +export async function commit( + message: string, + options: CommitOptions = {} +): Promise { + // ... +} + +// BAD - many boolean parameters +export async function commit( + message: string, + noVerify?: boolean, + amend?: boolean, + signoff?: boolean +): Promise { + // Hard to read at call site! +} +``` + +--- + +## Testing Library Modules + +Test the domain logic, mock external dependencies: + +```typescript +describe("buildCommitMessage", () => { + it("should format message with scope", () => { + const answers: CommitAnswers = { + type: "feat", + scope: "api", + subject: "add endpoint", + }; + + const message = buildCommitMessage(answers); + + expect(message).toBe("feat(api): add endpoint"); + }); +}); + +describe("commit", () => { + it("should call git with correct args", async () => { + vi.mock("execa"); + const mockExeca = vi.mocked(execa); + mockExeca.mockResolvedValue({ + stdout: "abc123 feat: test", + stderr: "", + exitCode: 0, + } as any); + + await commit("feat: test", {}); + + expect(mockExeca).toHaveBeenCalledWith("git", ["commit", "-m", "feat: test"]); + }); +}); +``` + +--- + +## Module Structure Template + +````typescript +// src/lib/module-name.ts + +import /* dependencies */ "..."; +import type {} from /* types */ "../types"; + +/** + * Brief description of module's purpose. + */ + +// Constants specific to this module +const MODULE_CONSTANT = "value"; + +/** + * Main exported function. + * + * @param param - Parameter description + * @returns Return value description + * @throws {ErrorType} When error condition + * + * @example + * ```typescript + * const result = await functionName('input'); + * ``` + */ +export async function mainFunction(param: string): Promise { + // Input validation + if (!param) { + throw new Error("Parameter required"); + } + + // Implementation + try { + const result = await doWork(param); + return result; + } catch (error) { + // Handle domain-specific errors + throw new Error(`Operation failed: ${error.message}`); + } +} + +/** + * Helper function (not exported). + */ +function helperFunction(input: string): string { + // Helper logic + return input.trim(); +} + +// Export types specific to this module +export type ReturnType = { + field: string; +}; +```` + +--- + +## Anti-Patterns to Avoid + +### ❌ Mixing Concerns + +```typescript +// BAD - git.ts shouldn't know about prompts +export async function commitInteractive(): Promise { + const answers = await promptUser(); // Wrong layer! + const message = buildCommitMessage(answers); // Wrong layer! + await execa("git", ["commit", "-m", message]); +} + +// GOOD - git.ts only does git +export async function commit(message: string): Promise { + await execa("git", ["commit", "-m", message]); +} +``` + +### ❌ Global State + +```typescript +// BAD - module-level mutable state +let lastCommit: CommitResult | null = null; + +export async function commit(message: string): Promise { + const result = await execa("git", ["commit", "-m", message]); + lastCommit = result; // Mutation! + return result; +} + +// GOOD - return everything needed +export async function commit(message: string): Promise { + const result = await execa("git", ["commit", "-m", message]); + return { + success: true, + hash: parseCommitHash(result.stdout), + message, + }; +} +``` + +### ❌ Tight Coupling + +```typescript +// BAD - message.ts directly imports git.ts +import { getCurrentBranch } from "./git"; + +export function buildCommitMessage(answers: CommitAnswers): string { + const branch = await getCurrentBranch(); // Tight coupling! + return `[${branch}] ${answers.type}: ${answers.subject}`; +} + +// GOOD - accept branch as parameter +export function buildCommitMessage(answers: CommitAnswers, branch?: string): string { + const prefix = branch ? `[${branch}] ` : ""; + return `${prefix}${answers.type}: ${answers.subject}`; +} +``` + +--- + +## Adding New Library Modules + +When adding a new module: + +1. **Identify the domain** - what ONE thing does this module do? +2. **Define the interface** - what functions will it export? +3. **Design for testing** - can you test without mocking everything? +4. **Follow the pattern** - pure functions when possible, explicit error handling +5. **Document with JSDoc** - explain purpose, parameters, return values +6. **Add tests** - test domain logic thoroughly + +Example: + +```typescript +// src/lib/new-module.ts + +/** + * Module description - what domain does this handle? + */ + +import type { InputType, OutputType } from "../types"; + +/** + * Main function description. + * + * @param input - Input description + * @returns Output description + * @throws {Error} When condition + */ +export async function mainFunction(input: InputType): Promise { + // Validate input + if (!isValid(input)) { + throw new Error("Invalid input"); + } + + // Implement domain logic + const result = await process(input); + + // Return result + return result; +} + +// Helper functions (not exported) +function isValid(input: InputType): boolean { + // Validation logic + return true; +} + +async function process(input: InputType): Promise { + // Processing logic + return {} as OutputType; +} +``` + +--- + +## Module Dependencies + +**Allowed**: + +- lib → types (type imports only) +- lib → utils (for validation, formatting) +- lib → external packages (execa, @inquirer/prompts, etc.) + +**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 diff --git a/.claude/rules/naming.md b/.claude/rules/naming.md new file mode 100644 index 0000000..675c3db --- /dev/null +++ b/.claude/rules/naming.md @@ -0,0 +1,331 @@ +# 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? + +--- + +## 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) { +} +``` + +**GOOD:** + +```typescript +const userName = "John"; +const cookieStrings = `; ${document.cookie}`.split("; "); +for (const cookie of cookies) { +} +``` + +**Complete Example - BAD:** + +```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; +} +``` + +**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; +} +``` + +### Rule 2: Never Abbreviate + +Write out full words. Clarity always wins over brevity. + +**BAD:** + +```typescript +const cfg = loadConfig(); +const msg = formatCommitMsg(); +const usr = getCurrentUsr(); +const idx = items.findIndex((i) => i.active); +const btn = document.getElementById("btn"); +``` + +**GOOD:** + +```typescript +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:** + +```typescript +const timeout = 5000; +const delay = 30; +const size = 1024; +const maxLength = 72; +``` + +**GOOD:** + +```typescript +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); +} + +function startUserSession(cookies: ParsedCookie[]): void { + // Only starts session, nothing else +} + +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:** + +```typescript +const valid = validateInput(message); +const staged = await checkStagedChanges(); +const admin = user.role === 'admin'; +const edit = checkPermissions(user, document); +const loading = true; +``` + +**GOOD:** + +```typescript +const isValid = validateInput(message); +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; + } +} +``` + +--- + +## 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 + +**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; +} +``` diff --git a/.claude/rules/security.md b/.claude/rules/security.md new file mode 100644 index 0000000..b05f541 --- /dev/null +++ b/.claude/rules/security.md @@ -0,0 +1,236 @@ +# 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) + +--- + +## Common Vulnerabilities to Avoid + +### Command Injection + +**Vulnerable:** + +```typescript +const result = execSync(`git log --author="${authorName}"`); +``` + +**Safe:** + +```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'); +} +const configFile = path.join(configDir, userInput); +``` + +### Arbitrary Code Execution + +**Vulnerable:** + +```typescript +const handler = new Function('return ' + userInput); +handler(); +``` + +**Safe:** + +```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(); +``` + +--- + +## Reporting Security Issues + +If you discover a security vulnerability: + +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 + +--- + +## References + +- [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) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..b1d07a2 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,505 @@ +--- +paths: + - "tests/**/*.test.ts" +--- + +# Testing Guidelines + +**Last Updated**: 2026-02-07 + +These guidelines apply to all test files in the `tests/` directory. + +--- + +## Testing Philosophy + +**Prioritize plain tests over mocks.** Write real, integration-style tests first. Only introduce mocks when absolutely necessary. + +### + +When to Use Mocks + +Use mocks for: + +- External API calls (though merlin-commit has none by design) +- File system operations that would modify real user files +- Git operations that would create actual commits during tests +- Time-dependent functionality requiring fixed timestamps + +### When NOT to Use Mocks + +Don't mock: + +- Internal module interactions +- Pure functions (message building, validation, formatting) +- Configuration parsing and validation +- String manipulation and constant lookups +- Business logic that doesn't touch the file system or git + +--- + +## Test File Naming Convention + +Test files must clearly indicate what they are testing. + +**For command tests:** + +``` +-command.test.ts +``` + +Examples: + +- `commit-command.test.ts` +- `config-command.test.ts` +- `init-command.test.ts` + +**For library module tests:** + +``` +.test.ts +``` + +Examples: + +- `git.test.ts` +- `message.test.ts` +- `prompt.test.ts` + +**For utility tests:** + +``` +.test.ts +``` + +Examples: + +- `config.test.ts` +- `validators.test.ts` + +--- + +## Test Structure + +Use the **Arrange-Act-Assert** pattern with nested `describe` blocks: + +```typescript +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'; + + // Act - Execute the function + const result = functionName(input); + + // Assert - Verify the outcome + expect(result).toBe(expected); + }); + }); + }); +}); +``` + +**Example from merlin-commit:** + +```typescript +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' + }; + + // Act + const message = buildCommitMessage(answers); + + // Assert + expect(message).toBe('feat(api): add user endpoint'); + }); + }); + + describe('when scope is not provided', () => { + it('should omit scope from message', () => { + // Arrange + const answers: CommitAnswers = { + type: 'fix', + subject: 'resolve bug' + }; + + // Act + const message = buildCommitMessage(answers); + + // Assert + expect(message).toBe('fix: resolve bug'); + }); + }); +}); +``` + +--- + +## Testing Pure Functions + +Pure functions are easiest to test - no mocks needed. + +```typescript +// src/utils/validators.ts +export function validateSubjectLength( + subject: string, + maxLength: number +): ValidationResult { + if (subject.length > maxLength) { + return { valid: false, error: `Subject exceeds ${maxLength} characters` }; + } + return { valid: true }; +} + +// tests/unit/validators.test.ts +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); + const result = validateSubjectLength(longSubject, 72); + + expect(result.valid).toBe(false); + expect(result.error).toContain('exceeds 72 characters'); + }); +}); +``` + +--- + +## Testing With Mocks + +### Mocking Git Operations + +```typescript +import { vi } from 'vitest'; +import { execa } from 'execa'; +import { commit } from '../src/lib/git'; + +// Mock execa +vi.mock('execa'); + +describe('commit', () => { + it('should call git commit with correct arguments', async () => { + // Arrange + const message = 'feat: add feature'; + const mockExeca = vi.mocked(execa); + mockExeca.mockResolvedValue({ + 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]); + }); +}); +``` + +### Mocking File System + +```typescript +import { vi } from 'vitest'; +import * as fs from 'fs'; +import { loadConfig } from '../src/utils/config'; + +vi.mock('fs'); + +describe('loadConfig', () => { + it('should load config from file', () => { + // Arrange + const mockConfig = { theme: 'wizard', maxSubjectLength: 72 }; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockConfig)); + + // Act + const config = loadConfig(); + + // Assert + expect(config).toEqual(mockConfig); + }); +}); +``` + +--- + +## Test Coverage + +### Target Coverage + +- **Overall**: >80% +- **Critical paths**: 100% (security, git operations, validation) +- **Utils**: 100% (they're pure functions, easy to test) + +### Don't Chase 100% + +- Some code is hard to test meaningfully +- Focus on behavior, not coverage numbers +- Prefer clear tests over complex mocks for coverage + +### Running Coverage + +```bash +npm run test:coverage +``` + +--- + +## Testing Commands (Orchestration Layer) + +Commands coordinate workflows, so focus on testing the coordination logic. + +```typescript +describe('commitCommand', () => { + beforeEach(() => { + // Mock dependencies + 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 () => { + // Arrange + vi.mocked(isGitRepo).mockResolvedValue(false); + + // Act & Assert + await expect(commitCommand({})).rejects.toThrow('Not a git repository'); + }); + + 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' + }); + vi.mocked(buildCommitMessage).mockReturnValue('feat: add feature'); + vi.mocked(confirm).mockResolvedValue(true); + + // Act + await commitCommand({}); + + // Assert + expect(commit).toHaveBeenCalledWith('feat: add feature', {}); + }); +}); +``` + +--- + +## Testing Library Modules (Implementation Layer) + +Lib modules implement domain logic. Test the logic, mock external dependencies. + +```typescript +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' + }; + + 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'); + }); +}); +``` + +--- + +## Testing Utils (Pure Function Layer) + +Utils must be pure functions, so tests are straightforward. + +```typescript +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) => { + const result = validateSubjectLength(subject, maxLength); + expect(result.valid).toBe(expectedValid); + }); + }); +}); +``` + +--- + +## Common Testing Patterns + +### Testing Async Functions + +```typescript +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'); +}); +``` + +### Testing Error Conditions + +```typescript +it('should throw error for invalid input', () => { + expect(() => functionName(invalidInput)).toThrow('Invalid input'); +}); +``` + +### Parameterized Tests + +```typescript +it.each([ + ['input1', 'output1'], + ['input2', 'output2'], + ['input3', 'output3'], +])('should handle %s correctly', (input, expected) => { + const result = functionName(input); + expect(result).toBe(expected); +}); +``` + +### Setup and Teardown + +```typescript +describe('functionName', () => { + beforeEach(() => { + // Runs before each test + }); + + afterEach(() => { + // Runs after each test + vi.clearAllMocks(); + }); + + beforeAll(() => { + // Runs once before all tests + }); + + afterAll(() => { + // Runs once after all tests + }); +}); +``` + +--- + +## Running Tests + +### Run All Tests + +```bash +npm run test +``` + +### Watch Mode + +```bash +npm run test:watch +``` + +### Coverage + +```bash +npm run test:coverage +``` + +### Single Test File + +```bash +npx vitest run tests/unit/message.test.ts +``` + +### Single Test + +```bash +npx vitest run -t "should format message" +``` + +--- + +## Best Practices + +1. **Test behavior, not implementation** + - Don't test private methods + - Test public API and outcomes + +2. **Write tests first for bugs** + - When you find a bug, write a failing test + - Fix the bug + - Test now passes + +3. **Keep tests simple** + - If a test is complex, the code might be too complex + - Refactor both + +4. **One assertion per test** (when reasonable) + - Makes failures clearer + - Each test has one purpose + +5. **Use descriptive test names** + - "should return user when ID is valid" + - Not "test1" or "works" + +6. **Don't test library code** + - Trust execa, inquirer, chalk, etc. + - Test YOUR logic only + +--- + +## Troubleshooting + +### Tests Fail Randomly + +- Likely async timing issues +- Use `await` properly +- Check for shared state between tests + +### Mocks Not Working + +- Ensure `vi.mock()` is called before imports +- Check mock is properly typed +- Verify mock path is correct + +### Coverage Not 100% + +- Don't worry about it +- 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 new file mode 100644 index 0000000..a25a5cb --- /dev/null +++ b/.claude/rules/utils.md @@ -0,0 +1,522 @@ +--- +paths: + - "src/utils/*.ts" +--- + +# Utility Function Rules + +**Last Updated**: 2026-03-13 + +These rules apply to all utility modules in `src/utils/` directory. + +--- + +## Utility Responsibilities + +Utilities are **PURE HELPERS** - stateless, reusable, pure functions that provide generic functionality. + +### MUST Requirements for Utils: + +1. **MUST be pure functions** - No side effects +2. **MUST be stateless** - No internal state +3. **MUST be reusable** - Generic, not command-specific +4. **MUST validate inputs** - Handle invalid data gracefully +5. **MUST have clear return types** - No `unknown` or `any` returns + +--- + +## What Are Pure Functions? + +A pure function: + +1. **Same input → Same output** (deterministic) +2. **No side effects** (no console.log, no file writes, no network calls, no mutations) +3. **No external dependencies** (no reading global state, dates, random numbers) + +**GOOD Examples:** + +```typescript +// Pure - same input always gives same output +export function validateSubjectLength( + subject: string, + maxLength: number +): ValidationResult { + if (subject.length > maxLength) { + return { valid: false, error: `Subject exceeds ${maxLength} characters` }; + } + return { valid: true }; +} + +// Pure - no side effects +export function trimAndLowercase(text: string): string { + return text.trim().toLowerCase(); +} + +// Pure - deterministic +export function calculateScore(a: number, b: number): number { + return a + b * 0.5; +} +``` + +**BAD Examples:** + +```typescript +// NOT PURE - side effect (console.log) +export function validateSubjectLength(subject: string, maxLength: number): boolean { + console.log("Validating:", subject); // Side effect! + return subject.length <= maxLength; +} + +// NOT PURE - mutates input +export function addToArray(arr: string[], item: string): string[] { + arr.push(item); // Mutation! + return arr; +} + +// NOT PURE - depends on external state +let count = 0; +export function incrementCounter(): number { + return ++count; // External state! +} + +// NOT PURE - non-deterministic (uses Date) +export function isExpired(timestamp: number): boolean { + return timestamp < Date.now(); // Different result each call! +} +``` + +--- + +## Current Utility Modules + +### config.ts (63 lines) - Configuration Validation + +**Purpose**: Pure validation of user configuration objects + +**Exports**: + +```typescript +export function validateConfig(userConfig: unknown): Partial; +``` + +**Note**: This module is purely functional - it validates and sanitizes config data without any I/O. File I/O operations (load, save, reset) have been moved to `lib/config-loader.ts`. + +### constants.ts (275 lines) - Constants and Themes + +**Purpose**: Centralized constants, commit types, default config, theme messages + +**Exports**: + +```typescript +export const BREAKING_CHANGE_PREFIX_REGEX: RegExp; +export const colors: ChalkColorMap; +export const COMMIT_TYPES: CommitType[]; +export const VALUE_COLUMN_WIDTH: number; +export const EMOJI_COLUMN_WIDTH: number; +export const VARIATION_SELECTOR: string; +export const WIZARD_MESSAGES: ThemeMessages; +export const STANDARD_MESSAGES: ThemeMessages; +export const DEFAULT_CONFIG: Required; +export const TEMPLATE_PARAMETER_NAMES: Readonly>; +export const MAX_CUSTOM_MESSAGE_LENGTH_CHARACTERS: number; +``` + +**Note**: All exports are constants - no functions. + +### theme.ts - Custom Theme Helpers + +**Purpose**: Pure helpers for resolving, validating and merging custom themes + +**Exports**: + +```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; +``` + +**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. + +### validators.ts (115 lines) - Input Validators + +**Purpose**: Factory functions that create Inquirer-compatible validators + +**Exports**: + +```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 createPatternValidator(pattern: RegExp, errorMessage: string): Validator; +export function composeValidators(...validators: Validator[]): Validator; +``` + +**All functions are pure** - they return `Validator` functions (`(value: string) => true | string`) compatible with Inquirer prompts. Uses a factory/composition pattern for reusability. + +--- + +## Validation Pattern + +Validators use a factory pattern returning Inquirer-compatible functions (`true | string`): + +```typescript +// GOOD - factory returns reusable validator +type Validator = (value: string) => true | string; + +export function createMaxLengthValidator( + maxLength: number, + fieldName?: string +): Validator { + return (value: string) => { + if (value.length > maxLength) { + return `${fieldName ?? "Value"} exceeds ${maxLength} characters`; + } + return true; + }; +} + +// GOOD - compose validators for complex rules +export function composeValidators(...validators: Validator[]): Validator { + return (value: string) => { + for (const validator of validators) { + const result = validator(value); + if (result !== true) return result; + } + return true; + }; +} + +// BAD - throws exceptions +export function validateSubjectLength(subject: string, maxLength: number): void { + if (subject.length > maxLength) { + throw new Error("Subject too long"); // Don't throw! + } +} +``` + +**Why?** Factory validators are composable, reusable, and directly compatible with Inquirer prompts. + +--- + +## Pure Function Checklist + +Before committing a util function, verify: + +- [ ] No `console.log` or any logging +- [ ] No file system operations (fs.readFile, fs.writeFile, etc.) +- [ ] No network calls (fetch, axios, http, etc.) +- [ ] No mutations of input parameters +- [ ] No reading/writing global variables +- [ ] No Date.now(), Math.random(), or other non-deterministic calls +- [ ] Same input always produces same output +- [ ] No dependencies on external state + +--- + +## Testing Utils + +Utils are the easiest to test - no mocks needed: + +```typescript +describe("createMaxLengthValidator", () => { + it("should return true for valid length", () => { + const validate = createMaxLengthValidator(72); + + expect(validate("short subject")).toBe(true); + }); + + it("should return error string for long input", () => { + const validate = createMaxLengthValidator(72, "Subject"); + const longSubject = "a".repeat(100); + + const result = validate(longSubject); + + expect(result).toContain("exceeds 72 characters"); + }); +}); + +describe("composeValidators", () => { + it("should run validators in order and return first error", () => { + const notEmpty = createNonEmptyValidator("Subject"); + const maxLen = createMaxLengthValidator(72); + const composed = composeValidators(notEmpty, maxLen); + + expect(composed("valid")).toBe(true); + expect(composed("")).not.toBe(true); + }); +}); +``` + +**Target: 100% coverage for utils** - they're pure functions, so full coverage is achievable and valuable. + +--- + +## Util Function Template + +````typescript +// src/utils/module-name.ts + +import type { InputType, ResultType } from "../types"; + +/** + * Brief description of what this utility does. + * + * @param input - Input parameter description + * @param options - Optional parameter description + * @returns Result description + * + * @example + * ```typescript + * const result = utilityFunction('input', 42); + * // result: { ... } + * ``` + */ +export function utilityFunction(input: InputType, options: number): ResultType { + // Input validation + if (!input) { + return { valid: false, error: "Input required" }; + } + + // Pure logic - no side effects + const processed = input.trim().toLowerCase(); + + // Return result + return { valid: true, value: processed }; +} +```` + +--- + +## Type Definitions + +Utils should define and export their result types: + +```typescript +// GOOD - clear types +export type ValidationResult = { + valid: boolean; + error?: string; + warnings?: string[]; +}; + +export function validateInput(input: string): ValidationResult { + // ... +} + +// BAD - unclear return type +export function validateInput(input: string): { valid: boolean; error?: string } { + // Inline type is harder to reuse +} +``` + +--- + +## Constants vs Functions + +### When to Use Constants + +For values that never change: + +```typescript +export const MAX_SUBJECT_LENGTH = 72; +export const MAX_SCOPE_LENGTH = 20; +export const COMMIT_TYPES = ['feat', 'fix', 'docs', ...]; +``` + +### When to Use Functions + +For values that depend on inputs: + +```typescript +// GOOD - function because it depends on config +export function getMaxLength(config: MerlinConfig): number { + return config.maxSubjectLength ?? 72; +} + +// BAD - this should be a function +export const MAX_LENGTH = loadConfig().maxSubjectLength; // Side effect! +``` + +--- + +## Dependency Rules for Utils + +**Allowed**: + +- utils → types (type imports) +- utils → other utils (sparingly) +- utils → external pure libraries (lodash, date-fns) + +**Not Allowed**: + +- utils → commands (utils shouldn't know about orchestration) +- utils → lib (utils shouldn't depend on domain logic) +- utils → cli (utils shouldn't know about CLI) + +**If you need something from lib or commands**: You're probably not writing a util. Consider moving to lib/. + +--- + +## Anti-Patterns to Avoid + +### ❌ Side Effects + +```typescript +// BAD - logging is a side effect +export function validateSubject(subject: string): boolean { + console.log("Validating:", subject); // Side effect! + return subject.length > 0; +} + +// GOOD - pure function +export function validateSubject(subject: string): boolean { + return subject.length > 0; +} +``` + +### ❌ Mutations + +```typescript +// BAD - mutates input array +export function addItem(arr: string[], item: string): string[] { + arr.push(item); // Mutation! + return arr; +} + +// GOOD - returns new array +export function addItem(arr: string[], item: string): string[] { + return [...arr, item]; +} +``` + +### ❌ Non-Deterministic + +```typescript +// BAD - different result each time +export function generateId(): string { + return Math.random().toString(36); // Non-deterministic! +} + +// GOOD - pass seed/input +export function generateId(seed: string): string { + return hashString(seed); // Deterministic for same seed +} +``` + +### ❌ External State + +```typescript +// BAD - depends on global state +let validationCount = 0; + +export function validate(input: string): boolean { + validationCount++; // External state! + return input.length > 0; +} + +// GOOD - no external state +export function validate(input: string): boolean { + return input.length > 0; +} +``` + +### ❌ Domain-Specific Logic + +```typescript +// BAD - git-specific logic doesn't belong in utils +export function isValidCommit(message: string): boolean { + // This is domain logic, belongs in lib/message.ts or lib/git.ts + return message.startsWith("feat:") || message.startsWith("fix:"); +} + +// GOOD - generic validation +export function matchesPattern(text: string, pattern: RegExp): boolean { + return pattern.test(text); +} +``` + +--- + +## When to Create New Utils + +Create a new util when you have: + +1. **Pure logic** used in multiple places +2. **Generic** functionality (not domain-specific) +3. **Validation** or **formatting** that's reusable + +Don't create utils for: + +- Command orchestration → commands/ +- Domain logic → lib/ +- One-off helpers → keep inline or in calling module + +--- + +## Refactoring to Utils + +If you find yourself copying validation or formatting code: + +**Before:** + +```typescript +// In commands/commit.ts +if (subject.length > 72) { + console.log("Subject too long"); +} + +// In commands/config.ts +if (input.length > 72) { + console.log("Subject too long"); +} +``` + +**After:** + +```typescript +// In utils/validators.ts - create reusable factory +export function createMaxLengthValidator( + maxLength: number, + fieldName?: string +): Validator { + return (value: string) => { + if (value.length > maxLength) { + return `${fieldName ?? "Value"} exceeds ${maxLength} characters`; + } + return true; + }; +} + +// In lib/prompt.ts - use in Inquirer prompts +const subjectValidator = createMaxLengthValidator(config.maxSubjectLength, "Subject"); +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. + +```bash +npm run test:coverage -- tests/unit/validators.test.ts +``` + +If coverage is < 100%, 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/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ef232fb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,44 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run lint)", + "Bash(npm run lint -- *)", + "Bash(npm run format)", + "Bash(npm run type-check)", + "Bash(npm run knip)", + "Bash(npm test)", + "Bash(npm run test)", + "Bash(npm run test:coverage)", + "Bash(npm run build)", + "Bash(npx vitest run *)", + "Bash(npx tsc --noEmit)", + "Bash(npx eslint *)", + "Bash(npx prettier *)", + "Bash(git status *)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git switch -c *)", + "Bash(git checkout -b *)" + ], + "deny": [ + "Bash(git commit *--no-verify*)", + "Bash(git commit * -n*)", + "Bash(git commit -n*)", + "Bash(git push *--force*)", + "Bash(git push -f*)", + "Bash(git push * -f*)", + "Bash(git push *--no-verify*)", + "Bash(git push * main)", + "Bash(git push * main *)", + "Bash(git push *:main*)", + "Bash(git push * develop)", + "Bash(git push * develop *)", + "Bash(git push *:develop*)", + "Bash(git push *+*)", + "Read(./.env)", + "Read(./.env.*)" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..432d358 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# 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. + +## Project + +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`. + +## 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 + ``` +2. No network calls. Merlin works fully offline. +3. No `eval()` or `new Function()`. Custom 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`. + +## 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 +``` + +### Before pushing + +These mirror the CI `ci` job in `.github/workflows/ci.yml`: + +```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`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..428aad4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +@AGENTS.md + +# 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. + +| Rule | Loads for | +| -------------------------------- | ----------------------------- | +| `.claude/rules/naming.md` | always | +| `.claude/rules/security.md` | always | +| `.claude/rules/code-style.md` | `**/*.ts` | +| `.claude/rules/clean-code.md` | `src/**/*.ts` | +| `.claude/rules/commands.md` | `src/commands/*.ts` | +| `.claude/rules/lib.md` | `src/lib/*.ts` | +| `.claude/rules/utils.md` | `src/utils/*.ts` | +| `.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.