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
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ help:
@printf " make test Run the Go test suite\n"
@printf " make codeguard-ci Validate and scan this repository with codeguard\n"
@printf " make check Run fmt-check, lint, test, and codeguard-ci\n"
@printf " make ci Run the local CI gate\n"
@printf " make ci Run the full local CI gate, including golangci-lint\n"
@printf " make build Build the codeguard CLI\n"
@printf " make release Build snapshot release artifacts with GoReleaser\n"
@printf " make release-check Validate GoReleaser config without publishing\n"
Expand Down Expand Up @@ -76,7 +76,7 @@ lint:
$(GO) vet ./...

lint-strict:
golangci-lint run
env -u GOROOT golangci-lint run

test:
@set -o pipefail; $(GO) test ./... 2>&1 | grep -v '\[no test files\]'
Expand All @@ -87,7 +87,7 @@ codeguard-ci: build

check: fmt-check lint test codeguard-ci

ci: check
ci: fmt-check lint lint-strict test codeguard-ci

build:
@mkdir -p dist
Expand Down
16 changes: 14 additions & 2 deletions docs/checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ Purpose:
- Unbounded concurrency: goroutines launched from loops (Go), accumulated/detached threads or tasks (C++), promises created in loops without a limiter (TS/JS), `asyncio` tasks created in loops without a semaphore (Python)
- Sequential `await` in TS/JS loops that could batch through `Promise.all`
- Memory-pressure patterns: `time.After` timers leaked in Go loops, `setInterval` without `clearInterval` and listeners added in TS/JS loops without cleanup, unbounded whole-input reads (`io.ReadAll` in Go handlers/loops, `.read()`/`.readlines()` in Python loops)
- Hot-path patterns: repeated linear membership scans in Go loops (`slices.Contains` / `slices.Index` family and obvious nested membership scans), mutexes held across blocking calls in Go, per-iteration stream flushes in C++ loops (`std::endl`, `std::flush`), and range-for copies of heavy C++ values
- Framework-aware smells, gated on file-level framework evidence: Django relation access in queryset loops, Django/SQLAlchemy ORM point queries in loops, expensive per-render work in React components, CPU-heavy synchronous calls in Express middleware
- Change intelligence (diff scans): loop-nesting complexity regressions in functions touched by the diff
- Measurement gates: artifact size budgets, clang `-ftime-trace` budgets, and `go test -bench` regression detection against a stored baseline
Expand All @@ -479,6 +480,7 @@ Config keys:
"detect_timer_leaks": true,
"detect_unbounded_reads": true,
"detect_complexity_regression": true,
"detect_hot_path_patterns": true,
"detect_framework_patterns": true,
"detect_rebuild_cascade": true
}
Expand Down Expand Up @@ -514,6 +516,11 @@ Rules:
| `performance.{typescript,javascript}.timer-listener-leak` | TS, JS | `detect_timer_leaks` |
| `performance.unbounded-read` | Go, Python | `detect_unbounded_reads` |
| `performance.complexity-regression` | Go | `detect_complexity_regression` (diff scans only) |
| `performance.go.nested-loop-scan` | Go | `detect_hot_path_patterns` |
| `performance.go.slice-membership-in-loop` | Go | `detect_hot_path_patterns` |
| `performance.go.lock-held-across-blocking-call` | Go | `detect_hot_path_patterns` |
| `performance.cpp.range-for-copy` | C++ | `detect_hot_path_patterns` |
| `performance.cpp.flush-in-loop` | C++ | `detect_hot_path_patterns` |
| `performance.python.django-nplusone-relation` | Python | `detect_framework_patterns` |
| `performance.python.orm-query-in-loop` | Python | `detect_framework_patterns` |
| `performance.{typescript,javascript}.react-expensive-render` | TS, JS | `detect_framework_patterns` |
Expand All @@ -531,6 +538,11 @@ Notes on precision:
- `defer-in-loop` scopes to the enclosing function: `defer wg.Done()` inside a goroutine launched from a loop runs per goroutine and is not flagged.
- `await-in-loop` exempts `for await` streams and any file using a concurrency limiter (`p-limit`/`p-queue`); keep the loop (or disable the toggle) when iterations genuinely depend on each other.
- `unbounded-read` does not fire when the reader is already bounded (`io.LimitReader`, `http.MaxBytesReader`, `read(n)`).
- `go.nested-loop-scan` is intentionally narrow: it looks for nested `range` loops where the equality test directly compares an outer loop variable to an inner loop variable. It does not yet infer indexed lookups, helper wrappers, or non-equality membership checks.
- `go.slice-membership-in-loop` currently targets explicit `slices.Contains` / `ContainsFunc` / `Index` / `IndexFunc` calls inside loops. It does not yet infer helper wrappers or prove the scanned slice is large enough to matter, so treat it as a hot-path review cue rather than a universal ban.
- `go.lock-held-across-blocking-call` targets obvious blocking calls while a `sync.Mutex` / `sync.RWMutex` is held: synchronous file I/O, `time.Sleep`, package-level HTTP calls, SQL-style `Query`/`Exec` methods on `db`/`tx`/`conn`/`pool`-shaped receivers, and `client.Do`. It is lexical rather than flow-sensitive, so treat it as a review cue when branches unlock before the call.
- `cpp.range-for-copy` currently flags structured bindings by value (`for (auto [k, v] : map)`) and explicit by-value iteration over obvious heavy standard-library types such as `std::string`, `std::vector`, and map/set pair types. Plain `auto value : items` is intentionally out of scope in this version.
- `cpp.flush-in-loop` matches `<< std::endl`, `<< endl`, `<< std::flush`, and `<< flush` inside loops. Ordinary newline writes such as `'\n'` are intentionally not flagged.
- The TS/JS timer/listener rule treats any `clearInterval` in the file as interval cleanup, and any `removeEventListener`/`AbortSignal` usage as listener cleanup.
- Python task creation is exempt when the file shows a bounding construct (`asyncio.Semaphore`, `TaskGroup`, `aiolimiter`, `anyio.CapacityLimiter`).

