From cdb351bed9c855483e7a407c92923e5b856c047e Mon Sep 17 00:00:00 2001 From: alex <53851759+alxxjohn@users.noreply.github.com> Date: Tue, 26 May 2026 21:45:30 -0400 Subject: [PATCH] feat(feat: repo folder for cleanr docs): repo folder for cleanr docs --- docs/best-practices.md | 20 ++++-- docs/ci.md | 2 +- docs/getting-started.md | 14 +++- internal/cli/cli_ops.go | 12 ++-- internal/cli/cli_paths.go | 79 +++++++++++++++++++++-- internal/cli/cli_run.go | 10 ++- tests/cli/cli_additional_test.go | 107 +++++++++++++++++++++++++++++++ 7 files changed, 220 insertions(+), 24 deletions(-) diff --git a/docs/best-practices.md b/docs/best-practices.md index 206bcc3..2f28708 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -111,9 +111,9 @@ Minimal shape: If you want the highest-value rollout, structure configs by pipeline stage: -- `cleanr-pr.yaml`: assertions, security, token optimization, light drift -- `cleanr-main.yaml`: adds trend tracking and moderate trend gates -- `cleanr-release.yaml`: adds full drift, load, chaos, replay artifacts, attestation, and `release_policy` +- `.cleanr/pr.yaml`: assertions, security, token optimization, light drift +- `.cleanr/main.yaml`: adds trend tracking and moderate trend gates +- `.cleanr/release.yaml`: adds full drift, load, chaos, replay artifacts, attestation, and `release_policy` Reference examples live in: @@ -124,9 +124,17 @@ Reference examples live in: The same rollout is now available directly from the setup flow: ```bash -cleanr setup --ci -provider openai -model gpt-4.1-mini -profile pr -output cleanr-pr.yaml -cleanr setup --ci -provider openai -model gpt-4.1-mini -profile main -output cleanr-main.yaml -cleanr setup --ci -provider openai -model gpt-4.1-mini -profile release -output cleanr-release.yaml +cleanr setup --ci -provider openai -model gpt-4.1-mini -profile pr -output .cleanr/pr.yaml +cleanr setup --ci -provider openai -model gpt-4.1-mini -profile main -output .cleanr/main.yaml +cleanr setup --ci -provider openai -model gpt-4.1-mini -profile release -output .cleanr/release.yaml +``` + +Then select the desired stage at runtime: + +```bash +cleanr run -profile pr +cleanr run -profile main +CLEANR_PROFILE=release cleanr validate ``` For agent-oriented configs: diff --git a/docs/ci.md b/docs/ci.md index 82f40ec..074fa8f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -224,7 +224,7 @@ cleanr setup agent --ci \ That generated config points to standard env var names instead of embedding credentials, so a user can set secrets once and run without editing the config. -`cleanr-connected.yml` also supports staged setup profiles via `CLEANR_PROFILE`: +`cleanr-connected.yml` also supports staged setup profiles via `CLEANR_PROFILE`. The same variable now selects staged local configs such as `.cleanr/pr.yaml`, `.cleanr/main.yaml`, and `.cleanr/release.yaml` for CLI commands like `cleanr run`, `cleanr validate`, and `cleanr snapshot`: - `pr`: light drift, security, token optimization, exploratory trend gates - `main`: retained trend history and moderate trend gates diff --git a/docs/getting-started.md b/docs/getting-started.md index 2eba3d0..335a669 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,9 +27,17 @@ cleanr setup --ci -provider openai -model gpt-4.1-mini -output cleanr.yaml For staged pipeline scaffolding: ```bash -cleanr setup --ci -provider openai -model gpt-4.1-mini -profile pr -output cleanr-pr.yaml -cleanr setup --ci -provider openai -model gpt-4.1-mini -profile main -output cleanr-main.yaml -cleanr setup --ci -provider openai -model gpt-4.1-mini -profile release -output cleanr-release.yaml +cleanr setup --ci -provider openai -model gpt-4.1-mini -profile pr -output .cleanr/pr.yaml +cleanr setup --ci -provider openai -model gpt-4.1-mini -profile main -output .cleanr/main.yaml +cleanr setup --ci -provider openai -model gpt-4.1-mini -profile release -output .cleanr/release.yaml +``` + +When you keep staged configs under `.cleanr/`, select them with `-profile` or `CLEANR_PROFILE`: + +```bash +cleanr validate -profile pr +cleanr run -profile main +CLEANR_PROFILE=release cleanr snapshot ``` If you prefer to start from checked-in examples instead of the setup flow, use one of: diff --git a/internal/cli/cli_ops.go b/internal/cli/cli_ops.go index 5cef538..d3da343 100644 --- a/internal/cli/cli_ops.go +++ b/internal/cli/cli_ops.go @@ -17,6 +17,7 @@ func trendsCmd(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("trends", flag.ContinueOnError) fs.SetOutput(stderr) configPath := fs.String("config", "", "Path to cleanr config") + profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release") trendFile := fs.String("trend-file", "", "Path to trend history file") format := fs.String("format", "text", "Output format: text or json") output := fs.String("output", "", "Optional output file") @@ -29,7 +30,7 @@ func trendsCmd(args []string, stdout, stderr io.Writer) int { return 2 } - trendPath, err := resolveTrendPath(*configPath, *trendFile) + trendPath, err := resolveTrendPath(*configPath, *profile, *trendFile) if err != nil { _, _ = fmt.Fprintf(stderr, "trends error: %v\n", err) return 2 @@ -81,6 +82,7 @@ func datasetExportCmd(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("dataset export", flag.ContinueOnError) fs.SetOutput(stderr) configPath := fs.String("config", "", "Path to cleanr config") + profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release") replayPath := fs.String("replay-artifact", "", "Path to replay artifact file") output := fs.String("output", "cleanr.dataset.yaml", "Path to write the exported scenario dataset") includeAll := fs.Bool("all", false, "Include all scenarios instead of only reviewed replay failures") @@ -88,7 +90,7 @@ func datasetExportCmd(args []string, stdout, stderr io.Writer) int { return 2 } - resolvedConfigPath, err := resolveConfigPath(*configPath) + resolvedConfigPath, err := resolveConfigPath(*configPath, *profile) if err != nil { _, _ = fmt.Fprintf(stderr, "dataset export error: %v\n", err) return 2 @@ -173,12 +175,13 @@ func pluginsCmd(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("plugins", flag.ContinueOnError) fs.SetOutput(stderr) configPath := fs.String("config", "", "Path to cleanr config") + profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release") format := fs.String("format", "text", "Output format: text or json") if err := fs.Parse(args); err != nil { return 2 } - resolvedConfigPath, err := resolveConfigPath(*configPath) + resolvedConfigPath, err := resolveConfigPath(*configPath, *profile) if err != nil { _, _ = fmt.Fprintf(stderr, "plugins error: %v\n", err) return 2 @@ -224,11 +227,12 @@ func validateCmd(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("validate", flag.ContinueOnError) fs.SetOutput(stderr) configPath := fs.String("config", "", "Path to cleanr config") + profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release") if err := fs.Parse(args); err != nil { return 2 } - resolvedConfigPath, err := resolveConfigPath(*configPath) + resolvedConfigPath, err := resolveConfigPath(*configPath, *profile) if err != nil { _, _ = fmt.Fprintf(stderr, "invalid: %v\n", err) return 2 diff --git a/internal/cli/cli_paths.go b/internal/cli/cli_paths.go index 02f0a28..ce65568 100644 --- a/internal/cli/cli_paths.go +++ b/internal/cli/cli_paths.go @@ -11,19 +11,34 @@ import ( "github.com/devr-tools/cleanr/cleanr" ) -func resolveConfigPath(configPath string) (string, error) { - if configPath != "" { +const configProfileEnvName = "CLEANR_PROFILE" + +var defaultConfigCandidates = []string{"cleanr.json", "cleanr.yaml", "cleanr.yml"} + +func resolveConfigPath(configPath, profile string) (string, error) { + if strings.TrimSpace(configPath) != "" { return configPath, nil } - candidates := []string{"cleanr.json", "cleanr.yaml", "cleanr.yml"} - for _, candidate := range candidates { + resolvedProfile, err := resolveConfigProfile(profile) + if err != nil { + return "", err + } + if resolvedProfile != "" { + return resolveStagedConfigPath(resolvedProfile) + } + + for _, candidate := range defaultConfigCandidates { if _, err := os.Stat(candidate); err == nil { return candidate, nil } } - return "", fmt.Errorf("no config file found; expected one of %s in %s", joinCandidates(candidates), mustGetwd()) + err = fmt.Errorf("no config file found; expected one of %s in %s", joinCandidates(defaultConfigCandidates), mustGetwd()) + if hasStagedConfigFiles() { + return "", fmt.Errorf("%w; found staged configs under .cleanr, rerun with -profile pr|main|release or set %s", err, configProfileEnvName) + } + return "", err } func joinCandidates(paths []string) string { @@ -42,6 +57,47 @@ func mustGetwd() string { return wd } +func resolveConfigProfile(profile string) (string, error) { + profile = strings.ToLower(strings.TrimSpace(firstNonEmpty(profile, os.Getenv(configProfileEnvName)))) + switch profile { + case "": + return "", nil + case "pr", "main", "release": + return profile, nil + default: + return "", fmt.Errorf("unsupported profile %q; expected pr, main, or release", profile) + } +} + +func resolveStagedConfigPath(profile string) (string, error) { + candidates := []string{ + filepath.Join(".cleanr", profile+".json"), + filepath.Join(".cleanr", profile+".yaml"), + filepath.Join(".cleanr", profile+".yml"), + } + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + return "", fmt.Errorf("no config file found for profile %q; expected one of %s in %s", profile, strings.Join(candidates, ", "), mustGetwd()) +} + +func hasStagedConfigFiles() bool { + for _, profile := range []string{"pr", "main", "release"} { + for _, candidate := range []string{ + filepath.Join(".cleanr", profile+".json"), + filepath.Join(".cleanr", profile+".yaml"), + filepath.Join(".cleanr", profile+".yml"), + } { + if _, err := os.Stat(candidate); err == nil { + return true + } + } + } + return false +} + func resolveConfigRelativePath(configPath, path string) string { path = strings.TrimSpace(path) if path == "" || filepath.IsAbs(path) { @@ -50,11 +106,11 @@ func resolveConfigRelativePath(configPath, path string) string { return filepath.Join(filepath.Dir(configPath), path) } -func resolveTrendPath(configPath, explicitTrendPath string) (string, error) { +func resolveTrendPath(configPath, profile, explicitTrendPath string) (string, error) { if strings.TrimSpace(explicitTrendPath) != "" { return explicitTrendPath, nil } - resolvedConfigPath, err := resolveConfigPath(configPath) + resolvedConfigPath, err := resolveConfigPath(configPath, profile) if err != nil { return "", err } @@ -77,3 +133,12 @@ func writeJSON(w io.Writer, value any) int { } return 0 } + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/cli/cli_run.go b/internal/cli/cli_run.go index e3c97c7..7415a89 100644 --- a/internal/cli/cli_run.go +++ b/internal/cli/cli_run.go @@ -15,6 +15,7 @@ import ( type runOptions struct { configPath string + profile string format string output string trendFile string @@ -30,7 +31,7 @@ func runCmd(args []string, stdout, stderr io.Writer) int { return 2 } - resolvedConfigPath, err := resolveConfigPath(opts.configPath) + resolvedConfigPath, err := resolveConfigPath(opts.configPath, opts.profile) if err != nil { _, _ = fmt.Fprintf(stderr, "config error: %v\n", err) return 2 @@ -81,6 +82,7 @@ func parseRunOptions(args []string, stderr io.Writer) (runOptions, error) { fs.SetOutput(stderr) opts := runOptions{} fs.StringVar(&opts.configPath, "config", "", "Path to cleanr config") + fs.StringVar(&opts.profile, "profile", "", "Optional staged config profile: pr, main, or release") fs.StringVar(&opts.format, "format", "", "Report format: text, json, junit") fs.StringVar(&opts.output, "output", "", "Optional output file") fs.StringVar(&opts.trendFile, "trend-file", "", "Optional trend history file") @@ -201,13 +203,14 @@ func snapshotCmd(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("snapshot", flag.ContinueOnError) fs.SetOutput(stderr) configPath := fs.String("config", "", "Path to cleanr config") + profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release") output := fs.String("output", "", "Path to write snapshot baseline") timeout := fs.Duration("timeout", 0, "Overall execution timeout") if err := fs.Parse(args); err != nil { return 2 } - resolvedConfigPath, err := resolveConfigPath(*configPath) + resolvedConfigPath, err := resolveConfigPath(*configPath, *profile) if err != nil { _, _ = fmt.Fprintf(stderr, "config error: %v\n", err) return 2 @@ -258,6 +261,7 @@ func generateCmd(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("generate", flag.ContinueOnError) fs.SetOutput(stderr) configPath := fs.String("config", "", "Path to cleanr config") + profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release") output := fs.String("output", "", "Path to write the generated scenario dataset") count := fs.Int("count", 0, "Optional override for scenario_generation.count") timeout := fs.Duration("timeout", 0, "Overall execution timeout") @@ -265,7 +269,7 @@ func generateCmd(args []string, stdout, stderr io.Writer) int { return 2 } - resolvedConfigPath, err := resolveConfigPath(*configPath) + resolvedConfigPath, err := resolveConfigPath(*configPath, *profile) if err != nil { _, _ = fmt.Fprintf(stderr, "generate error: %v\n", err) return 2 diff --git a/tests/cli/cli_additional_test.go b/tests/cli/cli_additional_test.go index b0ec91e..68145a4 100644 --- a/tests/cli/cli_additional_test.go +++ b/tests/cli/cli_additional_test.go @@ -123,3 +123,110 @@ func TestCLIMCPCommandReturnsOnEOF(t *testing.T) { t.Fatalf("expected mcp command to exit cleanly on EOF, got %d stderr=%s", code, stderr.String()) } } + +func TestCLIValidateSupportsStagedProfileConfigs(t *testing.T) { + dir := t.TempDir() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer func() { _ = os.Chdir(wd) }() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + if err := os.MkdirAll(".cleanr", 0o755); err != nil { + t.Fatalf("mkdir .cleanr: %v", err) + } + + cfg := cleanr.ExampleConfig() + cfg.Target.Name = "pr-profile" + if err := cleanr.WriteConfigFile(filepath.Join(".cleanr", "pr.yaml"), cfg); err != nil { + t.Fatalf("write staged config: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := cli.Run([]string{"validate", "-profile", "pr"}, &stdout, &stderr); code != 0 { + t.Fatalf("expected validate to succeed, got %d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "valid config for pr-profile with 2 scenarios") { + t.Fatalf("unexpected stdout: %s", stdout.String()) + } +} + +func TestCLIValidateSupportsStagedProfileFromEnv(t *testing.T) { + dir := t.TempDir() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer func() { _ = os.Chdir(wd) }() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Setenv("CLEANR_PROFILE", "release") + + if err := os.MkdirAll(".cleanr", 0o755); err != nil { + t.Fatalf("mkdir .cleanr: %v", err) + } + + cfg := cleanr.ExampleConfig() + cfg.Target.Name = "release-profile" + if err := cleanr.WriteConfigFile(filepath.Join(".cleanr", "release.yaml"), cfg); err != nil { + t.Fatalf("write staged config: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := cli.Run([]string{"validate"}, &stdout, &stderr); code != 0 { + t.Fatalf("expected validate to succeed, got %d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "valid config for release-profile with 2 scenarios") { + t.Fatalf("unexpected stdout: %s", stdout.String()) + } +} + +func TestCLIValidateHintsWhenStagedConfigsNeedProfileSelection(t *testing.T) { + dir := t.TempDir() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer func() { _ = os.Chdir(wd) }() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + + if err := os.MkdirAll(".cleanr", 0o755); err != nil { + t.Fatalf("mkdir .cleanr: %v", err) + } + + cfg := cleanr.ExampleConfig() + if err := cleanr.WriteConfigFile(filepath.Join(".cleanr", "pr.yaml"), cfg); err != nil { + t.Fatalf("write pr config: %v", err) + } + if err := cleanr.WriteConfigFile(filepath.Join(".cleanr", "main.yaml"), cfg); err != nil { + t.Fatalf("write main config: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := cli.Run([]string{"validate"}, &stdout, &stderr); code != 2 { + t.Fatalf("expected validate to fail, got %d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "found staged configs under .cleanr, rerun with -profile pr|main|release or set CLEANR_PROFILE") { + t.Fatalf("unexpected stderr: %s", stderr.String()) + } +} + +func TestCLIValidateRejectsInvalidProfile(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := cli.Run([]string{"validate", "-profile", "nightly"}, &stdout, &stderr); code != 2 { + t.Fatalf("expected validate to fail, got %d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), `invalid: unsupported profile "nightly"; expected pr, main, or release`) { + t.Fatalf("unexpected stderr: %s", stderr.String()) + } +}