Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .devtools/tasks/lint.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/shared-lint-exclusions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions internal/agentrepo/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func NewCommand() (*cobra.Command, *int) {
versionsCommand(code),
fuzzCommand(code),
lintTypesCommand(code),
lintLivenessCommand(code),
mergeCommand(code),
skillTokensCommand(code),
postCommand(code),
Expand Down
21 changes: 21 additions & 0 deletions internal/agentrepo/lint_liveness.go
Original file line number Diff line number Diff line change
@@ -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())
},
}
}
172 changes: 172 additions & 0 deletions internal/liveness/fake_tools_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading