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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ codeguard init
codeguard validate -config codeguard.yaml
codeguard doctor -config codeguard.yaml
codeguard scan -config codeguard.yaml
codeguard scan -config codeguard.yaml -memprofile /tmp/codeguard.heap.pprof
codeguard scan -config codeguard.yaml -folder ./internal/codeguard
codeguard scan -folder ./internal/codeguard -profile startup
codeguard scan -folder ./internal/codeguard -profile startup -set checks.quality=true -set output.format=json
Expand All @@ -108,6 +109,8 @@ 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.

Long-running scans report completed sections and a 30-second heap/GOMEMLIMIT heartbeat on stderr, leaving JSON, SARIF, GitHub, and CycloneDX stdout machine-readable. Pass `-memprofile <path>` to keep a rolling Go heap profile (written at startup, every heartbeat, and completion), then inspect it with `go tool pprof <path>`. Full scans skip dependency and generated trees named `node_modules`, `vendor`, and `cdk.out` at any depth; use `exclude` for repository-specific generated paths.

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`, folder scans use CodeGuard's built-in default config; add `-profile startup`, `-profile strict`, `-profile enterprise`, or `-profile ai-safe` to choose a default profile.

Use repeatable `-set key=value` flags to override config values from the terminal without writing a temporary config file. Keys use dotted YAML paths and are validated against the typed CodeGuard config; mistyped fields fail before the scan runs. String lists accept comma-separated values or a JSON string array.
Expand Down
18 changes: 18 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,24 @@ This page lists the current `codeguard` feature surface and the main config entr
See [Production rollout](production.md) for configuration, safe CI usage,
review workflow, and exit-code behavior.

## Repository traversal

Full scans always prune directories named `node_modules` and `cdk.out` at any
depth. Dependency manifests and lockfiles remain in scope for supply-chain,
license, vulnerability, and lockfile-integrity checks; installed and generated
source is not treated as first-party code.

Directories named `vendor` are also pruned by default. Repositories that commit
or patch vendored source can opt in explicitly while retaining the normal
per-file, corpus-byte, AST, and file-count limits:

```yaml
scan_vendored_source: true
```

Configured `exclude` patterns still take precedence, so an explicitly excluded
vendor subtree remains excluded when vendored-source scanning is enabled.

## External report ingestion

CodeGuard can import findings from scanners that have already run. It does not
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
pathAlias := fs.String("path", "", "alias for -folder")
enableAI := fs.Bool("ai", false, "enable optional AI-assisted analysis")
includeSuppressed := fs.Bool("include-suppressed", false, "include individual suppressed findings in JSON output")
heapProfile := fs.String("memprofile", "", "optional path for rolling Go heap profiles during the scan")
interactive := fs.Bool("interactive", false, "prompt for scan inputs in the terminal")
if ok, code := parseFlags(fs, args, stderr); !ok {
return code
Expand Down Expand Up @@ -116,7 +117,7 @@ func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
return exitError
}

if err := executeScan(stdout, cfg, scanMode, strings.TrimSpace(*inputs.baseRef), targetPath, *enableAI, *includeSuppressed); err != nil {
if err := executeScan(stdout, stderr, cfg, scanMode, strings.TrimSpace(*inputs.baseRef), targetPath, *enableAI, *includeSuppressed, *heapProfile); err != nil {
_, _ = fmt.Fprintf(stderr, "scan failed: %v\n", err)
return exitError
}
Expand Down
19 changes: 13 additions & 6 deletions internal/cli/commands_scan_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package cli
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
"strings"
"time"

service "github.com/devr-tools/codeguard/pkg/codeguard"
)
Expand Down Expand Up @@ -58,16 +60,20 @@ func parseScanMode(mode string) (service.ScanMode, error) {
return scanMode, nil
}

func executeScan(stdout io.Writer, cfg service.Config, scanMode service.ScanMode, baseRef string, targetPath string, enableAI bool, includeSuppressed bool) error {
report, err := service.RunWithOptions(context.Background(), cfg, service.ScanOptions{
func executeScan(stdout io.Writer, stderr io.Writer, cfg service.Config, scanMode service.ScanMode, baseRef string, targetPath string, enableAI bool, includeSuppressed bool, heapProfilePath string) error {
monitor := newScanMonitor(stderr, time.Now())
stopMonitoring := startScanMonitoring(monitor, strings.TrimSpace(heapProfilePath), scanHeartbeatInterval)
report, scanErr := service.RunWithOptions(context.Background(), cfg, service.ScanOptions{
Mode: scanMode,
BaseRef: baseRef,
TargetPath: targetPath,
EnableAI: enableAI,
IncludeSuppressed: includeSuppressed,
OnSectionComplete: monitor.writeSectionComplete,
})
if err != nil {
return err
monitorErr := stopMonitoring()
if scanErr != nil {
return scanErr
}
if err := writeScanMetadata(stdout, cfg.Output.Format, scanMode, baseRef); err != nil {
return err
Expand All @@ -76,10 +82,11 @@ func executeScan(stdout io.Writer, cfg service.Config, scanMode service.ScanMode
return fmt.Errorf("write report: %w", err)
}
writePerformanceUpgradeHint(stdout, cfg)
var findingsErr error
if report.Summary.FailedSections > 0 {
return fmt.Errorf("one or more sections failed")
findingsErr = fmt.Errorf("one or more sections failed")
}
return nil
return errors.Join(findingsErr, monitorErr)
}

func scanTargetPath(folderPath string, pathAlias string) (string, error) {
Expand Down
118 changes: 118 additions & 0 deletions internal/cli/scan_diagnostics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package cli

import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"runtime/pprof"
"sync"
"time"

service "github.com/devr-tools/codeguard/pkg/codeguard"
)

const scanHeartbeatInterval = 30 * time.Second

type scanMonitor struct {
mu sync.Mutex
writer io.Writer
started time.Time
}

func newScanMonitor(writer io.Writer, started time.Time) *scanMonitor {
return &scanMonitor{writer: writer, started: started}
}

func (monitor *scanMonitor) writeStarted() {
monitor.write("scan started\n")
}

func (monitor *scanMonitor) writeHeartbeat(now time.Time, heapBytes uint64, memoryLimit int64) {
elapsed := now.Sub(monitor.started).Round(time.Second)
monitor.write("scan in progress: elapsed=%s heap=%d MiB memory_limit=%s\n",
elapsed, heapBytes/(1<<20), formatMemoryLimit(memoryLimit))
}

func (monitor *scanMonitor) writeSectionComplete(section service.SectionResult) {
monitor.write("completed %s: %s (%d findings)\n", section.Name, section.Status, len(section.Findings))
}

func (monitor *scanMonitor) write(format string, args ...any) {
if monitor == nil || monitor.writer == nil {
return
}
monitor.mu.Lock()
defer monitor.mu.Unlock()
_, _ = fmt.Fprintf(monitor.writer, format, args...)
}

func formatMemoryLimit(limit int64) string {
if limit < 0 || limit >= 1<<62 {
return "unlimited"
}
return fmt.Sprintf("%d MiB", limit/(1<<20))
}

func startScanMonitoring(monitor *scanMonitor, heapProfilePath string, interval time.Duration) func() error {
monitor.writeStarted()
if heapProfilePath != "" {
if err := writeHeapProfile(heapProfilePath); err != nil {
monitor.write("heap profile update failed: %v\n", err)
}
}

stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case now := <-ticker.C:
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
monitor.writeHeartbeat(now, stats.HeapAlloc, debug.SetMemoryLimit(-1))
if heapProfilePath != "" {
if err := writeHeapProfile(heapProfilePath); err != nil {
monitor.write("heap profile update failed: %v\n", err)
}
}
case <-stop:
return
}
}
}()

return func() error {
close(stop)
<-done
if heapProfilePath != "" {
if err := writeHeapProfile(heapProfilePath); err != nil {
return fmt.Errorf("write final heap profile: %w", err)
}
}
return nil
}
}

func writeHeapProfile(path string) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".codeguard-heap-*.pprof")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if err := pprof.WriteHeapProfile(tmp); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
122 changes: 122 additions & 0 deletions internal/cli/scan_diagnostics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package cli

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"

service "github.com/devr-tools/codeguard/pkg/codeguard"
)

func TestScanMonitorReportsProgressAndMemoryWithoutPollutingReportOutput(t *testing.T) {
var diagnostics bytes.Buffer
monitor := newScanMonitor(&diagnostics, time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC))
monitor.writeStarted()
monitor.writeHeartbeat(time.Date(2026, 9, 1, 12, 1, 30, 0, time.UTC), 384<<20, 8<<30)
monitor.writeSectionComplete(service.SectionResult{Name: "Code Quality", Status: service.StatusPass})

got := diagnostics.String()
for _, want := range []string{
"scan started",
"scan in progress: elapsed=1m30s heap=384 MiB memory_limit=8192 MiB",
"completed Code Quality: pass",
} {
if !strings.Contains(got, want) {
t.Fatalf("diagnostics %q do not contain %q", got, want)
}
}
}

func TestWriteHeapProfileCreatesUsableProfile(t *testing.T) {
path := filepath.Join(t.TempDir(), "scan.heap.pprof")
if err := writeHeapProfile(path); err != nil {
t.Fatalf("write heap profile: %v", err)
}
data, err := os.ReadFile(path) //nolint:gosec // test-owned path under t.TempDir
if err != nil {
t.Fatalf("read heap profile: %v", err)
}
if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
t.Fatalf("heap profile does not have gzip header: %x", data[:min(len(data), 8)])
}
}

func TestRunScanStreamsProgressAndWritesRequestedHeapProfile(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
contextDisabled := false
configPath := filepath.Join(root, "codeguard.json")
cfg := service.Config{
Name: "diagnostic-scan",
Targets: []service.TargetConfig{{Name: "repo", Path: root, Language: "go"}},
Checks: service.CheckConfig{
Quality: true,
Context: &contextDisabled,
},
Output: service.OutputConfig{Format: "github"},
}
if err := service.WriteConfigFile(configPath, cfg); err != nil {
t.Fatalf("write config: %v", err)
}

profilePath := filepath.Join(root, "scan.heap.pprof")
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"scan", "-config", configPath, "-format", "github", "-memprofile", profilePath},
strings.NewReader(""), &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("scan exit code = %d, want 0; stderr: %s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "scan started") || !strings.Contains(stderr.String(), "completed Code Quality") {
t.Fatalf("stderr does not contain streamed progress: %q", stderr.String())
}
if strings.Contains(stdout.String(), "scan started") {
t.Fatalf("machine-readable stdout was polluted by progress: %q", stdout.String())
}
if info, err := os.Stat(profilePath); err != nil {
t.Fatalf("stat heap profile: %v", err)
} else if info.Size() == 0 {
t.Fatal("heap profile is empty")
}
}

func TestRunScanStillWritesReportWhenHeapProfileCannotBeWritten(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
contextDisabled := false
configPath := filepath.Join(root, "codeguard.json")
cfg := service.Config{
Name: "profile-failure-scan",
Targets: []service.TargetConfig{{Name: "repo", Path: root, Language: "go"}},
Checks: service.CheckConfig{
Quality: true,
Context: &contextDisabled,
},
Output: service.OutputConfig{Format: "github"},
}
if err := service.WriteConfigFile(configPath, cfg); err != nil {
t.Fatalf("write config: %v", err)
}

profilePath := filepath.Join(root, "missing", "scan.heap.pprof")
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"scan", "-config", configPath, "-format", "github", "-memprofile", profilePath},
strings.NewReader(""), &stdout, &stderr)
if exitCode == 0 {
t.Fatal("scan with unwritable requested heap profile returned success")
}
if !strings.Contains(stdout.String(), "::notice title=CodeGuard") {
t.Fatalf("completed scan report was not written: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "heap profile") {
t.Fatalf("heap profile failure was not diagnosed: %q", stderr.String())
}
}
15 changes: 10 additions & 5 deletions internal/codeguard/checks/quality/quality.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,22 @@ func Run(ctx context.Context, env support.Context) core.SectionResult {

func runQualitySection(ctx context.Context, env support.Context) core.SectionResult {
var unresolved []unresolvedMutationEvidence
var diagnostics []core.Diagnostic
findings := support.CollectTargetFindings(ctx, env, func(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding {
analysis := qualityTargetAnalysis(ctx, env, target)
unresolved = append(unresolved, analysis.unresolved...)
diagnostics = append(diagnostics, analysis.diagnostics...)
return analysis.findings
})
findings = append(findings, provenancePolicyFindings(env, findings)...) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up
return env.FinalizeSectionWithDiagnostics("quality", "Code Quality", findings, unresolvedMutationDiagnostics(unresolved))
diagnostics = append(diagnostics, unresolvedMutationDiagnostics(unresolved)...)
return env.FinalizeSectionWithDiagnostics("quality", "Code Quality", findings, diagnostics)
}

type qualityTargetScan struct {
findings []core.Finding
unresolved []unresolvedMutationEvidence
findings []core.Finding
unresolved []unresolvedMutationEvidence
diagnostics []core.Diagnostic
}

func qualityTargetAnalysis(ctx context.Context, env support.Context, target core.TargetConfig) qualityTargetScan {
Expand All @@ -39,7 +43,8 @@ func qualityTargetAnalysis(ctx context.Context, env support.Context, target core
findings = append(findings, rustToolchainDeadCodeFindings(ctx, env, target)...)
findings = append(findings, cppToolchainDeadCodeFindings(env, target)...)
findings = append(findings, pythonToolchainDeadCodeFindings(env, target)...)
findings = append(findings, cloneFindingsForTarget(env, target)...)
cloneAnalysis := cloneFindingsForTarget(env, target)
findings = append(findings, cloneAnalysis.findings...)
findings = append(findings, aiTargetFindings(env, target)...)
findings = append(findings, semanticFindings(ctx, env, target)...)
findings = append(findings, commandFindings(ctx, env, target)...)
Expand All @@ -50,7 +55,7 @@ func qualityTargetAnalysis(ctx context.Context, env support.Context, target core
}
maybePutAISlopArtifact(env, target, findings)
findings = append(findings, changeRiskFindings(env, target, findings)...) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up
return qualityTargetScan{findings: findings, unresolved: language.unresolved}
return qualityTargetScan{findings: findings, unresolved: language.unresolved, diagnostics: cloneAnalysis.diagnostics}
}

func unresolvedMutationDiagnostics(unresolved []unresolvedMutationEvidence) []core.Diagnostic {
Expand Down
Loading
Loading