Files
glint/cmd/glint/format.go
T
k3nny f5f8546bcf
ci / vet, staticcheck, test, build (push) Successful in 2m16s
release / Build and publish release (push) Successful in 1m9s
feat(cli): output formats, GL034-GL041 lint rules, include inputs and cache
Bundles three patch releases (v0.2.16–v0.2.18):

v0.2.18 — output formats (--format flag on glint check):
- json: stable JSON report (schema_version: 1, findings array, summary)
- sarif: SARIF 2.1.0 for GitHub Code Scanning / GitLab SAST
- junit: JUnit XML for CI test-report artifacts (artifacts:reports:junit)
- github: GitHub Actions ::error:: / ::warning:: annotation lines
- Unknown --format value exits 2 with a helpful error message
- Summary line routed to stderr in structured formats; context suppressed

v0.2.17 — include resolution improvements:
- Recursive include depth capped at 100 (matches GitLab's own limit)
- project: and component: includes tracked in visited set (cycle detection)
- $[[ inputs.KEY ]] / $[[ inputs.KEY | default(…) ]] substituted from with:
- --cache-dir: persist fetched remote templates to disk (SHA-256 keyed)
- --offline: serve from cache only; defaults to ~/.cache/glint

v0.2.16 — new lint rules (GL034–GL041):
- GL034: services map form requires name; alias must be valid DNS label
- GL035: rules:changes / rules:exists absolute path detection
- GL036: timeout format validation (job-level + default.timeout)
- GL037: id_tokens entries must have an aud key
- GL038: secrets entries must declare a provider (vault / gcp / azure)
- GL039: pages: keyword + artifacts.paths consistency
- GL040: duplicate stage names in stages: list
- GL041: cache.key.files must be exact paths, not globs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 10:09:16 +02:00

333 lines
8.0 KiB
Go

package main
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
// --- JSON ---
type jsonReport struct {
SchemaVersion int `json:"schema_version"`
GlintVersion string `json:"glint_version"`
Pipeline string `json:"pipeline"`
Findings []jsonFinding `json:"findings"`
Summary jsonSummary `json:"summary"`
}
type jsonFinding struct {
Rule string `json:"rule,omitempty"`
Severity string `json:"severity"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Job string `json:"job,omitempty"`
Message string `json:"message"`
}
type jsonSummary struct {
Total int `json:"total"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
}
func writeJSON(w io.Writer, findings []linter.Finding, pipeline string) {
errs, warns := countSeverities(findings)
jf := make([]jsonFinding, 0, len(findings))
for _, f := range findings {
jf = append(jf, jsonFinding{
Rule: f.Rule,
Severity: strings.ToLower(string(f.Severity)),
File: f.File,
Line: f.Line,
Job: f.Job,
Message: f.Message,
})
}
report := jsonReport{
SchemaVersion: 1,
GlintVersion: version,
Pipeline: pipeline,
Findings: jf,
Summary: jsonSummary{Total: len(findings), Errors: errs, Warnings: warns},
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(report)
}
// --- SARIF 2.1.0 ---
type sarifLog struct {
Schema string `json:"$schema"`
Version string `json:"version"`
Runs []sarifRun `json:"runs"`
}
type sarifRun struct {
Tool sarifTool `json:"tool"`
Results []sarifResult `json:"results"`
}
type sarifTool struct {
Driver sarifDriver `json:"driver"`
}
type sarifDriver struct {
Name string `json:"name"`
Version string `json:"version"`
InformationURI string `json:"informationUri"`
Rules []sarifRule `json:"rules"`
}
type sarifRule struct {
ID string `json:"id"`
ShortDescription sarifMessage `json:"shortDescription"`
HelpURI string `json:"helpUri,omitempty"`
}
type sarifMessage struct {
Text string `json:"text"`
}
type sarifResult struct {
RuleID string `json:"ruleId,omitempty"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Locations []sarifLocation `json:"locations,omitempty"`
}
type sarifLocation struct {
PhysicalLocation sarifPhysLoc `json:"physicalLocation"`
}
type sarifPhysLoc struct {
ArtifactLocation sarifArtifact `json:"artifactLocation"`
Region *sarifRegion `json:"region,omitempty"`
}
type sarifArtifact struct {
URI string `json:"uri"`
URIBaseID string `json:"uriBaseId,omitempty"`
}
type sarifRegion struct {
StartLine int `json:"startLine"`
}
func writeSARIF(w io.Writer, findings []linter.Finding, _ string) {
// Collect unique rule IDs (preserving first-seen order).
seenRules := map[string]bool{}
var rules []sarifRule
for _, f := range findings {
if f.Rule != "" && !seenRules[f.Rule] {
seenRules[f.Rule] = true
rules = append(rules, sarifRule{
ID: f.Rule,
ShortDescription: sarifMessage{Text: "glint rule " + f.Rule},
HelpURI: "https://git.k3nny.fr/glint",
})
}
}
if rules == nil {
rules = []sarifRule{}
}
var results []sarifResult
for _, f := range findings {
level := "warning"
if f.Severity == linter.Error {
level = "error"
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
result := sarifResult{
RuleID: f.Rule,
Level: level,
Message: sarifMessage{Text: msg},
}
if f.File != "" {
loc := sarifLocation{
PhysicalLocation: sarifPhysLoc{
ArtifactLocation: sarifArtifact{URI: f.File, URIBaseID: "%SRCROOT%"},
},
}
if f.Line > 0 {
loc.PhysicalLocation.Region = &sarifRegion{StartLine: f.Line}
}
result.Locations = []sarifLocation{loc}
}
results = append(results, result)
}
if results == nil {
results = []sarifResult{}
}
log := sarifLog{
Schema: "https://json.schemastore.org/sarif-2.1.0.json",
Version: "2.1.0",
Runs: []sarifRun{{
Tool: sarifTool{Driver: sarifDriver{
Name: "glint",
Version: strings.TrimPrefix(version, "v"),
InformationURI: "https://git.k3nny.fr/glint",
Rules: rules,
}},
Results: results,
}},
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(log)
}
// --- JUnit XML ---
type junitTestsuites struct {
XMLName xml.Name `xml:"testsuites"`
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Time string `xml:"time,attr"`
Suites []junitTestsuite `xml:"testsuite"`
}
type junitTestsuite struct {
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Time string `xml:"time,attr"`
Cases []junitTestcase `xml:"testcase"`
}
type junitTestcase struct {
Name string `xml:"name,attr"`
Classname string `xml:"classname,attr"`
Failure *junitFailure `xml:"failure,omitempty"`
}
type junitFailure struct {
Message string `xml:"message,attr"`
Type string `xml:"type,attr"`
Body string `xml:",chardata"`
}
func writeJUnit(w io.Writer, findings []linter.Finding, pipeline string) {
_, fails := countSeverities(findings)
_ = fails // re-derive below to count both errors and warnings as failures
var cases []junitTestcase
if len(findings) == 0 {
cases = []junitTestcase{{Name: "no issues found", Classname: pipeline}}
} else {
for _, f := range findings {
name := f.Rule
if name == "" {
name = "lint"
}
classname := f.File
if classname == "" {
classname = pipeline
}
if f.Job != "" {
classname += "#" + f.Job
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
cases = append(cases, junitTestcase{
Name: name,
Classname: classname,
Failure: &junitFailure{
Message: msg,
Type: strings.ToLower(string(f.Severity)),
Body: f.String(),
},
})
}
}
suite := junitTestsuite{
Name: pipeline,
Tests: len(cases),
Failures: len(findings), // all findings are failures
Time: "0",
Cases: cases,
}
suites := junitTestsuites{
Name: "glint",
Tests: len(cases),
Failures: len(findings),
Time: "0",
Suites: []junitTestsuite{suite},
}
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>`)
fmt.Fprintln(w)
enc := xml.NewEncoder(w)
enc.Indent("", " ")
_ = enc.Encode(suites)
_ = enc.Flush()
fmt.Fprintln(w)
}
// --- GitHub Actions annotations ---
// writeGitHub emits GitHub Actions workflow command annotation lines.
// Each finding becomes an ::error:: or ::warning:: line that GitHub CI
// renders as an inline comment on the relevant file in pull requests.
func writeGitHub(w io.Writer, findings []linter.Finding) {
for _, f := range findings {
level := "warning"
if f.Severity == linter.Error {
level = "error"
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
// GitHub annotation messages must not contain raw newlines, percent signs,
// carriage returns, or colons in the parameter block.
msg = strings.ReplaceAll(msg, "%", "%25")
msg = strings.ReplaceAll(msg, "\r", "%0D")
msg = strings.ReplaceAll(msg, "\n", "%0A")
var params []string
if f.File != "" {
params = append(params, "file="+f.File)
if f.Line > 0 {
params = append(params, fmt.Sprintf("line=%d", f.Line))
}
}
if f.Rule != "" {
params = append(params, "title="+f.Rule)
}
paramStr := ""
if len(params) > 0 {
paramStr = " " + strings.Join(params, ",")
}
fmt.Fprintf(w, "::%s%s::%s\n", level, paramStr, msg)
}
}
// --- shared helpers ---
func countSeverities(findings []linter.Finding) (errors, warnings int) {
for _, f := range findings {
if f.Severity == linter.Error {
errors++
} else {
warnings++
}
}
return
}