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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@
codeguard validate -config codeguard.yaml
codeguard doctor -config codeguard.yaml
codeguard scan -config codeguard.yaml
codeguard scan -config codeguard.yaml -folder ./service/api

Check warning on line 91 in README.md

View workflow job for this annotation

GitHub Actions / codeguard

[context.readme-drift] README.md references "./service/api", which does not exist in the repository; stale paths send agents down dead ends — update or remove the reference. Fix: Update the README's command examples to the current entrypoints, or restore the script, target, or path they invoke.
codeguard scan -profile startup
codeguard scan -folder ./service/api -profile startup
codeguard scan-history
codeguard rules
codeguard profiles
Expand All @@ -102,7 +104,14 @@

If you point `-config` at a directory such as `.codeguard`, `codeguard` will look inside it for `codeguard.*` or `config.*` files.

Use `codeguard scan -folder <path>` to scan only one folder. `-path <path>` is accepted as an alias.
Use `codeguard scan -folder <path>` to scan only one folder. `-path <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.

Expand Down
7 changes: 7 additions & 0 deletions docs/agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,7 +45,7 @@ codeguard baseline -config codeguard.yaml -output codeguard-baseline.json

`codeguard init` writes `codeguard.yaml` by default.

Use `codeguard scan -folder <path>` when you want to scan only one folder. `-path <path>` is accepted as an alias.
Use `codeguard scan -folder <path>` when you want to scan only one folder. `-path <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).

Expand Down
7 changes: 5 additions & 2 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 != "" {
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/commands_scan_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"bufio"
"context"
"flag"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions internal/cli/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package cli

import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"

service "github.com/devr-tools/codeguard/pkg/codeguard"
Expand Down Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions tests/cli/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading