@@ -3630,6 +3630,141 @@ function mapRunStepToCliTestStep(step: RunStepDto, run: RunResponse): CliTestSte
36303630 } ;
36313631}
36323632
3633+ export interface LintOptions extends CommonOptions {
3634+ planFrom ?: string ;
3635+ planFromDir ?: string ;
3636+ plans ?: string ;
3637+ steps ?: string ;
3638+ }
3639+
3640+ export interface CliLintIssue {
3641+ file : string ;
3642+ field : string ;
3643+ reason : string ;
3644+ }
3645+
3646+ export interface CliLintReport {
3647+ checked : number ;
3648+ valid : number ;
3649+ issues : CliLintIssue [ ] ;
3650+ }
3651+
3652+ /**
3653+ * `test lint` (issue #98): validate plan/steps files fully OFFLINE with the
3654+ * SAME validators the create paths run, but collecting EVERY problem instead
3655+ * of dying on the first one, and without any network write. The create-batch
3656+ * reader is first-error-fatal and only reachable through a command that POSTs,
3657+ * so authoring a 12-plan directory meant one error per paid round-trip. Zero
3658+ * network, zero credentials: exit 0 when everything is valid, 5 otherwise, so
3659+ * it drops into a pre-commit hook or CI step before `create-batch`.
3660+ */
3661+ export async function runLint ( opts : LintOptions , deps : TestDeps = { } ) : Promise < CliLintReport > {
3662+ const out = makeOutput ( opts . output , deps ) ;
3663+ const sources = [ opts . planFrom , opts . planFromDir , opts . plans , opts . steps ] . filter (
3664+ source => source !== undefined ,
3665+ ) ;
3666+ if ( sources . length !== 1 ) {
3667+ throw localValidationError (
3668+ 'plan-from' ,
3669+ 'exactly one of --plan-from, --plan-from-dir, --plans, or --steps is required' ,
3670+ ) ;
3671+ }
3672+
3673+ const issues : CliLintIssue [ ] = [ ] ;
3674+ let checked = 0 ;
3675+ // Run one existing validator, converting its typed throw into a report row
3676+ // (same envelopes, so `details.field` pointers like planSteps[2].type
3677+ // survive verbatim).
3678+ const collect = ( file : string , validate : ( ) => void ) : void => {
3679+ checked += 1 ;
3680+ try {
3681+ validate ( ) ;
3682+ } catch ( err ) {
3683+ if ( err instanceof ApiError ) {
3684+ issues . push ( {
3685+ file,
3686+ field : String ( err . getDetail ( 'field' ) ?? '(file)' ) ,
3687+ reason : String ( err . getDetail ( 'reason' ) ?? err . nextAction ?? err . message ) ,
3688+ } ) ;
3689+ } else {
3690+ issues . push ( {
3691+ file,
3692+ field : '(file)' ,
3693+ reason : err instanceof Error ? err . message : String ( err ) ,
3694+ } ) ;
3695+ }
3696+ }
3697+ } ;
3698+
3699+ if ( opts . planFrom !== undefined ) {
3700+ const planFrom = opts . planFrom ;
3701+ collect ( planFrom , ( ) => void readPlanFromGuarded ( planFrom ) ) ;
3702+ } else if ( opts . steps !== undefined ) {
3703+ const steps = opts . steps ;
3704+ collect ( steps , ( ) => void readPlanStepsFileGuarded ( steps ) ) ;
3705+ } else if ( opts . planFromDir !== undefined ) {
3706+ const dir = resolveAbsolute ( opts . planFromDir ) ;
3707+ let entries : string [ ] ;
3708+ try {
3709+ entries = readdirSync ( dir )
3710+ . filter ( name => name . endsWith ( '.json' ) )
3711+ . sort ( ) ;
3712+ } catch {
3713+ throw localValidationError ( 'plan-from-dir' , `cannot read directory: ${ dir } ` ) ;
3714+ }
3715+ if ( entries . length === 0 ) {
3716+ throw localValidationError ( 'plan-from-dir' , 'contains no *.json plan files' ) ;
3717+ }
3718+ for ( const entry of entries ) {
3719+ collect ( entry , ( ) => void readPlanFromGuarded ( join ( dir , entry ) ) ) ;
3720+ }
3721+ } else if ( opts . plans !== undefined ) {
3722+ // JSONL: validate PER LINE so every bad line reports (the create path's
3723+ // reader stays throw-on-first; this is the collecting counterpart).
3724+ const absolute = resolveAbsolute ( opts . plans ) ;
3725+ let content : string ;
3726+ try {
3727+ content = readFileSync ( absolute , 'utf8' ) ;
3728+ } catch {
3729+ throw localValidationError ( 'plans' , `cannot read file: ${ absolute } ` ) ;
3730+ }
3731+ const lines = content
3732+ . split ( '\n' )
3733+ . map ( line => line . trim ( ) )
3734+ . filter ( line => line . length > 0 ) ;
3735+ if ( lines . length === 0 ) throw localValidationError ( 'plans' , 'contains no plan lines' ) ;
3736+ lines . forEach ( ( line , index ) => {
3737+ collect ( `${ opts . plans } :${ index + 1 } ` , ( ) => {
3738+ let parsed : unknown ;
3739+ try {
3740+ parsed = JSON . parse ( line ) ;
3741+ } catch {
3742+ throw localValidationError (
3743+ 'plans' ,
3744+ `line ${ index + 1 } is not valid JSON` ,
3745+ undefined ,
3746+ 'field' ,
3747+ ) ;
3748+ }
3749+ assertPlanShape ( parsed , { specIndex : index } ) ;
3750+ } ) ;
3751+ } ) ;
3752+ }
3753+
3754+ const filesWithIssues = new Set ( issues . map ( issue => issue . file ) ) . size ;
3755+ const report : CliLintReport = { checked, valid : checked - filesWithIssues , issues } ;
3756+ out . print ( report , ( ) =>
3757+ [
3758+ ...issues . map ( issue => `${ issue . file } : ${ issue . field } : ${ issue . reason } ` ) ,
3759+ `${ report . valid } /${ report . checked } valid, ${ issues . length } problem(s)` ,
3760+ ] . join ( '\n' ) ,
3761+ ) ;
3762+ if ( issues . length > 0 ) {
3763+ throw new CLIError ( `lint: ${ issues . length } problem(s) across ${ report . checked } file(s)` , 5 ) ;
3764+ }
3765+ return report ;
3766+ }
3767+
36333768export async function runSteps (
36343769 opts : StepsOptions ,
36353770 deps : TestDeps = { } ,
@@ -7234,6 +7369,37 @@ export function createTestCommand(deps: TestDeps = {}): Command {
72347369 ) ;
72357370 } ) ;
72367371
7372+ test
7373+ . command ( 'lint' )
7374+ . description (
7375+ '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.' ,
7376+ )
7377+ . option ( '--plan-from <file>' , 'single plan JSON file' )
7378+ . option (
7379+ '--plan-from-dir <dir>' ,
7380+ 'directory of *.json plan files (each checked, all errors reported)' ,
7381+ )
7382+ . option ( '--plans <file>' , 'JSONL file with one plan spec per line (each line checked)' )
7383+ . option ( '--steps <file>' , 'plan-steps JSON file (the shape `test plan put` ingests)' )
7384+ . addHelpText ( 'after' , GLOBAL_OPTS_HINT )
7385+ . action (
7386+ async (
7387+ cmdOpts : { planFrom ?: string ; planFromDir ?: string ; plans ?: string ; steps ?: string } ,
7388+ command : Command ,
7389+ ) => {
7390+ await runLint (
7391+ {
7392+ ...resolveCommonOptions ( command ) ,
7393+ planFrom : cmdOpts . planFrom ,
7394+ planFromDir : cmdOpts . planFromDir ,
7395+ plans : cmdOpts . plans ,
7396+ steps : cmdOpts . steps ,
7397+ } ,
7398+ deps ,
7399+ ) ;
7400+ } ,
7401+ ) ;
7402+
72377403 test
72387404 . command ( 'result <test-id>' )
72397405 . description (
0 commit comments