diff --git a/cmd/preflight/cli/lint.go b/cmd/preflight/cli/lint.go deleted file mode 100644 index 25241f99..00000000 --- a/cmd/preflight/cli/lint.go +++ /dev/null @@ -1,98 +0,0 @@ -package cli - -import ( - "fmt" - "os" - - "github.com/pkg/errors" - "github.com/replicatedhq/troubleshoot/pkg/constants" - "github.com/replicatedhq/troubleshoot/pkg/lint" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -func LintCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "lint [spec-files...]", - Args: cobra.MinimumNArgs(1), - Short: "Lint preflight specs for syntax and structural errors", - Long: `Lint preflight specs for syntax and structural errors. - -This command validates troubleshoot specs and checks for: -- YAML syntax errors (missing colons, invalid structure) -- Missing required fields (apiVersion, kind, metadata, spec) -- Invalid template syntax ({{ .Values.* }}, {{ .Release.* }}, etc.) -- Missing analyzers or collectors -- Common structural issues -- Missing docStrings (warning) - -Both v1beta2 and v1beta3 apiVersions are supported. Use 'convert' if you need a full structural conversion between schema versions. - -The --fix flag can automatically repair: -- Missing colons in YAML (e.g., "metadata" → "metadata:") -- Missing or malformed apiVersion line. If templating ({{ }}) or docString fields are detected, apiVersion is set to v1beta3; otherwise v1beta2. -- Template expressions missing leading dot (e.g., "{{ Values.x }}" → "{{ .Values.x }}") - -Examples: - # Lint a single spec file - preflight lint my-preflight.yaml - - # Lint multiple spec files - preflight lint spec1.yaml spec2.yaml spec3.yaml - - # Lint with automatic fixes (may need to run multiple times for complex issues) - preflight lint --fix my-preflight.yaml - - # Lint and output as JSON for CI/CD integration - preflight lint --format json my-preflight.yaml - -Exit codes: - 0 - No errors found - 2 - Validation errors found`, - PreRun: func(cmd *cobra.Command, args []string) { - viper.BindPFlags(cmd.Flags()) - }, - RunE: func(cmd *cobra.Command, args []string) error { - v := viper.GetViper() - - opts := lint.LintOptions{ - FilePaths: args, - Fix: v.GetBool("fix"), - Format: v.GetString("format"), - } - - return runLint(opts) - }, - } - - cmd.Flags().Bool("fix", false, "Automatically fix issues where possible") - cmd.Flags().String("format", "text", "Output format: text or json") - - return cmd -} - -func runLint(opts lint.LintOptions) error { - // Validate file paths exist - for _, filePath := range opts.FilePaths { - if _, err := os.Stat(filePath); err != nil { - return errors.Wrapf(err, "file not found: %s", filePath) - } - } - - // Run linting - results, err := lint.LintFiles(opts) - if err != nil { - return errors.Wrap(err, "failed to lint files") - } - - // Format and print results - output := lint.FormatResults(results, opts.Format) - fmt.Print(output) - - // Return appropriate exit code - if lint.HasErrors(results) { - os.Exit(constants.EXIT_CODE_SPEC_ISSUES) - } - - return nil -} diff --git a/cmd/preflight/cli/root.go b/cmd/preflight/cli/root.go index 6c6a509d..bf97d3f4 100644 --- a/cmd/preflight/cli/root.go +++ b/cmd/preflight/cli/root.go @@ -89,7 +89,6 @@ that a cluster meets the requirements to run an application.`, cmd.AddCommand(TemplateCmd()) cmd.AddCommand(DocsCmd()) cmd.AddCommand(ConvertCmd()) - cmd.AddCommand(LintCmd()) preflight.AddFlags(cmd.PersistentFlags()) diff --git a/cmd/troubleshoot/cli/root.go b/cmd/troubleshoot/cli/root.go index 2b5996fb..1208bc2f 100644 --- a/cmd/troubleshoot/cli/root.go +++ b/cmd/troubleshoot/cli/root.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/replicatedhq/troubleshoot/cmd/internal/util" - preflightcli "github.com/replicatedhq/troubleshoot/cmd/preflight/cli" "github.com/replicatedhq/troubleshoot/internal/traces" "github.com/replicatedhq/troubleshoot/pkg/k8sutil" "github.com/replicatedhq/troubleshoot/pkg/logger" @@ -110,7 +109,6 @@ If no arguments are provided, specs are automatically loaded from the cluster by cmd.AddCommand(Schedule()) cmd.AddCommand(UploadCmd()) cmd.AddCommand(util.VersionCmd()) - cmd.AddCommand(preflightcli.LintCmd()) cmd.Flags().StringSlice("redactors", []string{}, "names of the additional redactors to use") cmd.Flags().Bool("redact", true, "enable/disable default redactions") diff --git a/examples/test-error-messages/helm-builtins-v1beta3.yaml b/examples/test-error-messages/helm-builtins-v1beta3.yaml deleted file mode 100644 index 68d40e57..00000000 --- a/examples/test-error-messages/helm-builtins-v1beta3.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: troubleshoot.sh/v1beta3 -kind: Preflight -metadata: - name: helm-builtins-example - labels: - release: {{ .Release.Name }} -spec: - analyzers: - - docString: | - Title: Example using Helm builtin objects - Requirement: Demonstrates .Values, .Release, .Chart, etc. - - Supported Helm builtin objects: - - .Values.* - User-provided values - - .Release.Name - Release name (default: "preflight") - - .Release.Namespace - Release namespace (default: "default") - - .Release.IsInstall - Whether this is an install (true) - - .Release.IsUpgrade - Whether this is an upgrade (false) - - .Release.Revision - Release revision (1) - - .Chart.Name - Chart name - - .Chart.Version - Chart version - - .Capabilities.KubeVersion - Kubernetes version capabilities - clusterVersion: - checkName: Kubernetes version check in {{ .Release.Namespace }} - outcomes: - - fail: - when: '< {{ .Values.minVersion | default "1.19.0" }}' - message: | - Release {{ .Release.Name }} requires Kubernetes {{ .Values.minVersion | default "1.19.0" }} or later. - Chart: {{ .Chart.Name }} - - pass: - when: '>= {{ .Values.minVersion | default "1.19.0" }}' - message: Kubernetes version is supported for release {{ .Release.Name }} diff --git a/examples/test-error-messages/invalid-yaml-v1beta3.yaml b/examples/test-error-messages/invalid-yaml-v1beta3.yaml deleted file mode 100644 index 5453c38c..00000000 --- a/examples/test-error-messages/invalid-yaml-v1beta3.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: troubleshoot.sh/v1beta3 -kind: Preflight -metadata - name: invalid-yaml -spec: - analyzers: - - clusterVersion: - checkName: Kubernetes version diff --git a/examples/test-error-messages/missing-apiversion-v1beta3.yaml b/examples/test-error-messages/missing-apiversion-v1beta3.yaml deleted file mode 100644 index 7f168f8b..00000000 --- a/examples/test-error-messages/missing-apiversion-v1beta3.yaml +++ /dev/null @@ -1,11 +0,0 @@ -kind: Preflight -metadata: - name: missing-apiversion -spec: - analyzers: - - clusterVersion: - checkName: Kubernetes version - outcomes: - - pass: - when: '>= 1.19.0' - message: Kubernetes version is supported diff --git a/examples/test-error-messages/missing-metadata-v1beta3.yaml b/examples/test-error-messages/missing-metadata-v1beta3.yaml deleted file mode 100644 index 4ce03bee..00000000 --- a/examples/test-error-messages/missing-metadata-v1beta3.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: troubleshoot.sh/v1beta3 -kind: Preflight -spec: - analyzers: - - clusterVersion: - checkName: Kubernetes version - outcomes: - - pass: - when: '>= 1.19.0' - message: Kubernetes version is supported diff --git a/examples/test-error-messages/no-analyzers-v1beta3.yaml b/examples/test-error-messages/no-analyzers-v1beta3.yaml deleted file mode 100644 index 6be07fd0..00000000 --- a/examples/test-error-messages/no-analyzers-v1beta3.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: troubleshoot.sh/v1beta3 -kind: Preflight -metadata: - name: no-analyzers -spec: - collectors: - - clusterInfo: {} diff --git a/examples/test-error-messages/simple-no-template-v1beta3.yaml b/examples/test-error-messages/simple-no-template-v1beta3.yaml deleted file mode 100644 index a8f7f4c0..00000000 --- a/examples/test-error-messages/simple-no-template-v1beta3.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: troubleshoot.sh/v1beta3 -kind: Preflight -metadata: - name: simple-no-template -spec: - analyzers: - - docString: | - Title: Kubernetes Version Check - Requirement: Kubernetes 1.19.0 or later - clusterVersion: - checkName: Kubernetes version - outcomes: - - fail: - when: '< 1.19.0' - message: Kubernetes version must be at least 1.19.0 - - pass: - when: '>= 1.19.0' - message: Kubernetes version is supported diff --git a/examples/test-error-messages/support-bundle-no-collectors-v1beta3.yaml b/examples/test-error-messages/support-bundle-no-collectors-v1beta3.yaml deleted file mode 100644 index 51d94e99..00000000 --- a/examples/test-error-messages/support-bundle-no-collectors-v1beta3.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: troubleshoot.sh/v1beta3 -kind: SupportBundle -metadata: - name: no-collectors -spec: - analyzers: - - clusterVersion: - checkName: Kubernetes version - outcomes: - - pass: - when: '>= 1.19.0' - message: Kubernetes version is supported diff --git a/examples/test-error-messages/support-bundle-valid-v1beta3.yaml b/examples/test-error-messages/support-bundle-valid-v1beta3.yaml deleted file mode 100644 index 9cccf5ab..00000000 --- a/examples/test-error-messages/support-bundle-valid-v1beta3.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: troubleshoot.sh/v1beta3 -kind: SupportBundle -metadata: - name: valid-support-bundle -spec: - collectors: - - clusterInfo: {} - - clusterResources: {} - analyzers: - - clusterVersion: - checkName: Kubernetes version - outcomes: - - pass: - when: '>= 1.19.0' - message: Kubernetes version is supported diff --git a/examples/test-error-messages/valid-v1beta3.yaml b/examples/test-error-messages/valid-v1beta3.yaml deleted file mode 100644 index 5eda464c..00000000 --- a/examples/test-error-messages/valid-v1beta3.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: troubleshoot.sh/v1beta3 -kind: Preflight -metadata: - name: valid-preflight -spec: - analyzers: - - docString: | - Title: Test Analyzer - Requirement: Test requirement - clusterVersion: - checkName: Kubernetes version - outcomes: - - pass: - when: '>= 1.19.0' - message: Kubernetes version is supported diff --git a/examples/test-error-messages/wrong-apiversion-v1beta3.yaml b/examples/test-error-messages/wrong-apiversion-v1beta3.yaml deleted file mode 100644 index ffc86cbd..00000000 --- a/examples/test-error-messages/wrong-apiversion-v1beta3.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: troubleshoot.sh/v1beta2 -kind: Preflight -metadata: - name: wrong-version -spec: - analyzers: - - clusterVersion: - checkName: Kubernetes version - outcomes: - - pass: - when: '>= 1.19.0' - message: Kubernetes version is supported diff --git a/pkg/lint/lint.go b/pkg/lint/lint.go deleted file mode 100644 index 1ed2930c..00000000 --- a/pkg/lint/lint.go +++ /dev/null @@ -1,768 +0,0 @@ -package lint - -import ( - "fmt" - "regexp" - "strings" - - "os" - - "github.com/pkg/errors" - "github.com/replicatedhq/troubleshoot/pkg/constants" - "sigs.k8s.io/yaml" -) - -type LintResult struct { - FilePath string - Errors []LintError - Warnings []LintWarning -} - -type LintError struct { - Line int - Column int - Message string - Field string -} - -type LintWarning struct { - Line int - Column int - Message string - Field string -} - -type LintOptions struct { - FilePaths []string - Fix bool - Format string // "text" or "json" -} - -// LintFiles validates v1beta3 troubleshoot specs for syntax and structural errors -func LintFiles(opts LintOptions) ([]LintResult, error) { - results := []LintResult{} - - for _, filePath := range opts.FilePaths { - result, err := lintFile(filePath, opts.Fix) - if err != nil { - return nil, err - } - results = append(results, result) - } - - return results, nil -} - -func lintFile(filePath string, fix bool) (LintResult, error) { - result := LintResult{ - FilePath: filePath, - Errors: []LintError{}, - Warnings: []LintWarning{}, - } - - // Read file - content, err := os.ReadFile(filePath) - if err != nil { - return result, errors.Wrapf(err, "failed to read file %s", filePath) - } - - // Check if file contains template expressions - hasTemplates := strings.Contains(string(content), "{{") && strings.Contains(string(content), "}}") - - // Validate YAML syntax (but be lenient with templated files) - var parsed map[string]interface{} - yamlParseErr := yaml.Unmarshal(content, &parsed) - if yamlParseErr != nil { - // If the file has templates, YAML parsing may fail - that's expected - // We'll still try to validate what we can - if !hasTemplates { - result.Errors = append(result.Errors, LintError{ - Line: extractLineFromError(yamlParseErr), - Message: fmt.Sprintf("YAML syntax error: %s", yamlParseErr.Error()), - }) - // Don't return yet - we want to try to fix this error - // Continue to applyFixes at the end - // Try to surface apiVersion issues even if YAML failed to parse - // Detect via simple textual scan - avLine, avValue := findAPIVersionLineAndValue(string(content)) - if avLine == 0 { - result.Errors = append(result.Errors, LintError{ - Line: 0, - Field: "apiVersion", - Message: "Missing or empty 'apiVersion' field", - }) - } else if avValue != constants.Troubleshootv1beta2Kind && avValue != constants.Troubleshootv1beta3Kind { - result.Errors = append(result.Errors, LintError{ - Line: avLine, - Field: "apiVersion", - Message: fmt.Sprintf("Invalid 'apiVersion' value %q; expected %s or %s", avValue, constants.Troubleshootv1beta2Kind, constants.Troubleshootv1beta3Kind), - }) - } - if fix { - fixed, err := applyFixes(filePath, string(content), result) - if err != nil { - return result, err - } - if fixed { - // Re-lint to verify fixes - return lintFile(filePath, false) - } - } - return result, nil - } - // For templated files, we can't parse YAML strictly, so just check template syntax - result.Errors = append(result.Errors, checkTemplateSyntax(string(content))...) - // Surface apiVersion issues via textual scan for templated files - avLine, avValue := findAPIVersionLineAndValue(string(content)) - if avLine == 0 { - result.Errors = append(result.Errors, LintError{ - Line: 0, - Field: "apiVersion", - Message: "Missing or empty 'apiVersion' field", - }) - } else if avValue != constants.Troubleshootv1beta2Kind && avValue != constants.Troubleshootv1beta3Kind { - result.Errors = append(result.Errors, LintError{ - Line: avLine, - Field: "apiVersion", - Message: fmt.Sprintf("Invalid 'apiVersion' value %q; expected %s or %s", avValue, constants.Troubleshootv1beta2Kind, constants.Troubleshootv1beta3Kind), - }) - } - // Continue to applyFixes for templates too - if fix { - fixed, err := applyFixes(filePath, string(content), result) - if err != nil { - return result, err - } - if fixed { - // Re-lint to verify fixes - return lintFile(filePath, false) - } - } - return result, nil - } - - // Check required fields - result.Errors = append(result.Errors, checkRequiredFields(parsed, string(content))...) - - // Check template syntax - result.Errors = append(result.Errors, checkTemplateSyntax(string(content))...) - - // Check for kind-specific requirements - if kind, ok := parsed["kind"].(string); ok { - switch kind { - case "Preflight": - result.Errors = append(result.Errors, checkPreflightSpec(parsed, string(content))...) - case "SupportBundle": - result.Errors = append(result.Errors, checkSupportBundleSpec(parsed, string(content))...) - } - } - - // Validate apiVersion value if present - if apiVersion, ok := parsed["apiVersion"].(string); ok && apiVersion != "" { - if apiVersion != constants.Troubleshootv1beta2Kind && apiVersion != constants.Troubleshootv1beta3Kind { - result.Errors = append(result.Errors, LintError{ - Line: findLineNumber(string(content), "apiVersion"), - Field: "apiVersion", - Message: fmt.Sprintf("Invalid 'apiVersion' value %q; expected %s or %s", apiVersion, constants.Troubleshootv1beta2Kind, constants.Troubleshootv1beta3Kind), - }) - } - } - - // Check for common issues - result.Warnings = append(result.Warnings, checkCommonIssues(parsed, string(content))...) - - // Apply fixes if requested - if fix && (len(result.Errors) > 0 || len(result.Warnings) > 0) { - fixed, err := applyFixes(filePath, string(content), result) - if err != nil { - return result, err - } - if fixed { - // Re-lint to verify fixes - return lintFile(filePath, false) - } - } - - return result, nil -} - -func checkRequiredFields(parsed map[string]interface{}, content string) []LintError { - errors := []LintError{} - - // Check apiVersion - if apiVersion, ok := parsed["apiVersion"].(string); !ok || apiVersion == "" { - errors = append(errors, LintError{ - Line: findLineNumber(content, "apiVersion"), - Field: "apiVersion", - Message: "Missing or empty 'apiVersion' field", - }) - } - - // Check kind - if kind, ok := parsed["kind"].(string); !ok || kind == "" { - errors = append(errors, LintError{ - Line: findLineNumber(content, "kind"), - Field: "kind", - Message: "Missing or empty 'kind' field", - }) - } else if kind != "Preflight" && kind != "SupportBundle" { - errors = append(errors, LintError{ - Line: findLineNumber(content, "kind"), - Field: "kind", - Message: fmt.Sprintf("Invalid kind '%s'. Must be 'Preflight' or 'SupportBundle'", kind), - }) - } - - // Check metadata - if _, ok := parsed["metadata"]; !ok { - errors = append(errors, LintError{ - Line: findLineNumber(content, "metadata"), - Field: "metadata", - Message: "Missing 'metadata' section", - }) - } else if metadata, ok := parsed["metadata"].(map[string]interface{}); ok { - if name, ok := metadata["name"].(string); !ok || name == "" { - errors = append(errors, LintError{ - Line: findLineNumber(content, "name"), - Field: "metadata.name", - Message: "Missing or empty 'metadata.name' field", - }) - } - } - - // Check spec - if _, ok := parsed["spec"]; !ok { - errors = append(errors, LintError{ - Line: findLineNumber(content, "spec"), - Field: "spec", - Message: "Missing 'spec' section", - }) - } - - return errors -} - -func checkTemplateSyntax(content string) []LintError { - errors := []LintError{} - lines := strings.Split(content, "\n") - - // Check for unmatched braces - for i, line := range lines { - // Count opening and closing braces - opening := strings.Count(line, "{{") - closing := strings.Count(line, "}}") - - if opening != closing { - errors = append(errors, LintError{ - Line: i + 1, - Message: fmt.Sprintf("Unmatched template braces: %d opening, %d closing", opening, closing), - }) - } - - // Check for common template syntax issues - // Look for templates that might be missing the leading dot - if strings.Contains(line, "{{") && strings.Contains(line, "}}") { - // Extract template expressions - templateExpr := extractTemplateBetweenBraces(line) - for _, expr := range templateExpr { - trimmed := strings.TrimSpace(expr) - - // Skip empty expressions - if trimmed == "" { - continue - } - - // Skip control structures (if, else, end, range, with, etc.) - if isControlStructure(trimmed) { - continue - } - - // Skip comments: {{/* ... */}} - if strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*/") { - continue - } - - // Skip template variables (start with $) - if strings.HasPrefix(trimmed, "$") { - continue - } - - // Skip expressions that start with a dot (valid references) - if strings.HasPrefix(trimmed, ".") { - continue - } - - // Skip string literals - if strings.HasPrefix(trimmed, "\"") || strings.HasPrefix(trimmed, "'") { - continue - } - - // Skip numeric literals - if regexp.MustCompile(`^[0-9]+$`).MatchString(trimmed) { - continue - } - - // Skip function calls (contain parentheses or pipes) - if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "|") { - continue - } - - // Skip known Helm functions/keywords - helmFunctions := []string{"toYaml", "toJson", "include", "required", "default", "quote", "nindent", "indent", "upper", "lower", "trim"} - isFunction := false - for _, fn := range helmFunctions { - if strings.HasPrefix(trimmed, fn+" ") || trimmed == fn { - isFunction = true - break - } - } - if isFunction { - continue - } - - // If we got here, it might be missing a leading dot - errors = append(errors, LintError{ - Line: i + 1, - Message: fmt.Sprintf("Template expression may be missing leading dot: {{ %s }}", expr), - }) - } - } - } - - return errors -} - -func checkPreflightSpec(parsed map[string]interface{}, content string) []LintError { - errors := []LintError{} - - spec, ok := parsed["spec"].(map[string]interface{}) - if !ok { - return errors - } - - // Check for analyzers - analyzers, hasAnalyzers := spec["analyzers"] - if !hasAnalyzers { - errors = append(errors, LintError{ - Line: findLineNumber(content, "spec:"), - Field: "spec.analyzers", - Message: "Preflight spec must contain 'analyzers'", - }) - } else if analyzersList, ok := analyzers.([]interface{}); ok { - if len(analyzersList) == 0 { - errors = append(errors, LintError{ - Line: findLineNumber(content, "analyzers"), - Field: "spec.analyzers", - Message: "Preflight spec must have at least one analyzer", - }) - } - } - - return errors -} - -func checkSupportBundleSpec(parsed map[string]interface{}, content string) []LintError { - errors := []LintError{} - - spec, ok := parsed["spec"].(map[string]interface{}) - if !ok { - return errors - } - - // Check for collectors - collectors, hasCollectors := spec["collectors"] - _, hasHostCollectors := spec["hostCollectors"] - - if !hasCollectors && !hasHostCollectors { - errors = append(errors, LintError{ - Line: findLineNumber(content, "spec:"), - Field: "spec.collectors", - Message: "SupportBundle spec must contain 'collectors' or 'hostCollectors'", - }) - } else { - // Check if collectors list is empty - if hasCollectors { - if collectorsList, ok := collectors.([]interface{}); ok && len(collectorsList) == 0 { - errors = append(errors, LintError{ - Line: findLineNumber(content, "collectors"), - Field: "spec.collectors", - Message: "Collectors list is empty", - }) - } - } - } - - return errors -} - -func checkCommonIssues(parsed map[string]interface{}, content string) []LintWarning { - warnings := []LintWarning{} - - // Check for missing docStrings in analyzers - spec, ok := parsed["spec"].(map[string]interface{}) - if !ok { - return warnings - } - - if analyzers, ok := spec["analyzers"].([]interface{}); ok { - for i, analyzer := range analyzers { - if analyzerMap, ok := analyzer.(map[string]interface{}); ok { - if _, hasDocString := analyzerMap["docString"]; !hasDocString { - warnings = append(warnings, LintWarning{ - Line: findAnalyzerLine(content, i), - Field: fmt.Sprintf("spec.analyzers[%d].docString", i), - Message: "Analyzer missing docString (recommended for v1beta3)", - }) - } - } - } - } - - return warnings -} - -func applyFixes(filePath, content string, result LintResult) (bool, error) { - fixed := false - newContent := content - lines := strings.Split(newContent, "\n") - - // Determine desired apiVersion for fixes - hasTemplates := strings.Contains(content, "{{") && strings.Contains(content, "}}") - hasDocStrings := strings.Contains(content, "docString:") - desiredAPIVersion := constants.Troubleshootv1beta2Kind - if hasTemplates || hasDocStrings { - desiredAPIVersion = constants.Troubleshootv1beta3Kind - } - - // Sort errors by line number (descending) to avoid line number shifts when editing - errorsByLine := make(map[int][]LintError) - for _, err := range result.Errors { - if err.Line > 0 { - errorsByLine[err.Line] = append(errorsByLine[err.Line], err) - } - } - - // Process errors line by line - for lineNum, errs := range errorsByLine { - if lineNum > len(lines) { - continue - } - - line := lines[lineNum-1] - originalLine := line - - for _, err := range errs { - // Fix 1: Add missing colon - // YAML parsers often report the error on the line AFTER the actual problem - if strings.Contains(err.Message, "could not find expected ':'") { - // Check current line first - if !strings.Contains(line, ":") { - trimmed := strings.TrimSpace(line) - indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] - line = indent + trimmed + ":" - fixed = true - } else if lineNum > 1 { - // Check previous line (where the colon is likely missing) - prevLine := lines[lineNum-2] - if !strings.Contains(prevLine, ":") && strings.TrimSpace(prevLine) != "" { - trimmed := strings.TrimSpace(prevLine) - indent := prevLine[:len(prevLine)-len(strings.TrimLeft(prevLine, " \t"))] - lines[lineNum-2] = indent + trimmed + ":" - fixed = true - } - } - } - - // Fix 2: Add missing leading dot in template expressions - if strings.Contains(err.Message, "Template expression may be missing leading dot:") { - // Extract the expression from the error message - re := regexp.MustCompile(`Template expression may be missing leading dot: \{\{ (.+?) \}\}`) - matches := re.FindStringSubmatch(err.Message) - if len(matches) > 1 { - badExpr := matches[1] - // Add the leading dot - fixedExpr := "." + badExpr - // Replace in the line - line = strings.Replace(line, "{{ "+badExpr+" }}", "{{ "+fixedExpr+" }}", 1) - line = strings.Replace(line, "{{"+badExpr+"}}", "{{"+fixedExpr+"}}", 1) - line = strings.Replace(line, "{{- "+badExpr+" }}", "{{- "+fixedExpr+" }}", 1) - line = strings.Replace(line, "{{- "+badExpr+" -}}", "{{- "+fixedExpr+" -}}", 1) - fixed = true - } - } - - // Fix 3: Replace invalid apiVersion value with desiredAPIVersion - if strings.Contains(err.Message, "Invalid 'apiVersion' value") && err.Field == "apiVersion" { - if strings.Contains(line, "apiVersion:") { - indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] - line = indent + "apiVersion: " + desiredAPIVersion - fixed = true - } - } - } - - // Update the line if it changed - if line != originalLine { - lines[lineNum-1] = line - } - } - - // Fix 4: Add missing required top-level fields - for _, err := range result.Errors { - if err.Field == "apiVersion" && strings.Contains(err.Message, "Missing or empty 'apiVersion'") { - // Replace existing empty apiVersion line if present; otherwise prepend - if avLine, avVal := findAPIVersionLineAndValue(newContent); avLine > 0 && strings.TrimSpace(avVal) == "" { - line := lines[avLine-1] - indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] - lines[avLine-1] = indent + "apiVersion: " + desiredAPIVersion - } else { - lines = append([]string{"apiVersion: " + desiredAPIVersion}, lines...) - } - fixed = true - } else if err.Field == "kind" && strings.Contains(err.Message, "Missing or empty 'kind'") { - // Try to determine if it should be Preflight or SupportBundle based on filename - kind := "Preflight" - if strings.Contains(strings.ToLower(filePath), "bundle") { - kind = "SupportBundle" - } - // Add kind after apiVersion - insertIndex := 0 - for i, line := range lines { - if strings.Contains(line, "apiVersion:") { - insertIndex = i + 1 - break - } - } - newLines := make([]string, 0, len(lines)+1) - newLines = append(newLines, lines[:insertIndex]...) - newLines = append(newLines, "kind: "+kind) - newLines = append(newLines, lines[insertIndex:]...) - lines = newLines - fixed = true - } else if err.Field == "metadata" && strings.Contains(err.Message, "Missing 'metadata'") { - // Add metadata section after kind - insertIndex := 0 - for i, line := range lines { - if strings.Contains(line, "kind:") { - insertIndex = i + 1 - break - } - } - newLines := make([]string, 0, len(lines)+2) - newLines = append(newLines, lines[:insertIndex]...) - newLines = append(newLines, "metadata:") - newLines = append(newLines, " name: my-spec") - newLines = append(newLines, lines[insertIndex:]...) - lines = newLines - fixed = true - } - } - - // Write fixed content back to file if changes were made - if fixed { - newContent = strings.Join(lines, "\n") - if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil { - return false, errors.Wrapf(err, "failed to write fixed content to %s", filePath) - } - } - - return fixed, nil -} - -func findLineNumber(content, search string) int { - lines := strings.Split(content, "\n") - for i, line := range lines { - if strings.Contains(line, search) { - return i + 1 - } - } - return 0 -} - -// findAPIVersionLineAndValue locates the first line that declares apiVersion and returns its -// 1-based line number and the trimmed value to the right of the colon. Returns (0, "") if not found. -func findAPIVersionLineAndValue(content string) (int, string) { - lines := strings.Split(content, "\n") - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "apiVersion:") { - // extract value after the first colon - parts := strings.SplitN(trimmed, ":", 2) - if len(parts) == 2 { - value := strings.TrimSpace(parts[1]) - return i + 1, value - } - return i + 1, "" - } - } - return 0, "" -} - -func findAnalyzerLine(content string, index int) int { - lines := strings.Split(content, "\n") - analyzerCount := 0 - inAnalyzers := false - - for i, line := range lines { - if strings.Contains(line, "analyzers:") { - inAnalyzers = true - continue - } - if inAnalyzers && strings.HasPrefix(strings.TrimSpace(line), "- ") { - if analyzerCount == index { - return i + 1 - } - analyzerCount++ - } - } - return 0 -} - -func extractLineFromError(err error) int { - // Try to extract line number from YAML error message - re := regexp.MustCompile(`line (\d+)`) - matches := re.FindStringSubmatch(err.Error()) - if len(matches) > 1 { - var line int - fmt.Sscanf(matches[1], "%d", &line) - return line - } - return 0 -} - -// extractTemplateBetweenBraces extracts template expressions from a line -func extractTemplateBetweenBraces(line string) []string { - var expressions []string - // Match {{ ... }} with optional whitespace trimming (-), including comments {{/* */}} - re := regexp.MustCompile(`\{\{-?\s*(.+?)\s*-?\}\}`) - matches := re.FindAllStringSubmatch(line, -1) - for _, match := range matches { - if len(match) > 1 { - // Clean up the expression - expr := match[1] - // Remove */ at the end if it's part of a comment - expr = strings.TrimSuffix(strings.TrimSpace(expr), "*/") - expressions = append(expressions, expr) - } - } - return expressions -} - -// isControlStructure checks if a template expression is a control structure -func isControlStructure(expr string) bool { - trimmed := strings.TrimSpace(expr) - controlKeywords := []string{"if", "else", "end", "range", "with", "define", "template", "block", "include"} - for _, keyword := range controlKeywords { - if strings.HasPrefix(trimmed, keyword+" ") || trimmed == keyword { - return true - } - } - return false -} - -// FormatResults formats lint results for output -func FormatResults(results []LintResult, format string) string { - if format == "json" { - return formatJSON(results) - } - return formatText(results) -} - -func formatText(results []LintResult) string { - var output strings.Builder - totalErrors := 0 - totalWarnings := 0 - - for _, result := range results { - if len(result.Errors) == 0 && len(result.Warnings) == 0 { - output.WriteString(fmt.Sprintf("✓ %s: No issues found\n", result.FilePath)) - continue - } - - output.WriteString(fmt.Sprintf("\n%s:\n", result.FilePath)) - - for _, err := range result.Errors { - output.WriteString(fmt.Sprintf(" ✗ Error (line %d): %s\n", err.Line, err.Message)) - if err.Field != "" { - output.WriteString(fmt.Sprintf(" Field: %s\n", err.Field)) - } - totalErrors++ - } - - for _, warn := range result.Warnings { - output.WriteString(fmt.Sprintf(" ⚠ Warning (line %d): %s\n", warn.Line, warn.Message)) - if warn.Field != "" { - output.WriteString(fmt.Sprintf(" Field: %s\n", warn.Field)) - } - totalWarnings++ - } - } - - output.WriteString(fmt.Sprintf("\nSummary: %d error(s), %d warning(s) across %d file(s)\n", totalErrors, totalWarnings, len(results))) - - return output.String() -} - -func formatJSON(results []LintResult) string { - // Simple JSON formatting without importing encoding/json - var output strings.Builder - output.WriteString("{\n") - output.WriteString(" \"results\": [\n") - - for i, result := range results { - output.WriteString(" {\n") - output.WriteString(fmt.Sprintf(" \"filePath\": %q,\n", result.FilePath)) - output.WriteString(" \"errors\": [\n") - - for j, err := range result.Errors { - output.WriteString(" {\n") - output.WriteString(fmt.Sprintf(" \"line\": %d,\n", err.Line)) - output.WriteString(fmt.Sprintf(" \"column\": %d,\n", err.Column)) - output.WriteString(fmt.Sprintf(" \"message\": %q,\n", err.Message)) - output.WriteString(fmt.Sprintf(" \"field\": %q\n", err.Field)) - output.WriteString(" }") - if j < len(result.Errors)-1 { - output.WriteString(",") - } - output.WriteString("\n") - } - - output.WriteString(" ],\n") - output.WriteString(" \"warnings\": [\n") - - for j, warn := range result.Warnings { - output.WriteString(" {\n") - output.WriteString(fmt.Sprintf(" \"line\": %d,\n", warn.Line)) - output.WriteString(fmt.Sprintf(" \"column\": %d,\n", warn.Column)) - output.WriteString(fmt.Sprintf(" \"message\": %q,\n", warn.Message)) - output.WriteString(fmt.Sprintf(" \"field\": %q\n", warn.Field)) - output.WriteString(" }") - if j < len(result.Warnings)-1 { - output.WriteString(",") - } - output.WriteString("\n") - } - - output.WriteString(" ]\n") - output.WriteString(" }") - if i < len(results)-1 { - output.WriteString(",") - } - output.WriteString("\n") - } - - output.WriteString(" ]\n") - output.WriteString("}\n") - - return output.String() -} - -// HasErrors returns true if any of the results contain errors -func HasErrors(results []LintResult) bool { - for _, result := range results { - if len(result.Errors) > 0 { - return true - } - } - return false -}