diff --git a/README.md b/README.md index 68224a6..f2a7c3a 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ codeguard validate -config codeguard.yaml codeguard doctor -config codeguard.yaml codeguard scan -config codeguard.yaml codeguard scan -config codeguard.yaml -folder ./service/api +codeguard scan -profile startup +codeguard scan -folder ./service/api -profile startup codeguard scan-history codeguard rules codeguard profiles @@ -102,7 +104,14 @@ By default, `codeguard` looks for `codeguard.yaml`, `codeguard.yml`, or `codegua If you point `-config` at a directory such as `.codeguard`, `codeguard` will look inside it for `codeguard.*` or `config.*` files. -Use `codeguard scan -folder ` to scan only one folder. `-path ` is accepted as an alias. +Use `codeguard scan -folder ` to scan only one folder. `-path ` is accepted as an alias. If no config file exists and you did not pass `-config`, scans use CodeGuard's built-in default config; add `-profile startup`, `-profile strict`, `-profile enterprise`, or `-profile ai-safe` to choose a default profile. + +For agent bootstrap flows, the shortest useful commands are: + +```bash +codeguard scan -profile startup +codeguard scan -folder ./service/api -profile startup +``` Text output includes ANSI color and emoji markers by default. Set `NO_COLOR=1` if you want plain terminal output. diff --git a/docs/agent-native.md b/docs/agent-native.md index 36f8720..cc1f36f 100644 --- a/docs/agent-native.md +++ b/docs/agent-native.md @@ -6,6 +6,13 @@ This document is a short status brief for `codeguard` features aimed at AI agent ### Implemented +- Fast configless scans for agents + - after installing the CLI, an agent can run `codeguard scan -profile startup` in a repository without creating a config file first + - an agent can scope work to a subfolder with `codeguard scan -folder ./path -profile startup`; `-path ./path` is accepted as an alias + - when no config file exists and `-config` was not passed, scans use the built-in default config plus any requested `-profile` + - explicit `-config` paths still fail if the file is missing, preserving deterministic configured runs + - configless scans disable the scan cache so a quick read-only assessment does not create `.codeguard/cache.json` + - `codeguard serve --mcp` - exposes MCP tools for `scan`, `validate_patch`, and `explain` - also exposes `validate_config` and `list_rules` diff --git a/docs/getting-started.md b/docs/getting-started.md index 4775cc6..7decf69 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -35,6 +35,8 @@ codeguard validate -config codeguard.yaml codeguard doctor -config codeguard.yaml codeguard scan -config codeguard.yaml codeguard scan -config codeguard.yaml -folder ./service/api +codeguard scan -profile startup +codeguard scan -folder ./service/api -profile startup codeguard rules codeguard profiles codeguard explain prompts.secret-interpolation @@ -43,7 +45,7 @@ codeguard baseline -config codeguard.yaml -output codeguard-baseline.json `codeguard init` writes `codeguard.yaml` by default. -Use `codeguard scan -folder ` when you want to scan only one folder. `-path ` is accepted as an alias. +Use `codeguard scan -folder ` when you want to scan only one folder. `-path ` is accepted as an alias. If no config file exists and you did not pass `-config`, scans use CodeGuard's built-in default config; add `-profile startup`, `-profile strict`, `-profile enterprise`, or `-profile ai-safe` to choose a default profile. If you prefer a JSON example, start from [examples/codeguard.json](/Users/alex/Documents/GitHub/codeguard/examples/codeguard.json:1). diff --git a/internal/cli/commands.go b/internal/cli/commands.go index ebb3df0..d3193b3 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -76,6 +76,7 @@ func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) if ok, code := parseFlags(fs, args, stderr); !ok { return code } + configFlagSet := flagWasSet(fs, "config") flags.applyTrustPolicy() if err := promptScanInputs(*interactive, stdin, stdout, &inputs); err != nil { @@ -94,8 +95,10 @@ func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) return exitError } - cfg, ok := loadConfigOrFail(*inputs.configPath, *flags.profile, stderr) - if !ok { + defaultConfigRequested := !configFlagSet && strings.TrimSpace(*inputs.configPath) == service.DefaultConfigPath() + cfg, err := loadScanConfigWithFallback(*inputs.configPath, *flags.profile, defaultConfigRequested) + if err != nil { + _, _ = fmt.Fprintf(stderr, "load config: %v\n", err) return exitError } if trimmedFormat := strings.TrimSpace(*format); trimmedFormat != "" { diff --git a/internal/cli/commands_scan_helpers.go b/internal/cli/commands_scan_helpers.go index 16b823e..cdd6a5a 100644 --- a/internal/cli/commands_scan_helpers.go +++ b/internal/cli/commands_scan_helpers.go @@ -3,6 +3,7 @@ package cli import ( "bufio" "context" + "flag" "fmt" "io" "strings" @@ -92,6 +93,16 @@ func scanTargetPath(folderPath string, pathAlias string) (string, error) { return pathAlias, nil } +func flagWasSet(fs *flag.FlagSet, name string) bool { + wasSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + wasSet = true + } + }) + return wasSet +} + func writePerformanceUpgradeHint(stdout io.Writer, cfg service.Config) { if cfg.Checks.Performance != nil { return diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index bfc0715..6cd6323 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -2,8 +2,10 @@ package cli import ( "bufio" + "errors" "fmt" "io" + "os" "strings" service "github.com/devr-tools/codeguard/pkg/codeguard" @@ -44,6 +46,29 @@ func loadConfigWithProfile(path string, profile string) (service.Config, error) if err != nil { return service.Config{}, err } + return applyProfileOverride(cfg, profile) +} + +func loadScanConfigWithFallback(path string, profile string, defaultConfigRequested bool) (service.Config, error) { + cfg, err := service.LoadConfigFile(path) + if err == nil { + return applyProfileOverride(cfg, profile) + } + if !defaultConfigRequested || !errors.Is(err, os.ErrNotExist) { + return service.Config{}, err + } + + cfg, err = exampleConfigForProfile(profile) + if err != nil { + return service.Config{}, err + } + disable := false + cfg.Cache.Enabled = &disable + cfg.Cache.Path = "" + return cfg, nil +} + +func applyProfileOverride(cfg service.Config, profile string) (service.Config, error) { if strings.TrimSpace(profile) != "" { cfg.Profile = strings.TrimSpace(profile) service.ApplyDefaults(&cfg) diff --git a/tests/cli/scan_test.go b/tests/cli/scan_test.go index 00f5f5d..ebe94a1 100644 --- a/tests/cli/scan_test.go +++ b/tests/cli/scan_test.go @@ -159,6 +159,92 @@ func TestRunScanPathFlagScopesFolder(t *testing.T) { } } +func TestRunScanFolderWithoutConfigUsesDefaultProfile(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir tempdir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(cwd) + }) + + writeScanTestFile(t, filepath.Join(dir, "sub", "go.mod"), "module example.com/configless\n\ngo 1.23.0\n") + writeScanTestFile(t, filepath.Join(dir, "sub", "main.go"), "package main\n\nfunc main() {}\n") + writeScanTestFile(t, filepath.Join(dir, "sub", "Makefile"), "test:\n\tgo test ./...\n") + writeScanTestFile(t, filepath.Join(dir, "sub", "README.md"), "# Configless scan\n\nRun `make test`.\n") + writeScanTestFile(t, filepath.Join(dir, "sub", "AGENTS.md"), "# Agent Notes\n\n## Build & test\n- `make test` runs the unit suite.\n") + writeScanTestFile(t, filepath.Join(dir, "sub", ".github", "workflows", "ci.yml"), "name: ci\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: go test ./...\n") + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "-folder", "sub", "-profile", "startup"}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("scan exit code = %d, stderr = %s\nstdout = %s", code, stderr.String(), stdout.String()) + } + if _, err := os.Stat(filepath.Join(dir, ".codeguard")); !os.IsNotExist(err) { + t.Fatalf("expected configless scan not to create .codeguard cache directory, stat err = %v", err) + } +} + +func TestRunScanWithoutConfigUsesDefaultProfileForCurrentDirectory(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir tempdir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(cwd) + }) + + writeScanTestFile(t, filepath.Join(dir, "go.mod"), "module example.com/configlessrepo\n\ngo 1.23.0\n") + writeScanTestFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n") + writeScanTestFile(t, filepath.Join(dir, "Makefile"), "test:\n\tgo test ./...\n") + writeScanTestFile(t, filepath.Join(dir, "README.md"), "# Configless repo scan\n\nRun `make test`.\n") + writeScanTestFile(t, filepath.Join(dir, "AGENTS.md"), "# Agent Notes\n\n## Build & test\n- `make test` runs the unit suite.\n") + writeScanTestFile(t, filepath.Join(dir, ".github", "workflows", "ci.yml"), "name: ci\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: go test ./...\n") + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "-profile", "startup"}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("scan exit code = %d, stderr = %s\nstdout = %s", code, stderr.String(), stdout.String()) + } + if _, err := os.Stat(filepath.Join(dir, ".codeguard")); !os.IsNotExist(err) { + t.Fatalf("expected configless scan not to create .codeguard cache directory, stat err = %v", err) + } +} + +func TestRunScanFolderWithExplicitMissingConfigStillFails(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatalf("mkdir sub: %v", err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "-config", filepath.Join(dir, "missing.yaml"), "-folder", filepath.Join(dir, "sub")}, strings.NewReader(""), &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit 1, got %d; stdout = %s", code, stdout.String()) + } + if !strings.Contains(stderr.String(), "load config:") { + t.Fatalf("expected load config error, got %s", stderr.String()) + } +} + +func writeScanTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) func stripANSI(value string) string {