diff --git a/docs/skills/vibe-validate/cli-reference.md b/docs/skills/vibe-validate/cli-reference.md index be80b926..4391820c 100644 --- a/docs/skills/vibe-validate/cli-reference.md +++ b/docs/skills/vibe-validate/cli-reference.md @@ -505,6 +505,7 @@ Create a new extractor plugin from template - `--detection-pattern ` - 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..cdba92d0 100644 --- a/packages/cli/src/commands/create-extractor.ts +++ b/packages/cli/src/commands/create-extractor.ts @@ -6,33 +6,40 @@ */ 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 + * Options for the create-extractor command. Exported for `gatherContext`'s + * unit tests. */ -interface CreateExtractorOptions { +export interface CreateExtractorOptions { name?: string; description?: string; author?: string; priority?: number; detectionPattern?: string; force?: boolean; + dryRun?: boolean; } /** - * 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" @@ -52,45 +59,10 @@ 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) - 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 - if (existsSync(pluginDir) && !options.force) { - console.error(chalk.red('❌ Plugin directory already exists:')); - console.error(chalk.gray(` ${pluginDir}`)); - console.error(chalk.gray(' Use --force to overwrite')); - process.exit(1); - } - - // 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); - - 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`)); - console.log(chalk.gray(` 6. Test the plugin: ${cmd} test-extractor .`)); - - process.exit(0); + await runCreateExtractor(name, options); } catch (error) { console.error(chalk.red('❌ Failed to create extractor plugin:'), error); process.exit(1); @@ -98,6 +70,128 @@ 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. + * + * 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. + * + * Exported for in-process unit testing — subprocess-based command tests + * don't contribute line hits to V8 coverage instrumentation. + */ +export 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`. + * + * Exported for in-process unit testing (see `getPluginFileList` for rationale). + */ +export 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 */ @@ -137,9 +231,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 { @@ -150,6 +248,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 +303,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 +950,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 +969,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..f1e29b3c 100644 --- a/packages/cli/test/commands/create-extractor.test.ts +++ b/packages/cli/test/commands/create-extractor.test.ts @@ -6,17 +6,38 @@ */ import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; +import { mkdirSyncReal, 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, + runCreateExtractor, + showCreateExtractorVerboseHelp, + type CreateExtractorOptions, + type TemplateContext, +} 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 +373,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 @@ -393,3 +571,322 @@ 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:'); + }); + }); + + 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); + } + }); + }); +}); +