From 1e21de3865e83cd023cc7d3778f548a2baf91a93 Mon Sep 17 00:00:00 2001 From: "tbhb.dev Agent" Date: Mon, 2 Nov 2026 08:55:33 -0500 Subject: [PATCH 1/3] feat(lint): prove enabled gates reject nested defects A lint gate can stay enabled and still stop reaching files below the top level after a selection or exclusion change. Every check kept passing on the files it still saw, so no one noticed. The new devtools:lint-liveness task copies the Git-visible tree into a temporary repository and confirms each defined gate passes there. Next, it plants a defect in a nested path and fails when the gate still accepts it. Coverage spans the prose, Markdown structure, Markdown wrap, shell, shell format, and curl gates, and the task skips any gate a consumer doesn't define. Stand-in tools drive the tests, so they don't depend on installed linters. Consumers get the task through the shared lint task file, and the lint exclusion guide describes how to run it and what it reports. Assisted-by: codex:gpt-6.1-sol Signed-off-by: Tony Burns --- .devtools/tasks/lint.toml | 4 + docs/shared-lint-exclusions.md | 4 + internal/agentrepo/command.go | 1 + internal/agentrepo/lint_liveness.go | 21 +++ internal/liveness/fake_tools_test.go | 172 +++++++++++++++++++++ internal/liveness/liveness.go | 223 +++++++++++++++++++++++++++ internal/liveness/liveness_test.go | 164 ++++++++++++++++++++ mise.toml | 4 + 8 files changed, 593 insertions(+) create mode 100644 internal/agentrepo/lint_liveness.go create mode 100644 internal/liveness/fake_tools_test.go create mode 100644 internal/liveness/liveness.go create mode 100644 internal/liveness/liveness_test.go 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..1bb241c3 --- /dev/null +++ b/internal/liveness/liveness.go @@ -0,0 +1,223 @@ +// 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) { + data, err := s.run(ctx, "mise", "tasks", "ls", "--json") + if err != nil { + return nil, 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/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. From 6c761a674b263bf160b6d1a6cff43b8239632ab8 Mon Sep 17 00:00:00 2001 From: "tbhb.dev Agent" Date: Mon, 2 Nov 2026 09:52:48 -0500 Subject: [PATCH 2/3] fix(liveness): decode only stdout of the mise task listing The coverage run in CI builds the test binary with instrumentation, and that binary is also the fake mise. It writes a GOCOVERDIR warning to stderr, and the task listing read stdout and stderr as one stream. The JSON decode then failed on every platform in the CI matrix while the plain local test run passed. Only stdout of the listing is JSON. The reader now takes stdout alone and reports a failed command with its own error. Assisted-by: claude:claude-sonnet-5-5 Signed-off-by: Tony Burns --- internal/liveness/liveness.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/liveness/liveness.go b/internal/liveness/liveness.go index 1bb241c3..bde4e11b 100644 --- a/internal/liveness/liveness.go +++ b/internal/liveness/liveness.go @@ -168,9 +168,12 @@ func (s sandbox) copyFile(file string) error { } func (s sandbox) tasks(ctx context.Context) (map[string]bool, error) { - data, err := s.run(ctx, "mise", "tasks", "ls", "--json") + // 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, err + return nil, fmt.Errorf("mise tasks ls: %w", err) } var tasks []struct { Name string `json:"name"` From 5de190ef4085e4e4614b84e1febd16ecf527d48f Mon Sep 17 00:00:00 2001 From: "tbhb.dev Agent" Date: Mon, 2 Nov 2026 11:47:16 -0500 Subject: [PATCH 3/3] test(liveness): run the published task in a fixture repository The existing cases call the probe package through a copy of the test binary and a synthesized task list, so they still pass when the published devtools:lint-liveness task or the command wiring breaks. Add a test that builds agentrepo and copies the real lint task file into a fixture repository that defines every gate task. It runs the task through mise and asserts success, with each gate rejecting its defect at the nested path. The test empties the global mise configuration so the installed agentrepo can't shadow the build. Refs: #839 Assisted-by: claude:claude-sonnet-5-5 Signed-off-by: Tony Burns --- internal/liveness/published_task_test.go | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 internal/liveness/published_task_test.go 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() +}