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