package cmd_test // `kettle gen scaffold` writes the documents an operator invokes and a model // loads. They are embedded in the binary, so this file tests the two properties // that follow from that: what it produces is the same twice over, and it is the // binary's answer rather than whatever happens to be on disk. import ( "os" "path/filepath" "strings" "testing" ) const ( genOpen = "" genClose = "" ) // everything the tree is made of. Named here rather than derived, because a file // that silently stopped being written is exactly the failure this catches. var scaffoldFiles = []string{ "agents/kettle-runner.md", "commands/kettle/api.md", "commands/kettle/auth.md", "commands/kettle/init.md", "commands/kettle/issue.md", "commands/kettle/project.md", "commands/kettle/sync.md", "skills/kettle-api/SKILL.md", "skills/kettle-issue/SKILL.md", "skills/kettle-issue/references/format.md", "skills/kettle-project/SKILL.md", "skills/kettle-sync/SKILL.md", } func TestGenWritesTheWholeTreeAndIsIdempotent(t *testing.T) { dir := t.TempDir() out := filepath.Join(dir, "out") first := mustRun(t, dir, "gen", "scaffold", "--out", out) for _, rel := range scaffoldFiles { path := filepath.Join(out, filepath.FromSlash(rel)) if _, err := os.Stat(path); err != nil { t.Fatalf("%s was not created: %v\n%s", rel, err, first.out()) } } // The four skills that carry a flag table carry the markers around it, so a // reader can see which half came from the registry. for _, group := range []string{"project", "issue", "sync", "api"} { path := filepath.Join(out, "skills", "kettle-"+group, "SKILL.md") body := readFile(t, path) if !strings.HasPrefix(body, "---\nname: kettle-"+group+"\n") { t.Errorf("%s does not name itself after its directory:\n%s", path, firstLines(body, 4)) } if !strings.Contains(body, genOpen) || !strings.Contains(body, genClose) { t.Errorf("%s has no region markers", path) } // The block has to say what wrote it: the first thing anybody who finds // it will want to do is edit it in place. if !strings.Contains(body, "kettle gen scaffold") { t.Errorf("%s does not name the command that regenerates it", path) } } // A command is invoked by a person who typed it, and takes its name from its // filename — a `name:` here would be a second spelling free to drift. initBody := readFile(t, filepath.Join(out, "commands", "kettle", "init.md")) if strings.Contains(firstLines(initBody, 6), "\nname:") { t.Errorf("the init command carries a name: of its own:\n%s", firstLines(initBody, 6)) } if !strings.Contains(initBody, "description:") { t.Errorf("the init command has no description for the command list:\n%s", firstLines(initBody, 6)) } // One command's documentation, end to end: usage line, short, a flag out of // the flag set, and a worked example with its explanation beside it. issues := readFile(t, filepath.Join(out, "skills", "kettle-issue", "SKILL.md")) for _, want := range []string{ "## `kettle evict […]`", "remove closed issues from the local store", "| `--dry-run` | `false` | print what would be removed; touch nothing |", "kettle evict --dry-run", "# print what would go; touch nothing", } { if !strings.Contains(issues, want) { t.Errorf("the issue skill is missing %q", want) } } // Deterministic to the byte: a regeneration of something that has not // changed must produce no diff at all, or every run of a CI step is a // spurious one. before := readAll(t, out) second := mustRun(t, dir, "gen", "scaffold", "--out", out) if strings.Contains(second.stdout, "updated") { t.Errorf("a second run rewrote a file:\n%s", second.out()) } for path, content := range before { if now := readFile(t, path); now != content { t.Errorf("%s changed on a second run with nothing else changed", path) } } if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 0 { t.Errorf("--check exited %d on files that were just written:\n%s", r.code, r.out()) } } // The reversal, and the one behaviour worth stating out loud: these files are // the binary's, whole. The old generator owned a region and left the prose // around it alone, because that prose was somebody's hand-written file. It is // embedded now — there is no hand-written half left to protect, and preserving // local edits would freeze a project's documentation at whatever version first // initialized it. func TestGenReplacesLocalEditsRatherThanPreservingThem(t *testing.T) { dir := t.TempDir() out := filepath.Join(dir, "out") mustRun(t, dir, "gen", "scaffold", "--out", out) path := filepath.Join(out, "skills", "kettle-issue", "SKILL.md") pristine := readFile(t, path) edited := strings.Replace(pristine, "# /kettle:issue", "# my own heading", 1) edited = strings.Replace(edited, genClose, "hand-added line\n"+genClose, 1) if edited == pristine { t.Fatal("the fixture did not actually edit anything") } if err := os.WriteFile(path, []byte(edited), 0o644); err != nil { t.Fatal(err) } // --check is the warning, and it comes before the loss rather than after. if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 1 { t.Errorf("--check exited %d on an edited file, want 1:\n%s", r.code, r.out()) } mustRun(t, dir, "gen", "scaffold", "--out", out) if got := readFile(t, path); got != pristine { t.Errorf("regenerating did not restore the binary's own copy:\n%s", firstLines(got, 8)) } } func TestGenCheckFailsOnAStaleFileAndNamesIt(t *testing.T) { dir := t.TempDir() out := filepath.Join(dir, "out") mustRun(t, dir, "gen", "scaffold", "--out", out) stale := filepath.Join(out, "skills", "kettle-sync", "SKILL.md") edited := strings.Replace(readFile(t, stale), genClose, "kettle push --thoroughly-renamed-flag\n"+genClose, 1) if err := os.WriteFile(stale, []byte(edited), 0o644); err != nil { t.Fatal(err) } r := run(t, dir, "gen", "scaffold", "--out", out, "--check") if r.code != 1 { t.Fatalf("--check exited %d, want 1 — this is what a hook or a CI step calls:\n%s", r.code, r.out()) } if !strings.Contains(r.stdout, stale) { t.Errorf("--check did not say which file is out of date:\n%s", r.out()) } // A question about the tree, never an answer written into it. if got := readFile(t, stale); got != edited { t.Error("--check wrote to the file it was asked about") } // A file that is not there at all is out of date too, not a nothing. if err := os.Remove(stale); err != nil { t.Fatal(err) } if r := run(t, dir, "gen", "scaffold", "--out", out, "--check"); r.code != 1 { t.Errorf("--check exited %d for a missing file, want 1:\n%s", r.code, r.out()) } if _, err := os.Stat(stale); err == nil { t.Error("--check created the file it was asked about") } } func TestGenDryRunWritesNothingAtAll(t *testing.T) { dir := t.TempDir() out := filepath.Join(dir, "out") fresh := mustRun(t, dir, "gen", "scaffold", "--out", out, "--dry-run") if !strings.Contains(fresh.stdout, "would create") { t.Errorf("a dry run said nothing about what it would do:\n%s", fresh.out()) } if _, err := os.Stat(out); err == nil { t.Fatal("a dry run created the output directory") } // And on an existing tree: the file is described, never touched. mustRun(t, dir, "gen", "scaffold", "--out", out) path := filepath.Join(out, "skills", "kettle-issue", "SKILL.md") edited := strings.Replace(readFile(t, path), genClose, "gutted\n"+genClose, 1) if err := os.WriteFile(path, []byte(edited), 0o644); err != nil { t.Fatal(err) } r := mustRun(t, dir, "gen", "scaffold", "--out", out, "--dry-run") if !strings.Contains(r.stdout, "would update") || !strings.Contains(r.stdout, "nothing was written") { t.Errorf("the dry run did not report the pending change:\n%s", r.out()) } if got := readFile(t, path); got != edited { t.Error("a dry run rewrote the file") } } // Without --out the tree goes under the project marker, resolved by the same // walk every other command uses. No marker is an answer, not a fallback: a // `.claude/` written into a plausible-looking directory is the failure the // marker exists to replace. func TestGenWithoutOutResolvesTheProject(t *testing.T) { dir := newProject(t) sub := filepath.Join(dir, "cli", "internal") if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } mustRun(t, sub, "gen", "scaffold") if _, err := os.Stat(filepath.Join(dir, ".claude", "skills", "kettle-issue", "SKILL.md")); err != nil { t.Errorf("run from %s, the tree did not land at the project root: %v", sub, err) } orphan := t.TempDir() r := run(t, orphan, "gen", "scaffold") if r.code == 0 { t.Fatalf("gen outside a project must stop:\n%s", r.out()) } if !strings.Contains(r.stderr, ".kettle") { t.Errorf("the refusal does not name what is missing:\n%s", r.out()) } if _, err := os.Stat(filepath.Join(orphan, ".claude")); err == nil { t.Error("the refused run created a tree anyway") } } func TestGenRefusesAnUnknownTarget(t *testing.T) { dir := t.TempDir() if r := run(t, dir, "gen", "agents", "--out", filepath.Join(dir, "x")); r.code == 0 { t.Errorf("an unknown target must be refused:\n%s", r.out()) } if _, err := os.Stat(filepath.Join(dir, "x")); err == nil { t.Error("the refused run created its output directory anyway") } } func readFile(t *testing.T, path string) string { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(raw) } // readAll is every file under root, by path, for a byte-for-byte comparison // after a second run. func readAll(t *testing.T, root string) map[string]string { t.Helper() out := map[string]string{} err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return err } raw, err := os.ReadFile(path) if err != nil { return err } out[path] = string(raw) return nil }) if err != nil { t.Fatal(err) } return out } func firstLines(s string, n int) string { lines := strings.SplitN(s, "\n", n+1) if len(lines) > n { lines = lines[:n] } return strings.Join(lines, "\n") }