Skip to content

Commit 4427319

Browse files
authored
Add --json flag to compile command and MCP server for machine-readable validation output (#3799)
1 parent 7f9721c commit 4427319

7 files changed

Lines changed: 492 additions & 30 deletions

File tree

.github/workflows/super-linter.lock.yml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/gh-aw/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ Examples:
158158
zizmor, _ := cmd.Flags().GetBool("zizmor")
159159
poutine, _ := cmd.Flags().GetBool("poutine")
160160
actionlint, _ := cmd.Flags().GetBool("actionlint")
161+
jsonOutput, _ := cmd.Flags().GetBool("json")
161162
verbose, _ := cmd.Flags().GetBool("verbose")
162163
if err := validateEngine(engineOverride); err != nil {
163164
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error()))
@@ -181,6 +182,7 @@ Examples:
181182
Zizmor: zizmor,
182183
Poutine: poutine,
183184
Actionlint: actionlint,
185+
JSONOutput: jsonOutput,
184186
}
185187
if _, err := cli.CompileWorkflows(config); err != nil {
186188
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error()))
@@ -345,6 +347,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
345347
compileCmd.Flags().Bool("zizmor", false, "Run zizmor security scanner on generated .lock.yml files")
346348
compileCmd.Flags().Bool("poutine", false, "Run poutine security scanner on generated .lock.yml files")
347349
compileCmd.Flags().Bool("actionlint", false, "Run actionlint linter on generated .lock.yml files")
350+
compileCmd.Flags().Bool("json", false, "Output validation results as JSON")
348351
rootCmd.AddCommand(compileCmd)
349352

350353
// Add flags to remove command

pkg/cli/compile_command.go

Lines changed: 138 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"encoding/json"
45
"errors"
56
"fmt"
67
"os"
@@ -163,6 +164,7 @@ type CompileConfig struct {
163164
Zizmor bool // Run zizmor security scanner on generated .lock.yml files
164165
Poutine bool // Run poutine security scanner on generated .lock.yml files
165166
Actionlint bool // Run actionlint linter on generated .lock.yml files
167+
JSONOutput bool // Output validation results as JSON
166168
}
167169

168170
// CompilationStats tracks the results of workflow compilation
@@ -173,6 +175,22 @@ type CompilationStats struct {
173175
FailedWorkflows []string // Names of workflows that failed compilation
174176
}
175177

178+
// ValidationError represents a single validation error or warning
179+
type ValidationError struct {
180+
Type string `json:"type"`
181+
Message string `json:"message"`
182+
Line int `json:"line,omitempty"`
183+
}
184+
185+
// ValidationResult represents the validation result for a single workflow
186+
type ValidationResult struct {
187+
Workflow string `json:"workflow"`
188+
Valid bool `json:"valid"`
189+
Errors []ValidationError `json:"errors"`
190+
Warnings []ValidationError `json:"warnings"`
191+
CompiledFile string `json:"compiled_file,omitempty"`
192+
}
193+
176194
// validateCompileConfig validates the configuration flags before compilation
177195
// This is extracted for faster testing without full compilation
178196
func validateCompileConfig(config CompileConfig) error {
@@ -216,12 +234,16 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
216234
zizmor := config.Zizmor
217235
poutine := config.Poutine
218236
actionlint := config.Actionlint
237+
jsonOutput := config.JSONOutput
219238

220-
compileLog.Printf("Starting workflow compilation: files=%d, validate=%v, watch=%v, noEmit=%v, dependabot=%v, zizmor=%v, poutine=%v, actionlint=%v", len(markdownFiles), validate, watch, noEmit, dependabot, zizmor, poutine, actionlint)
239+
compileLog.Printf("Starting workflow compilation: files=%d, validate=%v, watch=%v, noEmit=%v, dependabot=%v, zizmor=%v, poutine=%v, actionlint=%v, jsonOutput=%v", len(markdownFiles), validate, watch, noEmit, dependabot, zizmor, poutine, actionlint, jsonOutput)
221240

222241
// Track compilation statistics
223242
stats := &CompilationStats{}
224243

244+
// Track validation results for JSON output
245+
var validationResults []ValidationResult
246+
225247
// Validate configuration
226248
if err := validateCompileConfig(config); err != nil {
227249
return nil, err
@@ -286,45 +308,94 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
286308
var errorMessages []string
287309
for _, markdownFile := range markdownFiles {
288310
stats.Total++
311+
312+
// Initialize validation result for this workflow
313+
result := ValidationResult{
314+
Workflow: markdownFile,
315+
Valid: true,
316+
Errors: []ValidationError{},
317+
Warnings: []ValidationError{},
318+
}
319+
289320
// Resolve workflow ID or file path to actual file path
290321
compileLog.Printf("Resolving workflow file: %s", markdownFile)
291322
resolvedFile, err := resolveWorkflowFile(markdownFile, verbose)
292323
if err != nil {
293324
errMsg := fmt.Sprintf("failed to resolve workflow '%s': %v", markdownFile, err)
294-
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg))
325+
if !jsonOutput {
326+
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg))
327+
}
295328
errorMessages = append(errorMessages, err.Error())
296329
errorCount++
297330
stats.Errors++
298331
stats.FailedWorkflows = append(stats.FailedWorkflows, markdownFile)
332+
333+
// Add to validation results
334+
result.Valid = false
335+
result.Errors = append(result.Errors, ValidationError{
336+
Type: "resolution_error",
337+
Message: err.Error(),
338+
})
339+
validationResults = append(validationResults, result)
299340
continue
300341
}
301342
compileLog.Printf("Resolved to: %s", resolvedFile)
343+
344+
// Update result with resolved file name
345+
result.Workflow = filepath.Base(resolvedFile)
346+
lockFile := strings.TrimSuffix(resolvedFile, ".md") + ".lock.yml"
347+
if !noEmit {
348+
result.CompiledFile = lockFile
349+
}
302350

