diff --git a/internal/agentrepo/command.go b/internal/agentrepo/command.go index db8608bb..d01ff749 100644 --- a/internal/agentrepo/command.go +++ b/internal/agentrepo/command.go @@ -93,6 +93,7 @@ func NewCommand() (*cobra.Command, *int) { mergeCommand(code), skillTokensCommand(code), postCommand(code), + proseCommand(code), ) return root, code } diff --git a/internal/agentrepo/prose.go b/internal/agentrepo/prose.go new file mode 100644 index 00000000..ade82361 --- /dev/null +++ b/internal/agentrepo/prose.go @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package agentrepo + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +func proseCommand(code *int) *cobra.Command { + group := &cobra.Command{Use: "prose", Short: "Manage prose workflow evidence"} + group.AddCommand(&cobra.Command{ + Use: "check-suppressions --baseline TARGET | --diff STATE [TARGET] | --cleanup STATE", + Short: "Record, read, or remove a prose baseline", + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { + p := proseState{out: cmd.OutOrStdout(), stderr: cmd.ErrOrStderr()} + p.run = diagnosisRunner(cmd.Context()) + *code = p.execute(args) + }, + }) + return group +} + +type proseState struct { + root, gitDir string + out, stderr io.Writer + run func(dir, tool string, args ...string) ([]byte, error) +} + +func (p *proseState) refuse(message string) int { + fmt.Fprintln(p.stderr, "check-suppressions: "+message) + const invalidState = 2 + return invalidState +} + +func (p *proseState) git(args ...string) ([]byte, error) { + return p.run(p.root, "git", append([]string{ + "--no-pager", "-c", "core.quotePath=false", "-c", "color.ui=false", + "-c", "color.diff=false", "-c", "color.status=false", + "-c", "diff.noprefix=false", "-c", "diff.mnemonicPrefix=false", + }, args...)...) +} + +func (p *proseState) execute(args []string) int { + if problem := p.checkout(); problem != "" { + return p.refuse(problem) + } + if len(args) == 0 { + return p.refuse("expected --baseline, --diff, or --cleanup") + } + arg := "" + if len(args) > 1 { + arg = args[1] + } + if args[0] == "--baseline" { + return p.baseline(arg) + } + if args[0] != "--diff" && args[0] != "--cleanup" { + return p.refuse("expected --baseline, --diff, or --cleanup") + } + state, problem := p.load(arg) + if problem != "" { + return p.refuse(problem) + } + if args[0] == "--cleanup" { + if err := os.RemoveAll(state); err != nil { + return p.refuse(err.Error()) + } + return 0 + } + supplied := "" + const targetIndex = 2 + if len(args) > targetIndex { + supplied = args[targetIndex] + } + return p.targetDiff(state, supplied) +} + +func (p *proseState) checkout() string { + root, err := p.git("rev-parse", "--show-toplevel") + if err != nil { + return err.Error() + } + p.root, err = filepath.EvalSymlinks(strings.TrimSuffix(string(root), "\n")) + if err != nil { + return err.Error() + } + gitDir, err := p.git("rev-parse", "--absolute-git-dir") + if err != nil { + return err.Error() + } + p.gitDir = strings.TrimSuffix(string(gitDir), "\n") + return "" +} + +func (p *proseState) targetDiff(state, supplied string) int { + targetBytes, err := os.ReadFile(filepath.Join(state, "target")) + if err != nil { + return p.refuse(err.Error()) + } + target, _, _ := strings.Cut(string(targetBytes), "\n") + if supplied != "" { + supplied = p.canonicalTarget(supplied) + if supplied != target { + return p.refuse(fmt.Sprintf("state belongs to %s, not %s", target, supplied)) + } + } + return p.diff(state, target) +} + +func (p *proseState) canonicalTarget(target string) string { + // The shell changes to the checkout root first, so relative targets resolve there. + target = p.path(target) + if parent, err := filepath.EvalSymlinks(filepath.Dir(target)); err == nil { + target = filepath.Join(parent, filepath.Base(target)) + } + prefix := p.root + string(filepath.Separator) + target, _ = strings.CutPrefix(target, prefix) + return filepath.ToSlash(target) +} + +func (p *proseState) load(state string) (string, string) { + state = filepath.FromSlash(state) + if strings.HasPrefix(state, filepath.Join(".fix-prose", "run.")) { + state = p.root + string(filepath.Separator) + state + } + if !strings.HasPrefix(state, filepath.Join(p.root, ".fix-prose")+string(filepath.Separator)+"run.") && + !strings.HasPrefix(state, filepath.Join(p.gitDir, "fix-prose")+string(filepath.Separator)+"run.") { + if state == "" { + state = "(none)" + } + return "", "invalid workflow state: " + state + } + info, err := os.Stat(filepath.Join(state, "target")) + if err != nil || info.Size() == 0 { + return "", "workflow state is missing its target: " + state + } + return state, "" +} + +func (p *proseState) baseline(target string) int { + if target == "" { + return p.refuse("--baseline needs a target path") + } + target = p.canonicalTarget(target) + base := filepath.Join(p.root, ".fix-prose") + if err := os.MkdirAll(base, 0o700); err != nil { + return p.refuse(err.Error()) + } + ignore := filepath.Join(base, ".gitignore") + if _, statErr := os.Stat(ignore); os.IsNotExist(statErr) { + if err := os.WriteFile(ignore, []byte("*\n"), 0o600); err != nil { + return p.refuse(err.Error()) + } + } + state := filepath.Join(base, fmt.Sprintf("run.%x", sha256.Sum256([]byte(target)))) + if err := os.Mkdir(state, 0o700); err != nil { + const guidance = "an active workflow already owns %s; if that run is abandoned, clear it with: check-suppressions.sh --cleanup %s" + return p.refuse(fmt.Sprintf(guidance, target, state)) + } + if err := p.snapshot(state, target); err != nil { + return p.refuse(err.Error()) + } + fmt.Fprintln(p.out, "state: "+state) + return 0 +} + +func proseWrite(state, name string, data []byte) error { + root, err := os.OpenRoot(state) + if err != nil { + return fmt.Errorf("open workflow: %w", err) + } + defer root.Close() + const stateFileMode = 0o600 + if writeErr := root.WriteFile(name, data, stateFileMode); writeErr != nil { + return fmt.Errorf("record %s: %w", name, writeErr) + } + return nil +} + +func (p *proseState) snapshot(state, target string) error { + if err := proseWrite(state, "target", []byte(target+"\n")); err != nil { + return err + } + digest, err := p.digest(target) + if err != nil { + return err + } + if writeErr := proseWrite(state, "digest", digest); writeErr != nil { + return writeErr + } + if docErr := p.document(state, target); docErr != nil { + return docErr + } + return p.dictionaries(state) +} + +func (p *proseState) document(state, target string) error { + data, err := os.ReadFile(p.path(target)) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read target: %w", err) + } + directives := regexp.MustCompile( + `vale[\t\v\f\r ]+(off|on)|vale[\t\v\f\r ]+[A-Za-z][A-Za-z0-9._-]*[\t\v\f\r ]*=[\t\v\f\r ]*([Nn][Oo]|off)|cspell:|cSpell:|spell-checker:|markdownlint-disable|rumdl-disable`, + ) + var found strings.Builder + for i, line := range strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") { + if directives.MatchString(line) { + fmt.Fprintf(&found, "%d:%s\n", i+1, line) + } + } + if writeErr := proseWrite(state, "directives", []byte(found.String())); writeErr != nil { + return writeErr + } + if data != nil { + return proseWrite(state, "original", data) + } + return nil +} + +func (p *proseState) dictionaries(state string) error { + dictionaries, err := filepath.Glob(filepath.Join(p.root, ".vale/config/vocabularies/*/accept.txt")) + if err != nil { + return fmt.Errorf("find dictionaries: %w", err) + } + dictionaries = append(dictionaries, filepath.Join(p.root, ".cspell-words.txt")) + for _, file := range dictionaries { + data, readErr := os.ReadFile(file) + if os.IsNotExist(readErr) { + continue + } + if readErr != nil { + return fmt.Errorf("read dictionary: %w", readErr) + } + rel, relErr := filepath.Rel(p.root, file) + if relErr != nil { + return fmt.Errorf("locate dictionary: %w", relErr) + } + dest := filepath.Join("watched", rel) + if mkdirErr := os.MkdirAll(filepath.Dir(filepath.Join(state, dest)), 0o700); mkdirErr != nil { + return fmt.Errorf("create dictionary directory: %w", mkdirErr) + } + if writeErr := proseWrite(state, dest, data); writeErr != nil { + return writeErr + } + } + return nil +} + +func (p *proseState) path(path string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(p.root, path) +} + +func proseStyles(root string) (map[string]bool, error) { + paths := map[string]bool{} + err := fs.WalkDir(os.DirFS(root), ".vale", func(path string, d fs.DirEntry, err error) error { + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if d.Type().IsRegular() { + paths[path] = true + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("walk styles: %w", err) + } + return paths, nil +} + +func (p *proseState) watchedPaths() (map[string]bool, error) { + paths, err := proseStyles(p.root) + if err != nil { + return nil, err + } + for _, file := range []string{".vale.ini", ".cspell.jsonc", ".cspell-words.txt", ".pre-commit-config.yaml", "mise.toml", "Justfile"} { + if info, statErr := os.Stat(p.path(file)); statErr == nil && info.Mode().IsRegular() { + paths[file] = true + } + } + for _, args := range [][]string{{"diff", "--no-ext-diff", "HEAD", "--name-only"}, {"ls-files", "--others", "--exclude-standard"}} { + data, gitErr := p.git(args...) + if gitErr != nil { + return nil, gitErr + } + for path := range strings.SplitSeq(strings.TrimSuffix(string(data), "\n"), "\n") { + if path != "" { + paths[path] = true + } + } + } + return paths, nil +} + +func (p *proseState) digest(target string) ([]byte, error) { + paths, err := p.watchedPaths() + if err != nil { + return nil, err + } + keys := make([]string, 0, len(paths)) + for path := range paths { + if path != target && !strings.HasPrefix(path, ".fix-prose/") { + keys = append(keys, path) + } + } + sort.Strings(keys) + var out bytes.Buffer + for _, path := range keys { + data, readErr := os.ReadFile(p.path(path)) + hash := fmt.Sprintf("%x", [sha256.Size]byte{}) + if readErr == nil { + hash = fmt.Sprintf("%x", sha256.Sum256(data)) + } else if !os.IsNotExist(readErr) { + return nil, fmt.Errorf("read watched path: %w", readErr) + } + fmt.Fprintf(&out, "%s %s\n", hash, path) + } + return out.Bytes(), nil +} + +func (p *proseState) diff(state, target string) int { + original := filepath.Join(state, "original") + if info, err := os.Stat(original); err != nil || !info.Mode().IsRegular() { + return p.refuse("baseline kept no copy of " + target) + } + tool := "diff" + if runtime.GOOS == "windows" { + data, err := p.git("--exec-path") + if err != nil { + return p.refuse(err.Error()) + } + tool = filepath.Join(strings.TrimSpace(string(data)), "../../../usr/bin/diff.exe") + } + data, err := p.run(p.root, tool, "-u", original, target) + fmt.Fprint(p.out, string(data)) + if exit, ok := errors.AsType[*exec.ExitError](err); ok { + fmt.Fprint(p.stderr, string(exit.Stderr)) + } else if err != nil { + return p.refuse(err.Error()) + } + return 0 +} diff --git a/internal/agentrepo/prose_test.go b/internal/agentrepo/prose_test.go new file mode 100644 index 00000000..8e6ef3d7 --- /dev/null +++ b/internal/agentrepo/prose_test.go @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package agentrepo_test + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tbhb-dev/devtools/internal/agentrepo" +) + +type ( + proseFixture struct{ root, script string } + proseResult struct { + Code int + Out, Err string + } +) + +func newProseFixture(t *testing.T, label string) proseFixture { + t.Helper() + _, source, _, ok := runtime.Caller(0) + require.True(t, ok) + project := filepath.Join(filepath.Dir(source), "../..") + root := filepath.Join(project, fmt.Sprintf(".prose-test-%x", sha256.Sum256([]byte(t.Name()+label)))) + require.NoError(t, os.Mkdir(root, 0o700)) + root, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(root)) }) + f := proseFixture{ + root, + filepath.Join(project, "packages/agents/source/skills/fix-prose/shared/scripts/check-suppressions.sh"), + } + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if strings.HasPrefix(key, "GIT_") { + t.Setenv(key, "") + require.NoError(t, os.Unsetenv(key)) + } + } + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + f.git(t, "init", "-q") + f.write(t, "DRAFT.md", "committed draft\n") + f.write(t, ".gitignore", "ignored.md\n") + f.git(t, "add", "DRAFT.md", ".gitignore") + f.git(t, "commit", "-qm", "fixture") + return f +} + +func (f proseFixture) git(t *testing.T, args ...string) { + t.Helper() + c := exec.CommandContext(t.Context(), "git") + c.Args = append(c.Args, "-c", "core.hooksPath="+os.DevNull, "-c", "commit.gpgsign=false") + c.Args = append(c.Args, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com") + c.Args = append(c.Args, args...) + c.Dir = f.root + out, err := c.CombinedOutput() + require.NoError(t, err, "%s", out) +} + +func (f proseFixture) write(t *testing.T, path, content string) { + t.Helper() + path = filepath.Join(f.root, path) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +func (f proseFixture) run(t *testing.T, shell bool, args ...string) proseResult { + t.Helper() + return f.runIn(t, f.root, shell, args...) +} + +func (f proseFixture) runIn(t *testing.T, dir string, shell bool, args ...string) proseResult { + t.Helper() + var out, stderr bytes.Buffer + code := 0 + if shell { + c := exec.CommandContext(t.Context(), "bash") + c.Args = append(c.Args, f.script) + c.Args = append(c.Args, args...) + c.Dir, c.Stdout, c.Stderr = dir, &out, &stderr + if err := c.Run(); err != nil { + exit, ok := errors.AsType[*exec.ExitError](err) + require.True(t, ok, "%v", err) + code = exit.ExitCode() + } + } else { + t.Chdir(dir) + code = agentrepo.Run(append([]string{"prose", "check-suppressions"}, args...), nil, &out, &stderr) + } + return proseResult{code, out.String(), stderr.String()} +} + +func (f proseFixture) state(target string) string { + return filepath.Join(f.root, ".fix-prose", fmt.Sprintf("run.%x", sha256.Sum256([]byte(target)))) +} + +func (f proseFixture) shellBaseline(t *testing.T, target string) { + t.Helper() + if runtime.GOOS != "windows" { + require.Equal(t, 0, f.run(t, true, "--baseline", target).Code) + return + } + // Recorded shell layout for an otherwise clean checkout and an existing target. + data, err := os.ReadFile(filepath.Join(f.root, target)) + require.NoError(t, err) + f.write(t, ".fix-prose/.gitignore", "*\n") + for name, value := range map[string]string{"target": target + "\n", "original": string(data), "digest": "", "directives": ""} { + f.write(t, filepath.Join(".fix-prose", filepath.Base(f.state(target)), name), value) + } +} + +func (f proseFixture) evidence(t *testing.T) map[string]string { + t.Helper() + files := map[string]string{} + root := os.DirFS(f.root) + for _, dir := range []string{".fix-prose", ".git/fix-prose", "wrong"} { + err := fs.WalkDir(root, dir, func(path string, entry fs.DirEntry, err error) error { + if os.IsNotExist(err) { + return nil + } + require.NoError(t, err) + if !entry.IsDir() { + data, readErr := fs.ReadFile(root, path) + require.NoError(t, readErr) + files[path] = string(data) + } + return nil + }) + require.NoError(t, err) + } + return files +} + +func proseMetadataMode(t *testing.T, f proseFixture, readonly bool) { + t.Helper() + paths := map[string]fs.FileMode{} + require.NoError(t, fs.WalkDir(os.DirFS(f.root), ".git", func(path string, d fs.DirEntry, err error) error { + require.NoError(t, err) + mode := fs.FileMode(0o600) + if d.IsDir() { + mode = 0o700 + } + if readonly { + mode &^= 0o200 + } + paths[path] = mode + return nil + })) + for path, mode := range paths { + require.NoError(t, os.Chmod(filepath.Join(f.root, path), mode)) + } +} + +func proseNormalize(root, value string) string { + value = strings.ReplaceAll(value, root, "") + return regexp.MustCompile(`(?m)^(---|\+\+\+) ([^\n]*)\t[^\n]*`).ReplaceAllString(value, "$1 $2\t