diff --git a/lab/internal/cli/cli.go b/lab/internal/cli/cli.go index 7d9d6a3..6791439 100644 --- a/lab/internal/cli/cli.go +++ b/lab/internal/cli/cli.go @@ -48,7 +48,7 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer fail := func(err error) int { fmt.Fprintln(stderr, "forgecell:", err); return 1 } harness.SetRunningVersion(version) if len(args) == 0 || args[0] == "--help" || args[0] == "help" { - fmt.Fprintln(out, "Forgecell Lab — Go migration preview\n\n"+demoUsage+"\ninit [--lab DIR] [--harness ID | --rebind ID] [--meta-harness ID | --meta-command JSON_ARGV] [--harness-timeout-ms MS] [--max-timeout-ms MS] [--json] [--review ID | --approve ID | --dismiss ID]\ninit --model ID [--role analysis|coding|meta|review] [--lab DIR] [--json]\ninit --refresh-baseline [--lab DIR] [--json]\ninit --enable-review [--lab DIR] [--json]\ndoctor [--lab DIR] [--harness ID] [--json] [--verbose]\ndoctor --clean [--yes] [--lab DIR]\nrun [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --accept-protected-failures [--add-acceptance TEXT]... [--note TEXT] [--lab DIR] (declare the tests that failed by design in the latest protected regression run as expectedProtectedDeltas; continues checks-only; takes no --run: review the printed list, then approve the digest with run --approve DIGEST)\namend --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --parent DIGEST --rebase-formula [--lab DIR]\nrecover --plan DIGEST --confirm-stopped [--lab DIR]\nretire --reason TEXT [--lab DIR] (mark a stale Molecule retired: one-line reason of at most 200 characters; every record is kept, the loop never resumes it and may start a fresh Molecule for its Issue)\nloop [--lab DIR] [--select oldest|label:L|milestone:M] [--max-concurrent N] [--once]\nloop [--lab DIR] --watch [--interval DURATION] [--learn]\nledger [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion [--lab DIR] [--approve | --dismiss | --link-evaluation DIR | --baseline-ledger ID --candidate-ledger ID --comparability-basis TEXT] [--evaluation-parent DIR] [--json]\nrollback\ndev install [--from CHECKOUT] [--activate] [--allow-dirty]\nevaluate --inputs PLAN --out NEW_DIRECTORY [--source REPO] [--approve DIGEST]\nevaluate --cohort FILE --out DIRECTORY [--source REPO] [--approve DIGEST] [--parallel N] [--recheck]\nevaluate replay --repo OWNER/REPO --pr N [--pr M ...] --source CHECKOUT --out NEW_DIRECTORY\ndeliver --lab DIR --base BRANCH [--approve DIGEST --title TITLE] -- FILE...\nchecks --lab DIR --molecule ID --repo OWNER/REPO --pr NUMBER --commit SHA --required NAME [--required NAME]\n"+findingsUsage+"\nlabs [--json]\nplugin test [--json]\nui [--lab DIR] [--lab-name NAME] [--port N] [--no-open] [--demo]\n--version\n--protocol\n\nThe default Lab is keyed by the checkout's GitHub origin (OWNER/REPO), not its path; --lab DIR always wins and --lab-name NAME selects a named Lab for the repository on any command that takes a Lab. labs lists every Lab, read-only.\n\ndev install is developer-only: it builds the checkout's lab/cmd/forgecell with the local Go toolchain and then runs the result, so the checkout is trusted code. Without --activate the build is only staged under releases/ and current is unchanged. A tree with uncommitted, untracked or ignored Go source changes is refused unless --allow-dirty is given. --protocol prints the adapter protocol version.\n\nui serves the Lab UI on 127.0.0.1 behind a per-launch token and opens it in the browser unless --no-open is given. Its writes go through the engine and are bound to the digest you saw; it never merges or deploys. With no ready Lab it serves Set up your Lab; --demo shows synthetic sample data. It stops on SIGINT or SIGTERM.\n\n"+demoHelp+"\n\nRuns analyze a ticket before coding and wait for exact scope approval. Required checks run independently before final review.\n\nloop starts intake for eligible open Issues of the Formula's repository, oldest first, and approves a scope gate only where the Formula declares a policy that approves that exact plan. It stops each Molecule at any human gate, open question or failure and reports why; it never delivers, merges or approves a human gate. --once makes a single pass. --watch repeats single passes every --interval (default 5m, at least 1m) until SIGINT or SIGTERM, which stop it between passes after in-flight Molecules finish; it cannot be combined with --once. --learn, which is off by default, runs the pending learn requests after each pass like learn --pending; suggestions stay pending for your decision. For an interrupted Molecule whose owner is gone, the loop saves a recovery proposal (the exact forgecell recover command) under the Lab's recovery-proposals directory and shows it in its report and in doctor; it never runs recover or amend.\n\nTicket readiness is a development preview; existing Formulas require an explicitly reviewed scope gate.") + fmt.Fprintln(out, "Forgecell Lab — Go migration preview\n\n"+demoUsage+"\ninit [--lab DIR] [--harness ID | --rebind ID] [--meta-harness ID | --meta-command JSON_ARGV] [--harness-timeout-ms MS] [--max-timeout-ms MS] [--json] [--review ID | --approve ID | --dismiss ID]\ninit --model ID [--role analysis|coding|meta|review] [--lab DIR] [--json]\ninit --refresh-baseline [--lab DIR] [--json]\ninit --enable-review [--lab DIR] [--json]\ndoctor [--lab DIR] [--harness ID] [--json] [--verbose]\ndoctor --clean [--yes] [--lab DIR]\nrun [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --accept-protected-failures [--add-acceptance TEXT]... [--note TEXT] [--lab DIR] (declare the tests that failed by design in the latest protected regression run as expectedProtectedDeltas; continues checks-only; takes no --run: review the printed list, then approve the digest with run --approve DIGEST)\namend --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --parent DIGEST --rebase-formula [--lab DIR]\nrecover --plan DIGEST --confirm-stopped [--lab DIR]\nretire --reason TEXT [--lab DIR] (mark a stale Molecule retired: one-line reason of at most 200 characters; every record is kept, the loop never resumes it and may start a fresh Molecule for its Issue)\nloop [--lab DIR] [--select oldest|label:L|milestone:M] [--max-concurrent N] [--once]\nloop [--lab DIR] --watch [--interval DURATION] [--learn]\nledger [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion [--lab DIR] [--approve | --dismiss | --link-evaluation DIR | --baseline-ledger ID --candidate-ledger ID --comparability-basis TEXT] [--evaluation-parent DIR] [--json]\nrollback\ndev install [--from CHECKOUT] [--activate] [--allow-dirty]\nevaluate --inputs PLAN --out NEW_DIRECTORY [--source REPO] [--approve DIGEST]\nevaluate --cohort FILE --out DIRECTORY [--source REPO] [--approve DIGEST] [--parallel N] [--recheck]\nevaluate replay --repo OWNER/REPO --pr N [--pr M ...] --source CHECKOUT --out NEW_DIRECTORY\ndeliver --lab DIR --base BRANCH [--approve DIGEST --title TITLE] -- FILE...\nchecks --lab DIR --molecule ID --repo OWNER/REPO --pr NUMBER --commit SHA --required NAME [--required NAME]\n"+findingsUsage+"\nlabs [--json]\n"+pluginHelp+"\nui [--lab DIR] [--lab-name NAME] [--port N] [--no-open] [--demo]\n--version\n--protocol\n\nThe default Lab is keyed by the checkout's GitHub origin (OWNER/REPO), not its path; --lab DIR always wins and --lab-name NAME selects a named Lab for the repository on any command that takes a Lab. labs lists every Lab, read-only.\n\ndev install is developer-only: it builds the checkout's lab/cmd/forgecell with the local Go toolchain and then runs the result, so the checkout is trusted code. Without --activate the build is only staged under releases/ and current is unchanged. A tree with uncommitted, untracked or ignored Go source changes is refused unless --allow-dirty is given. --protocol prints the adapter protocol version.\n\nui serves the Lab UI on 127.0.0.1 behind a per-launch token and opens it in the browser unless --no-open is given. Its writes go through the engine and are bound to the digest you saw; it never merges or deploys. With no ready Lab it serves Set up your Lab; --demo shows synthetic sample data. It stops on SIGINT or SIGTERM.\n\n"+demoHelp+"\n\nRuns analyze a ticket before coding and wait for exact scope approval. Required checks run independently before final review.\n\nloop starts intake for eligible open Issues of the Formula's repository, oldest first, and approves a scope gate only where the Formula declares a policy that approves that exact plan. It stops each Molecule at any human gate, open question or failure and reports why; it never delivers, merges or approves a human gate. --once makes a single pass. --watch repeats single passes every --interval (default 5m, at least 1m) until SIGINT or SIGTERM, which stop it between passes after in-flight Molecules finish; it cannot be combined with --once. --learn, which is off by default, runs the pending learn requests after each pass like learn --pending; suggestions stay pending for your decision. For an interrupted Molecule whose owner is gone, the loop saves a recovery proposal (the exact forgecell recover command) under the Lab's recovery-proposals directory and shows it in its report and in doctor; it never runs recover or amend.\n\nTicket readiness is a development preview; existing Formulas require an explicitly reviewed scope gate.") return 0 } if args[0] == "--version" || args[0] == "-v" { diff --git a/lab/internal/cli/plugin.go b/lab/internal/cli/plugin.go index b722048..a9edb0f 100644 --- a/lab/internal/cli/plugin.go +++ b/lab/internal/cli/plugin.go @@ -16,13 +16,33 @@ import ( "github.com/DanielNetzer/forgecell/lab/internal/plugin/conformance" ) -const pluginUsage = "usage: forgecell plugin test [--json] | forgecell plugin list [--lab DIR] [--lab-name NAME] [--json] | forgecell plugin doctor [NAME] [--lab DIR] [--lab-name NAME] [--json]" +// The form of each plugin command, without "usage: forgecell ". pluginUsage, +// the per-command usages and the --help lines are all built from these, so +// they cannot drift apart. +const ( + pluginNewCommand = "plugin new --kind tracker|forge|reviewer --lang go|python|typescript [--dir DIR] " + pluginPinCommand = "plugin pin --server [--interpreter ]" + pluginTestCommand = "plugin test [--json] [--live --target T [--config FILE] [--change REF --commit SHA] [--allow-write --head BRANCH --base BRANCH]]" + pluginListCommand = "plugin list [--lab DIR] [--lab-name NAME] [--json]" + pluginDoctorCommand = "plugin doctor [NAME] [--lab DIR] [--lab-name NAME] [--json]" +) + +// pluginUsage names every plugin command, in the order new, pin, test, list +// and doctor. +const pluginUsage = "usage: forgecell " + pluginNewCommand + + " | forgecell " + pluginPinCommand + + " | forgecell " + pluginTestCommand + + " | forgecell " + pluginListCommand + + " | forgecell " + pluginDoctorCommand + +// pluginHelp is the plugin commands' lines of forgecell --help, one per line. +const pluginHelp = pluginNewCommand + "\n" + pluginPinCommand + "\n" + pluginTestCommand + "\n" + + pluginListCommand + "\n" + pluginDoctorCommand -// The usage of plugin new and plugin pin, printed by those commands only, so -// the usage that test, list and doctor print stays as it was. +// The usage of plugin new and plugin pin, printed by those commands only. const ( - pluginNewUsage = "usage: forgecell plugin new --kind tracker|forge|reviewer --lang go|python|typescript [--dir DIR] " - pluginPinUsage = "usage: forgecell plugin pin --server [--interpreter ]" + pluginNewUsage = "usage: forgecell " + pluginNewCommand + pluginPinUsage = "usage: forgecell " + pluginPinCommand ) // pluginTestOptions configures plugin test runs; tests replace it to keep runs @@ -101,9 +121,8 @@ func runPlugin(ctx context.Context, args []string, out, stderr io.Writer) int { return 0 } -// pluginTestLiveUsage is printed only when live flags are refused, so the usage -// that test, list and doctor print stays as it was. -const pluginTestLiveUsage = "usage: forgecell plugin test [--json] [--live --target T [--config FILE] [--change REF --commit SHA] [--allow-write --head BRANCH --base BRANCH]]" +// pluginTestLiveUsage is printed when live flags are refused. +const pluginTestLiveUsage = "usage: forgecell " + pluginTestCommand // Bounds of a --config file: the contract's config maxProperties, and 64 KiB. const ( diff --git a/lab/internal/cli/plugin_new_test.go b/lab/internal/cli/plugin_new_test.go index d939f36..6bede46 100644 --- a/lab/internal/cli/plugin_new_test.go +++ b/lab/internal/cli/plugin_new_test.go @@ -12,9 +12,13 @@ import ( "github.com/DanielNetzer/forgecell/lab/internal/plugin/plugintest" ) -// pluginUsageBeforeNew is the usage of plugin test, list and doctor, which the -// new and pin commands extend without changing. -const pluginUsageBeforeNew = "usage: forgecell plugin test [--json] | forgecell plugin list [--lab DIR] [--lab-name NAME] [--json] | forgecell plugin doctor [NAME] [--lab DIR] [--lab-name NAME] [--json]" +// pluginUsageAllCommands is the usage that names every plugin command: new, +// pin, test with its live flags, list and doctor. +const pluginUsageAllCommands = "usage: forgecell plugin new --kind tracker|forge|reviewer --lang go|python|typescript [--dir DIR] " + + " | forgecell plugin pin --server [--interpreter ]" + + " | forgecell plugin test [--json] [--live --target T [--config FILE] [--change REF --commit SHA] [--allow-write --head BRANCH --base BRANCH]]" + + " | forgecell plugin list [--lab DIR] [--lab-name NAME] [--json]" + + " | forgecell plugin doctor [NAME] [--lab DIR] [--lab-name NAME] [--json]" // noAbsolutePath fails when text names the temporary root or holds any // absolute path. @@ -186,20 +190,20 @@ func TestPluginPinPrintsTheDigestAndFormulaLine(t *testing.T) { } func TestPluginUsageKeepsTestListAndDoctor(t *testing.T) { - if pluginUsage != pluginUsageBeforeNew { - t.Fatalf("the test, list and doctor usage changed:\n%s", pluginUsage) + if pluginUsage != pluginUsageAllCommands { + t.Fatalf("the plugin usage is not the all-commands line:\n%s", pluginUsage) } if pluginNewUsage != "usage: forgecell plugin new --kind tracker|forge|reviewer --lang go|python|typescript [--dir DIR] " || pluginPinUsage != "usage: forgecell plugin pin --server [--interpreter ]" { t.Fatalf("new and pin usage: %q, %q", pluginNewUsage, pluginPinUsage) } - // Each usage error of plugin test, list and doctor prints the base text, - // byte for byte. + // Each usage error of plugin test, list and doctor prints the all-commands + // line, byte for byte. for _, args := range [][]string{ {"plugin"}, {"plugin", "frobnicate"}, {"plugin", "test"}, {"plugin", "test", "a", "b"}, {"plugin", "list", "extra"}, {"plugin", "doctor", "a", "b"}, } { - if code, out, errOut := runCLI(args...); code != 1 || out != "" || errOut != "forgecell: "+pluginUsageBeforeNew+"\n" { + if code, out, errOut := runCLI(args...); code != 1 || out != "" || errOut != "forgecell: "+pluginUsageAllCommands+"\n" { t.Errorf("%v: exit %d, %q%q", args, code, out, errOut) } } diff --git a/lab/internal/cli/plugin_usage_test.go b/lab/internal/cli/plugin_usage_test.go new file mode 100644 index 0000000..364bc12 --- /dev/null +++ b/lab/internal/cli/plugin_usage_test.go @@ -0,0 +1,134 @@ +package cli + +import ( + "strings" + "testing" +) + +// The plugin command forms as the help and usage show them, pinned here so a +// change to any shared form constant shows up as a test failure. +const ( + pluginNewForm = "plugin new --kind tracker|forge|reviewer --lang go|python|typescript [--dir DIR] " + pluginPinForm = "plugin pin --server [--interpreter ]" + pluginTestForm = "plugin test [--json] [--live --target T [--config FILE] [--change REF --commit SHA] [--allow-write --head BRANCH --base BRANCH]]" + pluginListForm = "plugin list [--lab DIR] [--lab-name NAME] [--json]" + pluginDoctorForm = "plugin doctor [NAME] [--lab DIR] [--lab-name NAME] [--json]" +) + +func TestPluginUsageNamesEveryCommand(t *testing.T) { + want := "usage: forgecell " + strings.Join([]string{ + pluginNewForm, pluginPinForm, pluginTestForm, pluginListForm, pluginDoctorForm, + }, " | forgecell ") + if pluginUsage != want { + t.Fatalf("pluginUsage:\n%s\nwant:\n%s", pluginUsage, want) + } + for _, args := range [][]string{{"plugin"}, {"plugin", "frobnicate"}} { + code, out, errOut := runCLI(args...) + if code != 1 || out != "" || errOut != "forgecell: "+want+"\n" { + t.Fatalf("%v: exit %d, %q%q", args, code, out, errOut) + } + for _, name := range []string{ + "forgecell plugin new ", "forgecell plugin pin ", "forgecell plugin test ", "--live", + "forgecell plugin list ", "forgecell plugin doctor ", + } { + if !strings.Contains(errOut, name) { + t.Errorf("%v: the usage does not name %q", args, name) + } + } + if strings.Count(errOut, "\n") != 1 { + t.Errorf("%v: the usage is not one line: %q", args, errOut) + } + } +} + +func TestPluginUsageIsBuiltFromThePerCommandUsages(t *testing.T) { + // The per-command usages stay byte-identical. + for _, c := range []struct{ got, want string }{ + {pluginNewUsage, "usage: forgecell " + pluginNewForm}, + {pluginPinUsage, "usage: forgecell " + pluginPinForm}, + {pluginTestLiveUsage, "usage: forgecell " + pluginTestForm}, + } { + if c.got != c.want { + t.Errorf("usage changed: %q, want %q", c.got, c.want) + } + } + rest := strings.TrimPrefix(pluginUsage, "usage: ") + for _, u := range []string{pluginNewUsage, pluginPinUsage, pluginTestLiveUsage} { + form, ok := strings.CutPrefix(u, "usage: ") + if !ok { + t.Fatalf("%q lacks the usage: prefix", u) + } + if !strings.Contains(rest, form) { + t.Errorf("pluginUsage lacks %q", form) + } + } + // The forms appear in the order new, pin, test, list, doctor. + parts := strings.Split(rest, " | ") + order := []string{pluginNewForm, pluginPinForm, pluginTestForm, pluginListForm, pluginDoctorForm} + if len(parts) != len(order) { + t.Fatalf("pluginUsage has %d commands: %q", len(parts), parts) + } + for i, form := range order { + if parts[i] != "forgecell "+form { + t.Errorf("command %d is %q, want %q", i, parts[i], "forgecell "+form) + } + } +} + +func TestHelpListsEveryPluginCommand(t *testing.T) { + code, out, _ := runCLI("--help") + if code != 0 { + t.Fatalf("exit %d", code) + } + lines := strings.Split(out, "\n") + at := -1 + for i, line := range lines { + if line == pluginNewForm { + at = i + break + } + } + if at < 1 || at+5 >= len(lines) { + t.Fatalf("help has no plugin new line:\n%s", out) + } + // The plugin lines replace the old plugin test line in place: labs comes + // before them and ui after them, as before. + want := []string{"labs [--json]", pluginNewForm, pluginPinForm, pluginTestForm, pluginListForm, pluginDoctorForm} + for i, w := range want { + if got := lines[at-1+i]; got != w { + t.Errorf("help line %d is %q, want %q", at+i, got, w) + } + } + if !strings.HasPrefix(lines[at+5], "ui [") { + t.Errorf("help line after the plugin lines is %q", lines[at+5]) + } + for _, form := range want[1:] { + if strings.Count(out, form+"\n") != 1 { + t.Errorf("help lists %q other than once", form) + } + } + // The plugin test line still names the base form. + if !strings.Contains(out, "plugin test [--json]") { + t.Error("help does not list plugin test") + } +} + +func TestPluginNewAndPinUsageErrorsPrintTheirOwnUsage(t *testing.T) { + for _, c := range []struct { + args []string + usage string + }{ + {[]string{"plugin", "new"}, pluginNewUsage}, + {[]string{"plugin", "new", "--kind", "tracker", "x"}, pluginNewUsage}, + {[]string{"plugin", "pin"}, pluginPinUsage}, + {[]string{"plugin", "pin", "./rv", "--server"}, pluginPinUsage}, + } { + code, out, errOut := runCLI(c.args...) + if code != 1 || out != "" || errOut != "forgecell: "+c.usage+"\n" { + t.Errorf("%v: exit %d, %q%q", c.args, code, out, errOut) + } + if strings.Contains(errOut, " | ") { + t.Errorf("%v: printed the all-commands usage: %q", c.args, errOut) + } + } +}