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
4 changes: 4 additions & 0 deletions .codeguard/codeguard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ checks:
max_function_lines: 80
max_parameters: 5
max_cyclomatic_complexity: 10
design_rules:
god_module_threshold: 32
performance_rules:
hot_package_importer_threshold: 32
rebuild_amplifier_threshold: 32
# Dogfood the measured budget gate on artifacts that always exist in the
# repository (dist/ is not built in CI, so a binary budget would only ever
# report "not found"): the user-facing rule reference must stay readable
Expand Down
4 changes: 2 additions & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ builds:
goarch:
- amd64
- arm64
# Embed only the grammars codeguard parses (TS/TSX/JS) instead of the
# Embed only the grammars codeguard parses (TS/TSX/JS/Python/C++) instead of the
# full ~206-grammar registry: ~+4 MB binary delta instead of ~+22 MB.
# See docs/treesitter-spike.md §5.3.
flags:
- -tags=grammar_subset,grammar_subset_typescript,grammar_subset_tsx,grammar_subset_javascript,grammar_subset_python
- -tags=grammar_subset,grammar_subset_typescript,grammar_subset_tsx,grammar_subset_javascript,grammar_subset_python,grammar_subset_cpp
ldflags:
- -s -w -X github.com/devr-tools/codeguard/internal/version.Number=v{{ .Version }}

Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- szr-codex:begin -->
@~/.codex/szr.md

Use szr as the default wrapper for noisy shell commands in this repository.
<!-- szr-codex:end -->
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ MENU_VERSION ?= $(if $(MANIFEST_VERSION),$(MANIFEST_VERSION),$(VERSION))
MENU_LDFLAGS := -X github.com/devr-tools/codeguard/internal/version.Number=v$(MENU_VERSION)
# GRAMMAR_TAGS restricts the gotreesitter grammar registry to the languages
# codeguard parses (docs/treesitter-spike.md §5.3): the subset build embeds
# only the TypeScript/TSX/JavaScript/Python grammar blobs instead of all
# only the TypeScript/TSX/JavaScript/Python/C++ grammar blobs instead of all
# ~206 (~+22 MB). Builds without these tags (plain `go build`, `go install`)
# still work; they just embed every grammar.
GRAMMAR_TAGS := grammar_subset,grammar_subset_typescript,grammar_subset_tsx,grammar_subset_javascript,grammar_subset_python
GRAMMAR_TAGS := grammar_subset,grammar_subset_typescript,grammar_subset_tsx,grammar_subset_javascript,grammar_subset_python,grammar_subset_cpp

export GOCACHE
export GOMODCACHE
Expand Down
112 changes: 103 additions & 9 deletions docs/checks.md

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This page lists the current `codeguard` feature surface and the main config entr
- `quality`
- maintainability thresholds
- clone detection
- language-native quality heuristics for Go, Python, TypeScript, JavaScript, Rust, Java, C#, and Ruby
- language-native quality heuristics for Go, Python, TypeScript, JavaScript, Rust, Java, C++, C#, and Ruby
- AI-quality heuristics such as swallowed errors, narrative comments, hallucinated imports, dead code, over-mocked tests, idiom drift, semantic review, provenance policy, and change-risk rollups
- changed-line coverage gating in diff mode
- `design`
Expand All @@ -31,6 +31,12 @@ This page lists the current `codeguard` feature surface and the main config entr
- lockfile presence and drift validation
- unpinned dependency detection
- dependency and manifest license policy
- Cargo manifest hygiene for missing package licenses and non-hermetic dependency sources
- `performance`
- N+1 query patterns, allocation-heavy loops, blocking I/O in request paths, and unbounded concurrency
- Go package rebuild-cascade analysis for rebuild hot spots and amplifiers
- Rust and C++ loop-smell coverage for regex construction, non-preallocated string growth, and polling sleeps
- build regression, benchmark regression, artifact-size budgets, and clang `-ftime-trace` budgets

## Agent-native features

Expand Down Expand Up @@ -60,7 +66,8 @@ This page lists the current `codeguard` feature surface and the main config entr
## Parsers

