[engine] Apply fixes for checks of all levels

Previously `shac fix` and `shac fmt` would only apply fixes for findings
with a level or "error", which meant there was no automatic way to apply
fixes for non-error findings.

I considered making it configurable whether or not non-error findings'
replacements are applied (e.g. via a `--level` flag that specifies the
minimum level of findings to automatically fix) but I couldn't think of
a nice interface, so for now it's simplest to not filter findings based
on level.

Change-Id: I12e49839163a0e0ae4f86b52cdc28fb7584e1bae
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/912740
Fuchsia-Auto-Submit: Oliver Newman <olivernewman@google.com>
Reviewed-by: Anthony Fandrianto <atyfto@google.com>
Commit-Queue: Auto-Submit <auto-submit@fuchsia-infra.iam.gserviceaccount.com>
diff --git a/internal/engine/fix.go b/internal/engine/fix.go
index e513c7c..425c929 100644
--- a/internal/engine/fix.go
+++ b/internal/engine/fix.go
@@ -30,7 +30,10 @@
 // Fix loads a main shac.star file from a root directory and runs checks defined
 // in it, then applies suggested fixes to files on disk.
 func Fix(ctx context.Context, o *Options, quiet bool) error {
-	fc := findingCollector{countsByCheck: map[string]int{}, quiet: quiet}
+	fc := findingCollector{
+		countsByCheck: map[string]int{},
+		quiet:         quiet,
+	}
 	if o.Report != nil {
 		return fmt.Errorf("cannot overwrite reporter")
 	}
@@ -176,11 +179,13 @@
 var _ Report = (*findingCollector)(nil)
 
 func (c *findingCollector) EmitFinding(ctx context.Context, check string, level Level, message, root, file string, s Span, replacements []string) error {
-	// Only findings with "error" level and a single replacement will be
-	// automatically fixed. Non-error findings may not be necessary to fix, and
-	// findings with more than one replacement do not have a single fix that can
+	// Only findings with a single replacement will be automatically fixed.
+	// Findings with more than one replacement do not have a single fix that can
 	// be automatically chosen.
-	if level == Error && len(replacements) == 1 {
+	// TODO(olivernewman): Add level-based filtering; it should be possible to
+	// only apply fixes for findings with level=error, as others are not
+	// necessary to fix for `shac check` to pass.
+	if len(replacements) == 1 {
 		c.mu.Lock()
 		defer c.mu.Unlock()
 		c.findings = append(c.findings, findingToFix{
diff --git a/internal/engine/fix_test.go b/internal/engine/fix_test.go
index 1efae69..169f8a2 100644
--- a/internal/engine/fix_test.go
+++ b/internal/engine/fix_test.go
@@ -35,45 +35,46 @@
 
 	// TODO(olivernewman): Checks that emit findings in different files.
 	data := []struct {
-		name string
-		want []string
+		name  string
+		want  []string
+		level Level
 	}{
 		{
-			"delete_lines.star",
-			[]string{
+			name: "delete_lines.star",
+			want: []string{
 				"These are",
 				"that may be modified",
 			},
 		},
 		{
-			"ignored_findings.star",
-			originalLines,
+			name: "ignored_findings.star",
+			want: originalLines,
 		},
 		{
-			"multiple_replacements_one_file.star",
-			[]string{
+			name: "multiple_replacements_one_file.star",
+			want: []string{
 				"<REPL1>",
 				"the contents",
 				"<REPL2>",
 			},
 		},
 		{
-			"replace_entire_file.star",
-			[]string{
+			name: "replace_entire_file.star",
+			want: []string{
 				"this text is a replacement",
 				"for the entire file",
 			},
 		},
 		{
-			"replace_entire_file_others_ignored.star",
-			[]string{
+			name: "replace_entire_file_others_ignored.star",
+			want: []string{
 				"this text is a replacement",
 				"for the entire file",
 			},
 		},
 		{
-			"replace_one_full_line.star",
-			[]string{
+			name: "replace_one_full_line.star",
+			want: []string{
 				"These are",
 				"the contents",
 				"UPDATED",
@@ -81,14 +82,23 @@
 			},
 		},
 		{
-			"replace_partial_line.star",
-			[]string{
+			name: "replace_partial_line.star",
+			want: []string{
 				"These are",
 				"the contents",
 				"of UPDATED file",
 				"that may be modified",
 			},
 		},
+		{
+			name: "various_level_findings.star",
+			want: []string{
+				"NOTICE",
+				"WARNING",
+				"ERROR",
+				"that may be modified",
+			},
+		},
 	}
 	want := make([]string, len(data))
 	for i := range data {
diff --git a/internal/engine/run.go b/internal/engine/run.go
index dbb6941..8665d39 100644
--- a/internal/engine/run.go
+++ b/internal/engine/run.go
@@ -35,6 +35,7 @@
 	"time"
 
 	"github.com/go-git/go-git/plumbing/format/gitignore"
+	flag "github.com/spf13/pflag"
 	"go.fuchsia.dev/shac-project/shac/internal/sandbox"
 	"go.starlark.net/resolve"
 	"go.starlark.net/starlark"
@@ -42,6 +43,7 @@
 	"google.golang.org/protobuf/encoding/prototext"
 )
 
+// TODO(olivernewman): Foo
 func init() {
 	// Enable not-yet-standard Starlark features.
 	resolve.AllowRecursion = true
@@ -95,6 +97,8 @@
 // level "error".
 type Level string
 
+var _ flag.Value = (*Level)(nil)
+
 // Valid Level values.
 const (
 	Notice  Level = "notice"
@@ -103,6 +107,35 @@
 	Nothing Level = ""
 )
 
+func (l *Level) Set(value string) error {
+	*l = Level(value)
+	if !l.isValid() {
+		return fmt.Errorf("invalid level value %q", l)
+	}
+	return nil
+}
+
+func (l *Level) String() string {
+	return string(*l)
+}
+
+func (l *Level) Type() string {
+	return "level"
+}
+
+// Meets returns whether `l` is semantically more severe than, or as severe as,
+// `other`.
+func (l Level) Meets(other Level) bool {
+	// Any Level object that gets passed from user code should have already been
+	// validated at this point, so no need to check `isValid`.
+	ordered := []Level{
+		Notice,
+		Warning,
+		Error,
+	}
+	return slices.Index(ordered, l) >= slices.Index(ordered, other)
+}
+
 func (l Level) isValid() bool {
 	switch l {
 	case Notice, Warning, Error:
@@ -233,7 +266,7 @@
 	if err != nil {
 		return err
 	}
-	err = runInner(ctx, root, tmpdir, main, o.Report, doc.AllowNetwork, doc.WritableRoot, o.Recurse, o.Filter, scm, packages)
+	err = runInner(ctx, root, tmpdir, main, doc.AllowNetwork, doc.WritableRoot, o, scm, packages)
 	if err2 := os.RemoveAll(tmpdir); err == nil {
 		err = err2
 	}
@@ -295,7 +328,7 @@
 	return res, nil
 }
 
-func runInner(ctx context.Context, root, tmpdir, main string, r Report, allowNetwork, writableRoot, recurse bool, filter CheckFilter, scm scmCheckout, packages map[string]fs.FS) error {
+func runInner(ctx context.Context, root, tmpdir, main string, allowNetwork, writableRoot bool, o *Options, scm scmCheckout, packages map[string]fs.FS) error {
 	sb, err := sandbox.New(tmpdir)
 	if err != nil {
 		return err
@@ -318,9 +351,9 @@
 		return &shacState{
 			allowNetwork: allowNetwork,
 			env:          &env,
-			filter:       filter,
+			filter:       o.Filter,
 			main:         main,
-			r:            r,
+			r:            o.Report,
 			root:         root,
 			sandbox:      sb,
 			scm:          scm,
@@ -330,7 +363,7 @@
 		}
 	}
 	var shacStates []*shacState
-	if recurse {
+	if o.Recurse {
 		// Each found shac.star is run in its own interpreter for maximum
 		// parallelism.
 		// Discover all the main files via the SCM. This enables us to not walk
diff --git a/internal/engine/runtime_ctx_emit.go b/internal/engine/runtime_ctx_emit.go
index 3897490..5639a03 100644
--- a/internal/engine/runtime_ctx_emit.go
+++ b/internal/engine/runtime_ctx_emit.go
@@ -116,7 +116,7 @@
 	c := ctxCheck(ctx)
 	message := string(argmessage)
 	if len(message) == 0 {
-		if c.formatter && file != "" && level == Error && len(replacements) == 1 {
+		if c.formatter && file != "" && len(replacements) == 1 {
 			// If the check is a formatter, and the finding would be fixed by
 			// `shac fmt`, let users omit `message` as long as `file` is
 			// specified, since `message` will always look something like the
diff --git a/internal/engine/testdata/fix/ignored_findings.star b/internal/engine/testdata/fix/ignored_findings.star
index 950eef6..2ea8045 100644
--- a/internal/engine/testdata/fix/ignored_findings.star
+++ b/internal/engine/testdata/fix/ignored_findings.star
@@ -14,11 +14,6 @@
 
 def cb(ctx):
   ctx.emit.finding(
-      level="warning",  # Warnings do not trigger fixes.
-      filepath="file.txt",
-      message="Just a warning",
-      replacements=["IGNORED"])
-  ctx.emit.finding(
       level="error",
       filepath="file.txt",
       message="Error, but multiple options",
diff --git a/internal/engine/testdata/fix/various_level_findings.star b/internal/engine/testdata/fix/various_level_findings.star
new file mode 100644
index 0000000..fda62e8
--- /dev/null
+++ b/internal/engine/testdata/fix/various_level_findings.star
@@ -0,0 +1,35 @@
+# Copyright 2023 The Shac Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+def cb(ctx):
+  ctx.emit.finding(
+      level="notice",
+      filepath="file.txt",
+      message="Just a notice",
+      line=1,
+      replacements=["NOTICE\n"])
+  ctx.emit.finding(
+      level="warning",
+      filepath="file.txt",
+      message="A warning",
+      line=2,
+      replacements=["WARNING\n"])
+  ctx.emit.finding(
+      level="error",
+      filepath="file.txt",
+      message="An error!!!",
+      line=3,
+      replacements=["ERROR\n"])
+
+shac.register_check(cb)