Expand Down Expand Up @@ -563,10 +575,10 @@ When the performance section runs and produces findings, each target publishes a
| Family | Rules | Weight |
|---|---|---|
| Query in loop (N+1) | `n-plus-one-query` | 5 |
| Blocking I/O | `sync-io-in-request-path`, `{typescript,javascript}.sync-io-in-handler`, `python.sync-io-in-async` | 4 |
| Blocking I/O | `sync-io-in-request-path`, `{typescript,javascript}.sync-io-in-handler`, `python.sync-io-in-async`, `go.lock-held-across-blocking-call` | 4 |
| Unbounded concurrency | `unbounded-goroutines-in-loop`, `cpp.unbounded-concurrency`, `{typescript,javascript,python}.unbounded-concurrency` | 4 |
| Memory pressure | `unbounded-read`, `go.timer-leak-in-loop`, `{typescript,javascript}.timer-listener-leak` | 3 |
| Repeated loop work | `regex-compile-in-loop`, `go.defer-in-loop`, `{go,rust,cpp}.sleep-in-loop`, `{typescript,javascript}.await-in-loop` | 2 |
| Repeated loop work | `regex-compile-in-loop`, `go.defer-in-loop`, `{go,rust,cpp}.sleep-in-loop`, `{typescript,javascript}.await-in-loop`, `go.nested-loop-scan`, `go.slice-membership-in-loop`, `cpp.range-for-copy`, `cpp.flush-in-loop` | 2 |
| Allocation churn | `{go,rust,cpp}.alloc-in-loop`, `string-concat-in-loop` | 1 |

