Skip to content
Merged
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
1 change: 1 addition & 0 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ func optionalTools() []tool.Tool {
tool.DebuggerTool{},
tool.DevEnvTool{},
tool.ProjectVerifyTool{},
tool.AppVerifyTool{},
tool.DependencyAuditTool{},
tool.GitHubTool{},
&tool.PRGeneratorTool{},
Expand Down
189 changes: 189 additions & 0 deletions internal/appverify/appverify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package appverify

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

func writeProject(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for name, content := range files {
path := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}

func TestDetectNextJS(t *testing.T) {
root := writeProject(t, map[string]string{
"package.json": `{"name":"app","scripts":{"dev":"next dev","build":"next build","test":"jest"},
"dependencies":{"next":"14.0.0","react":"18.0.0"}}`,
})
r := Detect(root)
if r.Ecosystem != "node" || r.AppKind != "web" || r.Port != 3000 {
t.Fatalf("recipe = %+v", r)
}
if r.SmokeKind != SmokeHTTP || r.SmokeTarget() != "http://127.0.0.1:3000/" {
t.Fatalf("smoke = %q target %q", r.SmokeKind, r.SmokeTarget())
}
want := []string{"npm", "run", "dev"}
if strings.Join(r.Start, " ") != strings.Join(want, " ") {
t.Fatalf("start = %v", r.Start)
}
}

func TestDetectNodeLibraryNoSmoke(t *testing.T) {
root := writeProject(t, map[string]string{
"package.json": `{"name":"lib","scripts":{"test":"vitest"},"devDependencies":{"vite":"5"}}`,
})
r := Detect(root)
// vite is a web framework marker; a lib with only vite and no start script
// still has no boot path.
if r.Start != nil {
t.Fatalf("unexpected start %v", r.Start)
}
if r.SmokeKind == SmokeHTTP {
t.Fatalf("library must not claim http smoke: %+v", r)
}
}

func TestDetectGoCLIAndLibrary(t *testing.T) {
cliRoot := writeProject(t, map[string]string{"go.mod": "module x\n\ngo 1.22\n", "main.go": "package main\n"})
r := Detect(cliRoot)
if r.Ecosystem != "go" || r.AppKind != "cli" || r.SmokeKind != SmokeCLI {
t.Fatalf("cli recipe = %+v", r)
}
libRoot := writeProject(t, map[string]string{"go.mod": "module y\n\ngo 1.22\n"})
r = Detect(libRoot)
if r.AppKind != "library" || r.SmokeKind != SmokeNone {
t.Fatalf("lib recipe = %+v", r)
}
}

func TestDetectDjango(t *testing.T) {
root := writeProject(t, map[string]string{
"manage.py": "#!/usr/bin/env python\n",
"requirements.txt": "django\n",
})
r := Detect(root)
if r.Ecosystem != "python" || r.AppLabel != "django app" || r.Port != 8000 || r.SmokeKind != SmokeHTTP {
t.Fatalf("recipe = %+v", r)
}
}

func TestDetectRust(t *testing.T) {
root := writeProject(t, map[string]string{"Cargo.toml": "[package]\nname=\"x\"\n"})
r := Detect(root)
if r.Ecosystem != "rust" || len(r.Test) == 0 || r.SmokeKind != SmokeCLI {
t.Fatalf("recipe = %+v", r)
}
}

func TestDetectUnknown(t *testing.T) {
r := Detect(t.TempDir())
if r.Ecosystem != "unknown" || r.SmokeKind != SmokeNone || len(r.Notes) == 0 {
t.Fatalf("recipe = %+v", r)
}
}

func TestManifestRoundTripAndPriority(t *testing.T) {
root := t.TempDir()
r, existed, err := LoadOrDetect(root)
if err != nil || existed {
t.Fatalf("LoadOrDetect = (%+v,%v,%v)", r, existed, err)
}
if _, err := os.Stat(ManifestPath(root)); err != nil {
t.Fatalf("manifest not persisted: %v", err)
}
// Second load reads the manifest, not detection.
again, existed2, err := LoadOrDetect(root)
if err != nil || !existed2 {
t.Fatalf("second LoadOrDetect = (%+v,%v,%v)", again, existed2, err)
}
if again.Ecosystem != r.Ecosystem {
t.Fatalf("round trip mismatch")
}
}

func TestLoadManifestCorruptIsError(t *testing.T) {
root := writeProject(t, map[string]string{
".hawk/verify/environment.json": "{not json",
})
if _, err := LoadManifest(root); err == nil {
t.Fatal("expected error for corrupt manifest")
}
}

func TestNormalizeFiltersGarbage(t *testing.T) {
raw, _ := json.Marshal(map[string]interface{}{
"ecosystem": " Node! ",
"appKind": "WEB",
"port": 99999,
"smokeKind": "bogus",
"install": []string{"npm", "", "ci\ninjected", "ci"},
"start": []string{"npm", "start"},
})
r, err := Normalize(raw)
if err != nil {
t.Fatalf("Normalize: %v", err)
}
if r.Ecosystem != "node" || r.AppKind != "web" {
t.Fatalf("tokens not sanitized: %+v", r)
}
if r.Port != 0 {
t.Fatalf("out-of-range port kept: %d", r.Port)
}
if r.SmokeKind != SmokeNone {
t.Fatalf("bogus smoke kind not reset: %q", r.SmokeKind)
}
for _, a := range r.Install {
if strings.ContainsAny(a, "\n\r\x00") {
t.Fatalf("unsafe arg survived: %q", a)
}
}
if got := strings.Join(r.Install, " "); got != "npm ci" {
t.Fatalf("install = %q", got)
}
}

func TestNormalizeHTTPRequiresStart(t *testing.T) {
raw, _ := json.Marshal(map[string]interface{}{"ecosystem": "go", "smokeKind": "http", "port": 8080})
r, err := Normalize(raw)
if err != nil {
t.Fatal(err)
}
if r.SmokeKind != SmokeNone {
t.Fatalf("http without start must downgrade to none, got %q", r.SmokeKind)
}
}

func TestBuildVerifyPromptPhases(t *testing.T) {
r := Recipe{
Ecosystem: "node", AppKind: "web", AppLabel: "next.js app",
Install: []string{"npm", "ci"},
Build: []string{"npm", "run", "build"},
Test: []string{"npm", "test"},
Start: []string{"npm", "run", "dev"},
Port: 3000,
SmokeKind: SmokeHTTP,
}
p := BuildVerifyPrompt(r)
for _, want := range []string{
"Phase 1 — Setup", "Phase 2 — Build and test", "Phase 3 — Boot the app",
"Phase 4 — Evidence", "Phase 5 — Teardown",
"http://127.0.0.1:3000/", EvidenceDir, "NOT success",
} {
if !strings.Contains(p, want) {
t.Fatalf("prompt missing %q", want)
}
}
}
82 changes: 82 additions & 0 deletions internal/appverify/manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package appverify

import (
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/GrayCodeAI/hawk/internal/safewrite"
)

// ManifestPath returns the location of the persisted verify environment
// manifest for a project: <root>/.hawk/verify/environment.json.
func ManifestPath(root string) string {
return filepath.Join(root, ".hawk", "verify", "environment.json")
}

// Manifest is the on-disk contract between recipe detection and execution.
// Once written, it is the highest-priority source of truth for verification:
// deterministic detection only runs when no manifest exists yet.
type Manifest struct {
Recipe Recipe `json:"recipe"`
}

// LoadManifest reads the manifest from disk. os.ErrNotExist is returned when
// no manifest has been persisted, so callers can fall back to Detect.
func LoadManifest(root string) (Recipe, error) {
raw, err := os.ReadFile(ManifestPath(root)) // #nosec G304 -- path built from caller-provided root
if err != nil {
return Recipe{}, err
}
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
return Recipe{}, fmt.Errorf("appverify: parse manifest: %w", err)
}
// Re-validate through Normalize so a hand-edited or agent-written manifest
// cannot smuggle unsafe values.
canonical, err := json.Marshal(m.Recipe)
if err != nil {
return Recipe{}, err
}
return Normalize(canonical)
}

// SaveManifest persists the recipe as the project's verify manifest. Existing
// manifests are overwritten atomically.
func SaveManifest(root string, r Recipe) error {
if r.Ecosystem == "" {
return fmt.Errorf("appverify: refusing to save recipe without ecosystem")
}
data, err := json.MarshalIndent(Manifest{Recipe: r}, "", " ")
if err != nil {
return fmt.Errorf("appverify: encode manifest: %w", err)
}
path := ManifestPath(root)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("appverify: create manifest dir: %w", err)
}
if err := safewrite.WriteFile(path, append(data, '\n')); err != nil {
return fmt.Errorf("appverify: write manifest: %w", err)
}
return nil
}

