From 2e1f6fe802e1113f35ddbc7c503d616c14bdf499 Mon Sep 17 00:00:00 2001 From: "tbhb.dev Agent" Date: Mon, 2 Nov 2026 08:58:13 -0500 Subject: [PATCH] feat(agentrepo): report prose preflight argument blockers Before any probe, the fix-prose preflight reports whether a run can start at all. Moving it into agentrepo starts with the three early BLOCKED reports: a directory outside Git, a missing target argument and a target that isn't a regular file. Each keeps the shell's report, streams and exit status, including the root-relative target display. Tests compare the Go output with the unchanged script where bash is available. A target that exists stops with a refusal for now, because scope probing and lint execution belong to later work. Nothing else changes: the shell script and its callers remain, which keeps this within the N-2 compatibility policy. Fixes #1029 Assisted-by: claude:claude-sonnet-5-5 Signed-off-by: Tony Burns --- internal/agentrepo/prose.go | 1 + internal/agentrepo/prose_preflight.go | 53 +++++++++ internal/agentrepo/prose_preflight_test.go | 118 +++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 internal/agentrepo/prose_preflight.go create mode 100644 internal/agentrepo/prose_preflight_test.go diff --git a/internal/agentrepo/prose.go b/internal/agentrepo/prose.go index ade82361..16f26943 100644 --- a/internal/agentrepo/prose.go +++ b/internal/agentrepo/prose.go @@ -33,6 +33,7 @@ func proseCommand(code *int) *cobra.Command { *code = p.execute(args) }, }) + group.AddCommand(proseCommandPreflight(code)) return group } diff --git a/internal/agentrepo/prose_preflight.go b/internal/agentrepo/prose_preflight.go new file mode 100644 index 00000000..f779b0fc --- /dev/null +++ b/internal/agentrepo/prose_preflight.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package agentrepo + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +func proseCommandPreflight(code *int) *cobra.Command { + return &cobra.Command{ + Use: "preflight TARGET [LINT COMMAND...]", + Short: "Report whether a prose run can start", + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { + p := proseState{out: cmd.OutOrStdout(), stderr: cmd.ErrOrStderr(), run: diagnosisRunner(cmd.Context())} + *code = p.preflight(args) + }, + } +} + +// preflight reports the three argument-handling BLOCKED outcomes that precede any probe. +func (p *proseState) preflight(args []string) int { + if p.checkout() != "" { + fmt.Fprintln(p.out, "verdict: BLOCKED (not a git repository)") + return 0 + } + target := "" + if len(args) > 0 { + target = args[0] + } + fmt.Fprintf(p.out, "== fix-prose preflight ==\nroot: %s\n", p.root) + if target == "" { + fmt.Fprintln(p.out, "target: (none given)") + fmt.Fprintln(p.out, "verdict: BLOCKED (no target in the arguments; ask which file)") + return 0 + } + // The shell changes to the checkout root first. A relative target resolves there. + rel, _ := strings.CutPrefix(target, p.root+string(filepath.Separator)) + if info, err := os.Stat(p.path(rel)); err != nil || !info.Mode().IsRegular() { + fmt.Fprintf(p.out, "target: %s (missing)\n", rel) + fmt.Fprintln(p.out, "verdict: BLOCKED (no such file)") + return 0 + } + fmt.Fprintln(p.stderr, "preflight: scope probes are not ported yet") + const deferred = 2 + return deferred +} diff --git a/internal/agentrepo/prose_preflight_test.go b/internal/agentrepo/prose_preflight_test.go new file mode 100644 index 00000000..b69df1fd --- /dev/null +++ b/internal/agentrepo/prose_preflight_test.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package agentrepo_test + +import ( + "bytes" + "errors" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tbhb-dev/devtools/internal/agentrepo" +) + +const proseBlockedHeader = "== fix-prose preflight ==\n" + +func proseGoPreflight(t *testing.T, dir string, args ...string) proseResult { + t.Helper() + t.Chdir(dir) + var out, stderr bytes.Buffer + code := agentrepo.Run(append([]string{"prose", "preflight"}, args...), nil, &out, &stderr) + return proseResult{code, out.String(), stderr.String()} +} + +func proseShellPreflight(t *testing.T, dir string, args ...string) proseResult { + t.Helper() + _, source, _, ok := runtime.Caller(0) + require.True(t, ok) + script := filepath.Join(filepath.Dir(source), "../..", + "packages/agents/source/skills/fix-prose/shared/scripts/preflight.sh") + var out, stderr bytes.Buffer + c := exec.CommandContext(t.Context(), "bash") + c.Args = append(append(c.Args, script), args...) + c.Dir, c.Stdout, c.Stderr = dir, &out, &stderr + code := 0 + if err := c.Run(); err != nil { + exit, isExit := errors.AsType[*exec.ExitError](err) + require.True(t, isExit, "%v", err) + code = exit.ExitCode() + } + return proseResult{code, out.String(), stderr.String()} +} + +// proseOracle compares a Go report with the executed shell report where bash is available. +func proseOracle(t *testing.T, root, dir string, got proseResult, args ...string) { + t.Helper() + if runtime.GOOS == "windows" { + return + } + want := proseShellPreflight(t, dir, args...) + require.Equal(t, want.Code, got.Code) + require.Equal(t, proseNormalize(root, want.Out), proseNormalize(root, got.Out)) + require.Equal(t, proseNormalize(root, want.Err), proseNormalize(root, got.Err)) +} + +func TestProsePreflightOutsideRepository(t *testing.T) { + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + dir := t.TempDir() + // Stop Git's upward search at the temporary directory's parent, whatever TMPDIR holds. + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(dir)) + got := proseGoPreflight(t, dir, "DRAFT.md") + require.Equal(t, proseResult{0, "verdict: BLOCKED (not a git repository)\n", ""}, got) + proseOracle(t, dir, dir, got, "DRAFT.md") +} + +func TestProsePreflightNoTarget(t *testing.T) { + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + f := newProseFixture(t, "bare") + got := proseGoPreflight(t, f.root) + want := proseBlockedHeader + "root: " + f.root + "\n" + + "target: (none given)\n" + + "verdict: BLOCKED (no target in the arguments; ask which file)\n" + require.Equal(t, proseResult{0, want, ""}, got) + proseOracle(t, f.root, f.root, got) +} + +func TestProsePreflightMissingTarget(t *testing.T) { + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + f := newProseFixture(t, "missing") + f.write(t, "sub/keep.md", "kept\n") + for name, c := range map[string]struct{ dir, target, shown string }{ + "relative": {f.root, "nope.md", "nope.md"}, + "absolute": {f.root, filepath.Join(f.root, "nope.md"), "nope.md"}, + "relative to root": {filepath.Join(f.root, "sub"), "keep.md", "keep.md"}, + "absolute outside": {f.root, filepath.Join(filepath.Dir(f.root), "nope.md"), filepath.Join(filepath.Dir(f.root), "nope.md")}, + "directory": {f.root, "sub", "sub"}, + "extra lint config": {f.root, "nope.md", "nope.md"}, + } { + t.Run(name, func(t *testing.T) { + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + args := []string{c.target} + if name == "extra lint config" { + args = append(args, "vale", "--no-global") + } + got := proseGoPreflight(t, c.dir, args...) + want := proseBlockedHeader + "root: " + f.root + "\n" + + "target: " + c.shown + " (missing)\n" + + "verdict: BLOCKED (no such file)\n" + require.Equal(t, proseResult{0, want, ""}, got) + proseOracle(t, f.root, c.dir, got, args...) + }) + } +} + +func TestProsePreflightExistingTargetIsNotPortedYet(t *testing.T) { + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + f := newProseFixture(t, "existing") + want := proseResult{ + 2, + proseBlockedHeader + "root: " + f.root + "\n", + "preflight: scope probes are not ported yet\n", + } + require.Equal(t, want, proseGoPreflight(t, f.root, "DRAFT.md")) +}