Select a file to view source
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| (*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 | } |
| Name | Line | Calls |
|---|---|---|
| (*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 | ) |
| Name | Line | Calls |
|---|---|---|
| (*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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |
| Name | Line | Calls |
|---|---|---|
| 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 | } |