From 350973f32319597f2c4b16c87c62ce16c9a6461f Mon Sep 17 00:00:00 2001 From: David Daniel Gonzalez Date: Sun, 17 May 2026 17:33:29 -0400 Subject: [PATCH 1/3] feat(create-extractor): add --dry-run + fix non-TTY + scrub stale refs Three changes to `vv create-extractor`, all surfaced while QA'ing the scaffolder against a fresh `rspec` extractor: 1. New `--dry-run` flag emits a YAML preview of every file that would be created (path + byte count + dir list + summary) without touching the filesystem. Mirrors the v0.18.0 YAML-as-default convention. A new `getPluginFileList(context)` helper is the single source of truth shared by the real write path (`createPluginDirectory`) and the dry-run preview (`emitDryRunPreview`), so the two cannot drift. 2. Non-TTY guard: `vv create-extractor ` - Detection keyword or pattern - `--priority ` - Detection priority (higher = check first) - `-f, --force` - Overwrite existing plugin directory +- `--dry-run` - Show which files would be created without writing anything --- diff --git a/packages/cli/src/commands/create-extractor.ts b/packages/cli/src/commands/create-extractor.ts index 8a58ed0b..83e2b67e 100644 --- a/packages/cli/src/commands/create-extractor.ts +++ b/packages/cli/src/commands/create-extractor.ts @@ -6,16 +6,20 @@ */ import { writeFileSync, existsSync, readFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { join, dirname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { mkdirSyncReal, normalizePath } from '@vibe-validate/utils'; +import { mkdirSyncReal, normalizePath, toForwardSlash } from '@vibe-validate/utils'; import chalk from 'chalk'; import type { Command } from 'commander'; import prompts from 'prompts'; import { getCommandName } from '../utils/command-name.js'; import { detectPackageManager, getInstallCommandUnfrozen } from '../utils/package-manager-commands.js'; +import { outputYamlResult } from '../utils/yaml-output.js'; + +/** Subdirectory inside the scaffolded plugin that holds real-world error samples. */ +const SAMPLES_DIR_NAME = 'samples'; /** * Options for the create-extractor command @@ -27,6 +31,7 @@ interface CreateExtractorOptions { priority?: number; detectionPattern?: string; force?: boolean; + dryRun?: boolean; } /** @@ -52,6 +57,7 @@ export function createExtractorCommand(program: Command): void { .option('--detection-pattern ', 'Detection keyword or pattern') .option('--priority ', 'Detection priority (higher = check first)', '70') .option('-f, --force', 'Overwrite existing plugin directory') + .option('--dry-run', 'Show which files would be created without writing anything') .action(async (name: string | undefined, options: CreateExtractorOptions) => { try { // Normalize cwd to avoid Windows short path issues (e.g., RUNNER~1) @@ -63,21 +69,26 @@ export function createExtractorCommand(program: Command): void { // Determine output directory const pluginDir = join(cwd, `vibe-validate-plugin-${context.pluginName}`); - // Check if directory exists - if (existsSync(pluginDir) && !options.force) { + // Check if directory exists (skipped in dry-run: we never overwrite, so no warning needed) + if (existsSync(pluginDir) && !options.force && !options.dryRun) { console.error(chalk.red('❌ Plugin directory already exists:')); console.error(chalk.gray(` ${pluginDir}`)); console.error(chalk.gray(' Use --force to overwrite')); process.exit(1); } + // Dry-run path: emit YAML preview without touching the filesystem + if (options.dryRun) { + await emitDryRunPreview(cwd, pluginDir, context); + process.exit(0); + } + // Create plugin directory console.log(chalk.blue('🔨 Creating extractor plugin...')); createPluginDirectory(pluginDir, context); console.log(chalk.green('✅ Extractor plugin created successfully!')); console.log(chalk.blue(`📁 Created: ${pluginDir}`)); - const cmd = getCommandName(); const pm = detectPackageManager(pluginDir); const installCmd = getInstallCommandUnfrozen(pm); @@ -88,7 +99,6 @@ export function createExtractorCommand(program: Command): void { console.log(chalk.gray(' 3. Add your sample error output to samples/sample-error.txt')); console.log(chalk.gray(' 4. Implement detect() and extract() functions in index.ts')); console.log(chalk.gray(` 5. Run tests: ${pm} test`)); - console.log(chalk.gray(` 6. Test the plugin: ${cmd} test-extractor .`)); process.exit(0); } catch (error) { @@ -98,6 +108,69 @@ export function createExtractorCommand(program: Command): void { }); } +/** + * Single source of truth for the files a scaffolded plugin contains. + * + * Both the real write path (`createPluginDirectory`) and the dry-run preview + * (`emitDryRunPreview`) iterate this list, guaranteeing they stay in sync. + * All generators here are pure (no I/O), so the same list is safe to use for + * both writing and previewing. + */ +function getPluginFileList(context: TemplateContext): Array<{ relPath: string; content: string }> { + return [ + { relPath: 'index.ts', content: generateIndexTs(context) }, + { relPath: 'index.test.ts', content: generateIndexTestTs(context) }, + { relPath: 'README.md', content: generateReadme(context) }, + { relPath: 'CLAUDE.md', content: generateClaudeMd(context) }, + { relPath: 'package.json', content: generatePackageJson(context) }, + { relPath: 'tsconfig.json', content: generateTsConfig(context) }, + { relPath: join(SAMPLES_DIR_NAME, 'sample-error.txt'), content: generateSampleError(context) }, + ]; +} + +/** + * Compute the list of files that would be created and emit a YAML preview to stdout. + * + * Calls every template generator (they are pure) and measures `Buffer.byteLength` + * for each result. Does not call `mkdirSyncReal` or `writeFileSync`. + */ +async function emitDryRunPreview( + cwd: string, + pluginDir: string, + context: TemplateContext +): Promise { + // Format a path relative to cwd with a leading `./` (matches what users see). + // POSIX-style separators in YAML output for consistency across platforms. + const toDisplayPath = (absPath: string): string => { + const posix = toForwardSlash(relative(cwd, absPath)); + return posix.startsWith('.') ? posix : `./${posix}`; + }; + + const samplesDir = join(pluginDir, SAMPLES_DIR_NAME); + + const wouldCreate = getPluginFileList(context).map(({ relPath, content }) => ({ + path: toDisplayPath(join(pluginDir, relPath)), + bytes: Buffer.byteLength(content, 'utf8'), + })); + + const totalBytes = wouldCreate.reduce((sum, entry) => sum + entry.bytes, 0); + + const result = { + dryRun: true, + pluginName: context.pluginName, + pluginDir: toDisplayPath(pluginDir), + wouldCreateDir: [toDisplayPath(pluginDir), toDisplayPath(samplesDir)], + wouldCreate, + summary: { + filesCount: wouldCreate.length, + dirsCount: 2, + totalBytes, + }, + }; + + await outputYamlResult(result); +} + /** * Build prompts configuration for missing options */ @@ -150,6 +223,19 @@ async function gatherContext( let responses: Record = {}; + // Bail fast when stdin is not a TTY and required info is missing. + // Without this guard, prompts() returns {} on closed stdin and the + // existing cancellation path silently exits 0 — confusing in CI / pipes. + if (!hasAllOptions && !process.stdin.isTTY) { + const cmd = getCommandName(); + console.error(chalk.red( + `error: plugin name is required when running non-interactively\n` + + ` pass it as a positional argument, plus the required flags:\n` + + ` ${cmd} create-extractor --description --author --detection-pattern ` + )); + process.exit(1); + } + // Only run prompts if we're missing required information if (!hasAllOptions) { responses = await prompts(buildPromptsConfig(name, options)); @@ -192,28 +278,13 @@ async function gatherContext( function createPluginDirectory(pluginDir: string, context: TemplateContext): void { // Create directories (using mkdirSyncReal to handle Windows short paths) const normalizedPluginDir = mkdirSyncReal(pluginDir, { recursive: true }); - mkdirSyncReal(join(normalizedPluginDir, 'samples'), { recursive: true }); + mkdirSyncReal(join(normalizedPluginDir, SAMPLES_DIR_NAME), { recursive: true }); // Write files (using normalized path to ensure consistency) - writeFileSync(join(normalizedPluginDir, 'index.ts'), generateIndexTs(context), 'utf-8'); - writeFileSync(join(normalizedPluginDir, 'index.test.ts'), generateIndexTestTs(context), 'utf-8'); - writeFileSync(join(normalizedPluginDir, 'README.md'), generateReadme(context), 'utf-8'); - writeFileSync(join(normalizedPluginDir, 'CLAUDE.md'), generateClaudeMd(context), 'utf-8'); - writeFileSync(join(normalizedPluginDir, 'package.json'), generatePackageJson(context), 'utf-8'); - writeFileSync(join(normalizedPluginDir, 'tsconfig.json'), generateTsConfig(context), 'utf-8'); - writeFileSync( - join(normalizedPluginDir, 'samples', 'sample-error.txt'), - generateSampleError(context), - 'utf-8' - ); - - console.log(chalk.gray(' ✓ Created index.ts')); - console.log(chalk.gray(' ✓ Created index.test.ts')); - console.log(chalk.gray(' ✓ Created README.md')); - console.log(chalk.gray(' ✓ Created CLAUDE.md')); - console.log(chalk.gray(' ✓ Created package.json')); - console.log(chalk.gray(' ✓ Created tsconfig.json')); - console.log(chalk.gray(' ✓ Created samples/sample-error.txt')); + for (const { relPath, content } of getPluginFileList(context)) { + writeFileSync(join(normalizedPluginDir, relPath), content, 'utf-8'); + console.log(chalk.gray(` ✓ Created ${relPath}`)); + } } /** @@ -854,10 +925,7 @@ npm install # or: pnpm install / yarn install / bun install # 6. Run tests npm test -# 7. Test with vibe-validate -vibe-validate test-extractor . - -# 8. Publish (optional) +# 7. Publish (optional) npm publish \`\`\` @@ -876,12 +944,10 @@ Generated plugins follow the vibe-validate plugin architecture: 2. **Implement detect()** function to identify your tool's output 3. **Implement extract()** function to parse errors 4. **Run tests** to validate functionality -5. **Test with vibe-validate** using \`test-extractor\` command -6. **Publish to npm** (optional) or use locally +5. **Publish to npm** (optional) or use locally ## Related Commands - \`vibe-validate fork-extractor \` - Copy built-in extractor for customization -- \`vibe-validate test-extractor \` - Validate plugin functionality and security `); } diff --git a/packages/cli/test/commands/create-extractor.test.ts b/packages/cli/test/commands/create-extractor.test.ts index 5ddf5f39..a852a7aa 100644 --- a/packages/cli/test/commands/create-extractor.test.ts +++ b/packages/cli/test/commands/create-extractor.test.ts @@ -9,14 +9,26 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import yaml from 'yaml'; import { showCreateExtractorVerboseHelp } from '../../src/commands/create-extractor.js'; import { executeVibeValidateCombined as execCLI, + executeVibeValidateCommand, setupTestDir, cleanupTestDir } from '../helpers/cli-execution-helpers.js'; +/** + * Parse the YAML document emitted between `---` separators by `outputYamlResult()`. + * Mirrors the pattern used in run.integration.test.ts. + */ +function parseYamlFrontMatter(stdout: string): any { + const yamlMatch = /^---\n([\s\S]*?)\n---/.exec(stdout); + expect(yamlMatch).toBeTruthy(); + return yaml.parse(yamlMatch![1]); +} + /** * Helper: Create an extractor plugin with standard options */ @@ -352,6 +364,163 @@ describe('create-extractor command', () => { }); }); + describe('--dry-run', () => { + it('should emit YAML preview and create no files', async () => { + const result = await executeVibeValidateCommand([ + 'create-extractor', + 'dry-tool', + '--description', 'Dry-run preview test', + '--author', 'Test ', + '--detection-pattern', 'DRY:', + '--dry-run', + ], { cwd: testDir }); + + expect(result.exitCode).toBe(0); + + // No files should exist on disk. + const pluginDir = join(testDir, 'vibe-validate-plugin-dry-tool'); + expect(existsSync(pluginDir)).toBe(false); + expect(readdirSync(testDir)).toHaveLength(0); + + // YAML should match the documented schema. + const parsed = parseYamlFrontMatter(result.stdout); + expect(parsed.dryRun).toBe(true); + expect(parsed.pluginName).toBe('dry-tool'); + expect(parsed.pluginDir).toBe('./vibe-validate-plugin-dry-tool'); + expect(parsed.wouldCreateDir).toEqual([ + './vibe-validate-plugin-dry-tool', + './vibe-validate-plugin-dry-tool/samples', + ]); + + // 7 files in canonical order: index.ts, index.test.ts, README.md, + // CLAUDE.md, package.json, tsconfig.json, samples/sample-error.txt + expect(parsed.wouldCreate).toHaveLength(7); + const paths = parsed.wouldCreate.map((entry: { path: string }) => entry.path); + expect(paths).toEqual([ + './vibe-validate-plugin-dry-tool/index.ts', + './vibe-validate-plugin-dry-tool/index.test.ts', + './vibe-validate-plugin-dry-tool/README.md', + './vibe-validate-plugin-dry-tool/CLAUDE.md', + './vibe-validate-plugin-dry-tool/package.json', + './vibe-validate-plugin-dry-tool/tsconfig.json', + './vibe-validate-plugin-dry-tool/samples/sample-error.txt', + ]); + for (const entry of parsed.wouldCreate) { + expect(typeof entry.bytes).toBe('number'); + expect(entry.bytes).toBeGreaterThan(0); + } + + // Summary should be consistent with the file list. + expect(parsed.summary.filesCount).toBe(7); + expect(parsed.summary.dirsCount).toBe(2); + const expectedTotal = parsed.wouldCreate.reduce( + (sum: number, entry: { bytes: number }) => sum + entry.bytes, + 0, + ); + expect(parsed.summary.totalBytes).toBe(expectedTotal); + + // "Next steps" guidance must NOT appear in dry-run (misleading otherwise). + expect(result.stdout + result.stderr).not.toContain('Next steps:'); + }); + + it('should emit YAML and skip overwrite warning when --dry-run + --force on existing dir', async () => { + // First, actually create the plugin so the directory exists. + await createPlugin(testDir, 'existing-tool', { + description: 'Existing plugin', + detectionPattern: 'ERR:', + }); + + const pluginDir = join(testDir, 'vibe-validate-plugin-existing-tool'); + expect(existsSync(pluginDir)).toBe(true); + const indexBefore = readFileSync(join(pluginDir, 'index.ts'), 'utf-8'); + + // Re-run with --dry-run --force: should preview, not overwrite. + const result = await executeVibeValidateCommand([ + 'create-extractor', + 'existing-tool', + '--description', 'Different description', + '--author', 'Test ', + '--detection-pattern', 'NEW:', + '--dry-run', + '--force', + ], { cwd: testDir }); + + expect(result.exitCode).toBe(0); + // The overwrite warning should NOT have fired (dry-run bypasses it). + expect(result.stderr).not.toContain('already exists'); + + // YAML preview emitted. + const parsed = parseYamlFrontMatter(result.stdout); + expect(parsed.dryRun).toBe(true); + expect(parsed.pluginName).toBe('existing-tool'); + + // The actual file on disk must be untouched. + const indexAfter = readFileSync(join(pluginDir, 'index.ts'), 'utf-8'); + expect(indexAfter).toBe(indexBefore); + }); + }); + + describe('non-TTY guard', () => { + it('should exit 1 with helpful error when stdin is not a TTY and required args missing', async () => { + // executeVibeValidateCommand spawns with stdio: ['ignore', ...], + // so process.stdin.isTTY is undefined in the child — matching the + // `vv create-extractor '); + expect(result.stderr).toContain('--description'); + expect(result.stderr).toContain('--author'); + expect(result.stderr).toContain('--detection-pattern'); + + // No files written. + expect(readdirSync(testDir)).toHaveLength(0); + }); + + it('should proceed normally when all required flags are passed (hasAllOptions=true)', async () => { + const result = await executeVibeValidateCommand([ + 'create-extractor', + 'full-flags-tool', + '--description', 'All flags supplied', + '--author', 'Test ', + '--detection-pattern', 'FULL:', + '--force', + ], { cwd: testDir }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain('plugin name is required when running non-interactively'); + expect(existsSync(join(testDir, 'vibe-validate-plugin-full-flags-tool', 'index.ts'))).toBe(true); + }); + }); + + describe('test-extractor reference scrubbed', () => { + it('should not mention test-extractor in post-creation output', async () => { + const output = await execCLI([ + 'create-extractor', + 'scrub-check', + '--description', 'Scrub check', + '--author', 'Test ', + '--detection-pattern', 'ERR:', + '--force', + ], { cwd: testDir }); + + expect(output).not.toContain('test-extractor .'); + expect(output).not.toContain('Test the plugin:'); + }); + + it('should not mention test-extractor in verbose help', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + showCreateExtractorVerboseHelp(); + const output = consoleSpy.mock.calls.map(call => call[0]).join('\n'); + consoleSpy.mockRestore(); + + // The verbose help must not advertise a command that doesn't exist yet. + // PR 3 will land `test-extractor` and re-add these references. + expect(output).not.toContain('test-extractor'); + }); + }); + describe('verbose help', () => { it('should display comprehensive help documentation', () => { // Spy on console.log to capture output From 290d34dac936f7b2a6f30a1b2f99ef5e87b68a19 Mon Sep 17 00:00:00 2001 From: David Daniel Gonzalez Date: Mon, 18 May 2026 15:26:20 -0400 Subject: [PATCH 2/3] test(create-extractor): add in-process unit tests for testable seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original PR commit was integration-tested via subprocess (the existing pattern in this file), but Vitest's V8 coverage instrumentation only sees code that runs inside the test process. Codecov flagged the patch at 7.94% coverage despite all 18 tests passing — the lines were exercised, just not visible to the instrumentation. Make the testable seams explicit: - Export `getPluginFileList`, `emitDryRunPreview`, `gatherContext`, `TemplateContext`, `CreateExtractorOptions` from create-extractor.ts. - 9 new in-process tests across 3 describe blocks: - getPluginFileList: returns 7 canonical entries in stable order, every entry has non-empty content, pluginName/className/detection pattern substitution. - emitDryRunPreview: emits YAML matching the documented schema, byte counts match Buffer.byteLength of generated content, paths are POSIX-normalized (no backslashes, leading `./`). - gatherContext non-TTY guard: exits 1 with helpful stderr when stdin is non-TTY + args missing; proceeds normally when all required args supplied. process.exit is mocked to throw so the test can halt the function without exiting the runner. create-extractor.ts coverage: ~9% -> 66.89% statements, 84.21% functions. A new captureStdout() helper mocks `process.stdout.write` to capture the multi-call YAML output emitted by `outputYamlResult`. Module-scope dummy paths use `normalizedTmpdir()` so the lint rules against publicly-writable directory literals pass; the paths are never written to (emitDryRunPreview is pure). --- packages/cli/src/commands/create-extractor.ts | 27 +- .../test/commands/create-extractor.test.ts | 237 +++++++++++++++++- 2 files changed, 254 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/create-extractor.ts b/packages/cli/src/commands/create-extractor.ts index 83e2b67e..f0d535d1 100644 --- a/packages/cli/src/commands/create-extractor.ts +++ b/packages/cli/src/commands/create-extractor.ts @@ -22,9 +22,10 @@ import { outputYamlResult } from '../utils/yaml-output.js'; const SAMPLES_DIR_NAME = 'samples'; /** - * Options for the create-extractor command + * Options for the create-extractor command. Exported for `gatherContext`'s + * unit tests. */ -interface CreateExtractorOptions { +export interface CreateExtractorOptions { name?: string; description?: string; author?: string; @@ -35,9 +36,10 @@ interface CreateExtractorOptions { } /** - * Template context for variable substitution + * Template context for variable substitution. Exported for in-process unit + * testing of `getPluginFileList` / `emitDryRunPreview`. */ -interface TemplateContext { +export interface TemplateContext { pluginName: string; // e.g., "my-plugin" className: string; // e.g., "MyPlugin" displayName: string; // e.g., "My Plugin" @@ -115,8 +117,11 @@ export function createExtractorCommand(program: Command): void { * (`emitDryRunPreview`) iterate this list, guaranteeing they stay in sync. * All generators here are pure (no I/O), so the same list is safe to use for * both writing and previewing. + * + * Exported for in-process unit testing — subprocess-based command tests + * don't contribute line hits to V8 coverage instrumentation. */ -function getPluginFileList(context: TemplateContext): Array<{ relPath: string; content: string }> { +export function getPluginFileList(context: TemplateContext): Array<{ relPath: string; content: string }> { return [ { relPath: 'index.ts', content: generateIndexTs(context) }, { relPath: 'index.test.ts', content: generateIndexTestTs(context) }, @@ -133,8 +138,10 @@ function getPluginFileList(context: TemplateContext): Array<{ relPath: string; c * * Calls every template generator (they are pure) and measures `Buffer.byteLength` * for each result. Does not call `mkdirSyncReal` or `writeFileSync`. + * + * Exported for in-process unit testing (see `getPluginFileList` for rationale). */ -async function emitDryRunPreview( +export async function emitDryRunPreview( cwd: string, pluginDir: string, context: TemplateContext @@ -210,9 +217,13 @@ function buildPromptsConfig( } /** - * Gather context from command-line arguments and interactive prompts + * Gather context from command-line arguments and interactive prompts. + * + * Exported so the non-TTY guard can be unit-tested in-process — driving the + * full Commander action via `parseAsync` is awkward because of how the test + * harness's `process.exit` mock interacts with command parsing. */ -async function gatherContext( +export async function gatherContext( name: string | undefined, options: CreateExtractorOptions ): Promise { diff --git a/packages/cli/test/commands/create-extractor.test.ts b/packages/cli/test/commands/create-extractor.test.ts index a852a7aa..171503e7 100644 --- a/packages/cli/test/commands/create-extractor.test.ts +++ b/packages/cli/test/commands/create-extractor.test.ts @@ -6,12 +6,20 @@ */ import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; +import { normalizedTmpdir, toForwardSlash } from '@vibe-validate/utils'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import yaml from 'yaml'; -import { showCreateExtractorVerboseHelp } from '../../src/commands/create-extractor.js'; +import { + emitDryRunPreview, + gatherContext, + getPluginFileList, + showCreateExtractorVerboseHelp, + type CreateExtractorOptions, + type TemplateContext, +} from '../../src/commands/create-extractor.js'; import { executeVibeValidateCombined as execCLI, executeVibeValidateCommand, @@ -562,3 +570,228 @@ describe('create-extractor command', () => { }); +/** + * In-process unit tests for the testable seams of `create-extractor.ts`. + * + * The subprocess-based tests above prove behaviour end-to-end but don't + * contribute to V8 coverage (Vitest's instrumentation only sees code that + * runs inside the test process). These tests call the exported functions + * directly so the patch coverage reflects what's actually exercised. + */ + +const SAMPLE_FILE = join('samples', 'sample-error.txt'); + +function makeContext(overrides: Partial = {}): TemplateContext { + return { + pluginName: 'test-tool', + className: 'TestTool', + displayName: 'Test Tool', + description: 'A test extractor plugin', + author: 'Test Author ', + priority: 70, + detectionPattern: 'ERROR:', + year: '2026', + ...overrides, + }; +} + +/** + * Capture every `process.stdout.write` call until the test restores the spy. + * `outputYamlResult` writes the document in four separate calls plus a + * "flush" trailing empty write; we concatenate everything but the empty + * pings so the YAML parser sees a single coherent string. + */ +function captureStdout(): { read: () => string; restore: () => void } { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array): boolean => { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return true; + }); + return { + read: () => chunks.join(''), + restore: () => spy.mockRestore(), + }; +} + +describe('create-extractor (in-process unit tests)', () => { + describe('getPluginFileList', () => { + it('returns the 7 canonical file entries in stable order', () => { + const list = getPluginFileList(makeContext()); + + // Order matters — both writers and previewers iterate this list and + // surface entries in the same sequence to the user. + expect(list.map(f => f.relPath)).toEqual([ + 'index.ts', + 'index.test.ts', + 'README.md', + 'CLAUDE.md', + 'package.json', + 'tsconfig.json', + SAMPLE_FILE, + ]); + }); + + it('returns non-empty content for every entry', () => { + const list = getPluginFileList(makeContext()); + + for (const { relPath, content } of list) { + expect(content.length, `empty content for ${relPath}`).toBeGreaterThan(0); + } + }); + + it('substitutes pluginName into generated content', () => { + const list = getPluginFileList(makeContext({ pluginName: 'my-cool-plugin' })); + const pkg = list.find(f => f.relPath === 'package.json'); + + expect(pkg).toBeDefined(); + expect(pkg!.content).toContain('vibe-validate-plugin-my-cool-plugin'); + }); + + it('substitutes className and detectionPattern into the main source', () => { + const list = getPluginFileList(makeContext({ + className: 'MyClass', + detectionPattern: 'FAIL:', + })); + const indexTs = list.find(f => f.relPath === 'index.ts'); + + expect(indexTs).toBeDefined(); + expect(indexTs!.content).toContain('detectMyClass'); + expect(indexTs!.content).toContain('FAIL:'); + }); + }); + + describe('emitDryRunPreview', () => { + // The function is pure with respect to its `cwd`/`pluginDir` arguments — + // it does no I/O, just path-relative arithmetic — so any string-shaped + // path is fine. Using `normalizedTmpdir()` satisfies the project's + // "no publicly-writable directory literals" lint rule while keeping the + // test self-contained (no actual files touched). + const dummyCwd = normalizedTmpdir(); + const dummyPluginDir = join(dummyCwd, 'vibe-validate-plugin-test-tool'); + + it('emits a YAML document with the documented schema', async () => { + const captured = captureStdout(); + + try { + await emitDryRunPreview(dummyCwd, dummyPluginDir, makeContext({ pluginName: 'test-tool' })); + } finally { + captured.restore(); + } + + const parsed = parseYamlFrontMatter(captured.read()); + expect(parsed.dryRun).toBe(true); + expect(parsed.pluginName).toBe('test-tool'); + expect(parsed.pluginDir).toBe('./vibe-validate-plugin-test-tool'); + expect(parsed.wouldCreateDir).toEqual([ + './vibe-validate-plugin-test-tool', + './vibe-validate-plugin-test-tool/samples', + ]); + expect(parsed.wouldCreate).toHaveLength(7); + expect(parsed.summary.filesCount).toBe(7); + expect(parsed.summary.dirsCount).toBe(2); + }); + + it('byte counts match Buffer.byteLength of the generated content', async () => { + const list = getPluginFileList(makeContext()); + const captured = captureStdout(); + + try { + await emitDryRunPreview(dummyCwd, dummyPluginDir, makeContext()); + } finally { + captured.restore(); + } + + const parsed = parseYamlFrontMatter(captured.read()); + const expectedTotal = list.reduce((sum, f) => sum + Buffer.byteLength(f.content, 'utf8'), 0); + + expect(parsed.summary.totalBytes).toBe(expectedTotal); + // Each entry's byte count matches its generator's output. We match by + // basename rather than full path because the emitted path is relative + // to cwd and the source list uses bare relPaths. + for (const entry of parsed.wouldCreate as Array<{ path: string; bytes: number }>) { + const filename = basename(entry.path); + const sourceEntry = list.find(f => f.relPath.endsWith(filename)); + expect(sourceEntry).toBeDefined(); + expect(entry.bytes).toBe(Buffer.byteLength(sourceEntry!.content, 'utf8')); + } + }); + + it('emits POSIX-style forward-slash paths regardless of OS path separator', async () => { + const captured = captureStdout(); + + try { + await emitDryRunPreview(dummyCwd, dummyPluginDir, makeContext()); + } finally { + captured.restore(); + } + + const parsed = parseYamlFrontMatter(captured.read()); + // toForwardSlash() guarantees no backslashes; the leading-`./` marker + // is set by emitDryRunPreview's display-path helper. We pre-normalize + // through toForwardSlash to satisfy the project's `no-path-startswith` + // lint rule (which insists on normalization before path comparisons). + for (const entry of parsed.wouldCreate as Array<{ path: string }>) { + expect(entry.path).not.toContain('\\'); + expect(toForwardSlash(entry.path).startsWith('./')).toBe(true); + } + }); + }); + + describe('gatherContext non-TTY guard', () => { + let originalIsTTY: boolean | undefined; + let exitSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + originalIsTTY = process.stdin.isTTY; + // Simulate a non-TTY stdin (e.g., `vv create-extractor { + throw new Error(`process.exit(${code ?? 'undefined'})`); + }) as never); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('exits 1 when stdin is not a TTY and required args are missing', async () => { + // No positional name, no --description etc. → guard should fire. + const opts: CreateExtractorOptions = {}; + + await expect(gatherContext(undefined, opts)).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + + // The stderr message lists the flags the user needs to pass. + const stderr = errorSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(stderr).toContain('plugin name is required when running non-interactively'); + expect(stderr).toContain('--description'); + expect(stderr).toContain('--author'); + expect(stderr).toContain('--detection-pattern'); + }); + + it('proceeds (no exit) when stdin is not a TTY but all required args supplied', async () => { + // hasAllOptions === true short-circuits both the prompts call AND the + // guard. Returning a fully-populated TemplateContext is the success + // path — the function never has to read from stdin. + const opts: CreateExtractorOptions = { + description: 'A non-interactive run', + author: 'CI ', + detectionPattern: 'ERR:', + priority: 70, + }; + + const ctx = await gatherContext('my-tool', opts); + expect(exitSpy).not.toHaveBeenCalled(); + expect(ctx.pluginName).toBe('my-tool'); + expect(ctx.description).toBe('A non-interactive run'); + expect(ctx.detectionPattern).toBe('ERR:'); + }); + }); +}); + From 2b13a34a427208dc694124e3b3e4ff6f495053c6 Mon Sep 17 00:00:00 2001 From: David Daniel Gonzalez Date: Wed, 20 May 2026 21:59:54 -0400 Subject: [PATCH 3/3] test(create-extractor): cover action body branches via extracted runCreateExtractor Extract the anonymous .action() arrow into an exported runCreateExtractor() so V8 coverage can see the directory-exists guard, --dry-run dispatch, and happy-path write. Adds three in-process tests for those branches; the Commander callback is now a thin try/catch wrapper around the new function. --- packages/cli/src/commands/create-extractor.ts | 96 ++++++++++-------- .../test/commands/create-extractor.test.ts | 97 ++++++++++++++++++- 2 files changed, 151 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/commands/create-extractor.ts b/packages/cli/src/commands/create-extractor.ts index f0d535d1..cdba92d0 100644 --- a/packages/cli/src/commands/create-extractor.ts +++ b/packages/cli/src/commands/create-extractor.ts @@ -62,47 +62,7 @@ export function createExtractorCommand(program: Command): void { .option('--dry-run', 'Show which files would be created without writing anything') .action(async (name: string | undefined, options: CreateExtractorOptions) => { try { - // Normalize cwd to avoid Windows short path issues (e.g., RUNNER~1) - const cwd = normalizePath(process.cwd()); - - // Interactive prompts for missing information (skip entirely if all options provided) - const context = await gatherContext(name, options); - - // Determine output directory - const pluginDir = join(cwd, `vibe-validate-plugin-${context.pluginName}`); - - // Check if directory exists (skipped in dry-run: we never overwrite, so no warning needed) - if (existsSync(pluginDir) && !options.force && !options.dryRun) { - console.error(chalk.red('❌ Plugin directory already exists:')); - console.error(chalk.gray(` ${pluginDir}`)); - console.error(chalk.gray(' Use --force to overwrite')); - process.exit(1); - } - - // Dry-run path: emit YAML preview without touching the filesystem - if (options.dryRun) { - await emitDryRunPreview(cwd, pluginDir, context); - process.exit(0); - } - - // Create plugin directory - console.log(chalk.blue('🔨 Creating extractor plugin...')); - createPluginDirectory(pluginDir, context); - - console.log(chalk.green('✅ Extractor plugin created successfully!')); - console.log(chalk.blue(`📁 Created: ${pluginDir}`)); - const pm = detectPackageManager(pluginDir); - const installCmd = getInstallCommandUnfrozen(pm); - - console.log(); - console.log(chalk.yellow('Next steps:')); - console.log(chalk.gray(' 1. cd ' + `vibe-validate-plugin-${context.pluginName}`)); - console.log(chalk.gray(` 2. ${installCmd}`)); - console.log(chalk.gray(' 3. Add your sample error output to samples/sample-error.txt')); - console.log(chalk.gray(' 4. Implement detect() and extract() functions in index.ts')); - console.log(chalk.gray(` 5. Run tests: ${pm} test`)); - - process.exit(0); + await runCreateExtractor(name, options); } catch (error) { console.error(chalk.red('❌ Failed to create extractor plugin:'), error); process.exit(1); @@ -110,6 +70,60 @@ export function createExtractorCommand(program: Command): void { }); } +/** + * Execute the create-extractor logic. + * + * Extracted from the Commander `.action()` closure so the directory-exists, + * dry-run dispatch, and write paths can be exercised by in-process unit tests + * (V8 coverage only sees code that runs inside the Vitest worker). + */ +export async function runCreateExtractor( + name: string | undefined, + options: CreateExtractorOptions +): Promise { + // Normalize cwd to avoid Windows short path issues (e.g., RUNNER~1) + const cwd = normalizePath(process.cwd()); + + // Interactive prompts for missing information (skip entirely if all options provided) + const context = await gatherContext(name, options); + + // Determine output directory + const pluginDir = join(cwd, `vibe-validate-plugin-${context.pluginName}`); + + // Check if directory exists (skipped in dry-run: we never overwrite, so no warning needed) + if (existsSync(pluginDir) && !options.force && !options.dryRun) { + console.error(chalk.red('❌ Plugin directory already exists:')); + console.error(chalk.gray(` ${pluginDir}`)); + console.error(chalk.gray(' Use --force to overwrite')); + process.exit(1); + } + + // Dry-run path: emit YAML preview without touching the filesystem + if (options.dryRun) { + await emitDryRunPreview(cwd, pluginDir, context); + process.exit(0); + } + + // Create plugin directory + console.log(chalk.blue('🔨 Creating extractor plugin...')); + createPluginDirectory(pluginDir, context); + + console.log(chalk.green('✅ Extractor plugin created successfully!')); + console.log(chalk.blue(`📁 Created: ${pluginDir}`)); + const pm = detectPackageManager(pluginDir); + const installCmd = getInstallCommandUnfrozen(pm); + + console.log(); + console.log(chalk.yellow('Next steps:')); + console.log(chalk.gray(' 1. cd ' + `vibe-validate-plugin-${context.pluginName}`)); + console.log(chalk.gray(` 2. ${installCmd}`)); + console.log(chalk.gray(' 3. Add your sample error output to samples/sample-error.txt')); + console.log(chalk.gray(' 4. Implement detect() and extract() functions in index.ts')); + console.log(chalk.gray(` 5. Run tests: ${pm} test`)); + + process.exit(0); +} + /** * Single source of truth for the files a scaffolded plugin contains. * diff --git a/packages/cli/test/commands/create-extractor.test.ts b/packages/cli/test/commands/create-extractor.test.ts index 171503e7..f1e29b3c 100644 --- a/packages/cli/test/commands/create-extractor.test.ts +++ b/packages/cli/test/commands/create-extractor.test.ts @@ -8,7 +8,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { basename, join } from 'node:path'; -import { normalizedTmpdir, toForwardSlash } from '@vibe-validate/utils'; +import { mkdirSyncReal, normalizedTmpdir, toForwardSlash } from '@vibe-validate/utils'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import yaml from 'yaml'; @@ -16,6 +16,7 @@ import { emitDryRunPreview, gatherContext, getPluginFileList, + runCreateExtractor, showCreateExtractorVerboseHelp, type CreateExtractorOptions, type TemplateContext, @@ -793,5 +794,99 @@ describe('create-extractor (in-process unit tests)', () => { expect(ctx.detectionPattern).toBe('ERR:'); }); }); + + describe('runCreateExtractor', () => { + // These tests cover the three branches inside the action body: + // 1. Existing directory + no --force/--dry-run → exit 1 + // 2. --dry-run dispatch → emitDryRunPreview, exit 0 (skips overwrite warning) + // 3. Happy path → createPluginDirectory writes files, exit 0 + // We spy on process.cwd() so the function writes into a real per-test + // tmpdir; process.exit is mocked to throw so each branch can be asserted. + let testDir: string; + let cwdSpy: ReturnType; + let exitSpy: ReturnType; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + const baseOpts: CreateExtractorOptions = { + description: 'Test extractor', + author: 'Test ', + detectionPattern: 'ERROR:', + priority: 70, + }; + + beforeEach(() => { + testDir = setupTestDir('vv-run-create-extractor'); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(testDir); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code ?? 'undefined'})`); + }) as never); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + cwdSpy.mockRestore(); + exitSpy.mockRestore(); + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + cleanupTestDir(testDir); + }); + + it('exits 1 when target directory already exists and neither --force nor --dry-run is set', async () => { + // Pre-create the plugin folder so the existsSync() guard fires. + mkdirSyncReal(join(testDir, 'vibe-validate-plugin-conflict'), { recursive: true }); + + await expect(runCreateExtractor('conflict', baseOpts)).rejects.toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + + const stderr = consoleErrorSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(stderr).toContain('Plugin directory already exists'); + expect(stderr).toContain('Use --force to overwrite'); + }); + + it('bypasses the overwrite check in --dry-run mode and emits YAML preview', async () => { + // Existing dir would normally hit the exit-1 branch above; --dry-run + // skips that check entirely (we never overwrite anyway). + const existingDir = join(testDir, 'vibe-validate-plugin-existing'); + mkdirSyncReal(existingDir, { recursive: true }); + + const captured = captureStdout(); + try { + await expect( + runCreateExtractor('existing', { ...baseOpts, dryRun: true }), + ).rejects.toThrow('process.exit(0)'); + } finally { + captured.restore(); + } + + expect(exitSpy).toHaveBeenCalledWith(0); + + const stderr = consoleErrorSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(stderr).not.toContain('already exists'); + + const parsed = parseYamlFrontMatter(captured.read()); + expect(parsed.dryRun).toBe(true); + expect(parsed.pluginName).toBe('existing'); + + // Pre-existing directory must remain untouched on disk. + expect(readdirSync(existingDir)).toHaveLength(0); + }); + + it('writes the full plugin file list on the happy path and exits 0', async () => { + await expect(runCreateExtractor('happy-path', baseOpts)).rejects.toThrow('process.exit(0)'); + expect(exitSpy).toHaveBeenCalledWith(0); + + const pluginDir = join(testDir, 'vibe-validate-plugin-happy-path'); + expect(existsSync(pluginDir)).toBe(true); + + // Every entry returned by getPluginFileList should be on disk now — + // this is what proves createPluginDirectory ran end-to-end. + const expected = getPluginFileList(makeContext({ pluginName: 'happy-path' })); + for (const { relPath } of expected) { + expect(existsSync(join(pluginDir, relPath)), `missing ${relPath}`).toBe(true); + } + }); + }); });