diff --git a/.devtools/tasks/lint.toml b/.devtools/tasks/lint.toml index d7cbbb01..b37e0274 100644 --- a/.devtools/tasks/lint.toml +++ b/.devtools/tasks/lint.toml @@ -5,6 +5,10 @@ # lint-editorconfig's file listing) stays in the consumer's root # mise.toml. +["devtools:lint-liveness"] +description = "Prove each defined lint gate still reaches nested files" +run = "agentrepo lint-liveness" + # tombi lint-checks every tracked *.toml, validated offline against # embedded SchemaStore schemas where one exists. The format gate runs in # --check --diff mode as well, so an unformatted TOML file fails the bar diff --git a/docs/shared-lint-exclusions.md b/docs/shared-lint-exclusions.md index 061f776e..1ecbac85 100644 --- a/docs/shared-lint-exclusions.md +++ b/docs/shared-lint-exclusions.md @@ -22,3 +22,7 @@ Because generated paths have no universal names, the shared tasks exclude only t The Go architecture starter at `.devtools/config/go/.go-arch-lint.yml` excludes the same third-party and deployed directories, along with its existing `testdata` and `tools` entries. Copy that starter into the consumer's configuration and add generated directories to its `exclude` list. The shared architecture task also applies Git ignore rules and `DEVTOOLS_GENERATED_PATHS` through a temporary configuration, preserving the consumer's existing exclusions. It requires uv, which runs the Python helper with its pinned YAML parser. Direct go-arch-lint calls still require tool-installed directories under `exclude`. The `devtools:lint-prose` template uses the same Git selection through `devtools:lint-source-files`. Include `.devtools/tasks/lint.toml` and keep Vale scope in `DEVTOOLS_PROSE_GLOB`. The default is `*`. Explicit path arguments narrow the Git selection. Ignored files stay excluded. The task uses Vale's `ai-tells-agent.tmpl` output template. + +Run `mise run devtools:lint-liveness` to confirm each gate still checks nested files. It requires the matching release of agentrepo on PATH and the tools that the consumer's gates use. The task checks exactly `lint-prose`, `devtools:lint-markdown`, `lint-markdown-wrap`, `lint-shell`, `lint-shell-fmt`, and `devtools:lint-curl`. Task presence enables a check, even when no combined lint task includes it. Each absent task prints `SKIP` with its name. + +The check copies Git-visible working files and installed Vale styles into a temporary repository with its own index and caches. Each defined gate must pass first. Next, it must fail on the planted defect and report the nested path. A missed defect or a failing baseline prints `FAIL`, the gate, and the path, and fails the task. The task deletes its temporary files and caches on success and on failure, and it plants nothing in the original tree or index. Run it from the repository root with the gate tools and Vale styles installed. A missing rule for the probe term, `e-mail`, counts as a failed prose check. Existing lint rules and exclusions stay in effect. diff --git a/internal/agentrepo/command.go b/internal/agentrepo/command.go index b116f06a..9849b641 100644 --- a/internal/agentrepo/command.go +++ b/internal/agentrepo/command.go @@ -92,6 +92,7 @@ func NewCommand() (*cobra.Command, *int) { versionsCommand(code), fuzzCommand(code), lintTypesCommand(code), + lintLivenessCommand(code), mergeCommand(code), skillTokensCommand(code), postCommand(code), diff --git a/internal/agentrepo/lint_liveness.go b/internal/agentrepo/lint_liveness.go new file mode 100644 index 00000000..e6b5f854 --- /dev/null +++ b/internal/agentrepo/lint_liveness.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package agentrepo + +import ( + "github.com/spf13/cobra" + + "github.com/tbhb-dev/devtools/internal/liveness" +) + +func lintLivenessCommand(code *int) *cobra.Command { + return &cobra.Command{ + Use: "lint-liveness", + Short: "Prove defined lint gates reject defects in nested files", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + *code = liveness.Run(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } +} diff --git a/internal/liveness/fake_tools_test.go b/internal/liveness/fake_tools_test.go new file mode 100644 index 00000000..f57a4c86 --- /dev/null +++ b/internal/liveness/fake_tools_test.go @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package liveness_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/tbhb-dev/devtools/internal/liveness" +) + +// TestMain re-executes this binary as the probe, Git or mise. No installed tool +// or shell is used, including on Windows. Unknown invocations fail closed. +func TestMain(m *testing.M) { + switch strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe") { + case "lint-liveness": + os.Exit(liveness.Run(context.Background(), os.Stdout, os.Stderr)) + case "git": + os.Exit(fakeGit(os.Args[1:])) + case "mise": + os.Exit(fakeMise(os.Args[1:])) + } + os.Exit(m.Run()) +} + +type modeledGate struct { + name, file, finding string +} + +// These independent patterns model the six tools' seeded findings, not their +// full rule sets. The real mise tasks remain the production probe. +func modeledGates() []modeledGate { + return []modeledGate{ + {"lint-prose", "prose.md", "e-mail"}, + {"devtools:lint-markdown", "structure.md", "# Probe\n\n# Probe"}, + {"lint-markdown-wrap", "wrap.md", "paragraph\ncontinues"}, + {"lint-shell", "shell.sh", "$lint_liveness_value"}, + {"lint-shell-fmt", "format.sh", ";then"}, + {"devtools:lint-curl", "curl.sh", "curl https://"}, + } +} + +// fakeGit models the exact Git operations the probe needs, with a file-backed +// index so the gate stubs only select paths the probe actually staged. +func fakeGit(args []string) int { + root, err := os.OpenRoot(".") + if err != nil { + return 2 + } + defer root.Close() + switch strings.Join(args, " ") { + case "ls-files -z --cached --others --exclude-standard": + data, readErr := root.ReadFile(".git/index") + if readErr != nil { + return 2 + } + fmt.Fprint(os.Stdout, strings.ReplaceAll(string(data), "\n", "\x00")) + return 0 + case "ls-files -z --others --ignored --exclude-standard -- .vale": + return 0 + case "init --quiet": + if mkdirErr := root.Mkdir(".git", 0o700); mkdirErr != nil { + return 2 + } + return 0 + case "add --all": + return saveIndex(root, "tasks.txt\nroot.sh\n") + } + data, err := root.ReadFile(".git/index") + if err != nil { + return 2 + } + if len(args) == 3 && args[0] == "add" && args[1] == "--" { + if _, statErr := root.Stat(args[2]); statErr != nil { + return 2 + } + return saveIndex(root, string(data)+args[2]+"\n") + } + if len(args) == 4 && slices.Equal(args[:3], []string{"rm", "--cached", "--"}) { + return saveIndex(root, strings.ReplaceAll(string(data), args[3]+"\n", "")) + } + return 2 +} + +func saveIndex(root *os.Root, data string) int { + if err := root.WriteFile(".git/index", []byte(data), 0o600); err != nil { + return 2 + } + return 0 +} + +func fakeMise(args []string) int { + mode, err := os.ReadFile("tasks.txt") + if err != nil { + return 2 + } + var tasks []struct { + Name string `json:"name"` + } + for _, gate := range modeledGates() { + if string(mode) != "none" && (string(mode) != "partial" || gate.name == "lint-shell") { + tasks = append(tasks, struct { + Name string `json:"name"` + }{gate.name}) + } + } + if slices.Equal(args, []string{"tasks", "ls", "--json"}) { + if err = json.NewEncoder(os.Stdout).Encode(tasks); err != nil { + return 2 + } + return 0 + } + if len(args) != 2 || args[0] != "run" { + return 2 + } + for _, gate := range modeledGates() { + if gate.name == args[1] { + return fakeGate(gate, string(mode)) + } + } + return 2 +} + +func fakeGate(gate modeledGate, mode string) int { + if gate.name == "lint-shell" && mode == "broken" { + fmt.Fprintln(os.Stdout, "missing.sh: file not found") + return 2 + } + index, err := os.ReadFile(".git/index") + if err != nil { + return 2 + } + for path := range strings.SplitSeq(string(index), "\n") { + if filepath.Base(path) != gate.file || + mode == "narrow" && gate.name == "lint-shell" && strings.Contains(path, "/") { + continue + } + content, readErr := os.ReadFile(path) + if readErr != nil { + return 2 + } + if strings.Contains(string(content), gate.finding) { + return fakeFinding(gate, path, content) + } + } + return 0 +} + +func fakeFinding(gate modeledGate, path string, content []byte) int { + // Model a tool caching planted content. Only private caches may hold it. + cache := os.Getenv("XDG_CACHE_HOME") + root, err := os.OpenRoot(filepath.Dir(cache)) + if err != nil { + return 2 + } + defer root.Close() + if err = root.MkdirAll(filepath.Base(cache), 0o700); err != nil { + return 2 + } + if err = root.WriteFile(filepath.Join(filepath.Base(cache), gate.file), content, 0o600); err != nil { + return 2 + } + fmt.Fprintf(os.Stdout, "%s: %s\n", path, gate.finding) + return 1 +} diff --git a/internal/liveness/liveness.go b/internal/liveness/liveness.go new file mode 100644 index 00000000..bde4e11b --- /dev/null +++ b/internal/liveness/liveness.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +// Package liveness checks nested file selection through the consumer's mise tasks. +package liveness + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type gate struct { + name, file, defect string +} + +func gates() []gate { + return []gate{ + {"lint-prose", "prose.md", "# Probe\n\nSend e-mail.\n"}, + {"devtools:lint-markdown", "structure.md", "# Probe\n\n# Probe\n"}, + {"lint-markdown-wrap", "wrap.md", "# Probe\n\nA nested paragraph\ncontinues here.\n"}, + {"lint-shell", "shell.sh", "#!/bin/sh\nprintf '%s\\n' $lint_liveness_value\n"}, + {"lint-shell-fmt", "format.sh", "#!/bin/sh\nif true;then echo ready;fi\n"}, + {"devtools:lint-curl", "curl.sh", "#!/bin/sh\ncurl https://example.invalid\n"}, + } +} + +// Run copies Git-visible files and installed Vale styles to a disposable repository. +// Gates use its index and private caches, and the source repository is read-only. +func Run(ctx context.Context, out, stderr io.Writer) int { + work, err := os.MkdirTemp("", "devtools-lint-liveness-") + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + defer func() { + if cleanupErr := os.RemoveAll(work); cleanupErr != nil { + fmt.Fprintln(stderr, cleanupErr) + } + }() + s := sandbox{root: filepath.Join(work, "tree"), env: privateEnv(work)} + if setupErr := s.prepare(ctx); setupErr != nil { + fmt.Fprintln(stderr, setupErr) + return 1 + } + tasks, err := s.tasks(ctx) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + status := 0 + for _, g := range gates() { + path := filepath.ToSlash(filepath.Join("lint-liveness", "probe", g.file)) + if !tasks[g.name] { + fmt.Fprintf(out, "SKIP %s (task not defined)\n", g.name) + continue + } + if problem := s.check(ctx, g, path); problem != "" { + fmt.Fprintf(out, "FAIL %s %s: %s\n", g.name, path, problem) + status = 1 + } else { + fmt.Fprintf(out, "PASS %s %s (nested defect rejected)\n", g.name, path) + } + } + if cleanupErr := os.RemoveAll(work); cleanupErr != nil { + fmt.Fprintln(stderr, cleanupErr) + return 1 + } + return status +} + +type sandbox struct { + root string + env []string +} + +func privateEnv(work string) []string { + cache := filepath.Join(work, "cache") + return append(os.Environ(), + "XDG_CACHE_HOME="+cache, "UV_CACHE_DIR="+filepath.Join(cache, "uv"), + "MISE_CACHE_DIR="+filepath.Join(cache, "mise"), "GOCACHE="+filepath.Join(cache, "go"), + "TMPDIR="+work, "TMP="+work, "TEMP="+work, + "MISE_TRUSTED_CONFIG_PATHS="+filepath.Join(work, "tree"), + "MISE_CEILING_PATHS="+work, "DEVTOOLS_DIR="+filepath.Join(work, "tree", ".devtools")) +} + +func (s sandbox) run(ctx context.Context, tool string, args ...string) ([]byte, error) { + child := exec.CommandContext(ctx, tool, args...) + child.Dir, child.Env = s.root, s.env + output, err := child.CombinedOutput() + if err != nil { + return output, fmt.Errorf("%s: %w: %s", tool, err, output) + } + return output, nil +} + +func (s sandbox) prepare(ctx context.Context) error { + files := exec.CommandContext(ctx, "git", "ls-files", "-z", "--cached", "--others", "--exclude-standard") + data, err := files.Output() + if err != nil { + return fmt.Errorf("list source files: %w", err) + } + styles := exec.CommandContext( + ctx, + "git", + "ls-files", + "-z", + "--others", + "--ignored", + "--exclude-standard", + "--", + ".vale", + ) + extra, err := styles.Output() + if err != nil { + return fmt.Errorf("list installed Vale styles: %w", err) + } + if mkdirErr := os.MkdirAll(s.root, 0o700); mkdirErr != nil { + return fmt.Errorf("create isolated tree: %w", mkdirErr) + } + for file := range strings.SplitSeq(string(data)+string(extra), "\x00") { + if file != "" { + if copyErr := s.copyFile(file); copyErr != nil { + return copyErr + } + } + } + _, err = s.run(ctx, "git", "init", "--quiet") + if err != nil { + return err + } + // Seed the private index so tracked-only selectors see the copied source. + _, err = s.run(ctx, "git", "add", "--all") + return err +} + +func (s sandbox) copyFile(file string) error { + data, err := os.ReadFile(file) + if os.IsNotExist(err) { + return nil // Git can list tracked files deleted in the working tree. + } + if err != nil { + return fmt.Errorf("read %s: %w", file, err) + } + info, err := os.Stat(file) + if err != nil { + return fmt.Errorf("stat %s: %w", file, err) + } + destination, err := os.OpenRoot(s.root) + if err != nil { + return fmt.Errorf("open isolated tree: %w", err) + } + defer destination.Close() + const directoryMode = 0o700 + if mkdirErr := destination.MkdirAll(filepath.Dir(file), directoryMode); mkdirErr != nil { + return fmt.Errorf("create copy directory: %w", mkdirErr) + } + if writeErr := destination.WriteFile(file, data, info.Mode().Perm()); writeErr != nil { + return fmt.Errorf("copy %s: %w", file, writeErr) + } + return nil +} + +func (s sandbox) tasks(ctx context.Context) (map[string]bool, error) { + // Only stdout is JSON. A coverage-instrumented tool writes warnings to stderr. + child := exec.CommandContext(ctx, "mise", "tasks", "ls", "--json") + child.Dir, child.Env = s.root, s.env + data, err := child.Output() + if err != nil { + return nil, fmt.Errorf("mise tasks ls: %w", err) + } + var tasks []struct { + Name string `json:"name"` + } + if decodeErr := json.Unmarshal(data, &tasks); decodeErr != nil { + return nil, fmt.Errorf("read mise tasks: %w", decodeErr) + } + defined := make(map[string]bool, len(tasks)) + for _, task := range tasks { + defined[task.Name] = true + } + return defined, nil +} + +func (s sandbox) check(ctx context.Context, g gate, path string) string { + if _, err := s.run(ctx, "mise", "run", g.name); err != nil { + return "baseline failed: " + err.Error() + } + dest := filepath.Join(s.root, path) + if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { + return err.Error() + } + file, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err.Error() + } + _, writeErr := io.WriteString(file, g.defect) + closeErr := file.Close() + if err = errors.Join(writeErr, closeErr); err != nil { + return err.Error() + } + if _, err = s.run(ctx, "git", "add", "--", path); err != nil { + return err.Error() + } + output, gateErr := s.run(ctx, "mise", "run", g.name) + if _, err = s.run(ctx, "git", "rm", "--cached", "--", path); err != nil { + return err.Error() + } + if err = os.Remove(dest); err != nil { + return err.Error() + } + if gateErr == nil { + return "gate accepted the nested defect" + } + exit, exited := errors.AsType[*exec.ExitError](gateErr) + if !exited || exit.ExitCode() <= 0 || !strings.Contains(string(output), path) { + return "gate failed without a finding for the nested path: " + gateErr.Error() + } + return "" +} diff --git a/internal/liveness/liveness_test.go b/internal/liveness/liveness_test.go new file mode 100644 index 00000000..e05d68f1 --- /dev/null +++ b/internal/liveness/liveness_test.go @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package liveness_test + +import ( + "crypto/sha256" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLintLiveness(t *testing.T) { + t.Parallel() + bin := fakeTools(t) + probe := filepath.Join(bin, executable("lint-liveness")) + for _, tc := range []struct { + name, tasks string + wantFailure bool + }{ + {"AC1", "all", false}, + {"AC2", "narrow", true}, + {"AC3/pass", "all", false}, + {"AC3/fail", "narrow", true}, + {"AC4", "partial", false}, + {"AC5", "none", false}, + {"unrelated-failure", "broken", true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir, env := fixture(t, bin, tc.tasks) + before := snapshot(t, dir) + index, err := os.ReadFile(filepath.Join(dir, ".git", "index")) + require.NoError(t, err) + head, err := os.ReadFile(filepath.Join(dir, ".git", "HEAD")) + require.NoError(t, err) + child := exec.CommandContext(t.Context(), probe) + child.Dir, child.Env = dir, env + output, runErr := child.CombinedOutput() + if tc.wantFailure { + require.Error(t, runErr, "%s", output) + require.Contains(t, string(output), "FAIL lint-shell") + require.Contains(t, string(output), "lint-liveness/probe/shell.sh") + } else { + require.NoError(t, runErr, "%s", output) + } + assertCoverage(t, string(output), tc.tasks) + require.Equal(t, before, snapshot(t, dir), "source tree, Git state or shared cache changed") + afterIndex, err := os.ReadFile(filepath.Join(dir, ".git", "index")) + require.NoError(t, err) + require.Equal(t, index, afterIndex, "source index changed") + afterHead, err := os.ReadFile(filepath.Join(dir, ".git", "HEAD")) + require.NoError(t, err) + require.Equal(t, head, afterHead, "source commit changed") + entries, err := os.ReadDir(filepath.Join(dir, ".scratch")) + require.NoError(t, err) + require.Empty(t, entries, "temporary tree or private cache remains") + t.Logf("modeled tasks: %s", output) + }) + } +} + +func fakeTools(t *testing.T) string { + t.Helper() + self, err := os.Executable() + require.NoError(t, err) + data, err := os.ReadFile(self) + require.NoError(t, err) + bin := t.TempDir() + root, err := os.OpenRoot(bin) + require.NoError(t, err) + defer root.Close() + for _, tool := range []string{"git", "mise", "lint-liveness"} { + name := executable(tool) + require.NoError(t, root.WriteFile(name, data, 0o600)) + require.NoError(t, root.Chmod(name, 0o700)) + } + return bin +} + +func executable(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} + +func fixture(t *testing.T, bin, tasks string) (string, []string) { + t.Helper() + dir := t.TempDir() + write(t, dir, "tasks.txt", tasks) + write(t, dir, "root.sh", "#!/bin/sh\nprintf '%s\\n' ready\n") + write(t, dir, ".git/index", "tasks.txt\nroot.sh\n") + write(t, dir, ".git/HEAD", "fixture-commit\n") + write(t, dir, ".rumdl_cache/sentinel", "keep\n") + write(t, dir, ".shared-cache/sentinel", "keep\n") + scratch := filepath.Join(dir, ".scratch") + require.NoError(t, os.Mkdir(scratch, 0o700)) + // The helpers inherit no PATH, home directory, tool configuration or caches. + env := []string{ + "PATH=" + bin, "TMPDIR=" + scratch, "TMP=" + scratch, "TEMP=" + scratch, + "XDG_CACHE_HOME=" + filepath.Join(dir, ".shared-cache"), + "UV_CACHE_DIR=" + filepath.Join(dir, ".shared-cache"), + } + return dir, env +} + +func assertCoverage(t *testing.T, output, tasks string) { + t.Helper() + var reports []string + for line := range strings.SplitSeq(output, "\n") { + if strings.HasPrefix(line, "PASS ") || strings.HasPrefix(line, "FAIL ") || strings.HasPrefix(line, "SKIP ") { + reports = append(reports, line) + } + } + require.Len(t, reports, len(modeledGates())) + for i, gate := range modeledGates() { + status := "PASS" + if tasks == "none" || tasks == "partial" && gate.name != "lint-shell" { + status = "SKIP" + } + if (tasks == "narrow" || tasks == "broken") && gate.name == "lint-shell" { + status = "FAIL" + } + require.True(t, strings.HasPrefix(reports[i], status+" "+gate.name+" "), reports[i]) + if status == "PASS" { + require.Contains(t, reports[i], "lint-liveness/probe/"+gate.file+" (nested defect rejected)") + } + } +} + +func write(t *testing.T, dir, file, content string) { + t.Helper() + destination, err := os.OpenRoot(dir) + require.NoError(t, err) + defer destination.Close() + require.NoError(t, destination.MkdirAll(filepath.Dir(file), 0o700)) + require.NoError(t, destination.WriteFile(file, []byte(content), 0o600)) +} + +func snapshot(t *testing.T, dir string) map[string][sha256.Size]byte { + t.Helper() + result := map[string][sha256.Size]byte{} + source, err := os.OpenRoot(dir) + require.NoError(t, err) + defer source.Close() + err = fs.WalkDir(source.FS(), ".", func(path string, entry fs.DirEntry, walkErr error) error { + require.NoError(t, walkErr) + if !entry.IsDir() { + data, readErr := source.ReadFile(path) + require.NoError(t, readErr) + result[path] = sha256.Sum256(data) + } + return nil + }) + require.NoError(t, err) + return result +} diff --git a/internal/liveness/published_task_test.go b/internal/liveness/published_task_test.go new file mode 100644 index 00000000..2334cb06 --- /dev/null +++ b/internal/liveness/published_task_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package liveness_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPublishedTask runs the task the repository publishes, through mise, in a +// fixture that defines all six gates. Each gate task fails when its probe file +// sits anywhere in the index, so it models a tool that selects nested paths. +func TestPublishedTask(t *testing.T) { + t.Parallel() + root, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + _, err = exec.LookPath("mise") + require.NoError(t, err, "the AC1 test needs mise on PATH") + bin := t.TempDir() + build := exec.CommandContext(t.Context(), "go", "install", "./cmd/agentrepo") + build.Dir = root + build.Env = append(os.Environ(), "GOBIN="+bin) + output, err := build.CombinedOutput() + require.NoError(t, err, "%s", output) + + dir := t.TempDir() + published, err := os.ReadFile(filepath.Join(root, ".devtools", "tasks", "lint.toml")) + require.NoError(t, err) + write(t, dir, ".devtools/tasks/lint.toml", string(published)) + write(t, dir, "mise.toml", fixtureTasks()) + write(t, dir, "root.sh", "#!/bin/sh\nprintf '%s\\n' ready\n") + for _, args := range [][]string{{"init", "--quiet"}, {"add", "--all"}} { + git := exec.CommandContext(t.Context(), "git", args...) + git.Dir = dir + out, gitErr := git.CombinedOutput() + require.NoError(t, gitErr, "%s", out) + } + + data := t.TempDir() + // An empty global and system configuration keeps the user's tool set, and its + // installed agentrepo, out of the fixture's PATH. + empty := filepath.Join(data, "empty.toml") + require.NoError(t, os.WriteFile(empty, nil, 0o600)) + task := exec.CommandContext(t.Context(), "mise", "run", "devtools:lint-liveness") + task.Dir = dir + task.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "MISE_TRUSTED_CONFIG_PATHS="+dir, "MISE_DATA_DIR="+data, "MISE_CACHE_DIR="+data, + "MISE_STATE_DIR="+data, "MISE_CEILING_PATHS="+filepath.Dir(dir), + "MISE_GLOBAL_CONFIG_FILE="+empty, "MISE_SYSTEM_CONFIG_FILE="+empty) + output, err = task.CombinedOutput() + require.NoError(t, err, "%s", output) + for _, g := range modeledGates() { + require.Contains(t, string(output), + "PASS "+g.name+" lint-liveness/probe/"+g.file+" (nested defect rejected)") + } + require.NotContains(t, string(output), "FAIL ") + require.NotContains(t, string(output), "SKIP ") +} + +// fixtureTasks defines the six gate tasks. Each reports the probe path and +// exits nonzero when the index lists its probe file in any directory. +func fixtureTasks() string { + var b strings.Builder + b.WriteString("[task_config]\nincludes = [\".devtools/tasks/lint.toml\"]\n") + shell := "sh" + if runtime.GOOS == "windows" { + shell = "bash" + } + for _, g := range modeledGates() { + b.WriteString("\n[tasks.\"" + g.name + "\"]\nshell = \"" + shell + " -c\"\n") + b.WriteString("run = \"found=$(git ls-files | grep '" + g.file + "$') || exit 0; ") + b.WriteString("echo \\\"$found\\\"; exit 1\"\n") + } + return b.String() +} diff --git a/mise.toml b/mise.toml index 97fea74d..0a82348d 100644 --- a/mise.toml +++ b/mise.toml @@ -392,6 +392,10 @@ run = "mise run devtools:lint-bandit" extends = "devtools:lint-prose" run = "DEVTOOLS_PROSE_GLOB='!{LICENSE,CHANGELOG.md,.vale/*,apm_modules/*,tmp/*,vendor/*,.venv/*,.claude/worktrees/*,.pytest_cache/*,.complexipy_cache/*,COMMIT_AGENTMSG,PR_AGENTDESC.md,SQUASH_AGENTMSG}' mise run devtools:lint-source-files -- prose $usage_paths" +[tasks."devtools:lint-liveness"] +description = "Prove each defined lint gate still reaches nested files" +run = "go run ./cmd/agentrepo lint-liveness" + # This repository's own check, applied to itself. `go run` rather than an # installed binary so the gate reflects the working tree: a change to the # detector is judged by the detector as changed, not by the last release.