303351
// Parse workflow file to get data
304352
compileLog.Printf("Parsing workflow file: %s", resolvedFile)
305353
workflowData, err := compiler.ParseWorkflowFile(resolvedFile)
306354
if err != nil {
307355
errMsg := fmt.Sprintf("failed to parse workflow file %s: %v", resolvedFile, err)
308-
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg))
356+
if !jsonOutput {
357+
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg))
358+
}
309359
errorMessages = append(errorMessages, err.Error())
310360
errorCount++
311361
stats.Errors++
312362
stats.FailedWorkflows = append(stats.FailedWorkflows, filepath.Base(resolvedFile))
363+
364+
// Add to validation results
365+
result.Valid = false
366+
result.Errors = append(result.Errors, ValidationError{
367+
Type: "parse_error",
368+
Message: err.Error(),
369+
})
370+
validationResults = append(validationResults, result)
313371
continue
314372
}
315373
workflowDataList = append(workflowDataList, workflowData)
316374

317375
compileLog.Printf("Starting compilation of %s", resolvedFile)
318-
if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict, validate && !noEmit); err != nil {
376+
if err := CompileWorkflowDataWithValidation(compiler, workflowData, resolvedFile, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict, validate && !noEmit); err != nil {
319377
// Always put error on a new line and don't wrap with "failed to compile workflow"
320-
fmt.Fprintln(os.Stderr, err.Error())
378+
if !jsonOutput {
379+
fmt.Fprintln(os.Stderr, err.Error())
380+
}
321381
errorMessages = append(errorMessages, err.Error())
322382
errorCount++
323383
stats.Errors++
324384
stats.FailedWorkflows = append(stats.FailedWorkflows, filepath.Base(resolvedFile))
385+
386+
// Add to validation results
387+
result.Valid = false
388+
result.Errors = append(result.Errors, ValidationError{
389+
Type: "compilation_error",
390+
Message: err.Error(),
391+
})
392+
validationResults = append(validationResults, result)
325393
continue
326394
}
327395
compiledCount++
396+
397+
// Add successful validation result
398+
validationResults = append(validationResults, result)
328399
}
329400

330401
// Get warning count from compiler
@@ -371,8 +442,17 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
371442
// Note: Instructions are only written by the init command
372443
// The compile command should not write instruction files
373444

374-
// Print summary
375-
printCompilationSummary(stats)
445+
// Output JSON if requested
446+
if jsonOutput {
447+
jsonBytes, err := json.MarshalIndent(validationResults, "", " ")
448+
if err != nil {
449+
return workflowDataList, fmt.Errorf("failed to marshal JSON: %w", err)
450+
}
451+
fmt.Println(string(jsonBytes))
452+
} else {
453+
// Print summary for text output
454+
printCompilationSummary(stats)
455+
}
376456

