feat(cli): colorized, columnized text output with line:col locations
Replace the per-line fmt.Println loop with writeTextFindings() in cmd/glint/output.go. The new renderer: - Aligns all findings into four space-separated columns: location, rule ID, severity, message — widths computed from the full finding set so all lines are flush - Colors severity words when stdout is a TTY and NO_COLOR is not set: red+bold for "error", orange+bold for "warning"; location dimmed; rule ID bold - Formats location as file:line:col when column is known, file:line otherwise, falling back to just file To populate column numbers, add Column int to model.Job (set from keyNode.Column in the YAML parser) and Finding.Column (set during the checkJob source-location attachment pass and in the ten cross-job check sites that explicitly set Line: job.Line). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-3
@@ -420,9 +420,7 @@ Examples:
|
|||||||
case "github":
|
case "github":
|
||||||
writeGitHub(os.Stdout, findings)
|
writeGitHub(os.Stdout, findings)
|
||||||
default: // "text"
|
default: // "text"
|
||||||
for _, f := range findings {
|
writeTextFindings(os.Stdout, findings)
|
||||||
fmt.Println(f)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(findings) == 0 {
|
if len(findings) == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.k3nny.fr/glint/internal/linter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ANSI escape sequences used for colorized output.
|
||||||
|
const (
|
||||||
|
ansiReset = "\033[0m"
|
||||||
|
ansiBold = "\033[1m"
|
||||||
|
ansiDim = "\033[2m"
|
||||||
|
ansiRed = "\033[31m"
|
||||||
|
ansiOrange = "\033[33m" // rendered as orange/amber in most terminals
|
||||||
|
)
|
||||||
|
|
||||||
|
// colorEnabled reports whether ANSI color should be used when writing to w.
|
||||||
|
// Colors are suppressed when NO_COLOR is set or when w is not a terminal.
|
||||||
|
func colorEnabled(w io.Writer) bool {
|
||||||
|
if os.Getenv("NO_COLOR") != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f, ok := w.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fi, err := f.Stat()
|
||||||
|
return err == nil && (fi.Mode()&os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTextFindings prints findings in a columnized format with optional
|
||||||
|
// ANSI color. All findings are scanned first to compute column widths so
|
||||||
|
// that each field aligns across all output lines.
|
||||||
|
//
|
||||||
|
// Output columns (space-separated, no borders):
|
||||||
|
//
|
||||||
|
// location RULE severity message
|
||||||
|
func writeTextFindings(w io.Writer, findings []linter.Finding) {
|
||||||
|
if len(findings) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
color := colorEnabled(w)
|
||||||
|
|
||||||
|
// Pre-compute locations and maximum location width.
|
||||||
|
locs := make([]string, len(findings))
|
||||||
|
maxLoc := 0
|
||||||
|
for i, f := range findings {
|
||||||
|
locs[i] = findingLocation(f)
|
||||||
|
if len(locs[i]) > maxLoc {
|
||||||
|
maxLoc = len(locs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule IDs are always 5 chars (GL001–GL999).
|
||||||
|
const ruleWidth = 5
|
||||||
|
// Severity width: "warning" = 7 chars.
|
||||||
|
const sevWidth = 7
|
||||||
|
|
||||||
|
for i, f := range findings {
|
||||||
|
loc := locs[i]
|
||||||
|
rule := f.Rule
|
||||||
|
sev := strings.ToLower(string(f.Severity))
|
||||||
|
|
||||||
|
msg := f.Message
|
||||||
|
if f.Job != "" {
|
||||||
|
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if color {
|
||||||
|
var sevSeq string
|
||||||
|
if f.Severity == linter.Error {
|
||||||
|
sevSeq = ansiRed + ansiBold
|
||||||
|
} else {
|
||||||
|
sevSeq = ansiOrange + ansiBold
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s%-*s%s %s%-*s%s %s%-*s%s %s\n",
|
||||||
|
ansiDim, maxLoc, loc, ansiReset,
|
||||||
|
ansiBold, ruleWidth, rule, ansiReset,
|
||||||
|
sevSeq, sevWidth, sev, ansiReset,
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "%-*s %-*s %-*s %s\n",
|
||||||
|
maxLoc, loc,
|
||||||
|
ruleWidth, rule,
|
||||||
|
sevWidth, sev,
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findingLocation formats the file:line:col location string for a finding.
|
||||||
|
func findingLocation(f linter.Finding) string {
|
||||||
|
if f.File == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case f.Line > 0 && f.Column > 0:
|
||||||
|
return fmt.Sprintf("%s:%d:%d", f.File, f.Line, f.Column)
|
||||||
|
case f.Line > 0:
|
||||||
|
return fmt.Sprintf("%s:%d", f.File, f.Line)
|
||||||
|
default:
|
||||||
|
return f.File
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding {
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: job.File,
|
File: job.File,
|
||||||
Line: job.Line,
|
Line: job.Line,
|
||||||
|
Column: job.Column,
|
||||||
Message: fmt.Sprintf("'dependencies' references unknown job %q", dep),
|
Message: fmt.Sprintf("'dependencies' references unknown job %q", dep),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
@@ -43,6 +44,7 @@ func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding {
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: job.File,
|
File: job.File,
|
||||||
Line: job.Line,
|
Line: job.Line,
|
||||||
|
Column: job.Column,
|
||||||
Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage),
|
Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ func evalRulesReachability(name string, job model.Job, jobVars map[string]string
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: job.File,
|
File: job.File,
|
||||||
Line: job.Line,
|
Line: job.Line,
|
||||||
|
Column: job.Column,
|
||||||
Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables",
|
Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job)
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: job.File,
|
File: job.File,
|
||||||
Line: job.Line,
|
Line: job.Line,
|
||||||
|
Column: job.Column,
|
||||||
Message: "'inherit: default:' is declared but the pipeline has no 'default:' block — declaration has no effect",
|
Message: "'inherit: default:' is declared but the pipeline has no 'default:' block — declaration has no effect",
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,7 @@ func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job)
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: job.File,
|
File: job.File,
|
||||||
Line: job.Line,
|
Line: job.Line,
|
||||||
|
Column: job.Column,
|
||||||
Message: fmt.Sprintf(
|
Message: fmt.Sprintf(
|
||||||
"'inherit: default: [%s]': %s not defined in the 'default:' block — %s",
|
"'inherit: default: [%s]': %s not defined in the 'default:' block — %s",
|
||||||
strings.Join(dead, ", "),
|
strings.Join(dead, ", "),
|
||||||
|
|||||||
@@ -22,15 +22,19 @@ type Finding struct {
|
|||||||
Job string // empty for pipeline-level findings
|
Job string // empty for pipeline-level findings
|
||||||
File string // source file where the finding originates
|
File string // source file where the finding originates
|
||||||
Line int // line number in File (0 = unknown)
|
Line int // line number in File (0 = unknown)
|
||||||
|
Column int // column number in File (0 = unknown; 1-indexed)
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f Finding) String() string {
|
func (f Finding) String() string {
|
||||||
var loc string
|
var loc string
|
||||||
if f.File != "" {
|
if f.File != "" {
|
||||||
if f.Line > 0 {
|
switch {
|
||||||
|
case f.Line > 0 && f.Column > 0:
|
||||||
|
loc = fmt.Sprintf("%s:%d:%d: ", f.File, f.Line, f.Column)
|
||||||
|
case f.Line > 0:
|
||||||
loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
|
loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
|
||||||
} else {
|
default:
|
||||||
loc = fmt.Sprintf("%s: ", f.File)
|
loc = fmt.Sprintf("%s: ", f.File)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +229,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
|||||||
if findings[i].Job != "" && findings[i].File == "" {
|
if findings[i].Job != "" && findings[i].File == "" {
|
||||||
findings[i].File = job.File
|
findings[i].File = job.File
|
||||||
findings[i].Line = job.Line
|
findings[i].Line = job.Line
|
||||||
|
findings[i].Column = job.Column
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return findings
|
return findings
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: job.File,
|
File: job.File,
|
||||||
Line: job.Line,
|
Line: job.Line,
|
||||||
|
Column: job.Column,
|
||||||
Message: fmt.Sprintf("needs unknown job %q", entry.job),
|
Message: fmt.Sprintf("needs unknown job %q", entry.job),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
@@ -71,6 +72,7 @@ func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: job.File,
|
File: job.File,
|
||||||
Line: job.Line,
|
Line: job.Line,
|
||||||
|
Column: job.Column,
|
||||||
Message: fmt.Sprintf(
|
Message: fmt.Sprintf(
|
||||||
"needs %q which is in a later stage (%q after %q)",
|
"needs %q which is in a later stage (%q after %q)",
|
||||||
entry.job, neededJob.Stage, job.Stage,
|
entry.job, neededJob.Stage, job.Stage,
|
||||||
@@ -111,6 +113,7 @@ func checkRulesNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: job.File,
|
File: job.File,
|
||||||
Line: job.Line,
|
Line: job.Line,
|
||||||
|
Column: job.Column,
|
||||||
Message: fmt.Sprintf(
|
Message: fmt.Sprintf(
|
||||||
"rules[%d].needs: references unknown job %q",
|
"rules[%d].needs: references unknown job %q",
|
||||||
i, entry.job,
|
i, entry.job,
|
||||||
@@ -171,6 +174,7 @@ func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []F
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: j.File,
|
File: j.File,
|
||||||
Line: j.Line,
|
Line: j.Line,
|
||||||
|
Column: j.Column,
|
||||||
Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name),
|
Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ func checkVariableRefs(p *model.Pipeline) []Finding {
|
|||||||
Job: name,
|
Job: name,
|
||||||
File: job.File,
|
File: job.File,
|
||||||
Line: job.Line,
|
Line: job.Line,
|
||||||
|
Column: job.Column,
|
||||||
Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName),
|
Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ func ParseBytes(data []byte) (*Pipeline, error) {
|
|||||||
return nil, fmt.Errorf("parsing job %q: %w", key, err)
|
return nil, fmt.Errorf("parsing job %q: %w", key, err)
|
||||||
}
|
}
|
||||||
j.Name = key
|
j.Name = key
|
||||||
j.Line = keyNode.Line // exact line of the job name key
|
j.Line = keyNode.Line // exact line of the job name key
|
||||||
|
j.Column = keyNode.Column // exact column of the job name key
|
||||||
p.Jobs[key] = j
|
p.Jobs[key] = j
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,10 @@ type Workflow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
Name string // set by parser, not from YAML
|
Name string // set by parser, not from YAML
|
||||||
File string // source file; set by Parse / resolver
|
File string // source file; set by Parse / resolver
|
||||||
Line int // line of the job key in its source file; set by parser
|
Line int // line of the job key in its source file; set by parser
|
||||||
|
Column int // column of the job key (1-indexed); set by parser
|
||||||
Stage string `yaml:"stage"`
|
Stage string `yaml:"stage"`
|
||||||
Script any `yaml:"script"` // []string or string (block scalar)
|
Script any `yaml:"script"` // []string or string (block scalar)
|
||||||
Run any `yaml:"run"` // alternative to script (CI steps)
|
Run any `yaml:"run"` // alternative to script (CI steps)
|
||||||
|
|||||||
Reference in New Issue
Block a user