The score trend is persisted per target next to the scan cache (`<cache>.perf-history.<ext>`) whenever the cache is enabled; subsequent scans annotate the artifact with `previous_score` and `delta`. `performance_rules.score_history: false` disables persistence and `performance_rules.score_history_limit` caps retained entries per target (default 100). Print the recorded trend with:
Expand Down
15 changes: 15 additions & 0 deletions internal/codeguard/checks/performance/performance_cpp.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ var (
cppStringConcatPattern = regexp.MustCompile(`^\s*([A-Za-z_]\w*)\s*\+=`)
cppStringAppendPattern = regexp.MustCompile(`\b([A-Za-z_]\w*)\.(?:append|push_back)\s*\(`)
cppThreadSleepPattern = regexp.MustCompile(`\bstd::this_thread::sleep_(?:for|until)\s*\(`)
cppStreamFlushPattern = regexp.MustCompile(`<<\s*(?:std::)?(?:endl|flush)\b`)
cppRangeForStructuredCopy = regexp.MustCompile(`\bfor\s*\(\s*(?:const\s+)?auto\s*\[[^\]]+\]\s*:`)
cppRangeForValueCopy = regexp.MustCompile(`\bfor\s*\(\s*(?:const\s+)?(?:std::(?:basic_string\s*<[^>]+>|string|wstring|u8string|u16string|u32string|vector\s*<[^>]+>|array\s*<[^>]+>|map\s*<[^>]+>|unordered_map\s*<[^>]+>|set\s*<[^>]+>|unordered_set\s*<[^>]+>|pair\s*<[^>]+>|tuple\s*<[^>]+>)|[A-Za-z_]\w*::[A-Za-z_]\w*(?:\s*<[^>]+>)?)\s+[A-Za-z_]\w*\s*:`)
cppUnboundedThreadPattern = regexp.MustCompile(`(?:\b[A-Za-z_]\w*\.(?:emplace_back|push_back)\s*\(\s*(?:std::)?(?:jthread|thread|async)\b|\bstd::(?:jthread|thread)\s*\([^;\n]*\)\s*\.detach\s*\()`)
cppThreadVectorDecl = regexp.MustCompile(`\bstd::vector\s*<\s*std::(?:jthread|thread)\s*>\s*([A-Za-z_]\w*)`)
cppContainerAppend = regexp.MustCompile(`\b([A-Za-z_]\w*)\.(?:emplace_back|push_back)\s*\(`)
Expand Down Expand Up @@ -88,6 +91,14 @@ func (s *cppPerformanceScan) checkLine(lineNo int, line string, rawLine string,
s.addFinding("performance.cpp.sleep-in-loop", lineNo,
"std::this_thread::sleep_* inside a loop usually marks a poll; prefer a condition variable, timer primitive, or bounded backoff helper")
}
if toggleEnabled(s.rules.DetectHotPathPatterns) && s.isRangeForCopy(rawLine) {
s.addFinding("performance.cpp.range-for-copy", lineNo,
"range-for loop copies each element by value; use const auto& or const T& unless a per-iteration copy is intentional")
}
if toggleEnabled(s.rules.DetectHotPathPatterns) && cppStreamFlushPattern.MatchString(line) {
s.addFinding("performance.cpp.flush-in-loop", lineNo,
"stream flush inside a loop defeats buffering and can dominate hot-path latency; write a newline and flush once after the loop unless immediate delivery is required")
}
if toggleEnabled(s.rules.DetectUnboundedConcurrency) && s.isUnboundedThreadLaunch(line) {
s.addFinding("performance.cpp.unbounded-concurrency", lineNo,
"C++ thread/task launch accumulated or detached inside a loop has no visible concurrency bound; use a fixed worker pool, semaphore, or bounded executor")
Expand All @@ -114,6 +125,10 @@ func (s *cppPerformanceScan) isStringGrowth(line string) bool {
return false
}

func (s *cppPerformanceScan) isRangeForCopy(line string) bool {
return cppRangeForStructuredCopy.MatchString(line) || cppRangeForValueCopy.MatchString(line)
}

func (s *cppPerformanceScan) addFinding(ruleID string, lineNo int, message string) {
s.findings = append(s.findings, warnFinding(s.env, ruleID, s.file, lineNo, 1, message))
}
151 changes: 151 additions & 0 deletions internal/codeguard/checks/performance/performance_go_hotpath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package performance

import (
"go/ast"
"go/token"
"strings"

"github.com/devr-tools/codeguard/internal/codeguard/checks/support"
"github.com/devr-tools/codeguard/internal/codeguard/core"
)

var goSliceMembershipNames = map[string]struct{}{
"Contains": {},
"ContainsFunc": {},
"Index": {},
"IndexFunc": {},
}

type goHotPathConfig struct {
slicesAliases map[string]struct{}
}

type goHotPathFinding struct {
ruleID string
pos token.Position
message string
}

func goHotPathFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File) []core.Finding {
if !toggleEnabled(env.Config.Checks.PerformanceRules.DetectHotPathPatterns) {
return nil
}
cfg := newGoHotPathConfig(parsed)
if !cfg.enabled() {
return nil
}

findings := make([]core.Finding, 0)
seen := make(map[int]struct{})
walkASTWithStack(parsed, func(node ast.Node, stack []ast.Node) bool {
var finding *goHotPathFinding
switch node := node.(type) {
case *ast.CallExpr:
if hasLoopAncestor(stack) {
finding = cfg.callFinding(node, fset)
}
case *ast.BinaryExpr:
finding = cfg.binaryFinding(node, stack, fset)
}
if finding == nil {
return true
}
if _, dup := seen[finding.pos.Line]; !dup {
seen[finding.pos.Line] = struct{}{}
findings = append(findings, warnFinding(env, finding.ruleID, file, finding.pos.Line, finding.pos.Column, finding.message))
}
return true
})
return findings
}

func newGoHotPathConfig(parsed *ast.File) goHotPathConfig {
aliases := importAliasesForPath(parsed, "slices")
for alias := range importAliasesForPath(parsed, "golang.org/x/exp/slices") {
aliases[alias] = struct{}{}
}
return goHotPathConfig{slicesAliases: aliases}
}

func (c goHotPathConfig) enabled() bool {
return true
}

func (c goHotPathConfig) callFinding(call *ast.CallExpr, fset *token.FileSet) *goHotPathFinding {
alias, name, ok := packageCall(call)
if !ok || !aliasHas(c.slicesAliases, alias) || !nameIn(goSliceMembershipNames, name) {
return nil
}
pos := fset.Position(call.Pos())
return &goHotPathFinding{
ruleID: "performance.go.slice-membership-in-loop",
pos: pos,
message: "slices membership scan inside a loop is linear each iteration; precompute a map/set or otherwise hoist the lookup if this path is hot",
}
}

func (c goHotPathConfig) binaryFinding(expr *ast.BinaryExpr, stack []ast.Node, fset *token.FileSet) *goHotPathFinding {
if expr.Op != token.EQL {
return nil
}
outerVars, innerVars := nestedLoopVarSets(stack)
if len(outerVars) == 0 || len(innerVars) == 0 {
return nil
}
left := identName(expr.X)
right := identName(expr.Y)
if left == "" || right == "" {
return nil
}
if (!outerVars[left] || !innerVars[right]) && (!outerVars[right] || !innerVars[left]) {
return nil
}
pos := fset.Position(expr.Pos())
return &goHotPathFinding{
ruleID: "performance.go.nested-loop-scan",
pos: pos,
message: "nested loops compare outer items against inner items linearly; if this is a membership test on hot data, precompute a map/set for the inner collection",
}
}

func nestedLoopVarSets(stack []ast.Node) (map[string]bool, map[string]bool) {
rangeLoops := make([]*ast.RangeStmt, 0, 4)
for _, node := range stack {
if loop, ok := node.(*ast.RangeStmt); ok {
rangeLoops = append(rangeLoops, loop)
}
}
if len(rangeLoops) < 2 {
return nil, nil
}
inner := rangeLoopVarSet(rangeLoops[len(rangeLoops)-1])
outer := make(map[string]bool)
for _, loop := range rangeLoops[:len(rangeLoops)-1] {
for name := range rangeLoopVarSet(loop) {
outer[name] = true
}
}
return outer, inner
}

func rangeLoopVarSet(loop *ast.RangeStmt) map[string]bool {
names := make(map[string]bool)
if loop == nil {
return names
}
if name := identName(loop.Key); name != "" && name != "_" {
names[name] = true
}
if name := identName(loop.Value); name != "" && name != "_" {
names[name] = true
}
return names
}

func identName(expr ast.Expr) string {
ident, ok := expr.(*ast.Ident)
if !ok {
return ""
}
return strings.TrimSpace(ident.Name)
}
Loading
Loading