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
5 changes: 4 additions & 1 deletion lab/internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,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 <issue> [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend <molecule> [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend <molecule> --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 <issue> --approve DIGEST)\namend <molecule> --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend <molecule> --parent DIGEST --rebase-formula [--lab DIR]\nrecover <molecule> --plan DIGEST --confirm-stopped [--lab DIR]\nretire <molecule> --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 <molecule> [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn <molecule...> [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion <id> [--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 <molecule> --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]\nlabs [--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 <issue> [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend <molecule> [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend <molecule> --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 <issue> --approve DIGEST)\namend <molecule> --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend <molecule> --parent DIGEST --rebase-formula [--lab DIR]\nrecover <molecule> --plan DIGEST --confirm-stopped [--lab DIR]\nretire <molecule> --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 <molecule> [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn <molecule...> [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion <id> [--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 <molecule> --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]\nlabs [--json]\nplugin test <manifest> [--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.")
return 0
}
if args[0] == "--version" || args[0] == "-v" {
Expand All @@ -63,6 +63,9 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer
if args[0] == "dev" {
return runDev(ctx, args[1:], version, out, stderr)
}
if args[0] == "plugin" {
return runPlugin(ctx, args[1:], out, stderr)
}
if args[0] == "demo" {
return runDemo(ctx, args[1:], out, stderr)
}
Expand Down
67 changes: 67 additions & 0 deletions lab/internal/cli/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package cli

import (
"context"
"fmt"
"io"
"os"
"path/filepath"

"github.com/DanielNetzer/forgecell/lab/internal/plugin/conformance"
)

const pluginUsage = "usage: forgecell plugin test <manifest> [--json]"

// pluginTestOptions configures plugin test runs; tests replace it to keep runs
// under their temporary directory and to fire the shutdown grace at once.
var pluginTestOptions = func() conformance.Options {
return conformance.Options{Root: os.TempDir(), LookupEnv: os.LookupEnv}
}

// runPlugin runs `plugin test <manifest> [--json]`: the contract-mode
// conformance kit, offline. It exits 0 when every check passes, 2 when one
// fails and 1 on bad usage. It needs no Lab.
func runPlugin(ctx context.Context, args []string, out, stderr io.Writer) int {
if len(args) == 0 || args[0] != "test" {
fmt.Fprintln(stderr, "forgecell:", pluginUsage)
return 1
}
manifest, jsonMode := "", false
for _, arg := range args[1:] {
switch {
case arg == "--json":
jsonMode = true
case manifest == "" && arg != "" && arg[0] != '-':
manifest = arg
default:
fmt.Fprintln(stderr, "forgecell:", pluginUsage)
return 1
}
}
if manifest == "" {
fmt.Fprintln(stderr, "forgecell:", pluginUsage)
return 1
}
abs, err := filepath.Abs(manifest)
if err != nil {
fmt.Fprintln(stderr, "forgecell: the manifest path cannot be resolved")
return 1
}
opts := pluginTestOptions()
if root, err := filepath.EvalSymlinks(opts.Root); err == nil {
opts.Root = root
}
report := conformance.Run(ctx, abs, opts)
if jsonMode {
err = report.WriteJSON(out)
} else {
err = report.WriteText(out)
}
if err != nil {
return 1
}
if !report.Passed {
return 2
}
return 0
}
119 changes: 119 additions & 0 deletions lab/internal/cli/plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package cli

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

"github.com/DanielNetzer/forgecell/lab/internal/plugin/conformance"
"github.com/DanielNetzer/forgecell/lab/internal/plugin/plugintest"
)

func firedGrace() <-chan time.Time {
c := make(chan time.Time)
close(c)
return c
}

// usePluginTestOptions keeps plugin runs under the test's temporary directory
// and fires the shutdown grace at once.
func usePluginTestOptions(t *testing.T, timeout time.Duration) string {
t.Helper()
root := plugintest.TempDir(t)
previous := pluginTestOptions
pluginTestOptions = func() conformance.Options {
return conformance.Options{Root: root, LookupEnv: func(string) (string, bool) { return "", false }, Grace: firedGrace, Timeout: timeout}
}
t.Cleanup(func() { pluginTestOptions = previous })
return root
}

func TestPluginTestAgainstTheFakeServer(t *testing.T) {
// One build of the fake server serves every case; each case writes its own
// manifest that pins it.
server := plugintest.Server(t)
manifest := func(t *testing.T, kind, mode string) string {
return plugintest.WriteManifest(t, plugintest.Manifest(t, server, kind, mode))
}

t.Run("pass", func(t *testing.T) {
root := usePluginTestOptions(t, 0)
path := manifest(t, "forge", "good")
code, out, errOut := runCLI("plugin", "test", path)
if code != 0 || !strings.Contains(out, "PASS describe") || !strings.Contains(out, "Result: PASS (8 of 8 checks passed") {
t.Fatalf("exit %d\n%s%s", code, out, errOut)
}
if strings.Contains(out, filepath.Dir(path)) || strings.Contains(out, root) || strings.Contains(out, plugintest.PlantedSecret) {
t.Fatalf("the report leaks a local path or stderr:\n%s", out)
}
})

t.Run("json", func(t *testing.T) {
usePluginTestOptions(t, 0)
code, out, _ := runCLI("plugin", "test", "--json", manifest(t, "tracker", "good"))
var r conformance.Report
if err := json.Unmarshal([]byte(out), &r); err != nil || code != 0 || !r.Passed || r.Plugin != "fake-tracker" || r.Manifest != "plugin.json" {
t.Fatalf("exit %d, report %+v, %v\n%s", code, r, err, out)
}
})

for _, c := range []struct{ kind, mode, failed string }{
{"forge", "wrong-contract", "FAIL initialize: contract-mismatch"},
{"tracker", "missing-tool", "FAIL tools-list: contract-mismatch"},
{"reviewer", "crash", "FAIL describe: failed"},
{"forge", "bad-input-crash", "FAIL malformed-input: failed"},
{"tracker", "oversized", "FAIL describe: output-too-large"},
{"reviewer", "slow", "FAIL describe: timeout"},
} {
t.Run(c.mode, func(t *testing.T) {
timeout := time.Duration(0)
if c.mode == "slow" {
// The slow server never answers, so any timeout gives the same outcome.
timeout = 10 * time.Millisecond
}
root := usePluginTestOptions(t, timeout)
code, out, _ := runCLI("plugin", "test", manifest(t, c.kind, c.mode), "--json")
var r conformance.Report
if err := json.Unmarshal([]byte(out), &r); err != nil || code != 2 || r.Passed {
t.Fatalf("exit %d, %v\n%s", code, err, out)
}
code, out, _ = runCLI("plugin", "test", manifest(t, c.kind, c.mode))
if code != 2 || !strings.Contains(out, c.failed) || !strings.Contains(out, "Result: FAIL") {
t.Fatalf("exit %d, want %q\n%s", code, c.failed, out)
}
if left, _ := os.ReadDir(root); len(left) != 0 {
t.Fatalf("the run left %d entries behind", len(left))
}
})
}
}

func TestPluginTestUsage(t *testing.T) {
usePluginTestOptions(t, 0)
for _, args := range [][]string{
{"plugin"},
{"plugin", "list"},
{"plugin", "test"},
{"plugin", "test", "a.json", "b.json"},
{"plugin", "test", "--verbose", "a.json"},
} {
if code, _, errOut := runCLI(args...); code != 1 || !strings.Contains(errOut, pluginUsage) {
t.Errorf("%v: exit %d, %q", args, code, errOut)
}
}
missing := filepath.Join(plugintest.TempDir(t), "missing.json")
code, out, _ := runCLI("plugin", "test", missing)
if code != 2 || !strings.Contains(out, "FAIL start: not-installed") || strings.Contains(out, filepath.Dir(missing)) {
t.Fatalf("exit %d\n%s", code, out)
}
}

func TestHelpListsPluginTest(t *testing.T) {
_, out, _ := runCLI("--help")
if !strings.Contains(out, "plugin test <manifest> [--json]") {
t.Fatal("help does not list plugin test")
}
}
65 changes: 65 additions & 0 deletions lab/internal/extension/exported.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package extension

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"

"github.com/DanielNetzer/forgecell/lab/internal/pinned"
)

// These helpers expose the mcp-provider/v1 rules to sibling manifests (the
// plugin kinds) and to strict decoders of plugin output, so one rule set holds
// for every manifest. They wrap the private rules and never change them.

// RequireExactKeys refuses any object key in raw, at any depth, that is not
// spelled exactly like a JSON field of t (a struct, or a pointer or slice of
// one). It catches case-variant and Kelvin-sign keys that encoding/json would
// otherwise match without regard to case.
func RequireExactKeys(raw []byte, t reflect.Type) error {
if t == nil {
return errors.New("no type to check keys against")
}
var doc any
if err := json.NewDecoder(bytes.NewReader(raw)).Decode(&doc); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
return checkKeys(doc, t)
}

// ValidatePin applies the manifest rule for a pinned file: a clean absolute path
// without placeholders and a lowercase SHA-256.
func ValidatePin(label string, f pinned.File) error { return validPin(label, f) }

// Validate applies the manifest rules for a command's executable and optional
// interpreter.
func (c Command) Validate(label string) error { return c.validate(label) }

// ValidName reports whether s is a valid extension or plugin name.
func ValidName(s string) bool { return namePattern.MatchString(s) }

// ValidToolName reports whether s is a valid MCP tool name for a manifest.
func ValidToolName(s string) bool { return toolPattern.MatchString(s) }

// ValidateArgs applies the manifest rules for an argv: no NUL byte, placeholders
// only as a whole argument or a clean path prefix, and no argument that names a
// pinned path.
func ValidateArgs(args []string, pins []pinned.File) error {
for _, arg := range args {
if strings.ContainsRune(arg, 0) {
return errors.New("an argument contains a NUL byte")
}
if err := checkPlaceholders(arg); err != nil {
return err
}
for _, p := range pins {
if pinned.PathInArg(arg, p.Path) {
return fmt.Errorf("argument %q contains the pinned path %s", arg, p.Path)
}
}
}
return nil
}
Loading
Loading