- `parsers.treesitter: "off" | "auto"` (default `"off"`) selects the parsing
substrate for TypeScript/TSX/JavaScript rules (`docs/treesitter-spike.md`).
substrate for TypeScript/TSX/JavaScript plus the migrated Python/C++ paths
(`docs/treesitter-spike.md`).
- `"off"`: the regex-based scanners run exactly as before.
- `"auto"`: script files parse through embedded tree-sitter grammars; the
migrated rules (`quality.typescript.explicit-any`,
Expand Down
96 changes: 0 additions & 96 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,99 +182,3 @@ func runBaseline(args []string, stdout io.Writer, stderr io.Writer) int {
_, _ = fmt.Fprintf(stdout, "wrote %s\n", *outputPath)
return exitOK
}

func promptInitValues(reader *bufio.Reader, stdout io.Writer, output *string, configName *string) error {
var err error
*output, err = promptString(reader, stdout, "config output path", *output)
if err != nil {
return err
}
*configName, err = promptString(reader, stdout, "config name", *configName)
return err
}

type scanInputs struct {
configPath *string
mode *string
baseRef *string
}

func promptScanInputs(interactive bool, stdin io.Reader, stdout io.Writer, inputs *scanInputs) error {
if !interactive {
return nil
}

reader := bufio.NewReader(stdin)
var err error
*inputs.configPath, err = promptString(reader, stdout, "config path", *inputs.configPath)
if err != nil {
return err
}
*inputs.mode, err = promptString(reader, stdout, "scan mode (full|diff)", *inputs.mode)
if err != nil {
return err
}
if strings.TrimSpace(*inputs.mode) != string(service.ScanModeDiff) {
return nil
}

*inputs.baseRef, err = promptString(reader, stdout, "base ref", *inputs.baseRef)
return err
}

func parseScanMode(mode string) (service.ScanMode, error) {
scanMode := service.ScanMode(strings.TrimSpace(mode))
if scanMode != service.ScanModeFull && scanMode != service.ScanModeDiff {
return "", fmt.Errorf("invalid scan mode %q", mode)
}
return scanMode, nil
}

func executeScan(stdout io.Writer, cfg service.Config, scanMode service.ScanMode, baseRef string, enableAI bool) error {
report, err := service.RunWithOptions(context.Background(), cfg, service.ScanOptions{
Mode: scanMode,
BaseRef: baseRef,
EnableAI: enableAI,
})
if err != nil {
return err
}
if err := writeScanMetadata(stdout, cfg.Output.Format, scanMode, baseRef); err != nil {
return err
}
if err := service.WriteReport(stdout, report, cfg.Output.Format); err != nil {
return fmt.Errorf("write report: %w", err)
}
writePerformanceUpgradeHint(stdout, cfg)
if report.Summary.FailedSections > 0 {
return fmt.Errorf("one or more sections failed")
}
return nil
}

// writePerformanceUpgradeHint nudges configs that predate the performance
// section (checks.performance absent, i.e. nil) to opt in. An explicit
// performance: false is a deliberate choice and stays silent, as do the
// machine-readable output formats.
func writePerformanceUpgradeHint(stdout io.Writer, cfg service.Config) {
if cfg.Checks.Performance != nil {
return
}
if format := strings.TrimSpace(cfg.Output.Format); format != "" && format != "text" {
return
}
_, _ = fmt.Fprintln(stdout, "\nnote: this config predates the performance check section (N+1 queries, alloc-heavy loops, blocking I/O in handlers, unbounded concurrency).")
_, _ = fmt.Fprintln(stdout, " enable it with `performance: true` under `checks:` in your codeguard config, or silence this note with `performance: false`. See docs/checks.md#performance.")
}

func writeScanMetadata(stdout io.Writer, format string, scanMode service.ScanMode, baseRef string) error {
trimmedFormat := strings.TrimSpace(format)
if trimmedFormat != "" && trimmedFormat != "text" {
return nil
}
if scanMode != service.ScanModeDiff {
return nil
}
_, err := fmt.Fprintf(stdout, "Base Ref: %s\n", baseRef)
return err
}
103 changes: 103 additions & 0 deletions internal/cli/commands_scan_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cli

import (
"bufio"
"context"
"fmt"
"io"
"strings"

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

func promptInitValues(reader *bufio.Reader, stdout io.Writer, output *string, configName *string) error {
var err error
*output, err = promptString(reader, stdout, "config output path", *output)
if err != nil {
return err
}
*configName, err = promptString(reader, stdout, "config name", *configName)
return err
}

type scanInputs struct {
configPath *string
mode *string
baseRef *string
}

func promptScanInputs(interactive bool, stdin io.Reader, stdout io.Writer, inputs *scanInputs) error {
if !interactive {
return nil
}

reader := bufio.NewReader(stdin)
var err error
*inputs.configPath, err = promptString(reader, stdout, "config path", *inputs.configPath)
if err != nil {
return err
}
*inputs.mode, err = promptString(reader, stdout, "scan mode (full|diff)", *inputs.mode)
if err != nil {
return err
}
if strings.TrimSpace(*inputs.mode) != string(service.ScanModeDiff) {
return nil
}

*inputs.baseRef, err = promptString(reader, stdout, "base ref", *inputs.baseRef)
return err
}

func parseScanMode(mode string) (service.ScanMode, error) {
scanMode := service.ScanMode(strings.TrimSpace(mode))
if scanMode != service.ScanModeFull && scanMode != service.ScanModeDiff {
return "", fmt.Errorf("invalid scan mode %q", mode)
}
return scanMode, nil
}

func executeScan(stdout io.Writer, cfg service.Config, scanMode service.ScanMode, baseRef string, enableAI bool) error {
report, err := service.RunWithOptions(context.Background(), cfg, service.ScanOptions{
Mode: scanMode,
BaseRef: baseRef,
EnableAI: enableAI,
})
if err != nil {
return err
}
if err := writeScanMetadata(stdout, cfg.Output.Format, scanMode, baseRef); err != nil {
return err
}
if err := service.WriteReport(stdout, report, cfg.Output.Format); err != nil {
return fmt.Errorf("write report: %w", err)
}
writePerformanceUpgradeHint(stdout, cfg)
if report.Summary.FailedSections > 0 {
return fmt.Errorf("one or more sections failed")
}
return nil
}

func writePerformanceUpgradeHint(stdout io.Writer, cfg service.Config) {
if cfg.Checks.Performance != nil {
return
}
if format := strings.TrimSpace(cfg.Output.Format); format != "" && format != "text" {
return
}
_, _ = fmt.Fprintln(stdout, "\nnote: this config predates the performance check section (N+1 queries, alloc-heavy loops, blocking I/O in handlers, unbounded concurrency).")
_, _ = fmt.Fprintln(stdout, " enable it with `performance: true` under `checks:` in your codeguard config, or silence this note with `performance: false`. See docs/checks.md#performance.")
}

func writeScanMetadata(stdout io.Writer, format string, scanMode service.ScanMode, baseRef string) error {
trimmedFormat := strings.TrimSpace(format)
if trimmedFormat != "" && trimmedFormat != "text" {
return nil
}
if scanMode != service.ScanModeDiff {
return nil
}
_, err := fmt.Fprintf(stdout, "Base Ref: %s\n", baseRef)
return err
}
7 changes: 2 additions & 5 deletions internal/cli/mcp_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,8 @@ func runServe(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer
// in-flight requests gracefully.
func serveMCPHTTP(addr string, path string, tools *mcpToolService, auth mcpAuthConfig, stderr io.Writer) error {
handler := newMCPHTTPHandler(tools, auth, path)
// Set server timeouts to defend against Slowloris-style attacks, where a
// slow client trickles request bytes to exhaust connection slots.
// ReadHeaderTimeout is the key defense; the others bound overall request,
// response, and idle-connection lifetimes. WriteTimeout is generous because
// MCP tool responses can be large and stream over a longer scan.
// Keep header reads short to avoid connection-slot exhaustion while still
// allowing longer streamed tool responses.
srv := &http.Server{
Addr: addr,
Handler: handler,
Expand Down
65 changes: 18 additions & 47 deletions internal/cli/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"flag"
"fmt"
"io"
"sort"
"strings"

service "github.com/devr-tools/codeguard/pkg/codeguard"
Expand Down Expand Up @@ -55,63 +54,35 @@ func runReport(args []string, stdout io.Writer, stderr io.Writer) int {
func writePerfHistoryReport(stdout io.Writer, cfg service.Config, limit int) int {
path := service.PerfScoreHistoryPath(cfg)
history := service.LoadPerfScoreHistory(path)
if len(history) == 0 {
_, _ = fmt.Fprintf(stdout, "no performance-score history recorded at %s\n", path)
return exitOK
}
keys := make([]string, 0, len(history))
for key := range history {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
entries := history[key]
if limit > 0 && len(entries) > limit {
entries = entries[len(entries)-limit:]
}
_, _ = fmt.Fprintf(stdout, "%s\n", key)
previousScore := 0
hasPrevious := false
for _, entry := range entries {
return writeScoreHistoryReport(stdout, scoreHistoryReportSpec[service.PerformanceHistoryEntry]{
path: path,
history: history,
limit: limit,
emptyLabel: "performance-score",
render: func(stdout io.Writer, entry service.PerformanceHistoryEntry, previousScore int, hasPrevious bool) int {
_, _ = fmt.Fprintf(stdout, " %s score %3d%s signals %d %s\n",
entry.Timestamp, entry.Score, formatSlopDelta(entry.Score, previousScore, hasPrevious),
entry.Signals, formatScoreComponents(entry.Components))
previousScore = entry.Score
hasPrevious = true
}
}
return 0
return entry.Score
},
})
}

func writeSlopHistoryReport(stdout io.Writer, cfg service.Config, limit int) int {
path := service.SlopHistoryPath(cfg)
history := service.LoadSlopHistory(path)
if len(history) == 0 {
_, _ = fmt.Fprintf(stdout, "no slop-score history recorded at %s\n", path)
return exitOK
}
keys := make([]string, 0, len(history))
for key := range history {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
entries := history[key]
if limit > 0 && len(entries) > limit {
entries = entries[len(entries)-limit:]
}
_, _ = fmt.Fprintf(stdout, "%s\n", key)
previousScore := 0
hasPrevious := false
for _, entry := range entries {
return writeScoreHistoryReport(stdout, scoreHistoryReportSpec[service.SlopHistoryEntry]{
path: path,
history: history,
limit: limit,
emptyLabel: "slop-score",
render: func(stdout io.Writer, entry service.SlopHistoryEntry, previousScore int, hasPrevious bool) int {
_, _ = fmt.Fprintf(stdout, " %s score %3d%s signals %d %s\n",
entry.Timestamp, entry.Score, formatSlopDelta(entry.Score, previousScore, hasPrevious),
entry.Signals, formatSlopComponents(entry))
previousScore = entry.Score
hasPrevious = true
}
}
return 0
return entry.Score
},
})
}

func formatSlopDelta(score int, previous int, hasPrevious bool) string {
Expand Down
Loading
Loading