ajhahn.de
← eeco
Go 159 lines
package cockpit

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

// flashOSAnswerKey is the operator's hand-built FlashOS handover skill —
// the living structural answer key for C1 dogfood parity. It is read-only
// and may be absent (CI, or a fresh clone), so the parity test skips rather
// than fails when it is missing.
const flashOSAnswerKey = "/Users/antonhahn/FlashOS/ajhahnde/.claude/skills/handover/SKILL.md"

func TestScratchRegenerate_WritesToScratchOnly(t *testing.T) {
	pb := loadHandover(t)
	scratch := t.TempDir()
	path, err := ScratchRegenerate(pb, "claude", scratch)
	if err != nil {
		t.Fatalf("ScratchRegenerate: %v", err)
	}
	if filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(path)))) != scratch {
		t.Errorf("scratch path %q not under scratch root %q", path, scratch)
	}
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read scratch artifact: %v", err)
	}
	shape := parseSkillShape(b)
	if shape.stepCount < 5 || !shape.hasOutput {
		t.Errorf("scratch artifact shape off: steps=%d output=%v", shape.stepCount, shape.hasOutput)
	}
}

func TestParity_FlashOSAnswerKey(t *testing.T) {
	if _, err := os.Stat(flashOSAnswerKey); err != nil {
		t.Skipf("FlashOS answer key absent (%v) — parity check skipped", err)
	}
	pb := loadHandover(t)
	res, err := Parity(pb, "claude", flashOSAnswerKey)
	if err != nil {
		t.Fatalf("Parity: %v", err)
	}
	if !res.LayerOK {
		t.Errorf("layer parity failed: %v", res.Notes)
	}
	if !res.CapOK {
		t.Errorf("capability parity failed: %v", res.Notes)
	}
	if !res.SafetyOK {
		t.Errorf("safety parity failed: %v", res.Notes)
	}
}

func TestParity_UnknownTarget(t *testing.T) {
	pb := loadHandover(t)
	if _, err := Parity(pb, "nosuchharness", flashOSAnswerKey); err == nil {
		t.Error("expected an error for an unknown target")
	}
	if _, err := ScratchRegenerate(pb, "nosuchharness", t.TempDir()); err == nil {
		t.Error("expected ScratchRegenerate to reject an unknown target")
	}
}

func TestParity_SafetyTierWarnsOnOverGrantingKey(t *testing.T) {
	// A hand-built answer key that over-grants a write-git verb is NOT eeco's
	// artifact, so it no longer hard-fails the safety tier — Tier 3 scopes to
	// the emitted allowlist. eeco's emit is clean, so SafetyOK holds; the
	// key's over-grant is surfaced as a warning Note instead.
	dir := t.TempDir()
	key := filepath.Join(dir, ".claude", "skills", "handover", "SKILL.md")
	if err := os.MkdirAll(filepath.Dir(key), 0o755); err != nil {
		t.Fatal(err)
	}
	poisoned := "---\nname: handover\ndescription: x\nallowed-tools: Read, Bash(git commit:*)\n---\n# Handover\n"
	if err := os.WriteFile(key, []byte(poisoned), 0o644); err != nil {
		t.Fatal(err)
	}
	pb := loadHandover(t)
	res, err := Parity(pb, "claude", key)
	if err != nil {
		t.Fatalf("Parity: %v", err)
	}
	if !res.SafetyOK {
		t.Errorf("over-granting answer key hard-failed the safety tier; should warn (Tier 3 is emitted-only): %v", res.Notes)
	}
	if !hasNote(res.Notes, "answer key over-grants") {
		t.Errorf("expected an over-grant warning Note, got %v", res.Notes)
	}
}

func TestParity_SafetyTierFailsOnEmittedForbiddenVerb(t *testing.T) {
	// The invariant that matters: a forbidden write-git verb in the EMITTED
	// allowlist hard-fails the safety tier. ScratchRegenerate renders without
	// the generation gate, so a poisoned playbook reaches the parity scan.
	dir := t.TempDir()
	key := filepath.Join(dir, ".claude", "skills", "handover", "SKILL.md")
	if err := os.MkdirAll(filepath.Dir(key), 0o755); err != nil {
		t.Fatal(err)
	}
	clean := "---\nname: handover\ndescription: x\nallowed-tools: Read\n---\n# Handover\n"
	if err := os.WriteFile(key, []byte(clean), 0o644); err != nil {
		t.Fatal(err)
	}
	pb := loadHandover(t)
	pb.Capabilities = append(pb.Capabilities, Capability{Kind: "bash", Verb: "git commit", Scope: "*"})
	res, err := Parity(pb, "claude", key)
	if err != nil {
		t.Fatalf("Parity: %v", err)
	}
	if res.SafetyOK {
		t.Error("safety tier passed an emitted allowlist that grants git commit")
	}
}

func TestParity_GitHeadCoverageAndKeyOverGrantWarns(t *testing.T) {
	// Tier 2: emitted "git branch --show-current" covers an answer key that
	// grants the broader "git branch" head — a scope refinement is not a
	// capability gap. Tier 3: the bare "git branch" in the key is a forbidden
	// over-grant → a warning Note, but SafetyOK stays true because the emitted
	// allowlist is clean.
	dir := t.TempDir()
	key := filepath.Join(dir, ".claude", "skills", "handover", "SKILL.md")
	if err := os.MkdirAll(filepath.Dir(key), 0o755); err != nil {
		t.Fatal(err)
	}
	keyBody := "---\nname: handover\ndescription: x\n" +
		"allowed-tools: Read, Write, Bash(git status:*), Bash(git log:*), Bash(git diff:*), Bash(git describe:*), Bash(git branch:*)\n" +
		"---\n# Handover\n"
	if err := os.WriteFile(key, []byte(keyBody), 0o644); err != nil {
		t.Fatal(err)
	}
	pb := loadHandover(t)
	res, err := Parity(pb, "claude", key)
	if err != nil {
		t.Fatalf("Parity: %v", err)
	}
	if !res.CapOK {
		t.Errorf("capability tier failed; head coverage should treat emitted \"git branch --show-current\" as covering key \"git branch\": %v", res.Notes)
	}
	if !res.SafetyOK {
		t.Errorf("safety tier should hold (emitted clean): %v", res.Notes)
	}
	if !hasNote(res.Notes, "answer key over-grants") {
		t.Errorf("expected an over-grant warning for the key's bare git branch, got %v", res.Notes)
	}
}

// hasNote reports whether any note contains substr.
func hasNote(notes []string, substr string) bool {
	for _, n := range notes {
		if strings.Contains(n, substr) {
			return true
		}
	}
	return false
}