// LoadOrDetect returns the persisted manifest recipe when present, otherwise
// runs deterministic detection and persists the result so subsequent runs are
// reproducible. The second return value reports whether the manifest already
// existed (false means it was just created from detection).
func LoadOrDetect(root string) (Recipe, bool, error) {
if r, err := LoadManifest(root); err == nil {
return r, true, nil
} else if !os.IsNotExist(err) {
// A corrupt manifest must not silently shadow detection; surface it.
return Recipe{}, false, fmt.Errorf("appverify: existing manifest invalid: %w", err)
}
r := Detect(root)
if err := SaveManifest(root, r); err != nil {
// Detection still works without persistence; report the recipe anyway.
return r, false, nil
}
return r, false, nil
}
93 changes: 93 additions & 0 deletions internal/appverify/prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package appverify

import (
"fmt"
"strings"
)

// EvidenceDir is the stable, workspace-relative directory for verification
// artifacts. Stable paths let reports and downstream tooling rely on them.
const EvidenceDir = ".hawk/verify/artifacts"

// BuildVerifyPrompt renders the phased QA-engineer prompt for the recipe. The
// discipline it encodes (adopted from grok-cli) is that build/test passing
// means nothing unless the app actually boots and serves: the workflow is
// mandatory and evidence is mandatory even on failure.
func BuildVerifyPrompt(r Recipe) string {
var b strings.Builder
b.WriteString("You are verifying this project end-to-end as a QA engineer. ")
b.WriteString("A green build is NOT success — the app must actually run. Follow every phase in order.\n\n")

fmt.Fprintf(&b, "Project: %s (%s/%s)\n", r.AppLabel, r.Ecosystem, r.AppKind)
if len(r.Notes) > 0 {
b.WriteString("\nRecipe notes:\n")
for _, n := range r.Notes {
fmt.Fprintf(&b, "- %s\n", n)
}
}

b.WriteString(`
## Phase 1 — Setup
- Probe for required runtimes before installing anything; only install what is missing.
`)

if len(r.Install) > 0 {
fmt.Fprintf(&b, "- Install dependencies with exactly: %s\n", argv(r.Install))
}

b.WriteString(`
## Phase 2 — Build and test
`)
if len(r.Build) > 0 {
fmt.Fprintf(&b, "- Build: %s\n", argv(r.Build))
}
if len(r.Test) > 0 {
fmt.Fprintf(&b, "- Test: %s\n", argv(r.Test))
}
if len(r.Build) == 0 && len(r.Test) == 0 {
b.WriteString("- No build/test commands in the recipe; record that plainly.\n")
}

b.WriteString(`
## Phase 3 — Boot the app (REQUIRED)
`)
switch {
case len(r.Start) > 0 && r.SmokeKind == SmokeHTTP:
target := r.SmokeTarget()
fmt.Fprintf(&b, "- Start the app in the background: %s\n", argv(r.Start))
fmt.Fprintf(&b, "- Wait for readiness by polling %s until HTTP 200 (bounded loop, ~60s max).\n", target)
b.WriteString("- If readiness never succeeds, capture the app log tail and report the exact command used.\n")
case len(r.Start) > 0 && r.SmokeKind == SmokeCLI:
fmt.Fprintf(&b, "- Run the entrypoint once: %s\n", argv(r.Start))
b.WriteString("- Treat a zero exit code plus sane stdout as the boot signal.\n")
default:
b.WriteString("- No start command is known. Inspect the project to determine how it runs; if it cannot be booted, say so explicitly instead of guessing.\n")
}

b.WriteString(`
## Phase 4 — Evidence (REQUIRED even on failure)
`)
fmt.Fprintf(&b, "- Save all artifacts under %s:\n", EvidenceDir)
b.WriteString(" - app log tail -> artifacts/app.log\n")
if r.SmokeKind == SmokeHTTP {
b.WriteString(" - screenshot of the served page when a browser/screenshot tool is available\n")
}
b.WriteString(`- Report in this exact structure:
Summary / Results / Evidence (exact artifact paths) / Blockers / Residual Risk.

## Phase 5 — Teardown
- Stop any background process you started BEFORE finishing, then verify no orphan listeners remain on the port.
`)

if r.Ecosystem == "node" {
b.WriteString(`
Bounded retry guidance:
- Native module build failures (lightningcss, sharp, @next/swc, esbuild): remove node_modules and reinstall once with optional deps enabled, then rebuild. At most one retry.
- Startup readiness failures: retry at most once binding HOST=0.0.0.0 and the explicit PORT.
- Anything else: do not thrash — report the blocker directly.
`)
}
return b.String()
}

func argv(args []string) string { return "`" + strings.Join(args, " ") + "`" }
Loading
Loading