— its own coverage

Statements 90.3%
Functions 96.7%
cmd/plumb argorder.go
93.7%
cmd/plumb check.go
96.3%
cmd/plumb checkjson.go
93.7%
cmd/plumb checkreport.go
100.0%
cmd/plumb diffcov.go
86.8%
cmd/plumb exitcode.go
100.0%
cmd/plumb main.go
97.4%
cmd/plumb module.go
90.0%
cmd/plumb report.go
75.6%
cmd/plumb run.go
91.4%
internal/gitdiff hunks.go
92.3%
internal/gitdiff runner.go
80.4%
internal/gittest gittest.go
100.0%
internal/profile annotate.go
96.7%
internal/profile funcs.go
93.1%
internal/profile profile.go
85.1%
internal/profile resolve.go
100.0%
internal/profile staleness.go
100.0%
internal/profile stats.go
100.0%
internal/report html.go
88.6%
internal/report report.go
100.0%

Select a file to view source

cmd/plumb/argorder.go
stmts 93.7% funcs 100.0%
Functions
NameLineCalls
parseFlags 23 ×1
reorderArgs 49 ×1
flagsGiven 95 ×1
1 package main
2
3 import (
4 "errors"
5 "flag"
6 "strings"
7 )
8
9 // parseFlags reorders args and parses them. The flag package writes
10 // its own message and the usage block to the set's output before it
11 // returns the error, so this wraps the error in a coded one: dispatch
12 // prints nothing for a coded error, and the message appears once
13 // instead of twice.
14 //
15 // The code is 2, the value the exit-code contract gives a usage
16 // error. This widens D-10, which gave a flag-parse error 1 and kept 2
17 // for a usage error dispatch raised itself. A pipeline reads 2 as
18 // "the command was called wrong" and 3 as "coverage fell", and the
19 // caller who types an unknown flag made the first kind of mistake.
20 //
21 // A help request keeps flag.ErrHelp, because dispatch answers that
22 // with exit code 0.
23 1 func parseFlags(fs *flag.FlagSet, args []string) error {
24 1 err := fs.Parse(reorderArgs(fs, args))
25 1 if err == nil || errors.Is(err, flag.ErrHelp) {
26 1 return err
27 1 }
28 1 return newExitError(2, err.Error())
29 }
30
31 // boolFlag is the interface the flag package's own boolean flags
32 // implement. reorderArgs uses it to tell a flag that takes a value
33 // from one that does not.
34 type boolFlag interface {
35 IsBoolFlag() bool
36 }
37
38 // reorderArgs moves every flag token, and the value it consumes,
39 // ahead of every positional argument. Go's flag.Parse stops reading
40 // flags at the first non-flag argument, so on its own it cannot
41 // parse "plumb check <profile> --min-statements N" or "plumb report
42 // <profile> --out file.html" — the forms the help text and the
43 // README document (D-23). Every command parses through this
44 // function, because a command that skips it drops a flag that comes
45 // after a positional argument and gives no warning.
46 //
47 // Everything after a bare "--" stays positional and is never scanned
48 // for flags, which matches the terminator flag.Parse itself honors.
49 1 func reorderArgs(fs *flag.FlagSet, args []string) []string {
50 1 var flags, positional []string
51 1 for i := 0; i < len(args); i++ {
52 1 a := args[i]
53 1 if a == "--" {
54 0 positional = append(positional, args[i+1:]...)
55 0 break
56 }
57 1 if !strings.HasPrefix(a, "-") || a == "-" {
58 1 positional = append(positional, a)
59 1 continue
60 }
61 1 flags = append(flags, a)
62 1 name := strings.TrimLeft(a, "-")
63 1 if eq := strings.IndexByte(name, '='); eq >= 0 {
64 1 continue // the value is attached; nothing more to consume
65 }
66 1 if name == "h" || name == "help" {
67 1 continue // the built-in help flag takes no value
68 }
69 1 fl := fs.Lookup(name)
70 1 if fl == nil {
71 1 // An unknown flag consumes nothing. Taking the next token
72 1 // could swallow a "--" terminator or a positional argument
73 1 // before flag.Parse ever reports the flag as unknown.
74 1 continue
75 }
76 1 if bf, ok := fl.Value.(boolFlag); ok && bf.IsBoolFlag() {
77 1 continue
78 }
79 1 if i+1 < len(args) {
80 1 flags = append(flags, args[i+1])
81 1 i++
82 1 }
83 }
84 1 return append(flags, positional...)
85 }
86
87 // flagsGiven returns the set of flag names the caller actually typed.
88 // A flag left at its default is absent from the map, so a command can
89 // tell "not given" from "given the zero value" — the technique D-33
90 // needs for the threshold guards and D-40 needs to know that
91 // --diff-base alone means diff mode. A sentinel default would break
92 // the moment a caller typed the sentinel; fs.Visit does not have that
93 // failure mode. Call it after parseFlags: fs.Visit reports nothing
94 // before the parse runs.
95 1 func flagsGiven(fs *flag.FlagSet) map[string]bool {
96 1 given := make(map[string]bool)
97 1 fs.Visit(func(f *flag.Flag) {
98 1 given[f.Name] = true
99 1 })
100 1 return given
101 }
cmd/plumb/check.go
stmts 96.3% funcs 100.0%
Functions
NameLineCalls
checkCmd 19 ×1
addCheckFlags 273 ×1
shortSHA 285 ×1
validThreshold 297 ×1
funcTotals 307 ×1
1 package main
2
3 import (
4 "flag"
5 "fmt"
6 "io"
7 "strings"
8
9 "github.com/z3le/plumb/internal/profile"
10 "github.com/z3le/plumb/internal/report"
11 )
12
13 // checkCmd reads a coverage profile and fails the build when
14 // statement coverage or function coverage falls below the minimum a
15 // flag sets. The statement path reads the profile and nothing else
16 // (D-18), so it runs against a downloaded profile artifact with no
17 // source tree present. The function path additionally reads the
18 // source tree, because WalkFuncs parses each source file.
19 1 func checkCmd(args []string, stdout, stderr io.Writer) error {
20 1 fs := flag.NewFlagSet("check", flag.ContinueOnError)
21 1 fs.SetOutput(stderr)
22 1 fs.Usage = func() {
23 1 fmt.Fprint(fs.Output(), `Usage: plumb check [flags] [profile]
24 1
25 1 Check coverage against a minimum threshold and fail the build when
26 1 it is not met.
27 1
28 1 Flags:
29 1 `)
30 1 fs.PrintDefaults()
31 1 fmt.Fprint(fs.Output(), `
32 1 Examples:
33 1 plumb check coverage.out --min-statements 80
34 1 plumb check coverage.out --min-statements 80 --min-functions 70
35 1 plumb check --min-statements 80 # reads .plumb/coverage.out
36 1 plumb check --min-diff 90 --format=markdown | gh pr comment -F -
37 1 plumb check --min-diff 90 --format=json | jq .diff.coverage
38 1 `)
39 1 }
40
41 1 cf := addCheckFlags(fs)
42 1
43 1 if err := parseFlags(fs, args); err != nil {
44 1 return err
45 1 }
46
47 // fs.Visit reports only the flags the caller actually typed, so a
48 // caller cannot pass a threshold by typing the zero-value default
49 // (D-33). A sentinel default would break the moment a caller typed
50 // the sentinel; Visit does not have that failure mode.
51 1 given := flagsGiven(fs)
52 1
53 1 // An unknown --format value is a mistyped invocation, so it fails
54 1 // the same way an out-of-range threshold does. Check it before any
55 1 // measurement runs: a caller who cannot read the answer gains
56 1 // nothing from plumb computing it.
57 1 if !validFormat(*cf.format) {
58 1 fmt.Fprintf(stderr, "plumb: --format value %q is not known, want one of: %s\n", *cf.format, strings.Join(formatNames, ", "))
59 1 fs.Usage()
60 1 return newExitError(2, "unknown output format")
61 1 }
62 // Both document formats own stdout: the human text lines never
63 // share the stream with a document a machine reads.
64 1 document := *cf.format != formatText
65 1
66 1 // --min-functions and --min-diff both need the module, and a run
67 1 // that asks for both would otherwise walk the tree for go.mod and
68 1 // parse it twice. Resolve it at most once, and only when a
69 1 // threshold that needs it was given: a statement-only check must
70 1 // still run against a downloaded profile with no source tree
71 1 // present (D-18).
72 1 var modulePath, moduleRoot string
73 1 var moduleErr error
74 1 moduleResolved := false
75 1 module := func() (string, string, error) {
76 1 if !moduleResolved {
77 1 modulePath, moduleRoot, moduleErr = resolveModule()
78 1 moduleResolved = true
79 1 }
80 1 return modulePath, moduleRoot, moduleErr
81 }
82
83 // --min-diff also satisfies this guard (D-39): a caller who wants
84 // only the diff percentage runs "plumb check --min-diff 0" with no
85 // other threshold.
86 1 if !given["min-statements"] && !given["min-functions"] && !given["min-diff"] {
87 1 fmt.Fprint(stderr, "plumb: no coverage threshold given, want --min-statements, --min-functions, or --min-diff\n")
88 1 fs.Usage()
89 1 return newExitError(2, "no coverage threshold given")
90 1 }
91
92 // Check min-statements first, then min-functions, then min-diff,
93 // and report the first rejected value only (D-35).
94 1 if given["min-statements"] && !validThreshold(*cf.minStmts) {
95 1 fmt.Fprintf(stderr, "plumb: --min-statements value %v is out of range, want 0 to 100\n", *cf.minStmts)
96 1 fs.Usage()
97 1 return newExitError(2, "threshold out of range")
98 1 }
99 1 if given["min-functions"] && !validThreshold(*cf.minFuncs) {
100 1 fmt.Fprintf(stderr, "plumb: --min-functions value %v is out of range, want 0 to 100\n", *cf.minFuncs)
101 1 fs.Usage()
102 1 return newExitError(2, "threshold out of range")
103 1 }
104 1 if given["min-diff"] && !validThreshold(*cf.minDiff) {
105 1 fmt.Fprintf(stderr, "plumb: --min-diff value %v is out of range, want 0 to 100\n", *cf.minDiff)
106 1 fs.Usage()
107 1 return newExitError(2, "threshold out of range")
108 1 }
109
110 1 profilePath, err := profileArg(fs, stderr)
111 1 if err != nil {
112 1 return err
113 1 }
114
115 1 profiles, err := profile.Parse(profilePath)
116 1 if err != nil {
117 0 return fmt.Errorf("parsing profile: %w", err)
118 0 }
119
120 // A profile that measures no coverable statement cannot answer
121 // either question, whichever flags the caller set (D-19). This is
122 // a plain error, not a coded one: the run produced no measurement
123 // rather than a coverage drop, and a pipeline must be able to
124 // tell the two apart (D-29).
125 1 stmtCovered, stmtTotal := profile.StmtTotalsAll(profiles)
126 1 if stmtTotal == 0 {
127 1 return fmt.Errorf("%s measures no coverable statement", profilePath)
128 1 }
129
130 1 var rep checkReport
131 1
132 1 if given["min-statements"] {
133 1 pct := float64(stmtCovered) / float64(stmtTotal) * 100
134 1
135 1 // Compare the raw percentage against the raw flag value,
136 1 // never the truncated print value: a value equal to the
137 1 // threshold passes, and a value one step below it fails
138 1 // (CHK-01 boundary).
139 1 rep.metrics = append(rep.metrics, metric{
140 1 noun: "statement",
141 1 short: "stmts",
142 1 title: "Statements",
143 1 key: keyStatements,
144 1 flag: "--min-statements",
145 1 got: report.TruncPct(pct),
146 1 want: report.TruncPct(*cf.minStmts),
147 1 pass: pct >= *cf.minStmts,
148 1 })
149 1 }
150
151 1 if given["min-functions"] {
152 1 modulePath, moduleRoot, err := module()
153 1 if err != nil {
154 0 return err
155 0 }
156
157 1 funcCovered, funcTotal, err := funcTotals(profiles, modulePath, moduleRoot)
158 1 if err != nil {
159 1 return err
160 1 }
161
162 1 var pct float64
163 1 if funcTotal > 0 {
164 1 pct = float64(funcCovered) / float64(funcTotal) * 100
165 1 }
166
167 1 rep.metrics = append(rep.metrics, metric{
168 1 noun: "function",
169 1 short: "funcs",
170 1 title: "Functions",
171 1 key: keyFunctions,
172 1 flag: "--min-functions",
173 1 got: report.TruncPct(pct),
174 1 want: report.TruncPct(*cf.minFuncs),
175 1 pass: pct >= *cf.minFuncs,
176 1 })
177 }
178
179 // --diff-base alone also turns diff mode on (D-40), so either flag
180 // reaches this block. 03-01 task 1 requires --diff-base to be
181 // given explicitly; 03-02 adds the default reference.
182 1 if given["min-diff"] || given[diffBaseFlagName] {
183 1 modulePath, moduleRoot, err := module()
184 1 if err != nil {
185 0 return err
186 0 }
187
188 1 dr, err := diffCoverage(profiles, modulePath, moduleRoot, *cf.diffBase, profilePath)
189 1 if err != nil {
190 1 return mapDiffCoverageError(err, stderr)
191 1 }
192
193 1 rep.diffBase = dr.Base
194 1 rep.diffMergeBase = dr.MergeBase
195 1 rep.skipped = dr.Skipped
196 1
197 1 // In text mode the reference prints as it is measured. Markdown
198 1 // mode carries the same fact inside the document instead, so
199 1 // nothing but the document itself reaches stdout.
200 1 if !document {
201 1 fmt.Fprintf(stdout, "plumb: diff against %s (merge base %s)\n", dr.Base, shortSHA(dr.MergeBase))
202 1 }
203
204 // The reason a file left the ratio is visible whether or not
205 // the gate passes, and it prints before the pass/fail lines
206 // below (D-18, D-38). stderr carries it in both formats: a
207 // build log must name a dropped file even when the comment
208 // that quotes the number goes somewhere else.
209 1 printSkipped(stderr, dr.Skipped)
210 1
211 1 if pct, ok := dr.Pct(); ok {
212 1 rep.metrics = append(rep.metrics, metric{
213 1 noun: "diff",
214 1 short: "diff",
215 1 title: "Diff",
216 1 key: keyDiff,
217 1 flag: "--min-diff",
218 1 got: report.TruncPct(pct),
219 1 want: report.TruncPct(*cf.minDiff),
220 1 pass: pct >= *cf.minDiff,
221 1 })
222 1 } else {
223 1 // D-37: a diff with nothing coverable to measure is not a
224 1 // 0% diff, so every threshold passes.
225 1 rep.noCoverableDiff = true
226 1 }
227 }
228
229 // A document describes the pass and the fail alike, so it is written
230 // the same way either way and only the exit code differs. Write it
231 // before the failure branch below returns.
232 1 switch *cf.format {
233 1 case formatMarkdown:
234 1 fmt.Fprint(stdout, rep.markdown())
235 1 case formatJSON:
236 1 doc, err := rep.jsonDoc()
237 1 if err != nil {
238 0 return err
239 0 }
240 1 fmt.Fprint(stdout, doc)
241 }
242
243 // Collect failures rather than return on the first one: two
244 // failed thresholds give two stderr lines, statements first, and
245 // one exit code (D-24).
246 1 if failures := rep.failures(); len(failures) > 0 {
247 1 for _, f := range failures {
248 1 fmt.Fprintln(stderr, f)
249 1 }
250 1 return newExitError(3, "coverage below threshold")
251 }
252
253 // Build the success line from the metrics the caller asked for,
254 // and from those only.
255 1 if !document {
256 1 fmt.Fprint(stdout, rep.successLine())
257 1 }
258 1 return nil
259 }
260
261 // checkFlags holds the flag values check reads. It is a struct rather
262 // than a list of returns because the count passed the point where a
263 // caller can keep the order straight.
264 type checkFlags struct {
265 minStmts *float64
266 minFuncs *float64
267 minDiff *float64
268 diffBase *string
269 format *string
270 }
271
272 // addCheckFlags registers the threshold flags check reads.
273 1 func addCheckFlags(fs *flag.FlagSet) checkFlags {
274 1 return checkFlags{
275 1 minStmts: fs.Float64("min-statements", 0, "minimum statement coverage percent"),
276 1 minFuncs: fs.Float64("min-functions", 0, "minimum function coverage percent (reads the source tree)"),
277 1 minDiff: fs.Float64("min-diff", 0, "minimum diff coverage percent (lines changed since --diff-base)"),
278 1 diffBase: addDiffBaseFlag(fs),
279 1 format: fs.String("format", formatText, "output format: text, markdown, or json"),
280 1 }
281 1 }
282
283 // shortSHA returns the first 7 characters of a git commit SHA, or the
284 // whole string when it is shorter than that.
285 1 func shortSHA(sha string) string {
286 1 if len(sha) <= 7 {
287 1 return sha
288 1 }
289 1 return sha[:7]
290 }
291
292 // validThreshold reports whether v lies in the closed range 0 to
293 // 100. Written as a single accepting condition rather than two
294 // separate rejecting comparisons: every comparison against NaN is
295 // false, so two negated comparisons would let a NaN threshold pass
296 // silently (D-35).
297 1 func validThreshold(v float64) bool {
298 1 return v >= 0 && v <= 100
299 1 }
300
301 // funcTotals walks the source tree behind every parsed profile and
302 // returns the module function totals. Unlike report.Build, it never
303 // drops a file it cannot read or parse: it returns the error instead,
304 // because a gate that quietly shrinks its own denominator can report
305 // a higher percentage from less code, which is the exact failure
306 // D-18 rejects.
307 1 func funcTotals(profiles []*profile.ParsedProfile, modulePath, moduleRoot string) (covered, total int, err error) {
308 1 for _, pp := range profiles {
309 1 var diskPath string
310 1 diskPath, err = profile.ResolveSafe(pp.FileName, modulePath, moduleRoot)
311 1 if err != nil {
312 1 return 0, 0, err
313 1 }
314
315 1 var funcs []profile.AnnotatedFunc
316 1 funcs, err = profile.WalkFuncs(pp.CoverProfile, diskPath)
317 1 if err != nil {
318 1 return 0, 0, fmt.Errorf("reading source for %s: %w", pp.FileName, err)
319 1 }
320
321 1 c, t := profile.FuncTotals(funcs)
322 1 covered += c
323 1 total += t
324 }
325 1 return covered, total, nil
326 }
cmd/plumb/checkjson.go
stmts 93.7% funcs 100.0%
Functions
NameLineCalls
(*checkReport).jsonDoc 55 ×1
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "strings"
7 )
8
9 // jsonMetric is one threshold in the JSON document. Minimum repeats the
10 // flag value the caller gave, so a reader never has to know which flags
11 // the job ran with to interpret the verdict.
12 type jsonMetric struct {
13 Coverage float64 `json:"coverage"`
14 Minimum float64 `json:"minimum"`
15 Pass bool `json:"pass"`
16 }
17
18 // jsonDiff carries the diff metric together with the reference that
19 // produced it. Coverage and Minimum are pointers because D-37 has no
20 // number to report: a diff with nothing coverable to measure is not a
21 // 0% diff, and a JSON null says that where a 0 would lie.
22 type jsonDiff struct {
23 Coverage *float64 `json:"coverage"`
24 Minimum *float64 `json:"minimum"`
25 Pass bool `json:"pass"`
26 Base string `json:"base"`
27 MergeBase string `json:"mergeBase"`
28 }
29
30 // jsonSkipped names one file that left the diff ratio, and why.
31 type jsonSkipped struct {
32 Name string `json:"name"`
33 Reason string `json:"reason"`
34 }
35
36 // jsonReport is the document --format=json writes. A metric the run did
37 // not measure is absent rather than zero, so a reader can tell "the job
38 // did not gate on this" from "the value is 0".
39 //
40 // The shape is a promised interface: a workflow reads it with jq, and a
41 // renamed or retyped field breaks that workflow silently. Add fields,
42 // and change none.
43 type jsonReport struct {
44 Plumb string `json:"plumb"`
45 Pass bool `json:"pass"`
46 Statements *jsonMetric `json:"statements,omitempty"`
47 Functions *jsonMetric `json:"functions,omitempty"`
48 Diff *jsonDiff `json:"diff,omitempty"`
49 Skipped []jsonSkipped `json:"skipped,omitempty"`
50 }
51
52 // jsonDoc renders the report as JSON. The exit code still carries the
53 // verdict, and the document repeats it in "pass" so a reader that keeps
54 // only the document keeps the answer too.
55 1 func (r *checkReport) jsonDoc() (string, error) {
56 1 doc := jsonReport{Plumb: version, Pass: len(r.failures()) == 0}
57 1
58 1 for _, m := range r.metrics {
59 1 switch m.key {
60 1 case keyStatements:
61 1 doc.Statements = &jsonMetric{Coverage: m.got, Minimum: m.want, Pass: m.pass}
62 1 case keyFunctions:
63 1 doc.Functions = &jsonMetric{Coverage: m.got, Minimum: m.want, Pass: m.pass}
64 1 case keyDiff:
65 1 doc.Diff = &jsonDiff{
66 1 Coverage: &m.got,
67 1 Minimum: &m.want,
68 1 Pass: m.pass,
69 1 Base: r.diffBase,
70 1 MergeBase: r.diffMergeBase,
71 1 }
72 }
73 }
74
75 // A diff that ran but found no coverable changed line reports the
76 // reference it used with a null coverage (D-37). Without this the
77 // document would drop the reference and say nothing about a diff
78 // the job did measure.
79 1 if r.measuredDiff() && doc.Diff == nil {
80 1 doc.Diff = &jsonDiff{Pass: true, Base: r.diffBase, MergeBase: r.diffMergeBase}
81 1 }
82
83 1 for _, s := range r.skipped {
84 1 doc.Skipped = append(doc.Skipped, jsonSkipped{Name: s.Name, Reason: s.Reason})
85 1 }
86
87 1 var b strings.Builder
88 1 enc := json.NewEncoder(&b)
89 1 enc.SetIndent("", " ")
90 1 // SetEscapeHTML stays on: a file name reaches this document from a
91 1 // coverage profile, and a document that a workflow may inline into
92 1 // a comment must not carry raw angle brackets.
93 1 if err := enc.Encode(doc); err != nil {
94 0 return "", fmt.Errorf("writing the JSON report: %w", err)
95 0 }
96 1 return b.String(), nil
97 }
cmd/plumb/checkreport.go
stmts 100.0% funcs 100.0%
Functions
NameLineCalls
(*checkReport).measuredDiff 54 ×1
(*checkReport).failures 60 ×1
(*checkReport).successLine 72 ×1
(*checkReport).markdown 91 ×1
plural 132 ×1
validFormat 153 ×1
1 package main
2
3 import (
4 "fmt"
5 "slices"
6 "strings"
7
8 "github.com/z3le/plumb/internal/report"
9 )
10
11 // metric is one threshold check that ran, with the value it measured
12 // and the value the caller demanded. Both numbers arrive truncated, so
13 // a printed number never exceeds the raw value it measured (D-20).
14 //
15 // The five name fields exist because one metric appears under five
16 // spellings: "statement coverage" in a failure line, "89.5% stmts" in
17 // the success line, "Statements" in a markdown table, "statements" as
18 // a JSON key, and "--min-statements" when the gate names the flag that
19 // failed. Holding them together keeps the five spellings of one metric
20 // in one place.
21 type metric struct {
22 noun string // "statement", for the failure line
23 short string // "stmts", for the success line
24 title string // "Statements", for the markdown table
25 key string // "statements", for the JSON object key
26 flag string // "--min-statements"
27 got float64
28 want float64
29 pass bool
30 }
31
32 // checkReport is everything one plumb check run measured. The command
33 // fills it while it walks the thresholds, then renders it once, so the
34 // text output and the markdown output cannot disagree about a number.
35 type checkReport struct {
36 metrics []metric
37
38 // diffBase and diffMergeBase name the reference the diff ran
39 // against. Both stay empty when the run measured no diff.
40 diffBase string
41 diffMergeBase string
42
43 // skipped lists the files that left the diff ratio, and why (D-38).
44 skipped []report.SkippedFile
45
46 // noCoverableDiff records D-37: the diff had nothing coverable to
47 // measure, which is not a 0% diff, so no diff metric exists and
48 // every threshold passes.
49 noCoverableDiff bool
50 }
51
52 // measuredDiff reports whether the run measured diff coverage at all,
53 // whether or not it found a coverable line to count.
54 1 func (r *checkReport) measuredDiff() bool {
55 1 return r.diffBase != ""
56 1 }
57
58 // failures returns one line per threshold the profile did not meet, in
59 // the order the thresholds ran. A run with no failure returns nil.
60 1 func (r *checkReport) failures() []string {
61 1 var out []string
62 1 for _, m := range r.metrics {
63 1 if !m.pass {
64 1 out = append(out, fmt.Sprintf("plumb: %s coverage %.1f%%, need %.1f%% (%s)", m.noun, m.got, m.want, m.flag))
65 1 }
66 }
67 1 return out
68 }
69
70 // successLine returns the single line a passing run prints, built from
71 // the metrics the caller asked for and from those only.
72 1 func (r *checkReport) successLine() string {
73 1 var parts []string
74 1 for _, m := range r.metrics {
75 1 parts = append(parts, fmt.Sprintf("%.1f%% %s", m.got, m.short))
76 1 }
77 1 if r.noCoverableDiff {
78 1 parts = append(parts, report.NoCoverableLinesChanged)
79 1 }
80 1 return fmt.Sprintf("plumb: coverage ok (%s)\n", strings.Join(parts, ", "))
81 }
82
83 // markerComment identifies a plumb comment in a pull request thread. A
84 // sticky-comment action matches this string to replace the previous
85 // comment instead of adding a second one.
86 const markerComment = "<!-- plumb-coverage -->"
87
88 // markdown renders the report as a pull request comment. The exit code
89 // still carries the pass or fail verdict, so a caller that pipes this
90 // into a comment never has to parse the table to learn what happened.
91 1 func (r *checkReport) markdown() string {
92 1 var b strings.Builder
93 1
94 1 b.WriteString(markerComment)
95 1 b.WriteString("\n## Coverage\n\n")
96 1
97 1 if len(r.metrics) > 0 {
98 1 b.WriteString("| Metric | Coverage | Minimum | Status |\n")
99 1 b.WriteString("| :--- | ---: | ---: | :---: |\n")
100 1 for _, m := range r.metrics {
101 1 status := "✅ pass"
102 1 if !m.pass {
103 1 status = "❌ fail"
104 1 }
105 1 fmt.Fprintf(&b, "| %s | %.1f%% | %.1f%% | %s |\n", m.title, m.got, m.want, status)
106 }
107 1 b.WriteString("\n")
108 }
109
110 1 if r.noCoverableDiff {
111 1 fmt.Fprintf(&b, "No coverable line changed, so the diff threshold passes. Plumb counts %s.\n\n", report.NoCoverableLinesChanged)
112 1 }
113
114 1 if r.measuredDiff() {
115 1 fmt.Fprintf(&b, "Diff measured against `%s`, merge base `%s`.\n\n", r.diffBase, shortSHA(r.diffMergeBase))
116 1 }
117
118 1 if len(r.skipped) > 0 {
119 1 fmt.Fprintf(&b, "<details><summary>%s left out of the diff ratio</summary>\n\n", plural(len(r.skipped), "file", "files"))
120 1 for _, s := range r.skipped {
121 1 fmt.Fprintf(&b, "- `%s` — %s\n", s.Name, s.Reason)
122 1 }
123 1 b.WriteString("\n</details>\n\n")
124 }
125
126 1 fmt.Fprintf(&b, "<sub>Measured by [plumb](https://github.com/z3le/plumb) %s.</sub>\n", version)
127 1 return b.String()
128 }
129
130 // plural picks the singular or the plural noun for n and returns it
131 // with the count in front.
132 1 func plural(n int, one, many string) string {
133 1 if n == 1 {
134 1 return fmt.Sprintf("%d %s", n, one)
135 1 }
136 1 return fmt.Sprintf("%d %s", n, many)
137 }
138
139 // Output formats plumb check writes. A caller pipes formatMarkdown
140 // into a pull request comment; formatText is what a human reads in a
141 // build log; formatJSON is what a workflow reads with jq.
142 const (
143 formatText = "text"
144 formatMarkdown = "markdown"
145 formatJSON = "json"
146 )
147
148 // formatNames lists every format, in the order the error message and
149 // the flag help offer them.
150 var formatNames = []string{formatText, formatMarkdown, formatJSON}
151
152 // validFormat reports whether v names an output format check knows.
153 1 func validFormat(v string) bool {
154 1 return slices.Contains(formatNames, v)
155 1 }
156
157 // JSON object keys for the three metrics. They are a promised
158 // interface: a workflow reads them with jq, so a rename breaks that
159 // workflow silently.
160 const (
161 keyStatements = "statements"
162 keyFunctions = "functions"
163 keyDiff = "diff"
164 )
cmd/plumb/diffcov.go
stmts 86.8% funcs 100.0%
Functions
NameLineCalls
(*diffResult).Pct 45 ×1
diffCoverage 58 ×1
mapDiffCoverageError 187 ×1
renameToProfileNames 203 ×1
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "io"
7 "maps"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "github.com/z3le/plumb/internal/gitdiff"
13 "github.com/z3le/plumb/internal/profile"
14 "github.com/z3le/plumb/internal/report"
15 )
16
17 // diffResult holds a diff coverage measurement: the counters that
18 // answer --min-diff, plus the reference and merge base that produced
19 // them and the files the measurement left out. Changed maps every
20 // file the diff touched (renamed to the profile's own naming
21 // convention) to its changed line numbers, keyed the same way before
22 // and after this function's own per-file loop consumes it — the
23 // report package's Build reads it directly to build its own file
24 // list and its own diff percentage, using the same
25 // profile.CoverableChanged this function calls (D-36).
26 type diffResult struct {
27 Base string
28 MergeBase string
29 Covered int
30 Total int
31 Skipped []report.SkippedFile
32 Changed map[string][]int
33
34 // Annotated holds the source lines this function already read and
35 // annotated, one entry per changed file it measured. report.Build
36 // reuses them rather than reading each file a second time; a
37 // caller that never builds a report (plumb check) simply ignores
38 // the field.
39 Annotated map[string][]profile.AnnotatedLine
40 }
41
42 // Pct returns the diff coverage percentage and true when Total is
43 // above zero. The false case is D-37's signal: a diff with nothing
44 // coverable to measure is not a 0% diff, it is no diff at all.
45 1 func (d *diffResult) Pct() (float64, bool) {
46 1 if d.Total <= 0 {
47 1 return 0, false
48 1 }
49 1 return float64(d.Covered) / float64(d.Total) * 100, true
50 }
51
52 // diffCoverage resolves base to a reference (D-43 when base is
53 // empty), computes its merge base against HEAD, reads the lines that
54 // changed since it, and intersects them with the coverage profiles
55 // to produce a diffResult. profilePath is the profile diffCoverage is
56 // measuring against, so it can compare each changed source file's
57 // modification time to the profile's own (D-45).
58 1 func diffCoverage(profiles []*profile.ParsedProfile, modulePath, moduleRoot, base, profilePath string) (*diffResult, error) {
59 1 // A --diff-base value that begins with a hyphen would be read by
60 1 // git as an option rather than a revision. internal/gitdiff refuses
61 1 // such a value in every method that puts a reference in an argv, so
62 1 // the guard travels with the danger and no caller has to remember
63 1 // it (T-03-01).
64 1 runner, err := gitdiff.NewRunner(".")
65 1 if err != nil {
66 0 return nil, err
67 0 }
68
69 1 repoRoot, err := runner.RepoRoot()
70 1 if err != nil {
71 1 return nil, err
72 1 }
73
74 1 resolvedBase, err := runner.ResolveBase(base)
75 1 if err != nil {
76 1 return nil, err
77 1 }
78
79 1 mergeBase, err := runner.MergeBase(resolvedBase)
80 1 if err != nil {
81 1 return nil, err
82 1 }
83
84 1 diffText, err := runner.Diff(mergeBase)
85 1 if err != nil {
86 0 return nil, err
87 0 }
88
89 1 hunks, err := gitdiff.ParseHunks(diffText)
90 1 if err != nil {
91 0 return nil, err
92 0 }
93
94 1 changedByName, err := renameToProfileNames(hunks, modulePath, moduleRoot, repoRoot)
95 1 if err != nil {
96 0 return nil, err
97 0 }
98
99 1 result := &diffResult{
100 1 Base: resolvedBase,
101 1 MergeBase: mergeBase,
102 1 Changed: changedByName,
103 1 Annotated: make(map[string][]profile.AnnotatedLine, len(changedByName)),
104 1 }
105 1
106 1 // remaining tracks which changed files this loop has matched
107 1 // against the profile, so the leftover keys after the loop are the
108 1 // D-38 case (a changed .go file the profile never mentions). It is
109 1 // a separate map from result.Changed, which callers such as
110 1 // report.Build need to keep holding every changed file's line
111 1 // numbers, matched or not.
112 1 remaining := maps.Clone(changedByName)
113 1
114 1 // Stat the profile once, before the file loop, and hold its
115 1 // modification time (D-45).
116 1 profileModTime := profile.ProfileModTime(profilePath)
117 1
118 1 for _, pp := range profiles {
119 1 lines, ok := changedByName[pp.FileName]
120 1 if !ok {
121 1 continue
122 }
123 1 delete(remaining, pp.FileName)
124 1
125 1 diskPath, err := profile.ResolveSafe(pp.FileName, modulePath, moduleRoot)
126 1 if err != nil {
127 0 return nil, err
128 0 }
129
130 // D-44 makes the working tree the thing plumb diffs, so an
131 // edit after a test run is the common local case. Name the
132 // file and keep going: the caveat sits beside the number, not
133 // in place of it, so the counters below are unaffected.
134 1 if profile.StaleAgainst(profileModTime, diskPath) {
135 1 result.Skipped = append(result.Skipped, report.SkippedFile{Name: pp.FileName, Reason: profile.StaleReason})
136 1 }
137
138 1 annotated, err := profile.Annotate(pp.CoverProfile, diskPath)
139 1 if err != nil {
140 0 return nil, fmt.Errorf("reading source for %s: %w", pp.FileName, err)
141 0 }
142
143 // Record before the D-51 branch below returns early: the file
144 // was read either way, so the report must not read it again
145 // just because it carried no coverable changed line.
146 1 result.Annotated[pp.FileName] = annotated
147 1
148 1 covered, total := profile.CoverableChanged(lines, annotated)
149 1 if total == 0 {
150 1 // The file is in the profile, but every line the diff
151 1 // touched in it is Uncoverable: the same D-37 rule as a
152 1 // whole empty diff, applied one level down (D-51).
153 1 result.Skipped = append(result.Skipped, report.SkippedFile{Name: pp.FileName, Reason: report.NoCoverableLinesChanged})
154 1 continue
155 }
156 1 result.Covered += covered
157 1 result.Total += total
158 }
159
160 // A changed .go file the profile never mentions leaves both
161 // counters alone; the caller learns why through Skipped (D-38).
162 1 for _, name := range slices.Sorted(maps.Keys(remaining)) {
163 1 result.Skipped = append(result.Skipped, report.SkippedFile{Name: name, Reason: "not in the coverage profile"})
164 1 }
165
166 // Deterministic stderr output: a file-scope skip (encountered in
167 // profile order above) and a not-in-profile skip (already sorted)
168 // interleave here into one alphabetical list.
169 1 slices.SortFunc(result.Skipped, func(a, b report.SkippedFile) int {
170 1 return strings.Compare(a.Name, b.Name)
171 1 })
172
173 1 return result, nil
174 }
175
176 // mapDiffCoverageError converts an error diffCoverage returned into
177 // the error dispatch should see, so report and check map a git
178 // failure to the same exit code (T-03-01). A reference the caller
179 // typed does not resolve, or looks like a flag: the caller's mistake,
180 // so it writes git's own message to stderr and exits 2 the same way
181 // the out-of-range threshold guard does (D-49). Every other diff
182 // failure — outside a repository, a shallow clone with no common
183 // ancestor, an exhausted default-reference chain — is an environment
184 // failure, not a caller mistake, and returns unwrapped so dispatch's
185 // existing default exit-1 path handles it, with no change needed
186 // there.
187 1 func mapDiffCoverageError(err error, stderr io.Writer) error {
188 1 var badRef *gitdiff.BadRefError
189 1 if errors.As(err, &badRef) {
190 1 fmt.Fprintf(stderr, "plumb: %v\n", badRef)
191 1 return newExitError(2, "diff-base reference does not resolve")
192 1 }
193 1 return err
194 }
195
196 // renameToProfileNames answers RESEARCH assumption A2: git reports
197 // every changed path relative to the repository root, but the
198 // coverage profile names every file with an import path rooted at
199 // the module path. It resolves both to one key space so the
200 // intersection with ParsedProfile.FileName is a plain map lookup,
201 // and it keeps working when go.mod sits in a subdirectory of the
202 // repository.
203 1 func renameToProfileNames(hunks map[string][]int, modulePath, moduleRoot, repoRoot string) (map[string][]int, error) {
204 1 realRepoRoot, err := filepath.EvalSymlinks(repoRoot)
205 1 if err != nil {
206 0 return nil, fmt.Errorf("resolving the repository root %s: %w", repoRoot, err)
207 0 }
208 1 realModuleRoot, err := filepath.EvalSymlinks(moduleRoot)
209 1 if err != nil {
210 0 return nil, fmt.Errorf("resolving the module root %s: %w", moduleRoot, err)
211 0 }
212
213 1 out := make(map[string][]int, len(hunks))
214 1 for gitPath, lines := range hunks {
215 1 abs := filepath.Join(realRepoRoot, filepath.FromSlash(gitPath))
216 1 rel, err := filepath.Rel(realModuleRoot, abs)
217 1 if err != nil {
218 0 continue
219 }
220 // The file belongs to another module in the same repository.
221 1 if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
222 0 continue
223 }
224 1 if !strings.HasSuffix(rel, ".go") || strings.HasSuffix(rel, "_test.go") {
225 1 continue
226 }
227 1 name := modulePath + "/" + filepath.ToSlash(rel)
228 1 out[name] = lines
229 }
230 1 return out, nil
231 }
cmd/plumb/exitcode.go
stmts 100.0% funcs 100.0%
Functions
NameLineCalls
newExitError 16 ×1
(*exitError).Error 20 ×1
(*exitError).ExitCode 26 ×1
1 package main
2
3 // exitError carries a process exit code. A command that returns an
4 // *exitError has already written its own report to the stderr writer
5 // it received, so dispatch returns the code and prints nothing more
6 // (D-30, D-31) — the same rule dispatch already applies to
7 // flag.ErrHelp.
8 type exitError struct {
9 code int
10 msg string
11 }
12
13 // newExitError builds a coded error. msg is the value Error()
14 // returns; it exists for Go's error interface and for a test
15 // assertion, not for a second print to the user.
16 1 func newExitError(code int, msg string) *exitError {
17 1 return &exitError{code: code, msg: msg}
18 1 }
19
20 1 func (e *exitError) Error() string {
21 1 return e.msg
22 1 }
23
24 // ExitCode reports the process exit code this error carries. dispatch
25 // reads it through errors.As, so a wrapped *exitError still resolves.
26 1 func (e *exitError) ExitCode() int {
27 1 return e.code
28 1 }
cmd/plumb/main.go
stmts 97.4% funcs 85.7%
Functions
NameLineCalls
allCommands 82 ×1
lookup 103 ×1
usage 115 ×1
helpCmd 128 ×1
versionCmd 134 ×1
dispatch 153 ×1
main 191 ✗
1 // Plumb measures Go test coverage and fails a build when the coverage
2 // of the lines a change touched falls below a threshold.
3 //
4 // Plumb reads the git history to find the lines a change touched, so
5 // it needs no coverage service, no API token, and no stored profile
6 // from an earlier build. Every other diff coverage tool for Go
7 // compares the current profile against a base profile that a previous
8 // build uploaded, which adds a storage backend and an expiry date to
9 // the pipeline. Plumb compares against a merge base instead.
10 //
11 // Usage:
12 //
13 // plumb <command> [flags] [args]
14 //
15 // The commands are:
16 //
17 // run Run tests with coverage and render the report
18 // report Render a coverage profile as an HTML report
19 // check Check coverage against a minimum threshold
20 // version Print the plumb version
21 // help Show this help text
22 //
23 // # Gate a build
24 //
25 // The check command compares a profile against the thresholds the
26 // caller sets and exits 3 when one of them fails:
27 //
28 // plumb check coverage.out --min-statements 80 --min-diff 90
29 //
30 // The --min-diff threshold measures only the lines that changed since
31 // the merge base. Add --format=markdown to print the result as a
32 // markdown table for a pull request comment.
33 //
34 // # Render a report
35 //
36 // The run command runs the tests, collects the profile, and writes a
37 // self-contained HTML report in one step:
38 //
39 // plumb run --open
40 //
41 // The report needs no external request to display: it carries its own
42 // styles, its own source view, and its own syntax highlighting.
43 //
44 // # Exit codes
45 //
46 // 0 the command succeeded, or the caller asked for help
47 // 1 the command failed
48 // 2 the caller called the command wrong
49 // 3 coverage fell below a threshold
50 package main
51
52 import (
53 "errors"
54 "flag"
55 "fmt"
56 "io"
57 "os"
58 )
59
60 // Set via -ldflags at release time. Default is "dev" so unreleased
61 // builds are obvious.
62 var version = "dev"
63
64 // command is one entry in the command registry. The registry is the
65 // single source for both dispatch and the --help text, so a command
66 // cannot exist without being listed and cannot be listed without
67 // existing.
68 type command struct {
69 name string
70 summary string
71 run func(args []string, stdout, stderr io.Writer) error
72 }
73
74 // allCommands returns every command plumb supports, in a fixed order.
75 // The order is part of the contract.
76 //
77 // This is a function, not a package-level variable, because helpCmd
78 // calls usage(), and usage() reads the registry. A var initializer
79 // with that shape is an initialization cycle that Go refuses to
80 // compile. A function body is not part of package initialization, so
81 // every entry — help included — keeps its run field here as data.
82 1 func allCommands() []command {
83 1 return []command{
84 1 {name: "run", summary: "Run tests with coverage and render the report", run: runCmd},
85 1 {name: "report", summary: "Render a coverage profile as an HTML report", run: reportCmd},
86 1 {name: "check", summary: "Check coverage against a minimum threshold", run: checkCmd},
87 1 {name: "version", summary: "Print the plumb version", run: versionCmd},
88 1 {name: "help", summary: "Show this help text", run: helpCmd},
89 1 }
90 1 }
91
92 // aliases maps a short or long flag form to a command name. Aliases are
93 // not registry entries, so they never appear in the help text.
94 var aliases = map[string]string{
95 "-h": "help",
96 "--help": "help",
97 "-v": "version",
98 "--version": "version",
99 }
100
101 // lookup finds a command by exact byte equality — no case folding and
102 // no Unicode normalization.
103 1 func lookup(name string) (command, bool) {
104 1 for _, c := range allCommands() {
105 1 if c.name == name {
106 1 return c, true
107 1 }
108 }
109 1 return command{}, false
110 }
111
112 // usage writes the top-level help text. The command lines are produced
113 // by ranging over commands, not written as a literal string, so the
114 // help text cannot drift from the dispatch again.
115 1 func usage(w io.Writer) {
116 1 fmt.Fprint(w, "plumb — better code coverage for Go\n\n")
117 1 fmt.Fprint(w, "Usage:\n")
118 1 fmt.Fprint(w, " plumb <command> [flags] [args]\n\n")
119 1 fmt.Fprint(w, "Commands:\n")
120 1 for _, c := range allCommands() {
121 1 fmt.Fprintf(w, " %-8s %s\n", c.name, c.summary)
122 1 }
123 1 fmt.Fprint(w, "\n")
124 1 fmt.Fprint(w, "Run \"plumb <command> -h\" for command-specific help.\n")
125 }
126
127 // helpCmd prints the top-level usage text to stdout.
128 1 func helpCmd(args []string, stdout, stderr io.Writer) error {
129 1 usage(stdout)
130 1 return nil
131 1 }
132
133 // versionCmd prints the plumb version to stdout.
134 1 func versionCmd(args []string, stdout, stderr io.Writer) error {
135 1 fmt.Fprintf(stdout, "plumb %s\n", version)
136 1 return nil
137 1 }
138
139 // dispatch resolves the command name, runs it, and returns the process
140 // exit code. The exit-code table is fixed for the whole phase:
141 //
142 // 0 — the command succeeded, or the caller asked for help
143 // 1 — the command returned an ordinary error, which includes a
144 // profile that measures no coverable statement
145 // 2 — a usage error: no argument, an unknown command, an unknown
146 // flag, a flag value the command cannot read, a second file
147 // name, or a threshold outside 0 to 100
148 // 3 — a coded error for a coverage threshold that the profile did
149 // not meet
150 //
151 // A pipeline reads 2 as "the command was called wrong" and 3 as
152 // "coverage fell", so every wrong call answers with 2.
153 1 func dispatch(args []string, stdout, stderr io.Writer) int {
154 1 if len(args) == 0 {
155 1 usage(stderr)
156 1 return 2
157 1 }
158 1 name := args[0]
159 1 if aliased, ok := aliases[name]; ok {
160 1 name = aliased
161 1 }
162 1 cmd, ok := lookup(name)
163 1 if !ok {
164 1 fmt.Fprintf(stderr, "unknown command: %s\n\n", name)
165 1 usage(stderr)
166 1 return 2
167 1 }
168 1 err := cmd.run(args[1:], stdout, stderr)
169 1 if err == nil {
170 1 return 0
171 1 }
172 // flag.ContinueOnError already wrote the command's own usage block
173 // to its output while parsing. Writing anything more here would
174 // print that block a second time, so a help request returns 0 and
175 // writes nothing further.
176 1 if errors.Is(err, flag.ErrHelp) {
177 1 return 0
178 1 }
179 // A coded error means the command already wrote its own report to
180 // the writer it received (D-30, D-31), so dispatch only returns
181 // the code and prints nothing more — the same rule the flag.ErrHelp
182 // branch above follows.
183 1 var ee *exitError
184 1 if errors.As(err, &ee) {
185 1 return ee.ExitCode()
186 1 }
187 1 fmt.Fprintf(stderr, "plumb: %v\n", err)
188 1 return 1
189 }
190
191 0 func main() {
192 0 os.Exit(dispatch(os.Args[1:], os.Stdout, os.Stderr))
193 0 }
cmd/plumb/module.go
stmts 90.0% funcs 100.0%
Functions
NameLineCalls
resolveModule 19 ×1
defaultProfilePath 46 ×1
profileArg 57 ×1
printSkipped 73 ×1
1 package main
2
3 import (
4 "flag"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9
10 "github.com/z3le/plumb/internal/profile"
11 "github.com/z3le/plumb/internal/report"
12 )
13
14 // resolveModule finds the go.mod file above the working directory and
15 // returns the module path it declares and the directory that holds
16 // it. Every command that reads a source tree needs both values, so
17 // they come from one place: a change to how plumb finds a module
18 // applies to each command at the same time.
19 1 func resolveModule() (modulePath, moduleRoot string, err error) {
20 1 cwd, err := os.Getwd()
21 1 if err != nil {
22 0 return "", "", fmt.Errorf("getting cwd: %w", err)
23 0 }
24 1 gomodPath, err := profile.FindGoMod(cwd)
25 1 if err != nil {
26 1 return "", "", fmt.Errorf("finding go.mod: %w", err)
27 1 }
28 1 modulePath, err = profile.ReadModulePath(gomodPath)
29 1 if err != nil {
30 0 return "", "", fmt.Errorf("reading module path: %w", err)
31 0 }
32 1 return modulePath, filepath.Dir(gomodPath), nil
33 }
34
35 // defaultProfilePathDir and defaultProfileName spell the profile plumb
36 // writes and the profile it reads when the caller names neither. run
37 // creates the directory, report and check read the file, and all three
38 // once held their own copy of the string.
39 const (
40 defaultProfileDir = ".plumb"
41 defaultProfileName = "coverage.out"
42 )
43
44 // defaultProfilePath is where plumb run writes its profile, and what
45 // report and check read when the caller gives no file name.
46 1 func defaultProfilePath() string {
47 1 return filepath.Join(defaultProfileDir, defaultProfileName)
48 1 }
49
50 // profileArg returns the profile the caller named, or the default when
51 // they named none. A second positional argument is a mistyped
52 // invocation, and it fails loudly: a silently dropped argument lets a
53 // gate report a result for a profile the caller did not name (WR-01).
54 //
55 // report and check each held their own copy of this, so the default
56 // path and the arity rule were written twice and could drift apart.
57 1 func profileArg(fs *flag.FlagSet, stderr io.Writer) (string, error) {
58 1 if fs.NArg() > 1 {
59 1 fmt.Fprintf(stderr, "plumb: unexpected argument %q, want one profile\n", fs.Arg(1))
60 1 fs.Usage()
61 1 return "", newExitError(2, "unexpected argument")
62 1 }
63 1 if fs.NArg() > 0 {
64 1 return fs.Arg(0), nil
65 1 }
66 1 return defaultProfilePath(), nil
67 }
68
69 // printSkipped writes one line per file that left a measurement, and
70 // why. report and check each had their own format before — "plumb:
71 // skipped %s: %s" and "plumb: %s: %s" — so the same fact read two ways
72 // in a build log, and the unlabelled form looked like an error.
73 1 func printSkipped(w io.Writer, files []report.SkippedFile) {
74 1 for _, f := range files {
75 1 fmt.Fprintf(w, "plumb: skipped %s: %s\n", f.Name, f.Reason)
76 1 }
77 }
cmd/plumb/report.go
stmts 75.6% funcs 83.3%
Functions
NameLineCalls
reportCmd 15 ×1
addReportFlags 62 ×1
addDiffBaseFlag 81 ×1
addReportDiffFlags 95 ×1
renderReport 116 ×1
openBrowser 223 ✗
1 package main
2
3 import (
4 "flag"
5 "fmt"
6 "io"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10
11 "github.com/z3le/plumb/internal/profile"
12 "github.com/z3le/plumb/internal/report"
13 )
14
15 1 func reportCmd(args []string, stdout, stderr io.Writer) error {
16 1 fs := flag.NewFlagSet("report", flag.ContinueOnError)
17 1 fs.SetOutput(stderr)
18 1 fs.Usage = func() {
19 1 fmt.Fprint(fs.Output(), `Usage: plumb report [flags] [profile]
20 1
21 1 Render a coverage profile as an HTML report.
22 1
23 1 Flags:
24 1 `)
25 1 fs.PrintDefaults()
26 1 fmt.Fprint(fs.Output(), `
27 1 Examples:
28 1 plumb report coverage.out --open
29 1 plumb report # reads .plumb/coverage.out
30 1 `)
31 1 }
32
33 1 open, out, title := addReportFlags(fs)
34 1 diffBase := addReportDiffFlags(fs)
35 1
36 1 if err := parseFlags(fs, args); err != nil {
37 1 return err
38 1 }
39
40 1 profilePath, err := profileArg(fs, stderr)
41 1 if err != nil {
42 1 return err
43 1 }
44
45 // A caller who types only --diff-base means diff mode (D-40), so
46 // plumb never ignores a flag it was given.
47 1 given := flagsGiven(fs)
48 1
49 1 return renderReport(reportOptions{
50 1 ProfilePath: profilePath,
51 1 Out: *out,
52 1 Title: *title,
53 1 Open: *open,
54 1 Diff: given[diffFlagName] || given[diffBaseFlagName],
55 1 DiffBase: *diffBase,
56 1 }, stdout, stderr)
57 }
58
59 // addReportFlags registers the output flags that report and run
60 // share, so the two commands cannot drift apart. Both commands end in
61 // the same renderReport call, so they must accept the same flags.
62 1 func addReportFlags(fs *flag.FlagSet) (open *bool, out, title *string) {
63 1 open = fs.Bool("open", false, "open report in browser after writing")
64 1 out = fs.String("out", "coverage.html", "output HTML file")
65 1 title = fs.String("title", "", "report title (default: module name)")
66 1 return open, out, title
67 1 }
68
69 // diffFlagName and diffBaseFlagName name the two diff flags once, so
70 // every fs.Visit given-map lookup across report, run, and check
71 // spells the name exactly as addReportDiffFlags and addDiffBaseFlag
72 // registered it (D-40, D-11) — no call site repeats the string.
73 const (
74 diffFlagName = "diff"
75 diffBaseFlagName = "diff-base"
76 )
77
78 // addDiffBaseFlag registers the reference flag report, run, and check
79 // all share (D-40, D-11), so its name and help text can never drift
80 // between commands.
81 1 func addDiffBaseFlag(fs *flag.FlagSet) *string {
82 1 return fs.String(diffBaseFlagName, "", "git reference to diff against (default: merge base with the default branch)")
83 1 }
84
85 // addReportDiffFlags registers the pair of flags D-40 gives report and
86 // run: --diff turns on diff coverage reporting, and --diff-base names
87 // the reference. New files must be added to the index to be included
88 // in the diff — git diff never sees an untracked file, and D-41 sells
89 // plumb run --diff as the whole local loop, so the help text says so
90 // (RESEARCH.md Pitfall 1).
91 // The --diff bool is registered but not returned: every caller reads it
92 // through flagsGiven, because D-40 makes --diff-base alone mean diff
93 // mode too, and a bare bool cannot carry that. Returning a pointer no
94 // caller may trust invited one to trust it.
95 1 func addReportDiffFlags(fs *flag.FlagSet) (diffBase *string) {
96 1 fs.Bool(diffFlagName, false, "report coverage on lines changed since --diff-base (new files must be git add-ed to be included)")
97 1 return addDiffBaseFlag(fs)
98 1 }
99
100 // reportOptions carries every option renderReport needs. It replaces
101 // a growing positional parameter list: report and run both build one
102 // of these and pass it through unchanged.
103 type reportOptions struct {
104 ProfilePath string
105 Out string
106 Title string
107 Open bool
108 Diff bool
109 DiffBase string
110 }
111
112 // renderReport parses a coverage profile and writes it as an HTML
113 // report. It is the render path shared by report and run: run calls it
114 // only after go test exits 0. Its body references no process-level
115 // stream — all output goes through stdout and stderr.
116 1 func renderReport(opts reportOptions, stdout, stderr io.Writer) error {
117 1 // Find module root and path
118 1 modulePath, moduleRoot, err := resolveModule()
119 1 if err != nil {
120 1 return err
121 1 }
122
123 // Parse the coverage profile
124 1 profiles, err := profile.Parse(opts.ProfilePath)
125 1 if err != nil {
126 0 return fmt.Errorf("parsing profile: %w", err)
127 0 }
128
129 1 buildOpts := report.BuildOptions{
130 1 ModulePath: modulePath,
131 1 ModuleRoot: moduleRoot,
132 1 Title: opts.Title,
133 1 Diff: opts.Diff,
134 1 }
135 1
136 1 // diffCoverage runs before report.Build, not after it, so the
137 1 // changed-line map it produces is available for Build to filter
138 1 // and measure by (D-46, D-47). A skipped file from diffCoverage
139 1 // joins Build's own Skipped list below and prints once through the
140 1 // one loop that follows, rather than through two loops (D-48).
141 1 var diffSkipped []report.SkippedFile
142 1 if opts.Diff {
143 1 dr, err := diffCoverage(profiles, modulePath, moduleRoot, opts.DiffBase, opts.ProfilePath)
144 1 if err != nil {
145 0 return mapDiffCoverageError(err, stderr)
146 0 }
147 1 fmt.Fprintf(stdout, "plumb: diff against %s (merge base %s)\n", dr.Base, shortSHA(dr.MergeBase))
148 1 buildOpts.Changed = dr.Changed
149 1 buildOpts.DiffBase = dr.Base
150 1 buildOpts.Annotated = dr.Annotated
151 1 diffSkipped = dr.Skipped
152 }
153
154 // Build the report data
155 1 r, err := report.Build(profiles, buildOpts)
156 1 if err != nil {
157 0 return fmt.Errorf("building report: %w", err)
158 0 }
159 // Build and diffCoverage both apply the D-51 rule, because each one
160 // serves a path the other does not: Build owns the HTML file list,
161 // and diffCoverage serves `check`, which never calls Build. So both
162 // report the same file, and a plain append lists it twice. Merge on
163 // the file name to keep the D-48 promise of one entry per file.
164 // Build's own reason wins, because Build knows why it dropped the
165 // file from the report.
166 1 seen := make(map[string]bool, len(r.Skipped))
167 1 for _, s := range r.Skipped {
168 1 seen[s.Name] = true
169 1 }
170 1 for _, s := range diffSkipped {
171 1 if seen[s.Name] {
172 1 continue
173 }
174 0 seen[s.Name] = true
175 0 r.Skipped = append(r.Skipped, s)
176 }
177
178 // diffPart, when diff mode is on, is the diff percentage phrase
179 // that leads the summary line, with its own trailing separator.
180 // It reads r.DiffPct and r.DiffMeasured — the same numbers
181 // report.Build computed with the same profile.CoverableChanged
182 // call the HTML view uses, so the CLI line and the HTML header can
183 // never disagree. The statement and function percentages above
184 // keep coming from report.Build, which sums over every file in the
185 // profile, so a label never describes a scope other than its own
186 // (D-47).
187 1 var diffPart string
188 1 if opts.Diff {
189 1 if r.DiffMeasured {
190 1 diffPart = fmt.Sprintf("%.1f%% diff, ", report.TruncPct(r.DiffPct))
191 1 } else {
192 1 // D-37: a diff with nothing coverable to measure is not a
193 1 // 0% diff, so the phrase replaces the number.
194 1 diffPart = report.NoCoverableLinesChanged + ", "
195 1 }
196 }
197
198 // A file the run could not read, or that the diff left out, is
199 // named here. A percentage from fewer files than the profile
200 // measured must never look like a complete result.
201 1 printSkipped(stderr, r.Skipped)
202 1 // In diff mode an empty file list is the normal outcome of a
203 1 // commit that touched no Go code, so this guard applies only when
204 1 // diff mode is off.
205 1 if !opts.Diff && len(r.Skipped) > 0 && len(r.Files) == 0 {
206 0 return fmt.Errorf("no source file could be read for %s", opts.ProfilePath)
207 0 }
208
209 // Render to HTML
210 1 if err := report.RenderToFile(opts.Out, r); err != nil {
211 0 return fmt.Errorf("writing report: %w", err)
212 0 }
213 1 fmt.Fprintf(stdout, "plumb: wrote %s (%s%.1f%% stmts, %.1f%% funcs)\n", opts.Out, diffPart, report.TruncPct(r.StmtPct), report.TruncPct(r.FuncPct))
214 1
215 1 if opts.Open {
216 0 if err := openBrowser(opts.Out); err != nil {
217 0 fmt.Fprintf(stderr, "plumb: could not open browser: %v\n", err)
218 0 }
219 }
220 1 return nil
221 }
222
223 0 func openBrowser(path string) error {
224 0 abs, err := filepath.Abs(path)
225 0 if err != nil {
226 0 return err
227 0 }
228 0 url := "file://" + abs
229 0 switch runtime.GOOS {
230 0 case "linux":
231 0 return exec.Command("xdg-open", url).Start()
232 0 case "darwin":
233 0 return exec.Command("open", url).Start()
234 0 case "windows":
235 0 return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
236 0 default:
237 0 return fmt.Errorf("unsupported OS: %s", runtime.GOOS)
238 }
239 }
cmd/plumb/run.go
stmts 91.4% funcs 100.0%
Functions
NameLineCalls
runCmd 20 ×1
goTestArgs 123 ×1
splitPassthrough 136 ×1
ensureProfileDir 148 ×1
1 package main
2
3 import (
4 "errors"
5 "flag"
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "slices"
12 )
13
14 // runCmd runs go test with coverage over the given package pattern,
15 // then renders the resulting profile through the same path plumb
16 // report uses. It splits args at a bare "--" before parsing its own
17 // flags, so a plumb flag can never collide with a go test flag: plumb
18 // parses only what precedes the separator, and forwards what follows
19 // it to go test verbatim (D-03).
20 1 func runCmd(args []string, stdout, stderr io.Writer) error {
21 1 before, passthrough := splitPassthrough(args)
22 1
23 1 fs := flag.NewFlagSet("run", flag.ContinueOnError)
24 1 fs.SetOutput(stderr)
25 1 fs.Usage = func() {
26 1 fmt.Fprint(fs.Output(), `Usage: plumb run [flags] [pattern] [-- go test args]
27 1
28 1 Run tests with coverage and render the report.
29 1
30 1 Flags:
31 1 `)
32 1 fs.PrintDefaults()
33 1 fmt.Fprint(fs.Output(), `
34 1 Everything after -- is passed to go test unchanged: plumb does not
35 1 inspect, rewrite, reorder, or drop any of it.
36 1
37 1 Examples:
38 1 plumb run --open
39 1 plumb run ./internal/...
40 1 plumb run ./internal/... -- -race -count=1
41 1 `)
42 1 }
43
44 1 open, out, title := addReportFlags(fs)
45 1 diffBase := addReportDiffFlags(fs)
46 1
47 1 if err := parseFlags(fs, before); err != nil {
48 1 return err
49 1 }
50
51 // D-02: the package pattern is the first positional argument, and
52 // defaults to ./... A second positional argument is a mistyped
53 // invocation — fail loudly instead of silently dropping it.
54 1 pattern := "./..."
55 1 if fs.NArg() > 0 {
56 1 pattern = fs.Arg(0)
57 1 }
58 // Exit 2, the same code report and check give a second positional
59 // argument. The exit-code contract in main.go puts every wrong call
60 // in that class, and a pipeline that branches on 2 against 1 must
61 // not read the same mistake two ways depending on the subcommand.
62 1 if fs.NArg() > 1 {
63 1 fmt.Fprintf(stderr, "plumb: unexpected argument %q, place go test arguments after --\n", fs.Arg(1))
64 1 fs.Usage()
65 1 return newExitError(2, "unexpected argument")
66 1 }
67
68 1 goBin, err := exec.LookPath("go")
69 1 if err != nil {
70 1 return fmt.Errorf("finding go toolchain (install it from https://go.dev/dl/): %w", err)
71 1 }
72
73 1 if err := ensureProfileDir(defaultProfileDir); err != nil {
74 0 return fmt.Errorf("preparing %s directory: %w", defaultProfileDir, err)
75 0 }
76 1 profilePath := defaultProfilePath()
77 1
78 1 // The same pattern drives both -coverpkg and the trailing package
79 1 // argument, so a test in one package always credits the package it
80 1 // calls (D-01, D-02). passthrough is appended last and untouched
81 1 // (D-03).
82 1 cmd := exec.Command(goBin, goTestArgs(pattern, profilePath, passthrough)...)
83 1 cmd.Stdout = stdout
84 1 cmd.Stderr = stderr
85 1 if err := cmd.Run(); err != nil {
86 1 var exitErr *exec.ExitError
87 1 if errors.As(err, &exitErr) {
88 1 // The child ran and returned a non-zero status. Its own
89 1 // output already streamed live through stdout/stderr
90 1 // (D-08); dispatch adds the "plumb:" prefix and exits 1.
91 1 return fmt.Errorf("go test failed: %w", err)
92 1 }
93 // The child never started at all (e.g. the binary vanished
94 // between LookPath and Run).
95 0 return fmt.Errorf("running go test: %w", err)
96 }
97
98 // A caller who types only --diff-base means diff mode (D-40), so
99 // plumb never ignores a flag it was given.
100 1 given := flagsGiven(fs)
101 1
102 1 // renderReport is reachable only on this path — the decision to
103 1 // render is gated on cmd.Run()'s error alone, never on whether
104 1 // .plumb/coverage.out exists or holds data. go test writes a
105 1 // complete, valid profile even when a test assertion fails, so a
106 1 // red run must never render or replace a report (D-09, RUN-05).
107 1 //
108 1 // The profile is written moments before the diff is read on this
109 1 // path, so the D-45 staleness warning can never fire here (D-41).
110 1 return renderReport(reportOptions{
111 1 ProfilePath: profilePath,
112 1 Out: *out,
113 1 Title: *title,
114 1 Open: *open,
115 1 Diff: given[diffFlagName] || given[diffBaseFlagName],
116 1 DiffBase: *diffBase,
117 1 }, stdout, stderr)
118 }
119
120 // goTestArgs builds the go test argv in a fixed order: test,
121 // -coverpkg, -coverprofile, the pattern, then any pass-through
122 // arguments last. It is a pure function with no I/O.
123 1 func goTestArgs(pattern, profilePath string, extra []string) []string {
124 1 args := []string{
125 1 "test",
126 1 "-coverpkg=" + pattern,
127 1 "-coverprofile=" + profilePath,
128 1 pattern,
129 1 }
130 1 return append(args, extra...)
131 1 }
132
133 // splitPassthrough splits args at the first element that is exactly
134 // "--". Elements before it are plumb's own args; elements after it are
135 // passed to go test verbatim.
136 1 func splitPassthrough(args []string) (before, after []string) {
137 1 if i := slices.Index(args, "--"); i >= 0 {
138 1 return args[:i], args[i+1:]
139 1 }
140 1 return args, nil
141 }
142
143 // ensureProfileDir creates dir if it does not exist, and writes a
144 // self-ignoring .gitignore inside it when one is not already there.
145 // It never touches any path outside dir, and never overwrites an
146 // existing .gitignore — plumb must never edit a .gitignore the
147 // developer owns.
148 1 func ensureProfileDir(dir string) error {
149 1 if err := os.MkdirAll(dir, 0o755); err != nil {
150 0 return err
151 0 }
152 1 gitignorePath := filepath.Join(dir, ".gitignore")
153 1 if _, err := os.Stat(gitignorePath); os.IsNotExist(err) {
154 1 return os.WriteFile(gitignorePath, []byte("*\n"), 0o644)
155 1 } else if err != nil {
156 0 return err
157 0 }
158 1 return nil
159 }
internal/gitdiff/hunks.go
stmts 92.3% funcs 100.0%
Functions
NameLineCalls
ParseHunks 26 ×1
1 // Package gitdiff turns git diff output into changed line numbers.
2 // It never runs git itself; Runner does that. ParseHunks is a pure
3 // function so the hunk grammar can be tested against fixture text
4 // with no git process and no filesystem.
5 package gitdiff
6
7 import (
8 "fmt"
9 "regexp"
10 "strconv"
11 "strings"
12 )
13
14 // hunkHeaderRE matches a hunk header line up through its closing "@@"
15 // marker. Git appends a language-specific function name after the
16 // second marker for many file types, so the pattern stops there and
17 // leaves the rest of the line as discardable text.
18 var hunkHeaderRE = regexp.MustCompile(`^@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@`)
19
20 // ParseHunks reads git diff --unified=0 output and returns the added
21 // or modified line numbers for each file, keyed by the path from the
22 // "+++ b/<path>" line with its "b/" prefix removed. A rename needs no
23 // special handling: the "+++" line already names the file's current
24 // path, which is the path the coverage profile also names. ParseHunks
25 // performs no I/O.
26 1 func ParseHunks(diff string) (map[string][]int, error) {
27 1 changed := make(map[string][]int)
28 1 var current string
29 1
30 1 for _, line := range strings.Split(diff, "\n") {
31 1 switch {
32 1 case strings.HasPrefix(line, "diff --git "):
33 1 // A header block with no hunk — a mode-only change or a
34 1 // 100%-similarity rename — must contribute nothing rather
35 1 // than attach its absence to the file before it.
36 1 current = ""
37 1 case strings.HasPrefix(line, "+++ "):
38 1 path := strings.TrimPrefix(line, "+++ ")
39 1 // A deleted file has no new-side path, so it names no
40 1 // file for the hunks that follow (DIFF-03).
41 1 if path == "/dev/null" {
42 1 current = ""
43 1 continue
44 }
45 1 current = strings.TrimPrefix(path, "b/")
46 1 case strings.HasPrefix(line, "@@"):
47 1 // No named file: a header block with no "+++" line yet,
48 1 // or a deleted file. Either way the hunk belongs to no
49 1 // file this function reports.
50 1 if current == "" {
51 1 continue
52 }
53 1 m := hunkHeaderRE.FindStringSubmatch(line)
54 1 if m == nil {
55 1 return nil, fmt.Errorf("gitdiff: malformed hunk header: %q", line)
56 1 }
57 1 start, err := strconv.Atoi(m[1])
58 1 if err != nil {
59 0 return nil, fmt.Errorf("gitdiff: malformed hunk header: %q: %w", line, err)
60 0 }
61 1 count := 1
62 1 if m[2] != "" {
63 1 count, err = strconv.Atoi(m[2])
64 1 if err != nil {
65 0 return nil, fmt.Errorf("gitdiff: malformed hunk header: %q: %w", line, err)
66 0 }
67 }
68 // A new-side count of 0 is a pure deletion: it names no
69 // line on the new side, so it contributes nothing
70 // (DIFF-03).
71 1 for i := range count {
72 1 changed[current] = append(changed[current], start+i)
73 1 }
74 }
75 }
76 1 return changed, nil
77 }
internal/gitdiff/runner.go
stmts 80.4% funcs 91.6%
Functions
NameLineCalls
NewRunner 23 ×1
(*Runner).runOutput 36 ×1
(*Runner).RepoRoot 56 ×1
(*BadRefError).Error 77 ✗
checkRef 95 ×1
(*NoMergeBaseError).Error 114 ×1
(*Runner).MergeBase 129 ×1
(*Runner).Diff 161 ×1
(*Runner).RemoteHead 181 ×1
(*Runner).Verify 194 ×1
(*Runner).IsShallow 210 ×1
(*Runner).ResolveBase 229 ×1
1 package gitdiff
2
3 import (
4 "errors"
5 "fmt"
6 "os/exec"
7 "strings"
8 )
9
10 // ErrNotARepo reports that a Runner's working directory lies outside
11 // any git repository.
12 var ErrNotARepo = errors.New("not a git repository")
13
14 // Runner runs git commands against one working directory, using the
15 // git binary resolved once at construction.
16 type Runner struct {
17 git string
18 dir string
19 }
20
21 // NewRunner resolves the git binary on PATH and returns a Runner that
22 // runs every command inside dir.
23 1 func NewRunner(dir string) (*Runner, error) {
24 1 git, err := exec.LookPath("git")
25 1 if err != nil {
26 0 return nil, fmt.Errorf("finding git (install it and put it on PATH): %w", err)
27 0 }
28 1 return &Runner{git: git, dir: dir}, nil
29 }
30
31 // runOutput runs git with args inside r.dir and returns its captured
32 // stdout, trimmed of a trailing newline. On a non-zero exit it
33 // returns the *exec.ExitError so a caller can inspect Stderr for a
34 // specific failure message. Any other failure to start the process is
35 // a plain wrapped error.
36 1 func (r *Runner) runOutput(args ...string) (string, *exec.ExitError, error) {
37 1 cmd := exec.Command(r.git, args...)
38 1 cmd.Dir = r.dir
39 1 out, err := cmd.Output()
40 1 if err != nil {
41 1 var exitErr *exec.ExitError
42 1 if errors.As(err, &exitErr) {
43 1 // The child ran and returned a non-zero status.
44 1 return "", exitErr, nil
45 1 }
46 // The child never started at all (e.g. the binary vanished
47 // between LookPath and Run).
48 0 return "", nil, fmt.Errorf("running git %s: %w", strings.Join(args, " "), err)
49 }
50 1 return strings.TrimRight(string(out), "\n"), nil, nil
51 }
52
53 // RepoRoot returns the absolute path to the root of the git
54 // repository holding r.dir. It returns ErrNotARepo when git reports
55 // that the directory lies outside a repository.
56 1 func (r *Runner) RepoRoot() (string, error) {
57 1 out, exitErr, err := r.runOutput("rev-parse", "--show-toplevel")
58 1 if err != nil {
59 0 return "", err
60 0 }
61 1 if exitErr != nil {
62 1 if strings.Contains(string(exitErr.Stderr), "not a git repository") {
63 1 return "", ErrNotARepo
64 1 }
65 0 return "", fmt.Errorf("git rev-parse --show-toplevel: %w", exitErr)
66 }
67 1 return out, nil
68 }
69
70 // BadRefError reports that a reference does not resolve. D-49 maps
71 // this case to exit 2: the caller typed a value plumb cannot use.
72 type BadRefError struct {
73 Ref string
74 Stderr string
75 }
76
77 0 func (e *BadRefError) Error() string {
78 0 if e.Stderr != "" {
79 0 return fmt.Sprintf("reference %q does not resolve: %s", e.Ref, e.Stderr)
80 0 }
81 0 return fmt.Sprintf("reference %q does not resolve", e.Ref)
82 }
83
84 // flagLikeRef is the message a reference that begins with a hyphen
85 // carries. git would read such a value as an option rather than a
86 // revision (T-03-01).
87 const flagLikeRef = "the value looks like a flag; a reference must not begin with a hyphen"
88
89 // checkRef rejects a reference that git would read as an option. Diff
90 // defends itself with a trailing "--" separator, but merge-base and
91 // rev-parse accept no end-of-options separator, so this package makes
92 // the check itself rather than trusting each caller to make it first.
93 // Every method that puts a caller-supplied reference in an argv calls
94 // this before it builds one.
95 1 func checkRef(ref string) error {
96 1 if strings.HasPrefix(ref, "-") {
97 1 return &BadRefError{Ref: ref, Stderr: flagLikeRef}
98 1 }
99 1 return nil
100 }
101
102 // NoMergeBaseError reports that base and HEAD share no reachable
103 // common ancestor. git writes nothing at all for this case — an
104 // empty stdout and an empty stderr, confirmed against real git
105 // 2.55.0 — so this error carries the message git never wrote.
106 // Shallow distinguishes a shallow clone, whose fix is to fetch more
107 // history, from two histories that are genuinely unrelated, which no
108 // CI setting can fix.
109 type NoMergeBaseError struct {
110 Ref string
111 Shallow bool
112 }
113
114 1 func (e *NoMergeBaseError) Error() string {
115 1 if e.Shallow {
116 1 return fmt.Sprintf("cannot find a common ancestor with %s. This clone is shallow. Set fetch-depth: 0 on the checkout step, or run git fetch --deepen.", e.Ref)
117 1 }
118 1 return fmt.Sprintf("cannot find a common ancestor with %s. The two histories share no common commit.", e.Ref)
119 }
120
121 // MergeBase returns the merge base of base and HEAD. An exit code of
122 // 128 with a message on stderr is a bad reference, mapped to
123 // BadRefError. An exit code of 1 with empty stdout and empty stderr
124 // is the no-common-ancestor case: git writes nothing for it at all,
125 // so MergeBase detects the silence itself and calls IsShallow to
126 // decide the message a NoMergeBaseError should carry. Do not
127 // simplify this branch back into a plain stderr relay — for the
128 // second case there is no stderr to relay.
129 1 func (r *Runner) MergeBase(base string) (string, error) {
130 1 if err := checkRef(base); err != nil {
131 1 return "", err
132 1 }
133 1 out, exitErr, err := r.runOutput("merge-base", base, "HEAD")
134 1 if err != nil {
135 0 return "", err
136 0 }
137 1 if exitErr != nil {
138 1 stderr := strings.TrimRight(string(exitErr.Stderr), "\n")
139 1 switch {
140 1 case exitErr.ExitCode() == 1 && stderr == "":
141 1 shallow, shallowErr := r.IsShallow()
142 1 if shallowErr != nil {
143 0 return "", shallowErr
144 0 }
145 1 return "", &NoMergeBaseError{Ref: base, Shallow: shallow}
146 1 case stderr != "":
147 1 return "", &BadRefError{Ref: base, Stderr: stderr}
148 0 default:
149 0 return "", fmt.Errorf("git merge-base %s HEAD: %w", base, exitErr)
150 }
151 }
152 1 return out, nil
153 }
154
155 // Diff returns the unified, zero-context diff between rev and the
156 // working tree. The trailing "--" end-of-options separator keeps rev
157 // from ever being read as a flag, and checkRef rejects a leading
158 // hyphen before that (T-03-01). Diff holds both because the separator
159 // defends the argv shape and checkRef defends every method, including
160 // the ones no separator can defend.
161 1 func (r *Runner) Diff(rev string) (string, error) {
162 1 if err := checkRef(rev); err != nil {
163 1 return "", err
164 1 }
165 1 out, exitErr, err := r.runOutput("diff", "--unified=0", rev, "--")
166 1 if err != nil {
167 0 return "", err
168 0 }
169 1 if exitErr != nil {
170 0 return "", fmt.Errorf("git diff --unified=0 %s: %w", rev, exitErr)
171 0 }
172 1 return out, nil
173 }
174
175 // RemoteHead resolves refs/remotes/origin/HEAD to a directly usable
176 // revision such as "origin/main". When the ref is unset, it returns
177 // an empty string and a nil error: an unset remote HEAD is not
178 // itself a failure, it is the signal for ResolveBase to fall through
179 // D-43's candidate chain. The exact message git prints is not a
180 // stable contract, so it is never inspected, only the exit code.
181 1 func (r *Runner) RemoteHead() (string, error) {
182 1 out, exitErr, err := r.runOutput("rev-parse", "--abbrev-ref", "refs/remotes/origin/HEAD")
183 1 if err != nil {
184 0 return "", err
185 0 }
186 1 if exitErr != nil {
187 1 return "", nil
188 1 }
189 1 return out, nil
190 }
191
192 // Verify resolves ref to the commit it names. A reference that does
193 // not resolve returns a BadRefError carrying git's own stderr text.
194 1 func (r *Runner) Verify(ref string) (string, error) {
195 1 if err := checkRef(ref); err != nil {
196 1 return "", err
197 1 }
198 1 out, exitErr, err := r.runOutput("rev-parse", "--verify", ref+"^{commit}")
199 1 if err != nil {
200 0 return "", err
201 0 }
202 1 if exitErr != nil {
203 1 return "", &BadRefError{Ref: ref, Stderr: strings.TrimRight(string(exitErr.Stderr), "\n")}
204 1 }
205 1 return out, nil
206 }
207
208 // IsShallow reports whether r.dir's repository is a shallow clone.
209 // It exits 0 inside any repository, whichever answer it gives.
210 1 func (r *Runner) IsShallow() (bool, error) {
211 1 out, exitErr, err := r.runOutput("rev-parse", "--is-shallow-repository")
212 1 if err != nil {
213 0 return false, err
214 0 }
215 1 if exitErr != nil {
216 0 return false, fmt.Errorf("git rev-parse --is-shallow-repository: %w", exitErr)
217 0 }
218 1 return out == "true", nil
219 }
220
221 // ResolveBase implements D-43. A given reference is verified and
222 // returned unchanged. An empty given tries RemoteHead first, then
223 // the candidates origin/main, origin/master, main, and master in
224 // that order, verifying each and returning the first that resolves.
225 // When none resolves, it returns a plain error naming --diff-base;
226 // the caller decides the exit code (D-49 gives this case exit 1,
227 // since plumb was called correctly and the environment cannot
228 // answer).
229 1 func (r *Runner) ResolveBase(given string) (string, error) {
230 1 if given != "" {
231 1 if _, err := r.Verify(given); err != nil {
232 1 return "", err
233 1 }
234 1 return given, nil
235 }
236
237 1 head, err := r.RemoteHead()
238 1 if err != nil {
239 0 return "", err
240 0 }
241
242 1 candidates := []string{"origin/main", "origin/master", "main", "master"}
243 1 if head != "" {
244 1 candidates = append([]string{head}, candidates...)
245 1 }
246
247 1 for _, c := range candidates {
248 1 if _, err := r.Verify(c); err == nil {
249 1 return c, nil
250 1 }
251 }
252 1 return "", fmt.Errorf("no default git reference resolved; pass --diff-base with the reference to compare against")
253 }
internal/gittest/gittest.go
stmts 100.0% funcs 100.0%
Functions
NameLineCalls
Run 26 ×1
RunIn 33 ×1
Output 42 ×1
OutputIn 49 ×1
Init 62 ×1
CommitAll 74 ×1
HeadSHA 81 ×1
1 // Package gittest builds throwaway git repositories for tests.
2 //
3 // It exists because two test packages need the same git plumbing:
4 // cmd/plumb drives the whole CLI against a fixture module, and
5 // internal/gitdiff drives the Runner against hand-written files. What
6 // they seed differs, so each keeps its own seeding helper; what they
7 // run is identical, and a fix for a git version difference must not
8 // land in one copy and be forgotten in the other.
9 //
10 // Do NOT call t.Parallel in a test that uses Chdir below: t.Chdir
11 // changes the working directory of the whole process, and Go panics
12 // when that is combined with a parallel test. Isolation comes from
13 // t.TempDir, not from parallelism.
14 package gittest
15
16 import (
17 "os/exec"
18 "strings"
19 "testing"
20
21 "github.com/stretchr/testify/require"
22 )
23
24 // Run runs git in the test's current working directory and fails the
25 // test when git exits non-zero, reporting git's combined output.
26 1 func Run(t *testing.T, args ...string) {
27 1 t.Helper()
28 1 RunIn(t, ".", args...)
29 1 }
30
31 // RunIn runs git inside dir, so a test can build more than one
32 // repository side by side without changing its working directory.
33 1 func RunIn(t *testing.T, dir string, args ...string) {
34 1 t.Helper()
35 1 full := append([]string{"-C", dir}, args...)
36 1 out, err := exec.Command("git", full...).CombinedOutput()
37 1 require.NoError(t, err, "git -C %s %v: %s", dir, args, out)
38 1 }
39
40 // Output runs git in the current working directory and returns its
41 // stdout with the trailing newline removed.
42 1 func Output(t *testing.T, args ...string) string {
43 1 t.Helper()
44 1 return OutputIn(t, ".", args...)
45 1 }
46
47 // OutputIn runs git inside dir and returns its stdout with the
48 // trailing newline removed.
49 1 func OutputIn(t *testing.T, dir string, args ...string) string {
50 1 t.Helper()
51 1 full := append([]string{"-C", dir}, args...)
52 1 out, err := exec.Command("git", full...).Output()
53 1 require.NoError(t, err, "git -C %s %v", dir, args)
54 1 return strings.TrimSpace(string(out))
55 1 }
56
57 // Init creates a repository in dir and sets the identity every commit
58 // needs. Pass an empty branch to accept git's own default; pass a name
59 // to fix it, which a test that depends on a branch name must do,
60 // because the default differs between git versions and between user
61 // configurations.
62 1 func Init(t *testing.T, dir, branch string) {
63 1 t.Helper()
64 1 args := []string{"init", "-q"}
65 1 if branch != "" {
66 1 args = append(args, "-b", branch)
67 1 }
68 1 RunIn(t, dir, args...)
69 1 RunIn(t, dir, "config", "user.email", "plumb@example.com")
70 1 RunIn(t, dir, "config", "user.name", "plumb")
71 }
72
73 // CommitAll stages everything in dir and commits it.
74 1 func CommitAll(t *testing.T, dir, message string) {
75 1 t.Helper()
76 1 RunIn(t, dir, "add", "-A")
77 1 RunIn(t, dir, "commit", "-q", "-m", message)
78 1 }
79
80 // HeadSHA returns the full SHA of dir's HEAD commit.
81 1 func HeadSHA(t *testing.T, dir string) string {
82 1 t.Helper()
83 1 return OutputIn(t, dir, "rev-parse", "HEAD")
84 1 }
internal/profile/annotate.go
stmts 96.7% funcs 100.0%
Functions
NameLineCalls
Annotate 28 ×1
CoverableChanged 70 ×1
1 package profile
2
3 import (
4 "os"
5 "strings"
6
7 "golang.org/x/tools/cover"
8 )
9
10 type LineStatus int
11
12 const (
13 Uncoverable LineStatus = iota
14 Covered
15 Uncovered
16 Partial // future: branch coverage
17 )
18
19 type AnnotatedLine struct {
20 Number int
21 Source string
22 Status LineStatus
23 Count int
24 }
25
26 // Annotate reads the source file at diskPath and annotates each line
27 // with coverage status from the profile blocks.
28 1 func Annotate(p *cover.Profile, diskPath string) ([]AnnotatedLine, error) {
29 1 data, err := os.ReadFile(diskPath)
30 1 if err != nil {
31 1 return nil, err
32 1 }
33
34 1 raw := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
35 1 lines := make([]AnnotatedLine, len(raw))
36 1 for i, src := range raw {
37 1 lines[i] = AnnotatedLine{
38 1 Number: i + 1,
39 1 Source: src,
40 1 Status: Uncoverable,
41 1 }
42 1 }
43
44 1 for _, b := range p.Blocks {
45 1 for ln := b.StartLine; ln <= b.EndLine; ln++ {
46 1 if ln-1 >= len(lines) {
47 0 break
48 }
49 1 l := &lines[ln-1]
50 1 if b.Count > 0 {
51 1 l.Status = Covered
52 1 l.Count = max(l.Count, b.Count)
53 1 } else if l.Status != Covered {
54 1 l.Status = Uncovered
55 1 }
56 }
57 }
58
59 1 return lines, nil
60 }
61
62 // CoverableChanged filters changed line numbers down to the ones the
63 // profile annotated as Covered or Uncovered. A changed number the
64 // annotated lines do not hold is skipped, and so is an Uncoverable
65 // line — a brace, an import, a comment — which is how a changed line
66 // with nothing to cover stays out of both sides of the ratio (D-36).
67 // It is the one implementation of that rule: cmd/plumb/diffcov.go and
68 // internal/report both call it, so the CLI percentage and the HTML
69 // percentage can never disagree.
70 1 func CoverableChanged(changed []int, lines []AnnotatedLine) (covered, total int) {
71 1 // Report.Build calls this for every file in the profile, and passes
72 1 // a nil slice for each one the diff never named (D-46). Leave before
73 1 // indexing the file: the loop below would perform no lookup anyway,
74 1 // and the index costs one map entry per line of every untouched
75 1 // file in the module.
76 1 if len(changed) == 0 {
77 1 return 0, 0
78 1 }
79
80 1 byLine := make(map[int]AnnotatedLine, len(lines))
81 1 for _, l := range lines {
82 1 byLine[l.Number] = l
83 1 }
84 1 for _, n := range changed {
85 1 l, ok := byLine[n]
86 1 if !ok || l.Status == Uncoverable {
87 1 continue
88 }
89 1 total++
90 1 if l.Status == Covered {
91 1 covered++
92 1 }
93 }
94 1 return covered, total
95 }
internal/profile/funcs.go
stmts 93.1% funcs 100.0%
Functions
NameLineCalls
WalkFuncs 21 ×1
funcName 53 ×1
callCount 70 ×1
1 package profile
2
3 import (
4 "fmt"
5 "go/ast"
6 "go/parser"
7 "go/token"
8 "os"
9
10 "golang.org/x/tools/cover"
11 )
12
13 type AnnotatedFunc struct {
14 Name string
15 StartLine int
16 Count int
17 }
18
19 // WalkFuncs parses the source file at diskPath and returns all function
20 // declarations annotated with call counts from the profile blocks.
21 1 func WalkFuncs(p *cover.Profile, diskPath string) ([]AnnotatedFunc, error) {
22 1 src, err := os.ReadFile(diskPath)
23 1 if err != nil {
24 1 return nil, err
25 1 }
26
27 1 fset := token.NewFileSet()
28 1 f, err := parser.ParseFile(fset, diskPath, src, 0)
29 1 if err != nil {
30 0 return nil, err
31 0 }
32
33 1 var funcs []AnnotatedFunc
34 1 ast.Inspect(f, func(n ast.Node) bool {
35 1 fd, ok := n.(*ast.FuncDecl)
36 1 if !ok || fd.Body == nil {
37 1 return true
38 1 }
39 1 bodyStart := fset.Position(fd.Body.Lbrace).Line
40 1 bodyEnd := fset.Position(fd.Body.Rbrace).Line
41 1 funcs = append(funcs, AnnotatedFunc{
42 1 Name: funcName(fd),
43 1 StartLine: fset.Position(fd.Pos()).Line,
44 1 Count: callCount(bodyStart, bodyEnd, p.Blocks),
45 1 })
46 1 return false
47 })
48
49 1 return funcs, nil
50 }
51
52 // funcName formats a function name, including receiver type for methods.
53 1 func funcName(fd *ast.FuncDecl) string {
54 1 if fd.Recv == nil || len(fd.Recv.List) == 0 {
55 1 return fd.Name.Name
56 1 }
57 1 recv := fd.Recv.List[0].Type
58 1 switch t := recv.(type) {
59 1 case *ast.StarExpr:
60 1 if ident, ok := t.X.(*ast.Ident); ok {
61 1 return fmt.Sprintf("(*%s).%s", ident.Name, fd.Name.Name)
62 1 }
63 1 case *ast.Ident:
64 1 return fmt.Sprintf("%s.%s", t.Name, fd.Name.Name)
65 }
66 0 return fd.Name.Name
67 }
68
69 // callCount returns the hit count of the first block inside the function body.
70 1 func callCount(bodyStart, bodyEnd int, blocks []cover.ProfileBlock) int {
71 1 for _, b := range blocks {
72 1 if b.StartLine >= bodyStart && b.StartLine <= bodyEnd {
73 1 return b.Count
74 1 }
75 }
76 1 return 0
77 }
internal/profile/profile.go
stmts 85.1% funcs 100.0%
Functions
NameLineCalls
Parse 20 ×1
Resolve 41 ×1
ResolveSafe 56 ×1
evalNearest 94 ×1
contains 135 ×1
1 package profile
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8
9 "golang.org/x/tools/cover"
10 )
11
12 // ParsedProfile wraps a cover.Profile with its resolved filename.
13 type ParsedProfile struct {
14 FileName string // import-path style, e.g. "github.com/foo/bar/pkg/auth.go"
15 CoverProfile *cover.Profile
16 }
17
18 // Parse reads a .coverprofile file and returns one ParsedProfile per
19 // non-test file.
20 1 func Parse(path string) ([]*ParsedProfile, error) {
21 1 raw, err := cover.ParseProfiles(path)
22 1 if err != nil {
23 1 return nil, err
24 1 }
25 1 var out []*ParsedProfile
26 1 for _, p := range raw {
27 1 if strings.HasSuffix(p.FileName, "_test.go") {
28 0 continue
29 }
30 1 out = append(out, &ParsedProfile{
31 1 FileName: p.FileName,
32 1 CoverProfile: p,
33 1 })
34 }
35 1 return out, nil
36 }
37
38 // Resolve maps an import-path filename to a disk path. It trims a
39 // prefix and joins; it does not remove a parent-directory segment.
40 // Prefer ResolveSafe for a name that comes from a profile file.
41 1 func Resolve(filename, modulePath, moduleRoot string) string {
42 1 rel := strings.TrimPrefix(filename, modulePath+"/")
43 1 return filepath.Join(moduleRoot, filepath.FromSlash(rel))
44 1 }
45
46 // ResolveSafe maps an import-path filename to a disk path and refuses
47 // a name that resolves outside the module root. A coverage profile is
48 // an input file, and a build downloads one as an artifact, so a name
49 // in it can carry parent-directory segments, and a file inside the
50 // tree can be a link to a file outside it. Every caller that reads
51 // the file it gets back must use this function, not Resolve.
52 //
53 // The check runs on the real path of both sides. A text comparison
54 // alone reads the link name, not its target, so a link inside the
55 // module root that points outside it would pass.
56 1 func ResolveSafe(filename, modulePath, moduleRoot string) (string, error) {
57 1 diskPath := Resolve(filename, modulePath, moduleRoot)
58 1
59 1 realRoot, err := filepath.EvalSymlinks(moduleRoot)
60 1 if err != nil {
61 1 return "", fmt.Errorf("resolving the module root %s: %w", moduleRoot, err)
62 1 }
63 1 realRoot, err = filepath.Abs(realRoot)
64 1 if err != nil {
65 0 return "", fmt.Errorf("resolving the module root %s: %w", moduleRoot, err)
66 0 }
67
68 // A file that does not exist has no real path. Check the nearest
69 // parent that does exist, so a missing file still gets a
70 // containment verdict instead of an error about the link.
71 1 realPath, err := evalNearest(diskPath)
72 1 if err != nil {
73 0 return "", fmt.Errorf("%s: %w", filename, err)
74 0 }
75
76 1 if !contains(realRoot, realPath) {
77 1 return "", fmt.Errorf("%s: path leaves the module root", filename)
78 1 }
79 1 return diskPath, nil
80 }
81
82 // maxLinkHops bounds the link chain evalNearest follows, so a cycle
83 // of links cannot hold the walk open.
84 const maxLinkHops = 64
85
86 // evalNearest returns the real path of p.
87 //
88 // It follows a link whose target does not exist by hand, because
89 // EvalSymlinks fails on such a link and would otherwise leave the
90 // link's own name as the answer — a link that points outside the
91 // module root would then read as contained. When neither the path nor
92 // its target exists, it resolves the nearest parent that does and
93 // rejoins the remainder, so a link in any parent still gets resolved.
94 1 func evalNearest(p string) (string, error) {
95 1 abs, err := filepath.Abs(p)
96 1 if err != nil {
97 0 return "", err
98 0 }
99
100 1 for i := 0; i < maxLinkHops; i++ {
101 1 if real, err := filepath.EvalSymlinks(abs); err == nil {
102 1 return real, nil
103 1 }
104 1 fi, err := os.Lstat(abs)
105 1 if err != nil || fi.Mode()&os.ModeSymlink == 0 {
106 1 break
107 }
108 1 target, err := os.Readlink(abs)
109 1 if err != nil {
110 0 break
111 }
112 1 if !filepath.IsAbs(target) {
113 0 target = filepath.Join(filepath.Dir(abs), target)
114 0 }
115 1 abs = target
116 }
117
118 1 rest := ""
119 1 cur := abs
120 1 for {
121 1 real, err := filepath.EvalSymlinks(cur)
122 1 if err == nil {
123 1 return filepath.Join(real, rest), nil
124 1 }
125 1 parent := filepath.Dir(cur)
126 1 if parent == cur {
127 0 return abs, nil
128 0 }
129 1 rest = filepath.Join(filepath.Base(cur), rest)
130 1 cur = parent
131 }
132 }
133
134 // contains reports whether p is root itself or lies below it.
135 1 func contains(root, p string) bool {
136 1 rel, err := filepath.Rel(root, p)
137 1 if err != nil {
138 0 return false
139 0 }
140 1 return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
141 }
internal/profile/resolve.go
stmts 100.0% funcs 100.0%
Functions
NameLineCalls
FindGoMod 12 ×1
ReadModulePath 28 ×1
1 package profile
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7
8 "golang.org/x/mod/modfile"
9 )
10
11 // FindGoMod walks up from start until it finds a go.mod file.
12 1 func FindGoMod(start string) (string, error) {
13 1 dir := start
14 1 for {
15 1 path := filepath.Join(dir, "go.mod")
16 1 if _, err := os.Stat(path); err == nil {
17 1 return path, nil
18 1 }
19 1 parent := filepath.Dir(dir)
20 1 if parent == dir {
21 1 return "", fmt.Errorf("go.mod not found")
22 1 }
23 1 dir = parent
24 }
25 }
26
27 // ReadModulePath reads the module path from a go.mod file.
28 1 func ReadModulePath(gomodPath string) (string, error) {
29 1 data, err := os.ReadFile(gomodPath)
30 1 if err != nil {
31 1 return "", fmt.Errorf("reading go.mod: %w", err)
32 1 }
33 1 f, err := modfile.Parse("go.mod", data, nil)
34 1 if err != nil {
35 1 return "", fmt.Errorf("parsing go.mod: %w", err)
36 1 }
37 1 return f.Module.Mod.Path, nil
38 }
internal/profile/staleness.go
stmts 100.0% funcs 100.0%
Functions
NameLineCalls
ProfileModTime 20 ×1
StaleAgainst 35 ×1
1 package profile
2
3 import (
4 "os"
5 "time"
6 )
7
8 // StaleReason is the reason a source file newer than its profile
9 // carries. plumb reports the number anyway and puts this caveat beside
10 // it, so a reader can tell a measurement that may describe older text
11 // (D-45).
12 const StaleReason = "newer than the profile"
13
14 // ProfileModTime returns the modification time of the coverage profile
15 // at path. It reports the zero time when the profile cannot be stat-ed,
16 // which StaleAgainst reads as "make no staleness claim at all". A stat
17 // failure must not fail a build: the run already succeeded once to
18 // produce the profile, and the reason would be unrelated to coverage
19 // (D-45).
20 1 func ProfileModTime(path string) time.Time {
21 1 info, err := os.Stat(path)
22 1 if err != nil {
23 1 return time.Time{}
24 1 }
25 1 return info.ModTime()
26 }
27
28 // StaleAgainst reports whether the source file at diskPath changed
29 // after the profile was written. It answers false whenever it cannot
30 // know: a zero profileModTime (the profile could not be stat-ed) and a
31 // source file that cannot be stat-ed both produce false rather than an
32 // error. An unreadable source file is already reported through the
33 // absent-file reason, and Annotate names it if it truly cannot be read,
34 // so a second error here would say the same thing twice.
35 1 func StaleAgainst(profileModTime time.Time, diskPath string) bool {
36 1 if profileModTime.IsZero() {
37 1 return false
38 1 }
39 1 info, err := os.Stat(diskPath)
40 1 if err != nil {
41 1 return false
42 1 }
43 1 return info.ModTime().After(profileModTime)
44 }
internal/profile/stats.go
stmts 100.0% funcs 100.0%
Functions
NameLineCalls
StmtTotals 8 ×1
StmtTotalsAll 26 ×1
Percent 51 ×1
FuncTotals 61 ×1
FuncPct 71 ×1
1 package profile
2
3 import "golang.org/x/tools/cover"
4
5 // StmtTotals returns the NumStmt-weighted covered and total statement
6 // counts for one profile — the same weight go tool cover -func
7 // applies to a file. Returns 0, 0 for a nil profile.
8 1 func StmtTotals(p *cover.Profile) (covered, total int) {
9 1 if p == nil {
10 1 return 0, 0
11 1 }
12 1 for _, b := range p.Blocks {
13 1 total += b.NumStmt
14 1 if b.Count > 0 {
15 1 covered += b.NumStmt
16 1 }
17 }
18 1 return covered, total
19 }
20
21 // StmtTotalsAll sums StmtTotals over every parsed profile, the module
22 // total that check compares against a threshold. An entry with a nil
23 // CoverProfile is skipped rather than counted as zero statements,
24 // so a profile that failed to parse cannot silently narrow the
25 // denominator.
26 1 func StmtTotalsAll(profiles []*ParsedProfile) (covered, total int) {
27 1 for _, pp := range profiles {
28 1 if pp == nil || pp.CoverProfile == nil {
29 1 continue
30 }
31 1 c, t := StmtTotals(pp.CoverProfile)
32 1 covered += c
33 1 total += t
34 }
35 1 return covered, total
36 }
37
38 // Percent returns covered out of total as a percentage, and 0 when
39 // total is not positive.
40 //
41 // Every coverage number plumb reports is this one division guarded
42 // against an empty denominator. The guard was written out at eight
43 // call sites before, so a change to the empty-denominator rule reached
44 // only the site it was made in.
45 //
46 // A 0 here means "nothing to measure", which is the right answer for a
47 // percentage but not always the right answer for a caller: D-37 needs
48 // "no coverable line changed" to differ from "0% of the changed lines
49 // are covered". A caller that must tell those apart tests total itself
50 // and does not ask this function.
51 1 func Percent(covered, total int) float64 {
52 1 if total <= 0 {
53 1 return 0
54 1 }
55 1 return float64(covered) / float64(total) * 100
56 }
57
58 // FuncTotals returns the covered and total function counts for a set of
59 // annotated funcs. A function counts as covered when its body ran at
60 // least once — the one place that rule is written down.
61 1 func FuncTotals(funcs []AnnotatedFunc) (covered, total int) {
62 1 for _, f := range funcs {
63 1 if f.Count > 0 {
64 1 covered++
65 1 }
66 }
67 1 return covered, len(funcs)
68 }
69
70 // FuncPct returns the function coverage percentage for a set of annotated funcs.
71 1 func FuncPct(funcs []AnnotatedFunc) float64 {
72 1 return Percent(FuncTotals(funcs))
73 1 }
internal/report/html.go
stmts 88.6% funcs 100.0%
Functions
NameLineCalls
fileID 39 ×1
Build 45 ×1
Render 184 ×1
RenderToFile 189 ×1
renderLines 202 ×1
highlightLines 289 ×1
1 package report
2
3 import (
4 "bytes"
5 "crypto/md5"
6 "embed"
7 "fmt"
8 "html/template"
9 "io"
10 "os"
11 "path"
12 "strings"
13 "sync"
14
15 "github.com/alecthomas/chroma/v2"
16 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
17 "github.com/alecthomas/chroma/v2/lexers"
18 "github.com/alecthomas/chroma/v2/styles"
19 "github.com/z3le/plumb/internal/profile"
20 )
21
22 //go:embed templates
23 var templateFS embed.FS
24
25 var tmpl = template.Must(
26 template.New("report.html.tmpl").Funcs(template.FuncMap{
27 "pctClass": pctClass,
28 "fileID": fileID,
29 "printf": fmt.Sprintf,
30 // noCoverableLines gives the template the same constant the Go
31 // code prints, so the phrase has one definition and the HTML
32 // cannot drift from the terminal output (D-37, D-51).
33 1 "noCoverableLines": func() string { return NoCoverableLinesChanged },
34 1 "pct": func(v float64) string { return fmt.Sprintf("%.1f", TruncPct(v)) },
35 }).ParseFS(templateFS, "templates/report.html.tmpl"),
36 )
37
38 // fileID returns a stable HTML id from a filename.
39 1 func fileID(name string) string {
40 1 return fmt.Sprintf("f%x", md5.Sum([]byte(name)))
41 1 }
42
43 // Build constructs a Report from parsed coverage profiles. See
44 // BuildOptions for the fields it reads.
45 1 func Build(profiles []*profile.ParsedProfile, opts BuildOptions) (*Report, error) {
46 1 title := opts.Title
47 1 if title == "" {
48 1 title = path.Base(opts.ModulePath)
49 1 }
50
51 1 r := &Report{Title: title, Diff: opts.Diff, DiffBase: opts.DiffBase}
52 1
53 1 var totalStmtCovered, totalStmtTotal int
54 1 var totalFuncsCovered, totalFuncsTotal int
55 1 var totalDiffCovered, totalDiffTotal int
56 1
57 1 for _, pp := range profiles {
58 1 // A nil profile carries no block to count or annotate. Skip it,
59 1 // the way StmtTotalsAll skips one, so the two agree.
60 1 if pp == nil || pp.CoverProfile == nil {
61 0 continue
62 }
63
64 1 diskPath, err := profile.ResolveSafe(pp.FileName, opts.ModulePath, opts.ModuleRoot)
65 1 if err != nil {
66 0 return nil, err
67 0 }
68
69 // A source file the run cannot read or highlight drops out of
70 // the report, the same way a file that fails to parse drops its
71 // function list. A report shows the files it can show: a build
72 // downloads a profile whose tree is not complete, and one absent
73 // file must not remove every other file from the report.
74 //
75 // A caller that already annotated this file passes it through
76 // opts.Annotated, so a diff run reads each changed file from
77 // disk once rather than twice. Annotate is deterministic in its
78 // two inputs, so a cached value equals the one this call would
79 // produce.
80 1 lines, cached := opts.Annotated[pp.FileName]
81 1 if !cached {
82 1 lines, err = profile.Annotate(pp.CoverProfile, diskPath)
83 1 if err != nil {
84 1 r.Skipped = append(r.Skipped, SkippedFile{Name: pp.FileName, Reason: err.Error()})
85 1 continue
86 }
87 }
88
89 1 funcs, err := profile.WalkFuncs(pp.CoverProfile, diskPath)
90 1 if err != nil {
91 1 // Non-fatal — some files may fail AST parsing (generated code etc.)
92 1 funcs = nil
93 1 }
94
95 // changedLines is nil for a file the changed map does not name,
96 // or when diff mode is off (opts.Changed is nil). renderLines
97 // and CoverableChanged both treat a nil slice as an empty
98 // set, so nothing downstream needs a second branch for "diff
99 // mode is off" (D-46).
100 1 changedLines, named := opts.Changed[pp.FileName]
101 1
102 1 stmtCovered, stmtTotal := profile.StmtTotals(pp.CoverProfile)
103 1 stmtPct := profile.Percent(stmtCovered, stmtTotal)
104 1 funcPct := profile.FuncPct(funcs)
105 1
106 1 // accumulate totals — unconditional, so filtering the file list
107 1 // below can never change a module-wide number (D-47).
108 1 totalStmtCovered += stmtCovered
109 1 totalStmtTotal += stmtTotal
110 1 funcCovered, funcTotal := profile.FuncTotals(funcs)
111 1 totalFuncsCovered += funcCovered
112 1 totalFuncsTotal += funcTotal
113 1
114 1 // The diff accumulator stays unconditional too: a file the
115 1 // changed map does not name contributes zero to both counters
116 1 // via CoverableChanged(nil, lines), which is the one
117 1 // implementation of D-36 the CLI path also calls.
118 1 diffCovered, diffTotal := profile.CoverableChanged(changedLines, lines)
119 1 totalDiffCovered += diffCovered
120 1 totalDiffTotal += diffTotal
121 1
122 1 diffPct := profile.Percent(diffCovered, diffTotal)
123 1
124 1 // Decide whether the file reaches the file list before any of
125 1 // its source is rendered. Highlighting is the most expensive
126 1 // work in the report, and in diff mode most files leave through
127 1 // one of the two branches below — rendering them first spent
128 1 // milliseconds each to produce HTML nothing ever reads. Every
129 1 // total above is already accumulated, so leaving the iteration
130 1 // here cannot change a module-wide number (D-47).
131 1 switch {
132 1 case !opts.Diff:
133 // Diff mode is off: every file the profile mentions renders,
134 // exactly as it did before this plan.
135 1 case !named:
136 1 // Case 1: the diff did not touch this file. Drop it from
137 1 // Files and add no skip entry — a file the diff did not
138 1 // touch is out of scope, not an omission, and a skip line
139 1 // for every untouched file would bury the real ones (D-46).
140 1 continue
141 1 case diffTotal > 0:
142 // Case 2: the diff named the file and it carries at least
143 // one coverable changed line.
144 1 default:
145 1 // Case 3: the diff named the file, but every line it
146 1 // touched there is Uncoverable. Leave it out of Files and
147 1 // name it in Skipped with the same phrase D-37 prints at
148 1 // whole-diff scope — one rule, read the same at both
149 1 // scopes (D-51).
150 1 r.Skipped = append(r.Skipped, SkippedFile{Name: pp.FileName, Reason: NoCoverableLinesChanged})
151 1 continue
152 }
153
154 1 rendered, err := renderLines(lines, diskPath, changedLines)
155 1 if err != nil {
156 0 r.Skipped = append(r.Skipped, SkippedFile{Name: pp.FileName, Reason: err.Error()})
157 0 continue
158 }
159
160 1 r.Files = append(r.Files, FileReport{
161 1 Name: pp.FileName,
162 1 ShortName: path.Base(pp.FileName),
163 1 Pkg: shortPkg(pp.FileName, opts.ModulePath),
164 1 StmtPct: stmtPct,
165 1 FuncPct: funcPct,
166 1 DiffPct: diffPct,
167 1 Lines: rendered,
168 1 Funcs: funcs,
169 1 })
170 }
171
172 1 r.StmtPct = profile.Percent(totalStmtCovered, totalStmtTotal)
173 1 r.FuncPct = profile.Percent(totalFuncsCovered, totalFuncsTotal)
174 1 // DiffMeasured is D-37's signal: a diff with no coverable changed
175 1 // line anywhere is not a 0% diff, it is no diff at all, so DiffPct
176 1 // must not be rendered unless this is true.
177 1 r.DiffMeasured = totalDiffTotal > 0
178 1 r.DiffPct = profile.Percent(totalDiffCovered, totalDiffTotal)
179 1
180 1 return r, nil
181 }
182
183 // Render writes the HTML report to w.
184 1 func Render(w io.Writer, r *Report) error {
185 1 return tmpl.Execute(w, r)
186 1 }
187
188 // RenderToFile writes the HTML report to the given path.
189 1 func RenderToFile(outPath string, r *Report) error {
190 1 var buf bytes.Buffer
191 1 if err := Render(&buf, r); err != nil {
192 0 return err
193 0 }
194 1 return os.WriteFile(outPath, buf.Bytes(), 0o644)
195 }
196
197 // renderLines runs chroma syntax highlighting on the source file and
198 // returns RenderedLines with HTML source for each line. changed holds
199 // the line numbers the diff touched in this file; a nil or empty
200 // slice marks every line unchanged, which is what a caller outside
201 // diff mode passes (D-46).
202 1 func renderLines(lines []profile.AnnotatedLine, diskPath string, changed []int) ([]RenderedLine, error) {
203 1 // Read source for chroma
204 1 src := make([]string, len(lines))
205 1 for i, l := range lines {
206 1 src[i] = l.Source
207 1 }
208 1 highlighted, err := highlightLines(strings.Join(src, "\n"), diskPath)
209 1 if err != nil {
210 0 // Fall back to plain text on highlight failure
211 0 highlighted = make([]template.HTML, len(lines))
212 0 for i, l := range lines {
213 0 highlighted[i] = template.HTML(template.HTMLEscapeString(l.Source))
214 0 }
215 }
216
217 1 changedSet := make(map[int]bool, len(changed))
218 1 for _, n := range changed {
219 1 changedSet[n] = true
220 1 }
221
222 1 out := make([]RenderedLine, len(lines))
223 1 for i, l := range lines {
224 1 h := template.HTML("")
225 1 if i < len(highlighted) {
226 1 h = highlighted[i]
227 1 }
228 1 out[i] = RenderedLine{
229 1 Number: l.Number,
230 1 HTML: h,
231 1 Status: l.Status,
232 1 Count: l.Count,
233 1 Changed: changedSet[l.Number],
234 1 }
235 }
236 1 return out, nil
237 }
238
239 // lexerFor returns the chroma lexer for a file name, and caches the
240 // answer by file extension.
241 //
242 // lexers.Match scans every filename glob of all 275 registered lexers,
243 // and retries each miss against a list of ignored suffixes. Chroma's
244 // own documentation calls it "not particularly efficient"; it measures
245 // near 4ms. Build calls it once per file, and every call in a Go
246 // coverage report returns the same Go lexer, so a module with 500
247 // files spent about two seconds matching globs it had already matched.
248 // The extension is what Match keys on in practice, so caching by
249 // extension returns the same lexer Match would.
250 1 var lexerFor = func() func(string) chroma.Lexer {
251 1 var mu sync.Mutex
252 1 cache := map[string]chroma.Lexer{}
253 1
254 1 return func(filename string) chroma.Lexer {
255 1 ext := path.Ext(filename)
256 1 mu.Lock()
257 1 defer mu.Unlock()
258 1 if l, ok := cache[ext]; ok {
259 1 return l
260 1 }
261 1 l := lexers.Match(filename)
262 1 if l == nil {
263 0 l = lexers.Fallback
264 0 }
265 1 cache[ext] = l
266 1 return l
267 }
268 }()
269
270 // chromaStyle and chromaFormatter are resolved once. Both are read-only
271 // after construction, and Build rebuilt them once per file before.
272 var (
273 1 chromaStyle = sync.OnceValue(func() *chroma.Style {
274 1 if s := styles.Get("github-dark"); s != nil {
275 1 return s
276 1 }
277 0 return styles.Fallback
278 })
279 1 chromaFormatter = sync.OnceValue(func() *chromahtml.Formatter {
280 1 return chromahtml.New(
281 1 chromahtml.WithClasses(false),
282 1 chromahtml.WithLineNumbers(false),
283 1 chromahtml.PreventSurroundingPre(true),
284 1 )
285 1 })
286 )
287
288 // highlightLines runs chroma on source and returns per-line HTML fragments.
289 1 func highlightLines(source, filename string) ([]template.HTML, error) {
290 1 lexer := lexerFor(filename)
291 1 style := chromaStyle()
292 1 formatter := chromaFormatter()
293 1
294 1 iterator, err := lexer.Tokenise(nil, source)
295 1 if err != nil {
296 0 return nil, err
297 0 }
298
299 1 var buf bytes.Buffer
300 1 if err := formatter.Format(&buf, style, iterator); err != nil {
301 0 return nil, err
302 0 }
303
304 1 raw := strings.Split(buf.String(), "\n")
305 1 result := make([]template.HTML, len(raw))
306 1 for i, line := range raw {
307 1 result[i] = template.HTML(line)
308 1 }
309 1 return result, nil
310 }
internal/report/report.go
stmts 100.0% funcs 100.0%
Functions
NameLineCalls
pctClass 93 ×1
shortPkg 105 ×1
TruncPct 125 ×1
1 package report
2
3 import (
4 "html/template"
5 "math"
6 "path"
7 "strings"
8
9 "github.com/z3le/plumb/internal/profile"
10 )
11
12 // Report is the top-level data structure passed to the HTML template.
13 type Report struct {
14 Title string
15 StmtPct float64
16 FuncPct float64
17
18 // Diff is true when the caller asked for the diff view (D-46).
19 // DiffPct is meaningless and must not be rendered unless the
20 // sibling bool below is true (D-37): a diff with no coverable
21 // changed line is not a 0% diff, it is no diff at all. DiffBase
22 // names the reference the run resolved, so the report says which
23 // two commits produced its number (D-43).
24 Diff bool
25 DiffPct float64
26 DiffMeasured bool
27 DiffBase string
28
29 Files []FileReport
30 Skipped []SkippedFile // files the run could not read, could not highlight, or left out of the diff view
31 }
32
33 // BuildOptions carries every option Build needs. ModulePath is the
34 // module path from go.mod (e.g. "github.com/foo/bar"). ModuleRoot is
35 // the directory containing go.mod. Changed maps a profile file name
36 // to the line numbers the diff touched in it; Build treats a nil or
37 // empty Changed the same as Diff being false, so the field is simply
38 // absent when there is nothing to filter by.
39 type BuildOptions struct {
40 ModulePath string
41 ModuleRoot string
42 Title string
43 Diff bool
44 Changed map[string][]int
45 DiffBase string
46
47 // Annotated carries source lines a caller has already annotated,
48 // keyed the way the profile names each file. Build reads a file
49 // from disk only when Annotated does not already hold it, so a
50 // diff run annotates each changed file once rather than once here
51 // and once in the caller. An absent key is not an error: Build
52 // falls back to reading the file, which is what a caller that
53 // annotated nothing gets.
54 Annotated map[string][]profile.AnnotatedLine
55 }
56
57 // NoCoverableLinesChanged is the phrase D-37 prints when a whole diff
58 // has nothing coverable to measure, and the phrase D-51 reuses for the
59 // same case scoped to one file — one rule, read the same way at both
60 // scopes. It is exported so cmd/plumb prints the same words this
61 // package renders, and so the two can never drift apart.
62 const NoCoverableLinesChanged = "no coverable lines changed"
63
64 // SkippedFile records a file the report left out, and why. A caller
65 // reports these so a missing file is visible, not silent.
66 type SkippedFile struct {
67 Name string // full import path, as the profile names it
68 Reason string
69 }
70
71 // FileReport holds everything needed to render one file in the report.
72 type FileReport struct {
73 Name string // full import path, e.g. "github.com/foo/bar/pkg/auth.go"
74 ShortName string // just the filename, e.g. "auth.go"
75 Pkg string // package path relative to module, e.g. "pkg/auth"
76 StmtPct float64
77 FuncPct float64
78 DiffPct float64 // this file's own diff coverage percentage (D-46)
79 Lines []RenderedLine
80 Funcs []profile.AnnotatedFunc
81 }
82
83 // RenderedLine is an annotated line with syntax-highlighted HTML source.
84 type RenderedLine struct {
85 Number int
86 HTML template.HTML
87 Status profile.LineStatus
88 Count int
89 Changed bool // true when the diff touched this line (D-46)
90 }
91
92 // pctClass returns a CSS class name for a coverage percentage.
93 1 func pctClass(pct float64) string {
94 1 if pct >= 80 {
95 1 return "good"
96 1 }
97 1 if pct >= 50 {
98 1 return "ok"
99 1 }
100 1 return "bad"
101 }
102
103 // shortPkg returns the last two path components of an import path without
104 // the filename, e.g. "github.com/foo/bar/pkg/auth.go" → "bar/pkg".
105 1 func shortPkg(name, modulePath string) string {
106 1 rel := strings.TrimPrefix(name, modulePath+"/")
107 1 dir := path.Dir(rel)
108 1 parts := strings.Split(dir, "/")
109 1 if len(parts) <= 2 {
110 1 return dir
111 1 }
112 1 return strings.Join(parts[len(parts)-2:], "/")
113 }
114
115 // TruncPct truncates a percentage to one decimal place.
116 //
117 // A bare %.1f rounds to nearest, so 79.96 would print as 80.0 beside a
118 // build that failed a threshold of 80 (D-20). Truncating first keeps a
119 // printed number from ever exceeding the raw value it measured.
120 //
121 // It lives here, not in the command, because the HTML report prints the
122 // same numbers the command prints. While the rule lived in cmd only,
123 // "plumb report" could write 79.9% to the terminal and 80.0% into the
124 // page it wrote in the same run.
125 1 func TruncPct(v float64) float64 {
126 1 return math.Trunc(v*10) / 10
127 1 }