import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { CURRENT_STEP_CONTRACT_VERSION, type FunnelContractDiagnostic, } from '@funnelsgrove/runtime'; export const VALIDATOR_CONTRACT_VERSION = CURRENT_STEP_CONTRACT_VERSION; export type FunnelValidationProtocol = { schemaVersion: 1; contractVersion: number; valid: boolean; diagnostics: readonly FunnelContractDiagnostic[]; }; type LoadedValidation = { contractVersion: number; diagnostics: readonly FunnelContractDiagnostic[]; }; type TextWriter = (text: string) => void; export type RunValidateFunnelCliOptions = { args?: readonly string[]; loadValidation?: () => Promise; writeStdout?: TextWriter; writeStderr?: TextWriter; }; export type FunnelValidationExitCode = 0 | 1 | 2; const internalDiagnostic = (): FunnelContractDiagnostic => ({ code: 'FG-CLIENT-001', severity: 'error', file: 'src/contract/validate-funnel.cli.ts', stepId: null, expected: 'loadable funnel project validation module', received: 'internal validator failure', reason: 'The funnel project validator could not load or run.', guide: 'docs/funnelsgrove/qa/local.md', repair: 'Fix project import errors, then rerun fgrove validate.', }); const loadProjectValidation = async (): Promise => { const projectValidation = await import('./funnel-project.validation'); return { contractVersion: projectValidation.FUNNEL_PROJECT_CONTRACT_VERSION, diagnostics: projectValidation.validateFunnelProject(), }; }; const describeUnknownError = (error: unknown): string => { try { if (error instanceof Error && error.message.trim()) { return error.message; } return String(error); } catch { return 'unknown validator failure'; } }; const renderValue = (value: unknown): string => { try { return JSON.stringify(value) ?? String(value); } catch { return '[unserializable]'; } }; const renderDiagnostic = (diagnostic: FunnelContractDiagnostic): string => { const location = diagnostic.file ? `${diagnostic.file}${diagnostic.stepId ? ` (${diagnostic.stepId})` : ''}` : diagnostic.stepId ?? 'project'; return [ `[${diagnostic.code}] ${diagnostic.reason}`, ` Location: ${location}`, ` Expected: ${renderValue(diagnostic.expected)}`, ` Received: ${renderValue(diagnostic.received)}`, ` Repair: ${diagnostic.repair}`, ` Guide: ${diagnostic.guide}`, ].join('\n'); }; const invokeWithoutStdoutNoise = async ( operation: () => Promise, writeStderr: TextWriter, ): Promise => { const originalWrite = process.stdout.write; let captured = ''; process.stdout.write = ((chunk: string | Uint8Array, ...args: unknown[]) => { const encoding = typeof args[0] === 'string' ? args[0] as BufferEncoding : undefined; captured += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(encoding); const callback = args.find((arg) => typeof arg === 'function'); if (typeof callback === 'function') { callback(); } return true; }) as typeof process.stdout.write; try { return await operation(); } finally { process.stdout.write = originalWrite; if (captured) { writeStderr(captured); if (!captured.endsWith('\n')) { writeStderr('\n'); } } } }; const isLoadedValidation = (value: LoadedValidation): boolean => { return value.contractVersion === VALIDATOR_CONTRACT_VERSION && Array.isArray(value.diagnostics); }; const emitProtocol = ( protocol: FunnelValidationProtocol, writeStdout: TextWriter, ): void => { writeStdout(`${JSON.stringify(protocol)}\n`); }; const protocolFor = ( diagnostics: readonly FunnelContractDiagnostic[], ): FunnelValidationProtocol => ({ schemaVersion: 1, contractVersion: VALIDATOR_CONTRACT_VERSION, valid: diagnostics.length === 0, diagnostics, }); const usageError = ( json: boolean, writeStdout: TextWriter, writeStderr: TextWriter, ): FunnelValidationExitCode => { const diagnostic = internalDiagnostic(); writeStderr('Usage: validate-funnel.cli.ts [--json]\n'); if (json) { emitProtocol(protocolFor([diagnostic]), writeStdout); } return 2; }; export const runValidateFunnelCli = async ( options: RunValidateFunnelCliOptions = {}, ): Promise => { const args = options.args ?? process.argv.slice(2); const writeStdout = options.writeStdout ?? ((text) => { process.stdout.write(text); }); const writeStderr = options.writeStderr ?? ((text) => { process.stderr.write(text); }); const json = args.includes('--json'); if (args.some((arg) => arg !== '--json') || args.filter((arg) => arg === '--json').length > 1) { return usageError(json, writeStdout, writeStderr); } try { const loaded = await invokeWithoutStdoutNoise( options.loadValidation ?? loadProjectValidation, writeStderr, ); if (!isLoadedValidation(loaded)) { throw new Error('The project validator returned an invalid protocol result.'); } const protocol = protocolFor(loaded.diagnostics); if (json) { emitProtocol(protocol, writeStdout); } for (const diagnostic of loaded.diagnostics) { writeStderr(`${renderDiagnostic(diagnostic)}\n`); } return protocol.valid ? 0 : 1; } catch (error) { const diagnostic = internalDiagnostic(); writeStderr(`Funnel validation failed internally: ${describeUnknownError(error)}\n`); if (json) { emitProtocol(protocolFor([diagnostic]), writeStdout); } return 2; } }; const isCliEntrypoint = (argv: readonly string[], modulePath: string): boolean => { const resolvedModulePath = path.resolve(modulePath); return path.basename(argv[1] ?? '') === 'vite-node' || argv.slice(1).some((argument) => path.resolve(argument) === resolvedModulePath); }; if (isCliEntrypoint(process.argv, fileURLToPath(import.meta.url))) { process.exitCode = await runValidateFunnelCli(); }