377457
// Return error if any compilations failed
378458
if errorCount > 0 {
@@ -445,26 +525,63 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
445525
var successCount int
446526
for _, file := range mdFiles {
447527
stats.Total++
528+
529+
// Initialize validation result for this workflow
530+
result := ValidationResult{
531+
Workflow: filepath.Base(file),
532+
Valid: true,
533+
Errors: []ValidationError{},
534+
Warnings: []ValidationError{},
535+
}
536+
537+
lockFile := strings.TrimSuffix(file, ".md") + ".lock.yml"
538+
if !noEmit {
539+
result.CompiledFile = lockFile
540+
}
541+
448542
// Parse workflow file to get data
449543
workflowData, err := compiler.ParseWorkflowFile(file)
450544
if err != nil {
451-
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("failed to parse workflow file %s: %v", file, err)))
545+
if !jsonOutput {
546+
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(fmt.Sprintf("failed to parse workflow file %s: %v", file, err)))
547+
}
452548
errorCount++
453549
stats.Errors++
454550
stats.FailedWorkflows = append(stats.FailedWorkflows, filepath.Base(file))
551+
552+
// Add to validation results
553+
result.Valid = false
554+
result.Errors = append(result.Errors, ValidationError{
555+
Type: "parse_error",
556+
Message: err.Error(),
557+
})
558+
validationResults = append(validationResults, result)
455559
continue
456560
}
457561
workflowDataList = append(workflowDataList, workflowData)
458562

459-
if err := CompileWorkflowDataWithValidation(compiler, workflowData, file, verbose, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict, validate && !noEmit); err != nil {
563+
if err := CompileWorkflowDataWithValidation(compiler, workflowData, file, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict, validate && !noEmit); err != nil {
460564
// Print the error to stderr (errors from CompileWorkflow are already formatted)
461-
fmt.Fprintln(os.Stderr, err.Error())
565+
if !jsonOutput {
566+
fmt.Fprintln(os.Stderr, err.Error())
567+
}
462568
errorCount++
463569
stats.Errors++
464570
stats.FailedWorkflows = append(stats.FailedWorkflows, filepath.Base(file))
571+
572+
// Add to validation results
573+
result.Valid = false
574+
result.Errors = append(result.Errors, ValidationError{
575+
Type: "compilation_error",
576+
Message: err.Error(),
577+
})
578+
validationResults = append(validationResults, result)
465579
continue
466580
}
467581
successCount++
582+
583+
// Add successful validation result
584+
validationResults = append(validationResults, result)
468585
}
469586

470587
// Get warning count from compiler
@@ -535,8 +652,17 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
535652
// Note: Instructions are only written by the init command
536653
// The compile command should not write instruction files
537654

538-
// Print summary
539-
printCompilationSummary(stats)
655+
// Output JSON if requested
656+
if jsonOutput {
657+
jsonBytes, err := json.MarshalIndent(validationResults, "", " ")
658+
if err != nil {
659+
return workflowDataList, fmt.Errorf("failed to marshal JSON: %w", err)
660+
}
661+
fmt.Println(string(jsonBytes))
662+
} else {
663+
// Print summary for text output
664+
printCompilationSummary(stats)
665+
}
540666

541667
// Return error if any compilations failed
542668
if errorCount > 0 {

pkg/cli/compile_command_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,3 +484,45 @@ This is a test workflow.
484484
// but it should not panic and should handle trial mode settings
485485
_ = err // We're just testing that the config is processed
486486
}
487+
488+
// TestValidationResult tests the ValidationResult structure
489+
func TestValidationResult(t *testing.T) {
490+
result := ValidationResult{
491+
Workflow: "test-workflow.md",
492+
Valid: false,
493+
Errors: []ValidationError{
494+
{
495+
Type: "schema_validation",
496+
Message: "Unknown property: toolz",
497+
Line: 5,
498+
},
499+
},
500+
Warnings: []ValidationError{},
501+
CompiledFile: ".github/workflows/test-workflow.lock.yml",
502+
}
503+
504+
if result.Workflow != "test-workflow.md" {
505+
t.Errorf("Expected workflow 'test-workflow.md', got %q", result.Workflow)
506+
}
507+
if result.Valid {
508+
t.Error("Expected Valid to be false")
509+
}
510+
if len(result.Errors) != 1 {
511+
t.Errorf("Expected 1 error, got %d", len(result.Errors))
512+
}
513+
if result.Errors[0].Type != "schema_validation" {
514+
t.Errorf("Expected error type 'schema_validation', got %q", result.Errors[0].Type)
515+
}
516+
}
517+
518+
// TestCompileConfig_JSONOutput tests the JSONOutput field
519+
func TestCompileConfig_JSONOutput(t *testing.T) {
520+
config := CompileConfig{
521+
MarkdownFiles: []string{"test.md"},
522+
JSONOutput: true,
523+
}
524+
525+
if !config.JSONOutput {
526+
t.Error("Expected JSONOutput to be true")
527+
}
528+
}

0 commit comments

Comments
 (0)