Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / bench (push) Has been cancelled
719 lines
24 KiB
Go
719 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"flag"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// spec:planctl/D§7.2
|
|
// updateGoldens is toggled by `go test -update`; when true, TestGoldenFixtures
|
|
// regenerates expected.golden + expected.exit sidecars instead of comparing.
|
|
var updateGoldens = flag.Bool("update", false, "regenerate testdata/**/expected.golden and expected.exit sidecars")
|
|
|
|
// spec:planctl-task-cmds/R5.3+R5.5+D§1
|
|
func TestRun_Help(t *testing.T) {
|
|
for _, arg := range []string{"--help", "-h"} {
|
|
t.Run(arg, func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{arg}, &stdout, &stderr)
|
|
if exit != 0 {
|
|
t.Fatalf("exit = %d, want 0", exit)
|
|
}
|
|
out := stdout.String()
|
|
// Must list all five subcommands.
|
|
for _, sub := range []string{"lint", "next", "list", "complete", "status"} {
|
|
if !strings.Contains(out, sub) {
|
|
t.Errorf("--help output missing subcommand %q", sub)
|
|
}
|
|
}
|
|
// Must not contain the old v2-reserved stub wording.
|
|
for _, bad := range []string{"v2-reserved", "new-plan"} {
|
|
if strings.Contains(out, bad) {
|
|
t.Errorf("--help output contains stale text %q", bad)
|
|
}
|
|
}
|
|
// Must mention flags.
|
|
for _, needle := range []string{"--format", "--strict", "--no-ears", "--color", "--help", "--version"} {
|
|
if !strings.Contains(out, needle) {
|
|
t.Errorf("--help output missing flag %q", needle)
|
|
}
|
|
}
|
|
// Must list all classification codes (stable public contract per design §4).
|
|
for _, code := range []string{
|
|
"tag-syntax", "tag-unclosed", "orphan-requirement", "orphan-design",
|
|
"uncovered-requirement", "uncovered-design", "missing-prd",
|
|
"missing-closeout-file", "ears-violation",
|
|
} {
|
|
if !strings.Contains(out, code) {
|
|
t.Errorf("--help output missing classification code %q", code)
|
|
}
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Errorf("--help wrote to stderr: %q", stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R5.3+D§1
|
|
func TestSubcmd_NextHelp(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"next", "--help"}, &stdout, &stderr)
|
|
if exit != 0 {
|
|
t.Fatalf("exit = %d, want 0", exit)
|
|
}
|
|
out := stdout.String()
|
|
for _, needle := range []string{"next", "Usage:", "--format", "Exit codes:"} {
|
|
if !strings.Contains(out, needle) {
|
|
t.Errorf("next --help missing %q; got:\n%s", needle, out)
|
|
}
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Errorf("next --help wrote to stderr: %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R5.3+D§1
|
|
func TestSubcmd_ListHelp(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"list", "--help"}, &stdout, &stderr)
|
|
if exit != 0 {
|
|
t.Fatalf("exit = %d, want 0", exit)
|
|
}
|
|
out := stdout.String()
|
|
for _, needle := range []string{"list", "--all", "Exit codes:"} {
|
|
if !strings.Contains(out, needle) {
|
|
t.Errorf("list --help missing %q", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R5.3+D§1
|
|
func TestSubcmd_CompleteHelp(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"complete", "--help"}, &stdout, &stderr)
|
|
if exit != 0 {
|
|
t.Fatalf("exit = %d, want 0", exit)
|
|
}
|
|
out := stdout.String()
|
|
for _, needle := range []string{"complete", "--dry-run", "<task-ref>", "Exit codes:", "snapshotting"} {
|
|
if !strings.Contains(out, needle) {
|
|
t.Errorf("complete --help missing %q", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R5.3+D§1
|
|
func TestSubcmd_StatusHelp(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"status", "--help"}, &stdout, &stderr)
|
|
if exit != 0 {
|
|
t.Fatalf("exit = %d, want 0", exit)
|
|
}
|
|
out := stdout.String()
|
|
for _, needle := range []string{"status", "--strict", "Exit codes:"} {
|
|
if !strings.Contains(out, needle) {
|
|
t.Errorf("status --help missing %q", needle)
|
|
}
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.7
|
|
func TestRun_Version(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"--version"}, &stdout, &stderr)
|
|
if exit != 0 {
|
|
t.Fatalf("exit = %d, want 0", exit)
|
|
}
|
|
if !strings.HasPrefix(stdout.String(), "planctl ") {
|
|
t.Errorf("--version output %q does not start with %q", stdout.String(), "planctl ")
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Errorf("--version wrote to stderr: %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R5.5
|
|
// TestRun_V2ReservedSubcommands is intentionally deleted — all v2 stubs have
|
|
// been replaced by real implementations. new-plan now hits the default
|
|
// unknown-subcommand path, covered by TestRun_UnknownSubcommand.
|
|
|
|
// spec:planctl/R5.8
|
|
func TestRun_UnknownSubcommand(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"frobnicate"}, &stdout, &stderr)
|
|
if exit != 2 {
|
|
t.Fatalf("exit = %d, want 2", exit)
|
|
}
|
|
if !strings.Contains(stderr.String(), "frobnicate") {
|
|
t.Errorf("stderr %q does not name the unknown subcommand", stderr.String())
|
|
}
|
|
// Usage must be included on error per R5.8.
|
|
if !strings.Contains(stderr.String(), "Usage:") {
|
|
t.Errorf("stderr missing usage: %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.8
|
|
func TestRun_UnknownTopLevelFlag(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"--bogus"}, &stdout, &stderr)
|
|
if exit != 2 {
|
|
t.Fatalf("exit = %d, want 2", exit)
|
|
}
|
|
if !strings.Contains(stderr.String(), "--bogus") {
|
|
t.Errorf("stderr %q does not name the unknown flag", stderr.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.8
|
|
func TestRun_NoArgs(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run(nil, &stdout, &stderr)
|
|
if exit != 2 {
|
|
t.Fatalf("exit = %d, want 2 (no args should require usage)", exit)
|
|
}
|
|
if !strings.Contains(stderr.String(), "Usage:") {
|
|
t.Errorf("stderr missing usage on no-args invocation: %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R3.1+R3.2+R5.3+R5.4+D§1
|
|
// TestRun_LintMissingPRD verifies the lint subcommand dispatches end-to-
|
|
// end and produces the missing-prd fatal path when prd.md is absent.
|
|
// Uses a temp dir so the test is isolated from the actual working-
|
|
// directory contents (otherwise Case C would discover this repo's own
|
|
// plan dirs). Parent 5.5 adds the full resolvePlans coverage.
|
|
func TestRun_LintMissingPRD(t *testing.T) {
|
|
dir := t.TempDir()
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"lint", dir}, &stdout, &stderr)
|
|
if exit != 1 {
|
|
t.Fatalf("exit = %d, want 1 (missing-prd); stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "missing-prd") {
|
|
t.Errorf("stdout lacks 'missing-prd': %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R5.1+D§3.5
|
|
// TestResolvePlans exercises the four R5.1 cases A/B/C/D using
|
|
// t.TempDir()-staged directory shapes. `/tmp` isn't used directly for
|
|
// Case D because the tempdir's ancestors are the actual test-subject
|
|
// for "no dev/plans/ ever" — any stray `dev/plans/` under /tmp would
|
|
// break the test, so we explicitly verify by filepath.
|
|
func TestResolvePlans(t *testing.T) {
|
|
t.Run("Case A: explicit valid path", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
got, err := resolvePlans(dir, "/irrelevant")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 result, got %d: %v", len(got), got)
|
|
}
|
|
abs, _ := filepath.Abs(dir)
|
|
if got[0] != abs {
|
|
t.Errorf("got %q, want %q", got[0], abs)
|
|
}
|
|
})
|
|
t.Run("Case A: explicit invalid path", func(t *testing.T) {
|
|
_, err := resolvePlans("/nonexistent/nope/never/1234567", "/irrelevant")
|
|
if err == nil {
|
|
t.Fatalf("want error for missing path, got nil")
|
|
}
|
|
})
|
|
t.Run("Case B: deeply-nested cwd inside plan dir", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
planDir := filepath.Join(root, "dev", "plans", "26172-planctl")
|
|
deep := filepath.Join(planDir, "sub", "deeper", "nested")
|
|
if err := os.MkdirAll(deep, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
got, err := resolvePlans("", deep)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 result, got %d: %v", len(got), got)
|
|
}
|
|
absPlan, _ := filepath.Abs(planDir)
|
|
if got[0] != absPlan {
|
|
t.Errorf("got %q, want %q", got[0], absPlan)
|
|
}
|
|
})
|
|
t.Run("Case C: repo root with plans + archive filter + lex sort", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Three plans + an archive and an unrelated dir; only the two
|
|
// pattern-matching non-archive dirs should be returned, sorted.
|
|
for _, d := range []string{
|
|
"dev/plans/26170-alpha",
|
|
"dev/plans/26172-beta",
|
|
"dev/plans/archive/26100-retired",
|
|
"dev/plans/not-a-plan",
|
|
} {
|
|
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", d, err)
|
|
}
|
|
}
|
|
got, err := resolvePlans("", root)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
absRoot, _ := filepath.Abs(root)
|
|
want := []string{
|
|
filepath.Join(absRoot, "dev", "plans", "26170-alpha"),
|
|
filepath.Join(absRoot, "dev", "plans", "26172-beta"),
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("got %v, want %v", got, want)
|
|
}
|
|
})
|
|
t.Run("Case D: no plans ancestor returns error", func(t *testing.T) {
|
|
// Walk up from a tempdir whose ancestry we've verified has no
|
|
// dev/plans/ layer. If /tmp happens to contain dev/plans/ this
|
|
// test would be fragile; sanity-check by scanning ancestors.
|
|
root := t.TempDir()
|
|
for d := root; ; {
|
|
if _, err := os.Stat(filepath.Join(d, "dev", "plans")); err == nil {
|
|
t.Skipf("skipping: dev/plans/ exists at ancestor %s of tempdir", d)
|
|
}
|
|
parent := filepath.Dir(d)
|
|
if parent == d {
|
|
break
|
|
}
|
|
d = parent
|
|
}
|
|
_, err := resolvePlans("", root)
|
|
if err == nil {
|
|
t.Fatalf("want error for Case D, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "no plan directory found") {
|
|
t.Errorf("error message should mention 'no plan directory found': %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// spec:planctl/R5.6+R5.8+D§3.6
|
|
// TestParseLintFlags covers the accepted flag surface (R5.6 / D§3.6)
|
|
// plus the unknown-flag rejection path (R5.8). --color values are
|
|
// validated but not acted on — v1 keeps the flag a no-op per D-1.
|
|
func TestParseLintFlags(t *testing.T) {
|
|
t.Run("defaults", func(t *testing.T) {
|
|
f, err := parseLintFlags(nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if f.format != "text" || f.color != "auto" || f.strict || f.noEars || f.planDir != "" {
|
|
t.Errorf("unexpected default flags: %+v", f)
|
|
}
|
|
})
|
|
t.Run("--format=json", func(t *testing.T) {
|
|
f, err := parseLintFlags([]string{"--format=json"})
|
|
if err != nil || f.format != "json" {
|
|
t.Errorf("want format=json, got %+v err=%v", f, err)
|
|
}
|
|
})
|
|
t.Run("--format json (space form)", func(t *testing.T) {
|
|
f, err := parseLintFlags([]string{"--format", "json"})
|
|
if err != nil || f.format != "json" {
|
|
t.Errorf("want format=json, got %+v err=%v", f, err)
|
|
}
|
|
})
|
|
t.Run("--strict", func(t *testing.T) {
|
|
f, _ := parseLintFlags([]string{"--strict"})
|
|
if !f.strict {
|
|
t.Errorf("strict not set")
|
|
}
|
|
})
|
|
t.Run("--no-ears", func(t *testing.T) {
|
|
f, _ := parseLintFlags([]string{"--no-ears"})
|
|
if !f.noEars {
|
|
t.Errorf("noEars not set")
|
|
}
|
|
})
|
|
t.Run("--color=never", func(t *testing.T) {
|
|
f, err := parseLintFlags([]string{"--color=never"})
|
|
if err != nil || f.color != "never" {
|
|
t.Errorf("want color=never, got %+v err=%v", f, err)
|
|
}
|
|
})
|
|
t.Run("unknown flag is an error", func(t *testing.T) {
|
|
_, err := parseLintFlags([]string{"--bogus"})
|
|
if err == nil {
|
|
t.Errorf("unknown flag should error")
|
|
}
|
|
})
|
|
t.Run("invalid --format value is an error", func(t *testing.T) {
|
|
_, err := parseLintFlags([]string{"--format=xml"})
|
|
if err == nil {
|
|
t.Errorf("invalid format should error")
|
|
}
|
|
})
|
|
t.Run("positional plan-dir", func(t *testing.T) {
|
|
f, err := parseLintFlags([]string{"--strict", "my-plan"})
|
|
if err != nil || f.planDir != "my-plan" || !f.strict {
|
|
t.Errorf("want planDir=my-plan strict=true, got %+v err=%v", f, err)
|
|
}
|
|
})
|
|
t.Run("last-value-wins on repeated flag", func(t *testing.T) {
|
|
f, _ := parseLintFlags([]string{"--format=text", "--format=json"})
|
|
if f.format != "json" {
|
|
t.Errorf("last value should win: got %q", f.format)
|
|
}
|
|
})
|
|
}
|
|
|
|
// spec:planctl/R1.1+R1.2+R1.3+R1.4+R1.5+R1.6+R2.4+R2.5+R2.6+R2.7+R3.5+R3.6+R3.7+R4.2+R5.2+R5.3+R5.4+R5.5+R5.9+R5.10+D§7.2
|
|
// TestGoldenFixtures sweeps testdata/{clean,dirty,inline-code-exclusion,
|
|
// multi}/** and checks each fixture against its expected.golden +
|
|
// expected.exit sidecars. An optional args.txt overrides the default
|
|
// invocation (defaults to `lint <fixtureDir>`); an optional cwd-rel.txt
|
|
// overrides the test's working directory relative to the fixture.
|
|
//
|
|
// `go test -update` regenerates the sidecars. Goldens are reviewed by
|
|
// hand before committing — the harness merely codifies the diff.
|
|
func TestGoldenFixtures(t *testing.T) {
|
|
classes := []string{"clean", "dirty", "inline-code-exclusion", "multi", "v2"}
|
|
for _, class := range classes {
|
|
classDir := filepath.Join("testdata", class)
|
|
if _, err := os.Stat(classDir); err != nil {
|
|
continue
|
|
}
|
|
// If the class directory itself looks like a plan (has prd.md)
|
|
// OR like a multi-plan root (has dev/plans/), treat it as a
|
|
// single fixture. This covers the flat-layout case
|
|
// (testdata/inline-code-exclusion/{prd.md,tasks.md}).
|
|
if isFixtureRoot(classDir) {
|
|
t.Run(class, func(t *testing.T) {
|
|
runGoldenFixture(t, classDir)
|
|
})
|
|
continue
|
|
}
|
|
entries, err := os.ReadDir(classDir)
|
|
if err != nil {
|
|
t.Fatalf("readdir %s: %v", classDir, err)
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
fixture := filepath.Join(classDir, e.Name())
|
|
t.Run(class+"/"+e.Name(), func(t *testing.T) {
|
|
runGoldenFixture(t, fixture)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// isFixtureRoot reports whether dir is itself a runnable fixture —
|
|
// either a single plan dir (has prd.md) or a multi-plan root
|
|
// (has dev/plans/). Used to allow both flat and nested fixture layouts.
|
|
func isFixtureRoot(dir string) bool {
|
|
if _, err := os.Stat(filepath.Join(dir, "prd.md")); err == nil {
|
|
return true
|
|
}
|
|
if info, err := os.Stat(filepath.Join(dir, "dev", "plans")); err == nil && info.IsDir() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// spec:planctl/D§7.2+D§7.4
|
|
// runGoldenFixture is the single-fixture test body. It:
|
|
// - loads the optional args.txt / cwd-rel.txt override files,
|
|
// - Chdir()s into the computed cwd so the fixture sees the right CWD,
|
|
// - invokes run() with the computed args,
|
|
// - normalises stdout (CRLF → LF) per design §7.4's Windows-CI guard,
|
|
// - compares against sidecar expectations, or regenerates them if
|
|
// -update is set.
|
|
func runGoldenFixture(t *testing.T, fixture string) {
|
|
t.Helper()
|
|
absFixture, err := filepath.Abs(fixture)
|
|
if err != nil {
|
|
t.Fatalf("abs %s: %v", fixture, err)
|
|
}
|
|
args, cwd := loadFixtureInvocation(t, fixture)
|
|
// spec:26174-planctl-context-tokens/R3.4+R4.1+D§7.2
|
|
setupFixtureContextEnv(t, fixture)
|
|
var stdout, stderr bytes.Buffer
|
|
restore := chdir(t, cwd)
|
|
defer restore()
|
|
exit := run(args, &stdout, &stderr)
|
|
got := strings.ReplaceAll(stdout.String(), "\r\n", "\n")
|
|
goldenPath := filepath.Join(absFixture, "expected.golden")
|
|
exitPath := filepath.Join(absFixture, "expected.exit")
|
|
if *updateGoldens {
|
|
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
|
|
t.Fatalf("write golden: %v", err)
|
|
}
|
|
if err := os.WriteFile(exitPath, []byte(strconv.Itoa(exit)+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write exit: %v", err)
|
|
}
|
|
return
|
|
}
|
|
wantGolden, err := os.ReadFile(goldenPath)
|
|
if err != nil {
|
|
t.Fatalf("read expected.golden: %v (run `go test -update` to seed)", err)
|
|
}
|
|
want := strings.ReplaceAll(string(wantGolden), "\r\n", "\n")
|
|
if got != want {
|
|
t.Errorf("stdout mismatch:\n--- want ---\n%s--- got ---\n%s--- stderr ---\n%s",
|
|
want, got, stderr.String())
|
|
}
|
|
wantExitRaw, err := os.ReadFile(exitPath)
|
|
if err != nil {
|
|
t.Fatalf("read expected.exit: %v", err)
|
|
}
|
|
wantExit, err := strconv.Atoi(strings.TrimSpace(string(wantExitRaw)))
|
|
if err != nil {
|
|
t.Fatalf("parse expected.exit: %v", err)
|
|
}
|
|
if exit != wantExit {
|
|
t.Errorf("exit = %d, want %d; stdout=%q stderr=%q", exit, wantExit, got, stderr.String())
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R3.4+R4.1+D§7.2
|
|
// setupFixtureContextEnv wires per-fixture environment for the context-window
|
|
// feature:
|
|
//
|
|
// - env.txt — one KEY=VALUE line per env var; trimmed, `#` comments skipped.
|
|
// Each key is pinned via t.Setenv (auto-restored on test end).
|
|
// - transcript.jsonl — staged under a temp HOME at
|
|
// ~/.claude/projects/<slug>/<uuid>.jsonl where slug is derived from
|
|
// CLAUDE_PROJECT_DIR (or fixture dir) with `/` → `-` and uuid is
|
|
// CLAUDE_SESSION_ID. When transcript.jsonl is present but the required
|
|
// env keys aren't, the staging step is skipped silently so non-context
|
|
// fixtures remain unaffected.
|
|
//
|
|
// Fixtures without env.txt or transcript.jsonl are untouched — baseline fixtures
|
|
// stay byte-identical.
|
|
func setupFixtureContextEnv(t *testing.T, fixture string) {
|
|
t.Helper()
|
|
envPath := filepath.Join(fixture, "env.txt")
|
|
if b, err := os.ReadFile(envPath); err == nil {
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
t.Setenv(strings.TrimSpace(k), strings.TrimSpace(v))
|
|
}
|
|
}
|
|
tp := filepath.Join(fixture, "transcript.jsonl")
|
|
tb, err := os.ReadFile(tp)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Need a uuid + a slug to derive the transcript path. If either is missing,
|
|
// skip staging — the fixture under test probably doesn't need it to pass.
|
|
uuid := os.Getenv("CLAUDE_SESSION_ID")
|
|
projectDir := os.Getenv("CLAUDE_PROJECT_DIR")
|
|
if uuid == "" || projectDir == "" {
|
|
return
|
|
}
|
|
// Stage a temp HOME and drop the transcript at the exact path planctl will look up.
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
slug := strings.ReplaceAll(projectDir, "/", "-")
|
|
dir := filepath.Join(home, ".claude", "projects", slug)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
t.Fatalf("mkdir transcript dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), tb, 0o600); err != nil {
|
|
t.Fatalf("stage transcript: %v", err)
|
|
}
|
|
}
|
|
|
|
// loadFixtureInvocation reads args.txt and cwd-rel.txt if present. The
|
|
// args.txt tokens may reference the literal `FIXTURE` substring, which
|
|
// is substituted with the absolute path of the fixture directory so
|
|
// goldens written once stay portable across dev machines.
|
|
//
|
|
// Defaults:
|
|
// - args = ["lint", <fixtureAbs>]
|
|
// - cwd = fixtureAbs (needed so Case C fixtures discover their own
|
|
// dev/plans/ before resolvePlans walks into the real repo root)
|
|
func loadFixtureInvocation(t *testing.T, fixture string) (args []string, cwd string) {
|
|
t.Helper()
|
|
absFixture, err := filepath.Abs(fixture)
|
|
if err != nil {
|
|
t.Fatalf("abs %s: %v", fixture, err)
|
|
}
|
|
args = []string{"lint", absFixture}
|
|
cwd = absFixture
|
|
if b, err := os.ReadFile(filepath.Join(fixture, "args.txt")); err == nil {
|
|
args = strings.Fields(strings.TrimSpace(string(b)))
|
|
for i, tok := range args {
|
|
args[i] = strings.ReplaceAll(tok, "FIXTURE", absFixture)
|
|
}
|
|
}
|
|
if b, err := os.ReadFile(filepath.Join(fixture, "cwd-rel.txt")); err == nil {
|
|
rel := strings.TrimSpace(string(b))
|
|
cwd = filepath.Join(absFixture, rel)
|
|
}
|
|
return args, cwd
|
|
}
|
|
|
|
// chdir changes the test's working directory for the duration of the
|
|
// caller's scope. The returned func restores the previous cwd; the test
|
|
// must defer it.
|
|
func chdir(t *testing.T, dir string) func() {
|
|
t.Helper()
|
|
prev, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatalf("chdir %s: %v", dir, err)
|
|
}
|
|
return func() { _ = os.Chdir(prev) }
|
|
}
|
|
|
|
// copyFixture copies all files from src into dst (non-recursively for the top
|
|
// level, but recursively for subdirs). Used by mutation tests to avoid
|
|
// dirtying testdata/.
|
|
func copyFixture(t *testing.T, src, dst string) {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(src)
|
|
if err != nil {
|
|
t.Fatalf("readdir %s: %v", src, err)
|
|
}
|
|
for _, e := range entries {
|
|
srcPath := filepath.Join(src, e.Name())
|
|
dstPath := filepath.Join(dst, e.Name())
|
|
if e.IsDir() {
|
|
if err := os.MkdirAll(dstPath, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", dstPath, err)
|
|
}
|
|
copyFixture(t, srcPath, dstPath)
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(srcPath)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", srcPath, err)
|
|
}
|
|
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", dstPath, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fileHash returns the SHA-256 hash of the file at path.
|
|
func fileHash(t *testing.T, path string) [32]byte {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
return sha256.Sum256(data)
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.2+R3.6+T4.4
|
|
// TestCompleteSuccess verifies that `complete T1.1 <dir>` marks the task
|
|
// checked, the file is written, and post-mutation lint is clean.
|
|
func TestCompleteSuccess(t *testing.T) {
|
|
src := filepath.Join("testdata", "v2", "complete-success")
|
|
tmp := t.TempDir()
|
|
copyFixture(t, src, tmp)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"complete", "T1.1", tmp}, &stdout, &stderr)
|
|
if exit != 0 {
|
|
t.Fatalf("exit = %d, want 0; stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
|
|
}
|
|
tasksPath := filepath.Join(tmp, "tasks.md")
|
|
data, err := os.ReadFile(tasksPath)
|
|
if err != nil {
|
|
t.Fatalf("read tasks.md: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "- [x] 1.1") {
|
|
t.Errorf("tasks.md should contain '- [x] 1.1' after complete; got:\n%s", data)
|
|
}
|
|
if !strings.Contains(stdout.String(), "T1.1") {
|
|
t.Errorf("stdout should name the completed task; got: %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.3+T4.5
|
|
// TestCompleteAlreadyDone verifies that completing an already-checked task
|
|
// exits 0 and does not modify the file.
|
|
func TestCompleteAlreadyDone(t *testing.T) {
|
|
src := filepath.Join("testdata", "v2", "complete-already-done")
|
|
tmp := t.TempDir()
|
|
copyFixture(t, src, tmp)
|
|
tasksPath := filepath.Join(tmp, "tasks.md")
|
|
before := fileHash(t, tasksPath)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"complete", "T1.0", tmp}, &stdout, &stderr)
|
|
if exit != 0 {
|
|
t.Fatalf("exit = %d, want 0; stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
|
|
}
|
|
after := fileHash(t, tasksPath)
|
|
if before != after {
|
|
t.Errorf("tasks.md was modified despite task already being done")
|
|
}
|
|
if !strings.Contains(stdout.String(), "already complete") {
|
|
t.Errorf("stdout should say 'already complete'; got: %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.6+T4.9
|
|
// TestCompleteTriggersLint verifies that completing the last task in a plan
|
|
// with no closeout files exits 1 and reports missing-closeout-file.
|
|
func TestCompleteTriggersLint(t *testing.T) {
|
|
src := filepath.Join("testdata", "v2", "complete-triggers-lint")
|
|
tmp := t.TempDir()
|
|
copyFixture(t, src, tmp)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"complete", "T1.1", tmp}, &stdout, &stderr)
|
|
if exit != 1 {
|
|
t.Fatalf("exit = %d, want 1 (lint error after completion); stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "missing-closeout-file") {
|
|
t.Errorf("stdout should mention missing-closeout-file; got: %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.8+M6+T4.10
|
|
// TestCompleteDryRun verifies that --dry-run exits 0, prints "Would change",
|
|
// and does NOT modify tasks.md (SHA-256 invariant M6).
|
|
func TestCompleteDryRun(t *testing.T) {
|
|
src := filepath.Join("testdata", "v2", "complete-dry-run")
|
|
tmp := t.TempDir()
|
|
copyFixture(t, src, tmp)
|
|
tasksPath := filepath.Join(tmp, "tasks.md")
|
|
before := fileHash(t, tasksPath)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exit := run([]string{"complete", "--dry-run", "T1.1", tmp}, &stdout, &stderr)
|
|
if exit != 0 {
|
|
t.Fatalf("exit = %d, want 0; stdout=%q stderr=%q", exit, stdout.String(), stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Would change") {
|
|
t.Errorf("stdout should contain 'Would change'; got: %q", stdout.String())
|
|
}
|
|
after := fileHash(t, tasksPath)
|
|
if before != after {
|
|
t.Errorf("tasks.md was modified during --dry-run (M6 violation)")
|
|
}
|
|
}
|
|
|
|
// Ensure sha256 and io are used (linter guard).
|
|
var _ = sha256.Sum256
|
|
var _ io.Writer
|