@@ -3862,6 +3862,144 @@ function renderRunDiffText(diff: CliRunDiff): string {
38623862 return lines . join ( '\n' ) ;
38633863}
38643864
3865+ export interface LintOptions extends CommonOptions {
3866+ planFrom ?: string ;
3867+ planFromDir ?: string ;
3868+ plans ?: string ;
3869+ steps ?: string ;
3870+ }
3871+
3872+ export interface CliLintIssue {
3873+ file : string ;
3874+ field : string ;
3875+ reason : string ;
3876+ }
3877+
3878+ export interface CliLintReport {
3879+ checked : number ;
3880+ valid : number ;
3881+ issues : CliLintIssue [ ] ;
3882+ }
3883+
3884+ /**
3885+ * `test lint` (issue #98): validate plan/steps files fully OFFLINE with the
3886+ * SAME validators the create paths run, but collecting EVERY problem instead
3887+ * of dying on the first one, and without any network write. The create-batch
3888+ * reader is first-error-fatal and only reachable through a command that POSTs,
3889+ * so authoring a 12-plan directory meant one error per paid round-trip. Zero
3890+ * network, zero credentials: exit 0 when everything is valid, 5 otherwise, so
3891+ * it drops into a pre-commit hook or CI step before `create-batch`.
3892+ */
3893+ export async function runLint ( opts : LintOptions , deps : TestDeps = { } ) : Promise < CliLintReport > {
3894+ const out = makeOutput ( opts . output , deps ) ;
3895+ const sources = [ opts . planFrom , opts . planFromDir , opts . plans , opts . steps ] . filter (
3896+ source => source !== undefined ,
3897+ ) ;
3898+ if ( sources . length !== 1 ) {
3899+ throw localValidationError (
3900+ 'plan-from' ,
3901+ 'exactly one of --plan-from, --plan-from-dir, --plans, or --steps is required' ,
3902+ ) ;
3903+ }
3904+
3905+ const issues : CliLintIssue [ ] = [ ] ;
3906+ let checked = 0 ;
3907+ // Run one existing validator, converting its typed throw into a report row
3908+ // (same envelopes, so `details.field` pointers like planSteps[2].type
3909+ // survive verbatim).
3910+ const collect = ( file : string , validate : ( ) => void ) : void => {
3911+ checked += 1 ;
3912+ try {
3913+ validate ( ) ;
3914+ } catch ( err ) {
3915+ if ( err instanceof ApiError ) {
3916+ issues . push ( {
3917+ file,
3918+ field : String ( err . getDetail ( 'field' ) ?? '(file)' ) ,
3919+ reason : String ( err . getDetail ( 'reason' ) ?? err . nextAction ?? err . message ) ,
3920+ } ) ;
3921+ } else {
3922+ issues . push ( {
3923+ file,
3924+ field : '(file)' ,
3925+ reason : err instanceof Error ? err . message : String ( err ) ,
3926+ } ) ;
3927+ }
3928+ }
3929+ } ;
3930+
3931+ if ( opts . planFrom !== undefined ) {
3932+ const planFrom = opts . planFrom ;
3933+ collect ( planFrom , ( ) => void readPlanFromGuarded ( planFrom ) ) ;
3934+ } else if ( opts . steps !== undefined ) {
3935+ const steps = opts . steps ;
3936+ collect ( steps , ( ) => void readPlanStepsFileGuarded ( steps ) ) ;
3937+ } else if ( opts . planFromDir !== undefined ) {
3938+ const dir = resolveAbsolute ( opts . planFromDir ) ;
3939+ let entries : string [ ] ;
3940+ try {
3941+ entries = readdirSync ( dir )
3942+ . filter ( name => name . endsWith ( '.json' ) )
3943+ . sort ( ) ;
3944+ } catch {
3945+ throw localValidationError ( 'plan-from-dir' , `cannot read directory: ${ dir } ` ) ;
3946+ }
3947+ if ( entries . length === 0 ) {
3948+ throw localValidationError ( 'plan-from-dir' , 'contains no *.json plan files' ) ;
3949+ }
3950+ for ( const entry of entries ) {
3951+ collect ( entry , ( ) => void readPlanFromGuarded ( join ( dir , entry ) ) ) ;
3952+ }
3953+ } else if ( opts . plans !== undefined ) {
3954+ // JSONL: validate PER LINE so every bad line reports (the create path's
3955+ // reader stays throw-on-first; this is the collecting counterpart).
3956+ const absolute = resolveAbsolute ( opts . plans ) ;
3957+ let content : string ;
3958+ try {
3959+ content = readFileSync ( absolute , 'utf8' ) ;
3960+ } catch {
3961+ throw localValidationError ( 'plans' , `cannot read file: ${ absolute } ` ) ;
3962+ }
3963+ // Index lines BEFORE dropping blanks so every reported `file:N` points at
3964+ // the PHYSICAL line in the file (a blank separator line must not shift all
3965+ // subsequent line numbers).
3966+ const numberedLines = content
3967+ . split ( '\n' )
3968+ . map ( ( rawLine , physicalIndex ) => ( { line : rawLine . trim ( ) , lineNo : physicalIndex + 1 } ) )
3969+ . filter ( entry => entry . line . length > 0 ) ;
3970+ if ( numberedLines . length === 0 ) throw localValidationError ( 'plans' , 'contains no plan lines' ) ;
3971+ for ( const { line, lineNo } of numberedLines ) {
3972+ collect ( `${ opts . plans } :${ lineNo } ` , ( ) => {
3973+ let parsed : unknown ;
3974+ try {
3975+ parsed = JSON . parse ( line ) ;
3976+ } catch {
3977+ throw localValidationError (
3978+ 'plans' ,
3979+ `line ${ lineNo } is not valid JSON` ,
3980+ undefined ,
3981+ 'field' ,
3982+ ) ;
3983+ }
3984+ assertPlanShape ( parsed , { specIndex : lineNo - 1 } ) ;
3985+ } ) ;
3986+ }
3987+ }
3988+
3989+ const filesWithIssues = new Set ( issues . map ( issue => issue . file ) ) . size ;
3990+ const report : CliLintReport = { checked, valid : checked - filesWithIssues , issues } ;
3991+ out . print ( report , ( ) =>
3992+ [
3993+ ...issues . map ( issue => `${ issue . file } : ${ issue . field } : ${ issue . reason } ` ) ,
3994+ `${ report . valid } /${ report . checked } valid, ${ issues . length } problem(s)` ,
3995+ ] . join ( '\n' ) ,
3996+ ) ;
3997+ if ( issues . length > 0 ) {
3998+ throw new CLIError ( `lint: ${ issues . length } problem(s) across ${ report . checked } file(s)` , 5 ) ;
3999+ }
4000+ return report ;
4001+ }
4002+
38654003export async function runSteps (
38664004 opts : StepsOptions ,
38674005 deps : TestDeps = { } ,
@@ -7669,6 +7807,37 @@ export function createTestCommand(deps: TestDeps = {}): Command {
76697807 await runDiff ( { ...resolveCommonOptions ( command ) , runA, runB } , deps ) ;
76707808 } ) ;
76717809
7810+ test
7811+ . command ( 'lint' )
7812+ . description (
7813+ 'Validate plan/steps files offline with the same validators `create` runs, collecting EVERY problem. No network, no credentials. Exit 0 when all valid, 5 otherwise.' ,
7814+ )
7815+ . option ( '--plan-from <file>' , 'single plan JSON file' )
7816+ . option (
7817+ '--plan-from-dir <dir>' ,
7818+ 'directory of *.json plan files (each checked, all errors reported)' ,
7819+ )
7820+ . option ( '--plans <file>' , 'JSONL file with one plan spec per line (each line checked)' )
7821+ . option ( '--steps <file>' , 'plan-steps JSON file (the shape `test plan put` ingests)' )
7822+ . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
7823+ . action (
7824+ async (
7825+ cmdOpts : { planFrom ?: string ; planFromDir ?: string ; plans ?: string ; steps ?: string } ,
7826+ command : Command ,
7827+ ) => {
7828+ await runLint (
7829+ {
7830+ ...resolveCommonOptions ( command ) ,
7831+ planFrom : cmdOpts . planFrom ,
7832+ planFromDir : cmdOpts . planFromDir ,
7833+ plans : cmdOpts . plans ,
7834+ steps : cmdOpts . steps ,
7835+ } ,
7836+ deps ,
7837+ ) ;
7838+ } ,
7839+ ) ;
7840+
76727841 test
76737842 . command ( 'result <test-id>' )
76747843 . description (
0 commit comments