diff --git a/.claude/knowledge/architecture-boundaries.md b/.claude/knowledge/architecture-boundaries.md index 1812969..e18361c 100644 --- a/.claude/knowledge/architecture-boundaries.md +++ b/.claude/knowledge/architecture-boundaries.md @@ -29,3 +29,4 @@ Key architectural decisions, service boundaries, data flow, integration points, - **Git subprocess hardening contract (threat model: untrusted PR).** `base_ref` from MCP/CLI is validated by `runnersupport.ValidateBaseRef` (rejects leading `-` to block git option injection; restricts charset; preserves the `stdin` sentinel) BEFORE reaching git, and `--end-of-options` is passed to git diff/show/worktree. All git/govulncheck subprocesses use `exec.CommandContext` with a contained timeout and read output through `io.LimitReader` (`maxGitOutputBytes`) to prevent hangs/OOM. The `//nolint:contextcheck` markers in the git layer (`runner/support`, `ai/fix`, `ai/semantic`, `checks/quality/quality.go`, `runner/runner.go`) are DELIBERATE — the contained timeout is the chosen design; threading caller `ctx` end-to-end is a tracked follow-up. Don't "fix" them by removing the nolint without actually threading ctx. `runner/support/MatchPattern` (glob→regex) uses `regexp.Compile` + a `sync.Map` cache and returns false (never panics) on a malformed config glob. - **The usage menu makes one outbound call (opt-out).** Running `codeguard` with no args or `help` renders a "What's New" banner (`internal/whatsnew` + `internal/cli/whatsnew.go`) that queries the GitHub releases API for the latest version. It is disabled when `CODEGUARD_NO_UPDATE_CHECK` is truthy OR `$CI` is non-empty (so CI/automated runs make no surprise call), caches the result for 24h under `os.UserCacheDir()/codeguard/update-check.json`, uses the SSRF-guarded `ai/safehttp.Client` with a 1.5s timeout, and fails silent (falls back to stale cache, then to version-only). Changelog bullets come from `CHANGELOG.md` embedded via a **module-root package** (`/changelog.go`, `package codeguard`, imported as `root "github.com/devr-tools/codeguard"`) — `go:embed` cannot reference `../CHANGELOG.md`, so the embed directive must live at the repo root, not in `internal/`. - **Lint is enforced in CI.** `make lint` is `go vet`; `make lint-strict` / the CI `golangci-lint-action` step (now blocking — no `continue-on-error`) runs the 16-linter `.golangci.yml`. `golangci-lint run` must report 0 before pushing. The config excludes revive's documentation/naming style rules (`exported`, `package-comments`, `unused-parameter`) and excludes `tests/` from gosec/errcheck/prealloc/bodyclose (intentional fixtures). Cache/identity keys use sha256 (not sha1). Documenting the public `pkg/codeguard` API with doc comments is a known follow-up (revive `exported` is currently excluded rather than satisfied). +- **Adding a check section touches a fixed wiring list; two are silent-failure traps.** Beyond the obvious (check package with `Run(ctx, env) core.SectionResult`, a `sectionDef` in `runner/checks/registry.go`, a `CheckConfig` bool + rules struct in `core/config_types.go`/`config_rule_types.go`, catalog + fix-template maps merged in `rules/catalog.go`/`catalog_fix_templates.go`, defaults in `config/defaults*.go` + `example*.go`, docs): (1) `runner/support/cache_helpers.go` — add the section to `SectionConfigHashes` AND `sectionConfigFamily`, else the section's per-file cache keys fall back to the all-checks fingerprint (correct but invalidates on any config change); (2) top-level `CheckConfig` bools get NO defaulting in `applyCheckDefaults` — an omitted `checks.
` key means **off**, which is how the opt-in `performance` and `supply_chain` sections work. The performance section (v0.9.0) is the reference move: rules migrated from quality with renamed ids (`quality.*` → `performance.*`), no runtime aliasing, `rules.RetiredPerformanceRuleIDs()` powers a `codeguard doctor` waiver migration hint. diff --git a/.claude/knowledge/local-dev-setup.md b/.claude/knowledge/local-dev-setup.md new file mode 100644 index 0000000..1e090aa --- /dev/null +++ b/.claude/knowledge/local-dev-setup.md @@ -0,0 +1,6 @@ +# Local Development Setup + +How to set up, run, and work with this project locally. Non-obvious dependencies, environment config, common setup issues. + +- **The self-scan cache lives at `.codeguard/.codeguard/cache.json`, not `.codeguard/cache.json`.** `cache.path` resolves relative to the config directory (`config.containConfigArtifactPaths`), and `make codeguard-ci` uses `-config .codeguard/codeguard.yaml`, so the default `.codeguard/cache.json` nests. When iterating on **rule logic**, delete that file before re-running `make codeguard-ci`: cache keys cover config + file content + the release-bumped scanner-version constant, so a code-only change to a check serves stale findings from the cache and looks like your fix didn't work. +- The Makefile runs Go via `env -u GOROOT go` (Makefile:6), so `make` targets work even with the shell's stale GOROOT; direct `go` commands need `export GOROOT=/opt/homebrew/opt/go/libexec && export PATH=$GOROOT/bin:$PATH` first. diff --git a/.codeguard/codeguard.yaml b/.codeguard/codeguard.yaml index fb40b79..628d37e 100644 --- a/.codeguard/codeguard.yaml +++ b/.codeguard/codeguard.yaml @@ -3,6 +3,7 @@ exclude: - .claude/** - .codeguard/cache.json - .codeguard/cache.slop-history.json + - .codeguard/cache.perf-history.json - .gomodcache/** - tests/**/.codeguard/cache.json # Detection-precision fixtures: intentionally vulnerable-looking sample @@ -19,9 +20,21 @@ waivers: - rule: ci.test-without-assertion path: tests/**/trust_main_test.go reason: Go TestMain functions bootstrap package-wide trust policy and are not assertion-bearing tests - - rule: quality.unbounded-goroutines-in-loop + - rule: performance.unbounded-goroutines-in-loop path: internal/codeguard/runner/checks/checks.go reason: section workers are bounded by a NumCPU-sized semaphore before each goroutine is launched + - rule: performance.unbounded-goroutines-in-loop + path: internal/codeguard/runner/support/findings.go + reason: the per-file scan launches a fixed worker-count pool joined by a WaitGroup, which is the bounding the rule asks for + - rule: performance.unbounded-goroutines-in-loop + path: tests/support/rule_stats_collector_test.go + reason: the concurrency test launches a fixed 16 workers joined by a WaitGroup to prove race-freedom + - rule: performance.go.defer-in-loop + path: internal/codeguard/checks/support/treesitter/bench_cgo_test.go + reason: the design-spike benchmark defers parser cleanup per iteration deliberately; the spike module is excluded from the production build + - rule: performance.go.sleep-in-loop + path: tests/mcp/http_test.go + reason: the HTTP helper-process test polls /healthz for server readiness; a short sleep between probes is the intended pattern - rule: ci.test-file-location path: internal/codeguard/checks/support/treesitter/*_test.go reason: the tree-sitter design spike is an isolated Go module whose differential tests must live beside the prototype they validate (docs/treesitter-spike.md) @@ -39,6 +52,7 @@ targets: - cmd/codeguard checks: quality: true + performance: true design: true security: false prompts: false @@ -49,6 +63,20 @@ checks: max_function_lines: 80 max_parameters: 5 max_cyclomatic_complexity: 10 + performance_rules: + # 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 + # in one sitting. + budgets: + - name: checks-doc + kind: file-size + path: docs/checks.md + max_bytes: 204800 + - name: readme + kind: file-size + path: README.md + max_bytes: 65536 ci_rules: require_workflow_dir: true required_workflow_files: diff --git a/.gitignore b/.gitignore index 251ce32..acc328f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ go.work.sum .idea/ .vscode/ **/.codeguard/cache.slop-history.json +**/.codeguard/cache.perf-history.json **/.codeguard/cache.rule-stats-history.json diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 88bdcc6..6ce2aa1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,7 +18,7 @@ builds: # 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 + - -tags=grammar_subset,grammar_subset_typescript,grammar_subset_tsx,grammar_subset_javascript,grammar_subset_python ldflags: - -s -w -X github.com/devr-tools/codeguard/internal/version.Number=v{{ .Version }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f300ac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# codeguard + +Static-analysis CLI that scans repositories (full or PR-diff mode) across check +sections — quality, performance, design, security, prompts, CI, supply chain, +agent context — and reports pass/warn/fail findings per rule. + +## Build, test, verify + +- `make build` → `dist/codeguard`; `make test`; `make lint` (vet) and `make lint-strict` (golangci-lint, CI-blocking, must be 0 issues) +- `make codeguard-ci` — validate + self-scan with `.codeguard/codeguard.yaml` +- `make check` — the full CI gate (fmt-check, lint, test, codeguard-ci) +- Scope gofmt to `gofmt -l cmd internal pkg tests changelog.go` (repo root picks up worktrees/module caches) +- Tests live under `tests/` as external `_test` packages only — never next to the code (enforced by the repo's own `ci.test-file-location` rule) + +## Layout + +- `internal/codeguard/checks/
/` — check implementations (one package per section) +- `internal/codeguard/runner/` — orchestration, registry, caching; `internal/codeguard/rules/` — rule metadata catalog +- `internal/codeguard/config/` — config load/defaults/validation; `internal/cli/` — command handlers; `pkg/codeguard/` — public SDK facade +- `docs/checks.md` — authoritative user-facing rule/config reference; update it when adding or changing rules + +## Available Context + +Additional context is available in the files below. Consult the relevant file when working in a related area — see each description for scope. + +- `.claude/knowledge/local-dev-setup.md` — Local Development Setup: toolchain quirks, self-scan cache location, common setup issues. +- `.claude/knowledge/testing-patterns.md` — Testing Patterns: trust-policy TestMains, external-test-package rule, secret-fixture assembly, MCP test harnesses, catalog rule test requirements. +- `.claude/knowledge/architecture-boundaries.md` — Architecture & System Boundaries: trust gating for config-supplied commands, safehttp, scanner hardening invariants, section wiring checklist. +- `.claude/knowledge/deployment-release.md` — Deployment & Release: release-please/GoReleaser flow, cosign pinning, npm/PyPI trusted publishing. diff --git a/Makefile b/Makefile index a974bca..5da0a1d 100644 --- a/Makefile +++ b/Makefile @@ -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 grammar blobs (~+4 MB) instead of all +# only the TypeScript/TSX/JavaScript/Python 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_TAGS := grammar_subset,grammar_subset_typescript,grammar_subset_tsx,grammar_subset_javascript,grammar_subset_python export GOCACHE export GOMODCACHE diff --git a/docs/checks.md b/docs/checks.md index a10858f..2a3d1cb 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -8,6 +8,7 @@ This file documents the current check categories in `codeguard` and the config k { "checks": { "quality": true, + "performance": false, "design": true, "security": true, "prompts": true, @@ -20,6 +21,8 @@ This file documents the current check categories in `codeguard` and the config k Each top-level boolean enables or disables an entire check family. +`performance` is opt-in and covers N+1 query patterns, allocation-heavy loops, blocking I/O in request paths, unbounded concurrency, memory-pressure and framework-aware smells, diff-mode complexity regressions, and measurement gates (size budgets, benchmark regression); see [Performance](#performance) for the rule list and the migration note for the former `quality.*` ids. + `context` covers agent-context legibility: when the key is omitted the family defaults to enabled in full scans and disabled in diff scans; see [Agent Context](#agent-context). `supply_chain` is opt-in and currently covers normalized manifest parsing plus initial policy checks for missing lockfiles, content-based lockfile drift validation, unpinned dependencies, and dependency license policy resolved from local manifest and installed metadata where available. @@ -387,6 +390,221 @@ Behavior: - findings warn by default and escalate to fail below `fail_under` (unset by default) - changed lines that are not measurable (comments, declarations, files absent from the coverage report) are excluded from the percentage; a failed coverage run produces a warn finding instead of aborting the scan +## Performance + +Purpose: +- N+1 query / remote-fetch patterns inside loops (Go, Python, TypeScript, JavaScript) +- Allocation-heavy loops: string concatenation and `fmt.Sprintf` accumulation (Go, Python, TS/JS) and (opt-in) append without preallocation (Go) +- Repeated work inside loops: regex compilation (Go, Python, TS/JS), `defer` accumulation (Go), polling sleeps (Go) +- Blocking I/O in request paths: synchronous file I/O in Go HTTP handlers, `*Sync` calls in TS/JS handlers, blocking calls in Python `async def` bodies +- Unbounded concurrency: goroutines launched from loops (Go), 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) +- 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 and `go test -bench` regression detection against a stored baseline +- An opt-in AI-assisted lens for judgment-call concerns (missing caching, algorithmic complexity) when the semantic runtime is configured +- A `performance_score` artifact with per-target history so the smell trend is visible across scans + +Config keys: + +```json +{ + "checks": { + "performance": true, + "performance_rules": { + "detect_n_plus_one_query": true, + "detect_alloc_in_loop": true, + "detect_prealloc_in_loop": false, + "detect_sync_io_in_handlers": true, + "detect_unbounded_concurrency": true, + "detect_regex_compile_in_loop": true, + "detect_defer_in_loop": true, + "detect_sleep_in_loop": true, + "detect_await_in_loop": true, + "detect_timer_leaks": true, + "detect_unbounded_reads": true, + "detect_complexity_regression": true, + "detect_framework_patterns": true + } + } +} +``` + +The family is **opt-in** (`performance: false` by default). Within it, every rule toggle defaults to enabled except `detect_prealloc_in_loop`, which stays opt-in because preallocating is a micro-optimization that idiomatic accumulation loops legitimately skip. + +Rules: + +| Rule | Languages | Toggle | +|---|---|---| +| `performance.n-plus-one-query` | Go, Python, TS, JS | `detect_n_plus_one_query` | +| `performance.go.alloc-in-loop` | Go | `detect_alloc_in_loop` (+ `detect_prealloc_in_loop`) | +| `performance.string-concat-in-loop` | Python, TS, JS | `detect_alloc_in_loop` | +| `performance.regex-compile-in-loop` | Go, Python, TS, JS | `detect_regex_compile_in_loop` | +| `performance.go.defer-in-loop` | Go | `detect_defer_in_loop` | +| `performance.go.sleep-in-loop` | Go | `detect_sleep_in_loop` | +| `performance.sync-io-in-request-path` | Go | `detect_sync_io_in_handlers` | +| `performance.{typescript,javascript}.sync-io-in-handler` | TS, JS | `detect_sync_io_in_handlers` | +| `performance.python.sync-io-in-async` | Python | `detect_sync_io_in_handlers` | +| `performance.unbounded-goroutines-in-loop` | Go | `detect_unbounded_concurrency` | +| `performance.{typescript,javascript}.unbounded-concurrency` | TS, JS | `detect_unbounded_concurrency` | +| `performance.python.unbounded-concurrency` | Python | `detect_unbounded_concurrency` | +| `performance.{typescript,javascript}.await-in-loop` | TS, JS | `detect_await_in_loop` | +| `performance.go.timer-leak-in-loop` | Go | `detect_timer_leaks` | +| `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.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` | +| `performance.{typescript,javascript}.express-sync-middleware` | TS, JS | `detect_framework_patterns` | + +Notes on precision: +- `regex-compile-in-loop` fires only on **literal** patterns: compiling a variable pattern in a loop usually means the pattern differs per iteration (e.g. compiling config-supplied patterns), which is not hoistable. +- `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)`). +- 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`). + +### Complexity regression (diff scans only) + +`performance.complexity-regression` compares each function touched by the diff against the same function at the base ref and warns when its **maximum loop-nesting depth increased** (e.g. a changed function went from one loop to a loop inside a loop). The message names the function and both depths, so review can focus on whether the added iteration runs over unbounded data. + +Behavior and precision: +- **Diff scans only.** The rule needs a base ref to compare against, so it activates only in diff mode (`--diff`); full scans never emit it. The toggle (`detect_complexity_regression`) defaults to enabled, which is safe precisely because full scans are unaffected. +- Functions are matched by name (methods by `ReceiverType.Name`). Functions that do not exist at the base ref — new or renamed — are skipped: there is no baseline to regress from, and the absolute-depth rules cover new code. +- Only functions whose lines intersect the diff's changed ranges are compared; untouched functions are never re-litigated. +- Nesting depth is syntactic and includes function literals at their nesting position: a closure that loops, launched per loop iteration, still multiplies the iteration space. +- Files that are added, deleted, or unparseable at either revision are skipped. +- **Language coverage: Go only** in this version (the comparison parses both revisions via `go/ast`). Python and TypeScript/JavaScript changes are not checked. + +### Framework-aware rules + +Framework-aware rules (`detect_framework_patterns`): every rule requires file-level framework evidence before any pattern is tried, so non-framework code never matches — a Django import or `.objects.` manager usage for `django-nplusone-relation` and the Django half of `orm-query-in-loop`, a SQLAlchemy import for `session.get` (so `requests.Session().get(url)` loops never match), a `react` import/require for `react-expensive-render`, and an `express` import/require for `express-sync-middleware`. Precision notes and honest limits: +- `django-nplusone-relation` only fires inside a loop whose iterable is queryset-shaped (contains `.objects.` or a variable assigned from one), stays quiet for the whole file once `select_related`/`prefetch_related` appears anywhere, and skips chains whose final segment is immediately called (`item.name.strip()` reads as a scalar method, not a relation load) — so relation-loading *method* chains like `item.author.get_absolute_url()` are deliberately missed, except through the always-flagged `item.relation_set.` reverse-manager form. A scalar attribute chain that is not a relation (`item.profile.bio` where `profile` is a plain object) can still false-positive; add `select_related` or waive. +- `orm-query-in-loop` covers only the ORM call shapes the generic `performance.n-plus-one-query` pattern misses (`.objects.get(`, `.objects.filter(`, SQLAlchemy `session.get(`), and skips loop headers plus any line the generic pattern matches, so one line never reports under both rules. +- `react-expensive-render` needs a component/custom-hook region (`function`/`const` named with a capital letter or `use*`) and flags only a chain of two or more `.sort`/`.filter`/`.map` calls on one line, `new Array(`, or `JSON.parse(`; anything on or inside a `useMemo`/`useCallback`/`useEffect`/`useLayoutEffect` wrapper is exempt. The heuristic does not distinguish event-handler callbacks declared in the component body (work there runs per event, not per render) and misses chains split across lines. +- `express-sync-middleware` sticks to a fixed CPU-heavy shortlist (`bcrypt.hashSync`/`compareSync`, `crypto.pbkdf2Sync`/`scryptSync`, `zlib` `*Sync`, `child_process.execSync`, including destructured bare-name calls) inside `app.use(`/`router.use(` regions; it takes precedence over the generic `sync-io-in-handler` finding on the same line, and other `*Sync` calls (e.g. `fs.readFileSync`) stay with the generic rule. + +Parsers & precision: with `parsers.treesitter: "auto"`, Python `performance.n-plus-one-query` runs on the embedded tree-sitter Python grammar instead of the line regex — only genuine call expressions (`cursor.execute(...)`, `requests`/`httpx` HTTP verbs, `session.query(...)`) inside `for`/`while` statements match, so query-shaped text inside comments and string literals no longer fires, and tree-path findings report `confidence: high`. Rule ID, level, and message are identical on both paths. The regex scan remains the automatic fallback whenever the tree is unavailable (`parsers.treesitter: "off"` — the default — a build without the `grammar_subset_python` tag, oversized files, or a parse failure). + +### Performance score artifact + +When the performance section runs and produces findings, each target publishes a `performance_score` artifact (mirroring the quality section's `slop_score`): `score` is the weighted finding count scaled by 10 and capped at 100, `signals` is the number of contributing findings, and `components` breaks the total down per rule. Weights are assigned per rule family and are deliberately simple and stable: + +| 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 | +| Unbounded concurrency | `unbounded-goroutines-in-loop`, `{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.sleep-in-loop`, `{typescript,javascript}.await-in-loop` | 2 | +| Allocation churn | `go.alloc-in-loop`, `string-concat-in-loop` | 1 | + +The score trend is persisted per target next to the scan cache (`.perf-history.`) 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: + +``` +codeguard report -perf-history [-config path] [-limit n] +``` + +(mirroring `codeguard report -slop-history` for the slop-score trend). + +When a config omits the `performance` key entirely, text-format `scan` output appends a one-line note suggesting the upgrade; setting the key explicitly (`true` or `false`) silences it. + +**Migration note:** these rules previously ran inside the quality section under `quality.*` ids (`quality.n-plus-one-query`, `quality.go.alloc-in-loop`, `quality.sync-io-in-request-path`, `quality.unbounded-goroutines-in-loop`, the `quality.typescript.*`/`quality.javascript.*` mirrors, and `quality.python.sync-io-in-async`), gated by `quality_rules.detect_*` keys. There is no runtime aliasing: waivers, baselines, and configs that reference the old ids stop matching when you enable `checks.performance`, and `codeguard doctor` flags any waiver still pointing at a retired id with the replacement to use. + +### AI-assisted performance review + +When the command-backed semantic review runtime is available, the performance section gains an LLM-assisted lens over the changed functions. It is strictly opt-in and requires **all three** of: + +- the AI runtime enabled (or the `CODEGUARD_SEMANTIC_CHECKS` env gate set), the same guards the quality section's semantic review uses +- a semantic command configured through `ai.provider.type=command` plus `ai.provider.command`/`args`, or through `CODEGUARD_SEMANTIC_COMMAND` +- `checks.performance: true` — with the performance section disabled, semantic requests and scan output are byte-identical to a build without this lens + +Behavior: +- emits `performance.ai.semantic-perf` (warn) when the semantic runtime finds a performance concern static rules cannot judge: repeated expensive calls that want caching or memoization, algorithmic complexity out of line with the input sizes the diff makes plausible, or obviously redundant work across the change; it is instructed **not** to flag micro-optimizations, style preferences, or anything without clear evidence in the diff +- emits `performance.ai.semantic-runtime` at `fail` level when the lens is enabled but the semantic command is missing, crashes, or returns invalid JSON, instead of silently skipping coverage +- diff-driven and cached: the lens reviews only changed files (patch input or a git diff against the scan base ref) and rides in the **same** semantic request as the quality lenses, so enabling it adds no extra runtime invocation, and verdicts are cached by hashed request content alongside the quality verdicts + +Repo-specific performance policies can also be expressed as natural-language custom rules (see [Custom rule packs](#custom-rule-packs)); these evaluate per file through `CODEGUARD_AI_RUNTIME_COMMAND` and are independent of the semantic lens above: + +```json +{ + "rule_packs": [ + { + "name": "perf-policy", + "rules": [ + { + "id": "custom.no-queries-in-loops", + "title": "No per-item database queries", + "severity": "warn", + "message": "database work inside a loop should be batched", + "how_to_fix": "Fetch the rows in one batched query before the loop.", + "paths": ["internal/**"], + "natural_language": "never issue a database query or remote API call once per element of a collection; batch the lookups before the loop instead" + } + ] + } + ] +} +``` + +### Performance budgets + +`performance.budget` compares real artifact sizes against configured byte budgets — a measurement-based gate, unlike the pattern rules above. Each `performance_rules.budgets` entry names one budget: + +```json +{ + "checks": { + "performance": true, + "performance_rules": { + "budgets": [ + {"name": "cli-binary", "kind": "file-size", "path": "dist/codeguard", "max_bytes": 41943040}, + {"name": "js-bundles", "kind": "file-size", "path": "dist/*.js", "max_bytes": 512000, "level": "fail"}, + {"name": "bundle-total", "kind": "bundle-stats", "path": "build/meta.json", "max_bytes": 1048576}, + {"name": "main-chunk", "kind": "bundle-stats", "path": "build/stats.json", "asset": "main.js", "max_bytes": 262144} + ] + } + } +} +``` + +- `kind: file-size` budgets the on-disk size of a file, or the **summed** size of every file a glob matches. +- `kind: bundle-stats` parses a bundler stats JSON — the common minimal shapes are supported: an esbuild metafile (`outputs..bytes`) and webpack stats (`assets[].size`) — and budgets the total across assets, or a single asset when `asset` names one. +- `level` is `warn` (default) or `fail`; `max_bytes` must be positive and `name` non-empty (validated at config load). +- A **missing artifact is a warn finding, never a hard error** — budgets on optional build outputs (a `dist/` that only exists after a release build) stay usable, and `level: fail` does not apply to absence. +- `path` is resolved relative to the target directory and is contained within it: absolute paths and `..` segments are rejected at validation, and artifacts that resolve outside the target through a symlink are skipped with a warn finding. codeguard never reads outside the repository to measure a budget. +- Budget findings carry the artifact path in the message rather than as a finding path, so they are reported in diff scans too (a built artifact is a repository-level gate, not a changed-line lint). + +### Benchmark regression + +`performance.benchmark-regression` runs `go test -run=^$ -bench=. -benchmem` over the configured packages and warns when a benchmark's ns/op regresses beyond the threshold relative to a stored baseline. + +```json +{ + "checks": { + "performance": true, + "performance_rules": { + "benchmarks": { + "enabled": true, + "packages": ["./internal/..."], + "max_regression_percent": 20, + "baseline_path": ".codeguard/cache.bench-baseline.json" + } + } + } +} +``` + +- **Off by default** (`enabled: false`): the gate executes the repository's own test code via `go test`, so only enable it for repositories whose test suite you would run anyway. The `go` binary is codeguard's own fixed tool invocation (like `git`) — there is deliberately no config override for the command in this version, which keeps the added trust surface at zero. +- `packages` must be explicit relative Go package patterns (`"."`, `"./..."`, `"./internal/..."`); anything flag-shaped, absolute, or containing `..` segments is rejected at validation. Full scans **require** an explicit list; diff scans default to the packages containing changed `.go` files (and benchmark nothing when the diff touches no Go files). +- `max_regression_percent` (default 20) is the tolerated ns/op slowdown per benchmark. +- `baseline_path` defaults to a sibling of the scan cache (`cache.path` with a `.bench-baseline` suffix, e.g. `.codeguard/cache.bench-baseline.json`) and, like the other config-controlled artifact paths, must stay inside the config directory. The **first run writes the baseline and reports nothing**; later runs compare against it, record newly appearing benchmarks, and never overwrite existing entries — delete the baseline file to accept a new cost and re-baseline. Benchmark names are stored with the `-GOMAXPROCS` suffix stripped so a core-count change does not orphan the baseline. +- The run is bounded like every other subprocess: contained timeout, output capped, packages validated before they reach `go test`. + +**Future work:** pprof profile ingestion/fusion (attributing regressions to functions by diffing CPU/heap profiles) is deliberately out of scope for this version. + ## Design Purpose: diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 6cf389a..6d70a22 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -245,12 +245,28 @@ func executeScan(stdout io.Writer, cfg service.Config, scanMode service.ScanMode 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" { diff --git a/internal/cli/doctor_rule_health.go b/internal/cli/doctor_rule_health.go index 73fd5a5..9aabff9 100644 --- a/internal/cli/doctor_rule_health.go +++ b/internal/cli/doctor_rule_health.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/devr-tools/codeguard/internal/codeguard/rules" service "github.com/devr-tools/codeguard/pkg/codeguard" ) @@ -43,6 +44,10 @@ func waiverDoctorChecks(cfg service.Config, today time.Time) []doctorCheck { func waiverHealthCheck(waiver service.WaiverConfig, catalog map[string]bool, today time.Time) (doctorCheck, bool) { if waiver.Rule != "*" && !catalog[waiver.Rule] { + if renamed, retired := rules.RetiredPerformanceRuleIDs()[waiver.Rule]; retired { + return warnDoctorCheck("waiver:"+waiver.Rule, + fmt.Sprintf("rule moved to the performance section as %s; update the waiver's rule id (the section is opt-in via checks.performance)", renamed)), true + } return warnDoctorCheck("waiver:"+waiver.Rule, "waiver matches no catalog rule and never suppresses anything; remove it or fix the rule id"), true } if waiverExpired(waiver.ExpiresOn, today) { diff --git a/internal/cli/menu.go b/internal/cli/menu.go index ca5e18f..6ab40a1 100644 --- a/internal/cli/menu.go +++ b/internal/cli/menu.go @@ -43,7 +43,7 @@ var menuGroups = []menuGroup{ title: "Fix & report", items: []menuItem{ {"fix", "AI-assisted fix for a specific finding"}, - {"report", "Print the slop / AI-risk history report"}, + {"report", "Print the slop / performance history report"}, }, }, { diff --git a/internal/cli/report.go b/internal/cli/report.go index b35f71f..1b4a9d1 100644 --- a/internal/cli/report.go +++ b/internal/cli/report.go @@ -16,12 +16,17 @@ func runReport(args []string, stdout io.Writer, stderr io.Writer) int { configPath := fs.String("config", service.DefaultConfigPath(), "config file or directory path") profile := fs.String("profile", "", "optional policy profile override") slopHistory := fs.Bool("slop-history", false, "print the persisted slop-score trend per target") + perfHistory := fs.Bool("perf-history", false, "print the persisted performance-score trend per target") limit := fs.Int("limit", 0, "maximum history entries to print per target (0 = all)") if err := fs.Parse(args); err != nil { return exitError } - if !*slopHistory { - _, _ = fmt.Fprintln(stderr, "report requires a mode flag: -slop-history") + if !*slopHistory && !*perfHistory { + _, _ = fmt.Fprintln(stderr, "report requires a mode flag: -slop-history or -perf-history") + return exitError + } + if *slopHistory && *perfHistory { + _, _ = fmt.Fprintln(stderr, "report accepts only one mode flag: -slop-history or -perf-history") return exitError } @@ -29,9 +34,45 @@ func runReport(args []string, stdout io.Writer, stderr io.Writer) int { if !ok { return exitError } + if *perfHistory { + return writePerfHistoryReport(stdout, cfg, *limit) + } return writeSlopHistoryReport(stdout, cfg, *limit) } +// writePerfHistoryReport mirrors writeSlopHistoryReport for the +// performance-score trend. +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 { + _, _ = 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 +} + func writeSlopHistoryReport(stdout io.Writer, cfg service.Config, limit int) int { path := service.SlopHistoryPath(cfg) history := service.LoadSlopHistory(path) @@ -71,8 +112,12 @@ func formatSlopDelta(score int, previous int, hasPrevious bool) string { } func formatSlopComponents(entry service.SlopHistoryEntry) string { - parts := make([]string, 0, len(entry.Components)) - for _, component := range entry.Components { + return formatScoreComponents(entry.Components) +} + +func formatScoreComponents(components []service.SlopScoreComponent) string { + parts := make([]string, 0, len(components)) + for _, component := range components { parts = append(parts, fmt.Sprintf("%s=%d", component.RuleID, component.Count)) } return strings.Join(parts, " ") diff --git a/internal/codeguard/ai/semantic/analyze.go b/internal/codeguard/ai/semantic/analyze.go index b6f4ebc..008cec5 100644 --- a/internal/codeguard/ai/semantic/analyze.go +++ b/internal/codeguard/ai/semantic/analyze.go @@ -31,23 +31,16 @@ func Analyze(ctx context.Context, opts Options) ([]core.Finding, error) { if !ok { return nil, nil } - cache := loadVerdictCache(opts.CachePath) key := requestHash(req) - if key != "" { - if entry, ok := cache.entries[key]; ok { - return findingsFromResponse(opts.NewFinding, entry.Response), nil - } + if resp, ok := cachedResponse(opts.CachePath, key); ok { + return findingsFromResponse(opts.NewFinding, opts.EmitRule, resp), nil } - resp, err := runCommand(ctx, opts.Command, req) + resp, err := runCommandShared(ctx, key, opts.Command, req) if err != nil { return nil, err } - if key != "" { - cache.entries[key] = cacheEntry{Response: resp} - cache.dirty = true - _ = cache.save() - } - return findingsFromResponse(opts.NewFinding, resp), nil + storeCachedResponse(opts.CachePath, key, resp) + return findingsFromResponse(opts.NewFinding, opts.EmitRule, resp), nil } type Options struct { @@ -59,5 +52,9 @@ type Options struct { Command string Enabled bool CheckSelection CheckSelection - NewFinding func(ruleID string, level string, path string, line int, message string) core.Finding + // EmitRule filters which verdict rule ids this caller emits as findings + // (nil emits every supported rule). The quality and performance sections + // share one combined request/response and demultiplex it by rule id here. + EmitRule func(ruleID string) bool + NewFinding func(ruleID string, level string, path string, line int, message string) core.Finding } diff --git a/internal/codeguard/ai/semantic/inflight.go b/internal/codeguard/ai/semantic/inflight.go new file mode 100644 index 0000000..84e7170 --- /dev/null +++ b/internal/codeguard/ai/semantic/inflight.go @@ -0,0 +1,77 @@ +package semantic + +import ( + "context" + "sync" +) + +// The quality and performance sections issue the same combined semantic +// request (all lenses in one payload, demultiplexed by rule id) and sections +// run in parallel, so a cold cache would otherwise trigger two identical +// runtime invocations. runCommandShared collapses concurrent identical +// requests (by request hash) into a single command run; the on-disk verdict +// cache then serves every later scan. +var ( + inflightMu sync.Mutex + inflightCalls = map[string]*inflightCall{} + + // cacheMu serializes read-modify-write cycles on the on-disk verdict + // cache so concurrent sections cannot drop each other's entries. + cacheMu sync.Mutex +) + +type inflightCall struct { + done chan struct{} + resp Response + err error +} + +func runCommandShared(ctx context.Context, key string, command string, req Request) (Response, error) { + if key == "" { + return runCommand(ctx, command, req) + } + inflightMu.Lock() + if call, ok := inflightCalls[key]; ok { + inflightMu.Unlock() + select { + case <-call.done: + return call.resp, call.err + case <-ctx.Done(): + return Response{}, ctx.Err() + } + } + call := &inflightCall{done: make(chan struct{})} + inflightCalls[key] = call + inflightMu.Unlock() + + call.resp, call.err = runCommand(ctx, command, req) + close(call.done) + + inflightMu.Lock() + delete(inflightCalls, key) + inflightMu.Unlock() + return call.resp, call.err +} + +func cachedResponse(path string, key string) (Response, bool) { + if key == "" { + return Response{}, false + } + cacheMu.Lock() + defer cacheMu.Unlock() + cache := loadVerdictCache(path) + entry, ok := cache.entries[key] + return entry.Response, ok +} + +func storeCachedResponse(path string, key string, resp Response) { + if key == "" { + return + } + cacheMu.Lock() + defer cacheMu.Unlock() + cache := loadVerdictCache(path) + cache.entries[key] = cacheEntry{Response: resp} + cache.dirty = true + _ = cache.save() +} diff --git a/internal/codeguard/ai/semantic/prompt.go b/internal/codeguard/ai/semantic/prompt.go index 05ef220..5f0e62c 100644 --- a/internal/codeguard/ai/semantic/prompt.go +++ b/internal/codeguard/ai/semantic/prompt.go @@ -55,6 +55,8 @@ func rulePromptTemplate(ruleID string, frameworks []FrameworkRef) RulePromptTemp Focus: "Check whether changed branches, outputs, or failure paths appear unexercised by nearby changed or local tests.", Threshold: "Emit only when the changed behavior has no clear nearby test exercise path.", } + case PerformanceRuleID: + return performancePromptTemplate(frameworks) default: return RulePromptTemplate{RuleID: ruleID} } diff --git a/internal/codeguard/ai/semantic/prompt_performance.go b/internal/codeguard/ai/semantic/prompt_performance.go new file mode 100644 index 0000000..cadf0a6 --- /dev/null +++ b/internal/codeguard/ai/semantic/prompt_performance.go @@ -0,0 +1,41 @@ +package semantic + +// PerformanceRuleID is the rule id for the optional LLM-assisted performance +// lens. Its verdicts are emitted into the performance section (not quality); +// see checks/performance.semanticPerformanceFindings. +const PerformanceRuleID = "performance.ai.semantic-perf" + +// performancePromptTemplate mirrors the contract-drift template shape: a +// concrete focus, framework-aware considerations, explicit non-goals, and a +// conservative emission threshold. +func performancePromptTemplate(frameworks []FrameworkRef) RulePromptTemplate { + return RulePromptTemplate{ + RuleID: PerformanceRuleID, + Focus: "Find performance problems introduced by the changed functions that a static pattern rule cannot judge: repeated expensive calls whose results should be cached or memoized, algorithmic complexity that is clearly out of line with the input sizes the local evidence makes plausible, and obviously redundant work performed across the change.", + Consider: performanceConsiderations(frameworks), + Avoid: []string{ + "Do not flag micro-optimizations (allocation tuning, instruction-level or constant-factor concerns) whose real-world impact is negligible or unproven.", + "Do not flag style or idiom preferences as performance problems.", + "Do not flag anything without clear evidence in the diff, source snapshots, or tests that the expensive work actually repeats or scales badly.", + "Do not speculate about workloads, input sizes, or hot paths that the provided context does not support.", + }, + Threshold: "Emit only when the local evidence shows concrete repeated or super-linear work whose cost would plausibly matter at realistic input sizes, and a caching, batching, hoisting, or algorithm change would clearly remove it.", + } +} + +func performanceConsiderations(frameworks []FrameworkRef) []string { + items := []string{ + "Check whether the same expensive call (I/O, network, query, parse, compile, crypto, large copy) repeats with identical inputs inside a loop, recursion, or repeatedly-invoked path when its result could be computed once, cached, or memoized.", + "Estimate the algorithmic complexity of changed loops and recursion relative to the input sizes that surrounding code, tests, and names make plausible, and flag clearly super-linear work on inputs that scale, such as nested scans over the same collection, membership tests against a slice inside a loop, or repeated sorting.", + "Look for obviously redundant work across the change: recomputing a value that is already available, re-reading or re-parsing the same data, or per-item work that a batch API already visible in the snapshots covers.", + } + for _, framework := range frameworks { + if hasHint(framework, "component-props-contract") || hasHint(framework, "stateful-component") { + items = append(items, "For React or Next.js components, check whether changed render paths repeat expensive computation on every render that memoization or module-level caching should hoist.") + } + if hasHint(framework, "route-handler-contract") || hasHint(framework, "middleware-order-sensitive") || hasHint(framework, "middleware-next-chain") { + items = append(items, "For route handlers and middleware, check whether per-request work such as config loads, client construction, or pattern compilation could move to initialization time.") + } + } + return uniqueNonEmptyStrings(items) +} diff --git a/internal/codeguard/ai/semantic/request.go b/internal/codeguard/ai/semantic/request.go index bf65d50..15e0f25 100644 --- a/internal/codeguard/ai/semantic/request.go +++ b/internal/codeguard/ai/semantic/request.go @@ -13,6 +13,7 @@ var supportedRuleIDs = map[string]struct{}{ "quality.ai.semantic-error-message": {}, "quality.ai.semantic-test-coverage": {}, "quality.ai.semantic-test-adequacy": {}, + PerformanceRuleID: {}, } func buildRequest(opts Options) (Request, bool) { @@ -88,15 +89,25 @@ func semanticCheckSpecs(selection CheckSelection) []CheckSpec { Description: "Flag changed production behavior when nearby tests look weak or mismatched, including low-value assertions, over-mocking, happy-path-only coverage, missing negative-path checks, missing boundary checks, or risky changes without a matching test update.", }) } + if selection.PerformanceReview { + checks = append(checks, CheckSpec{ + RuleID: PerformanceRuleID, + Title: "AI-assisted performance review", + Description: "Flag changed functions with performance concerns a static rule cannot judge: repeated expensive calls that want caching or memoization, algorithmic complexity out of line with plausible input sizes, or obviously redundant work across the change.", + }) + } return checks } -func findingsFromResponse(newFinding func(string, string, string, int, string) core.Finding, resp Response) []core.Finding { +func findingsFromResponse(newFinding func(string, string, string, int, string) core.Finding, emitRule func(string) bool, resp Response) []core.Finding { findings := make([]core.Finding, 0, len(resp.Verdicts)) for _, verdict := range resp.Verdicts { if _, ok := supportedRuleIDs[verdict.RuleID]; !ok || strings.TrimSpace(verdict.Message) == "" { continue } + if emitRule != nil && !emitRule(verdict.RuleID) { + continue + } level := verdict.Level if strings.TrimSpace(level) == "" { level = "warn" diff --git a/internal/codeguard/ai/semantic/types.go b/internal/codeguard/ai/semantic/types.go index 9959e8f..255dcfc 100644 --- a/internal/codeguard/ai/semantic/types.go +++ b/internal/codeguard/ai/semantic/types.go @@ -107,4 +107,9 @@ type CheckSelection struct { MisleadingErrorMessages bool TestBehaviorCoverage bool TestAdequacy bool + // PerformanceReview adds the performance lens (performance.ai.semantic-perf) + // to the request. It is driven by checks.performance being enabled, so that + // requests are byte-identical to previous releases when the performance + // section is off. + PerformanceReview bool } diff --git a/internal/codeguard/checks/README.md b/internal/codeguard/checks/README.md index a5d1c8d..7181845 100644 --- a/internal/codeguard/checks/README.md +++ b/internal/codeguard/checks/README.md @@ -3,6 +3,7 @@ Built-in checks live under `internal/codeguard/checks/` and are split by policy category: - `quality/` +- `performance/` - `design/` - `security/` - `prompts/` diff --git a/internal/codeguard/checks/performance/performance.go b/internal/codeguard/checks/performance/performance.go new file mode 100644 index 0000000..44d11e8 --- /dev/null +++ b/internal/codeguard/checks/performance/performance.go @@ -0,0 +1,74 @@ +// Package performance implements the performance check section: N+1 query +// patterns, allocation-heavy loops, blocking I/O in request paths, and +// unbounded concurrency. These rules previously lived in the quality section +// under quality.* rule IDs; they moved here as performance.* in v0.9.0. +package performance + +import ( + "context" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func Run(ctx context.Context, env support.Context) core.SectionResult { + findings := support.CollectTargetFindings(ctx, env, performanceTargetFindings) + return env.FinalizeSection("performance", "Performance", findings) +} + +func performanceTargetFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { + findings := make([]core.Finding, 0) + findings = append(findings, complexityRegressionFindings(env, target)...) + switch support.NormalizedLanguage(target.Language) { + case "", "go": + findings = append(findings, env.ScanTargetFiles(target, "performance", func(rel string) bool { + return strings.HasSuffix(rel, ".go") + }, func(file string, data []byte) []core.Finding { + return goFindingsForFile(env, file, data) + })...) + case "python", "py": + findings = append(findings, env.ScanTargetFiles(target, "performance", func(rel string) bool { + return strings.HasSuffix(strings.ToLower(rel), ".py") + }, func(file string, data []byte) []core.Finding { + return pythonPerformanceFindings(env, file, data) + })...) + case "typescript", "javascript", "ts", "tsx", "js", "jsx": + findings = append(findings, typeScriptPerformanceTargetFindings(env, target)...) + } + findings = append(findings, semanticPerformanceFindings(ctx, env, target)...) + // Measurement-based gates: artifact size budgets are language-agnostic and + // run for every target; the benchmark-regression gate only applies to Go + // targets (it shells out to go test -bench). + findings = append(findings, budgetFindings(env, target)...) + findings = append(findings, benchmarkFindings(ctx, env, target)...) + maybePutPerformanceScoreArtifact(env, target, findings) + return findings +} + +func goFindingsForFile(env support.Context, file string, data []byte) []core.Finding { + fset, parsed, err := support.ParseGoSource(env, file, data) + if err != nil { + // Unparseable Go is the quality section's problem (quality.parse-error); + // the performance pass just has nothing to inspect. + return nil + } + return goPerformanceFindings(env, file, fset, parsed) +} + +// warnFinding builds a warn-level finding; every performance rule reports at +// warn severity with the unspecified/medium confidence default. +func warnFinding(env support.Context, ruleID string, file string, line int, column int, message string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: ruleID, + Level: "warn", + Path: file, + Line: line, + Column: column, + Message: message, + }) +} + +func isTypeScriptLikeFile(rel string) bool { + return support.IsTypeScriptLikeFile(rel) +} diff --git a/internal/codeguard/checks/performance/performance_ai_semantic.go b/internal/codeguard/checks/performance/performance_ai_semantic.go new file mode 100644 index 0000000..67cf15d --- /dev/null +++ b/internal/codeguard/checks/performance/performance_ai_semantic.go @@ -0,0 +1,50 @@ +package performance + +import ( + "context" + "fmt" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/ai/semantic" + "github.com/devr-tools/codeguard/internal/codeguard/checks/semanticreview" + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// semanticPerformanceFindings runs the shared command-backed semantic review +// and emits only the performance.* verdicts (performance.ai.semantic-perf). +// The section registry already gates this pass on checks.performance, and the +// same guards the quality section uses apply on top: the AI runtime plus the +// semantic runtime must be enabled (ai.semantic.enabled or the +// CODEGUARD_SEMANTIC_CHECKS env gate) and a semantic command configured. +// +// Both sections build a byte-identical combined request through +// semanticreview.Options, so the verdict cache plus the in-process +// single-flight in the semantic package guarantee one runtime invocation per +// scan even though sections run in parallel; each side then demultiplexes the +// response by rule-id prefix. +func semanticPerformanceFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { + if !semanticreview.Enabled(env) { + return nil + } + opts := semanticreview.Options(env, target, "performance.") + if strings.TrimSpace(opts.Command) == "" { + return []core.Finding{semanticPerformanceRuntimeFinding(env, "semantic review is enabled but no semantic command is configured")} + } + findings, err := semantic.Analyze(ctx, opts) + if err != nil { + return []core.Finding{semanticPerformanceRuntimeFinding(env, fmt.Sprintf("semantic review command failed for target %q: %v", target.Name, err))} + } + return findings +} + +func semanticPerformanceRuntimeFinding(env support.Context, message string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: "performance.ai.semantic-runtime", + Level: "fail", + Path: "", + Line: 0, + Column: 0, + Message: message, + }) +} diff --git a/internal/codeguard/checks/performance/performance_benchmarks.go b/internal/codeguard/checks/performance/performance_benchmarks.go new file mode 100644 index 0000000..fa345cc --- /dev/null +++ b/internal/codeguard/checks/performance/performance_benchmarks.go @@ -0,0 +1,139 @@ +package performance + +import ( + "context" + "fmt" + "path" + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" + "github.com/devr-tools/codeguard/internal/codeguard/runner/benchregression" +) + +// benchmarkFindings runs the opt-in benchmark-regression gate for Go targets. +// The heavy lifting (subprocess, parsing, baseline persistence, comparison) +// lives in runner/benchregression — a govulncheck-style dedicated runner — +// so this function only wires config to the runner and turns regressions into +// findings. Findings are pathless (details live in the message) so they are +// reported in diff mode too, where a path outside the diff scope would drop +// them. +func benchmarkFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { + cfg := env.Config.Checks.PerformanceRules.Benchmarks + if cfg.Enabled == nil || !*cfg.Enabled || !isGoBenchmarkTarget(target) { + return nil + } + packages, finding := benchmarkPackages(env, cfg) + if finding != nil { + return []core.Finding{*finding} + } + baselinePath := benchmarkBaselinePath(env, cfg) + if baselinePath == "" { + return []core.Finding{benchmarkWarn(env, "no baseline path available: set performance_rules.benchmarks.baseline_path or enable cache.path")} + } + output, err := benchregression.RunBenchmarks(ctx, target.Path, packages) + results := benchregression.ParseOutput(output) + if err != nil && len(results) == 0 { + return []core.Finding{benchmarkWarn(env, fmt.Sprintf("benchmark run failed: %v", err))} + } + if len(results) == 0 { + return []core.Finding{benchmarkWarn(env, fmt.Sprintf("no benchmarks found in %s; disable performance_rules.benchmarks or point packages at code with Benchmark functions", strings.Join(packages, ", ")))} + } + return compareAgainstBaseline(env, cfg, baselinePath, results) +} + +// compareAgainstBaseline loads (or seeds) the baseline and reports regressions +// beyond the configured threshold. The first run writes the baseline and emits +// nothing; later runs never overwrite existing baseline entries — a regressed +// measurement must not become the new normal — but do record benchmarks that +// appear for the first time. +func compareAgainstBaseline(env support.Context, cfg core.PerformanceBenchmarksConfig, baselinePath string, results []benchregression.Result) []core.Finding { + baseline, ok := benchregression.LoadBaseline(baselinePath) + if !ok { + if err := benchregression.WriteBaseline(baselinePath, results); err != nil { + return []core.Finding{benchmarkWarn(env, fmt.Sprintf("could not write benchmark baseline %q: %v", baselinePath, err))} + } + return nil + } + findings := make([]core.Finding, 0) + for _, regression := range benchregression.Compare(baseline, results, cfg.MaxRegressionPercent) { + findings = append(findings, benchmarkWarn(env, fmt.Sprintf( + "benchmark %s regressed: %.1f ns/op vs baseline %.1f ns/op (+%.1f%%, threshold %.0f%%)", + regression.Name, regression.CurrentNsPerOp, regression.BaselineNsPerOp, regression.Percent, cfg.MaxRegressionPercent))) + } + if _, err := benchregression.MergeNewBenchmarks(baselinePath, baseline, results); err != nil { + findings = append(findings, benchmarkWarn(env, fmt.Sprintf("could not update benchmark baseline %q: %v", baselinePath, err))) + } + return findings +} + +// benchmarkPackages resolves which packages to benchmark: the explicit config +// list wins; in diff mode the default is the packages containing changed .go +// files; a full scan with no explicit list reports a warn finding, since +// benchmarking an entire unknown repository by default would be far too +// expensive. +func benchmarkPackages(env support.Context, cfg core.PerformanceBenchmarksConfig) ([]string, *core.Finding) { + if len(cfg.Packages) > 0 { + return cfg.Packages, nil + } + if env.Mode == core.ScanModeDiff { + if packages := changedGoPackages(env.ChangedFiles); len(packages) > 0 { + return packages, nil + } + return nil, nil // no Go changes in the diff: nothing to benchmark, nothing to report + } + finding := benchmarkWarn(env, "full scans require an explicit performance_rules.benchmarks.packages list (e.g. [\"./...\"]); diff scans default to the packages containing changed Go files") + return nil, &finding +} + +// changedGoPackages maps changed .go files to their containing package +// patterns ("./dir"), deduplicated and sorted. Test files count too: a slower +// helper in a _test.go file regresses benchmarks just as much. +func changedGoPackages(changedFiles []string) []string { + seen := map[string]struct{}{} + for _, file := range changedFiles { + if !strings.HasSuffix(file, ".go") { + continue + } + dir := path.Dir(strings.ReplaceAll(file, "\\", "/")) + pkg := "./" + dir + if dir == "." { + pkg = "." + } + seen[pkg] = struct{}{} + } + packages := make([]string, 0, len(seen)) + for pkg := range seen { + packages = append(packages, pkg) + } + sort.Strings(packages) + return packages +} + +// benchmarkBaselinePath resolves where the baseline JSON lives: the explicit +// config path (contained within the config directory at load time, like the +// other artifact paths) or a sibling of the scan cache file. +func benchmarkBaselinePath(env support.Context, cfg core.PerformanceBenchmarksConfig) string { + if trimmed := strings.TrimSpace(cfg.BaselinePath); trimmed != "" { + return trimmed + } + return benchregression.BaselinePathForBase(env.Config.Cache.Path) +} + +func benchmarkWarn(env support.Context, message string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: "performance.benchmark-regression", + Level: "warn", + Message: message, + }) +} + +func isGoBenchmarkTarget(target core.TargetConfig) bool { + switch support.NormalizedLanguage(target.Language) { + case "", "go": + return true + default: + return false + } +} diff --git a/internal/codeguard/checks/performance/performance_budgets.go b/internal/codeguard/checks/performance/performance_budgets.go new file mode 100644 index 0000000..8ff8d4c --- /dev/null +++ b/internal/codeguard/checks/performance/performance_budgets.go @@ -0,0 +1,158 @@ +package performance + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// budgetFindings evaluates every performance_rules.budgets entry against the +// target directory. Budget findings carry the artifact path in the message +// rather than in Finding.Path so they survive diff-mode scoping: a budget is +// a repository-level gate on a built artifact, not a per-changed-line lint. +func budgetFindings(env support.Context, target core.TargetConfig) []core.Finding { + budgets := env.Config.Checks.PerformanceRules.Budgets + if len(budgets) == 0 { + return nil + } + findings := make([]core.Finding, 0) + for _, budget := range budgets { + findings = append(findings, evaluateBudget(env, target, budget)...) + } + return findings +} + +func evaluateBudget(env support.Context, target core.TargetConfig, budget core.PerformanceBudgetConfig) []core.Finding { + switch budget.Kind { + case core.PerformanceBudgetKindFileSize: + return fileSizeBudgetFindings(env, target, budget) + case core.PerformanceBudgetKindBundleStats: + return bundleStatsBudgetFindings(env, target, budget) + default: + // Config validation rejects unknown kinds; a programmatically built + // config that skipped validation still gets a diagnostic, not a panic. + return []core.Finding{budgetIssueFinding(env, budget, fmt.Sprintf("unknown budget kind %q; budget skipped", budget.Kind))} + } +} + +func fileSizeBudgetFindings(env support.Context, target core.TargetConfig, budget core.PerformanceBudgetConfig) []core.Finding { + paths, finding := resolveBudgetArtifacts(env, target, budget) + if finding != nil { + return []core.Finding{*finding} + } + var total int64 + for _, path := range paths { + info, err := os.Stat(path) //nolint:gosec // path containment verified by resolveBudgetArtifacts + if err != nil || !info.Mode().IsRegular() { + continue + } + total += info.Size() + } + if total <= budget.MaxBytes { + return nil + } + return []core.Finding{budgetExceededFinding(env, budget, fmt.Sprintf("%q totals %d bytes", budget.Path, total))} +} + +// resolveBudgetArtifacts resolves the budget path (a literal path, or a glob +// for file-size budgets) within the target directory and enforces containment: +// every resolved artifact must stay inside the target after symlink +// resolution, mirroring the config.containConfigArtifactPaths threat model — +// the budget path comes from repository config, which is untrusted, and must +// never steer codeguard into reading outside the repository. When nothing +// resolves, the returned finding is the warn-level "not found" diagnostic. +func resolveBudgetArtifacts(env support.Context, target core.TargetConfig, budget core.PerformanceBudgetConfig) ([]string, *core.Finding) { + pattern := filepath.ToSlash(strings.TrimSpace(budget.Path)) + if pattern == "" || filepath.IsAbs(budget.Path) || hasDotDotSegment(pattern) { + finding := budgetIssueFinding(env, budget, fmt.Sprintf("path %q escapes the target directory; budget skipped", budget.Path)) + return nil, &finding + } + matches, err := filepath.Glob(filepath.Join(target.Path, filepath.FromSlash(pattern))) + if err != nil || len(matches) == 0 { + finding := budgetIssueFinding(env, budget, fmt.Sprintf("artifact %q not found; budget skipped", budget.Path)) + return nil, &finding + } + root, err := canonicalDir(target.Path) + if err != nil { + finding := budgetIssueFinding(env, budget, fmt.Sprintf("target directory could not be resolved (%v); budget skipped", err)) + return nil, &finding + } + contained := make([]string, 0, len(matches)) + for _, match := range matches { + if pathWithinRoot(root, match) { + contained = append(contained, match) + } + } + if len(contained) == 0 { + finding := budgetIssueFinding(env, budget, fmt.Sprintf("path %q resolves outside the target directory; budget skipped", budget.Path)) + return nil, &finding + } + return contained, nil +} + +// pathWithinRoot reports whether path stays inside root once symlinks are +// resolved. The path is absolutized first: EvalSymlinks keeps a relative +// input relative (targets configured with relative paths hit this), and +// filepath.Rel cannot mix a relative path with the absolute root. Glob only +// returns existing paths, so resolution is expected to succeed; any failure +// is treated as escape (fail closed). +func pathWithinRoot(root string, path string) bool { + abs, err := filepath.Abs(path) + if err != nil { + return false + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return false + } + rel, err := filepath.Rel(root, resolved) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func canonicalDir(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", err + } + return filepath.EvalSymlinks(abs) +} + +func hasDotDotSegment(slashPath string) bool { + for _, segment := range strings.Split(slashPath, "/") { + if segment == ".." { + return true + } + } + return false +} + +func budgetExceededFinding(env support.Context, budget core.PerformanceBudgetConfig, measurement string) core.Finding { + level := "warn" + if budget.Level == "fail" { + level = "fail" + } + return env.NewFinding(support.FindingInput{ + RuleID: "performance.budget", + Level: level, + Message: fmt.Sprintf("performance budget %q exceeded: %s, over the max_bytes budget of %d", budget.Name, measurement, budget.MaxBytes), + }) +} + +// budgetIssueFinding reports a budget that could not be measured (missing +// artifact, unreadable stats, escaping path). Always warn-level, regardless of +// the entry's configured level: an absent artifact must never hard-fail a scan +// (dist/ may simply not be built in this environment). +func budgetIssueFinding(env support.Context, budget core.PerformanceBudgetConfig, detail string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: "performance.budget", + Level: "warn", + Message: fmt.Sprintf("performance budget %q: %s", budget.Name, detail), + }) +} diff --git a/internal/codeguard/checks/performance/performance_budgets_stats.go b/internal/codeguard/checks/performance/performance_budgets_stats.go new file mode 100644 index 0000000..2f6e96e --- /dev/null +++ b/internal/codeguard/checks/performance/performance_budgets_stats.go @@ -0,0 +1,117 @@ +package performance + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// maxStatsFileBytes caps how much of a bundler stats JSON is read into +// memory: the stats file lives in the scanned repository, which may be an +// untrusted pull request, so the read is bounded like every other repository +// input. +const maxStatsFileBytes = 16 << 20 // 16 MiB + +func bundleStatsBudgetFindings(env support.Context, target core.TargetConfig, budget core.PerformanceBudgetConfig) []core.Finding { + paths, finding := resolveBudgetArtifacts(env, target, budget) + if finding != nil { + return []core.Finding{*finding} + } + stats, err := readBundleStats(paths[0]) + if err != nil { + return []core.Finding{budgetIssueFinding(env, budget, fmt.Sprintf("stats file %q: %v; budget skipped", budget.Path, err))} + } + if budget.Asset != "" { + size, ok := stats.assets[budget.Asset] + if !ok { + return []core.Finding{budgetIssueFinding(env, budget, fmt.Sprintf("asset %q not found in stats file %q; budget skipped", budget.Asset, budget.Path))} + } + if size <= budget.MaxBytes { + return nil + } + return []core.Finding{budgetExceededFinding(env, budget, fmt.Sprintf("asset %q is %d bytes", budget.Asset, size))} + } + if stats.total <= budget.MaxBytes { + return nil + } + return []core.Finding{budgetExceededFinding(env, budget, fmt.Sprintf("assets in %q total %d bytes", budget.Path, stats.total))} +} + +// bundleStats is the minimal shape shared by the supported stats formats: a +// per-asset size map and the total across assets. +type bundleStats struct { + total int64 + assets map[string]int64 +} + +// readBundleStats reads and parses a bundler stats JSON, supporting the two +// common minimal shapes: an esbuild metafile (outputs..bytes) and a +// webpack stats file (assets[].size). +func readBundleStats(path string) (bundleStats, error) { + info, err := os.Stat(path) //nolint:gosec // containment verified by caller + if err != nil { + return bundleStats{}, err + } + if info.Size() > maxStatsFileBytes { + return bundleStats{}, fmt.Errorf("stats file is %d bytes, larger than the %d byte limit", info.Size(), maxStatsFileBytes) + } + f, err := os.Open(path) //nolint:gosec // containment verified by caller + if err != nil { + return bundleStats{}, err + } + defer func() { _ = f.Close() }() + data, err := io.ReadAll(io.LimitReader(f, maxStatsFileBytes)) + if err != nil { + return bundleStats{}, err + } + return parseBundleStats(data) +} + +func parseBundleStats(data []byte) (bundleStats, error) { + if stats, ok := parseEsbuildMetafile(data); ok { + return stats, nil + } + if stats, ok := parseWebpackStats(data); ok { + return stats, nil + } + return bundleStats{}, fmt.Errorf("unrecognized stats format (expected an esbuild metafile with outputs..bytes or webpack stats with assets[].size)") +} + +func parseEsbuildMetafile(data []byte) (bundleStats, bool) { + var metafile struct { + Outputs map[string]struct { + Bytes int64 `json:"bytes"` + } `json:"outputs"` + } + if err := json.Unmarshal(data, &metafile); err != nil || len(metafile.Outputs) == 0 { + return bundleStats{}, false + } + stats := bundleStats{assets: make(map[string]int64, len(metafile.Outputs))} + for name, output := range metafile.Outputs { + stats.assets[name] = output.Bytes + stats.total += output.Bytes + } + return stats, true +} + +func parseWebpackStats(data []byte) (bundleStats, bool) { + var webpack struct { + Assets []struct { + Name string `json:"name"` + Size int64 `json:"size"` + } `json:"assets"` + } + if err := json.Unmarshal(data, &webpack); err != nil || len(webpack.Assets) == 0 { + return bundleStats{}, false + } + stats := bundleStats{assets: make(map[string]int64, len(webpack.Assets))} + for _, asset := range webpack.Assets { + stats.assets[asset.Name] = asset.Size + stats.total += asset.Size + } + return stats, true +} diff --git a/internal/codeguard/checks/performance/performance_complexity_regression.go b/internal/codeguard/checks/performance/performance_complexity_regression.go new file mode 100644 index 0000000..a1799ff --- /dev/null +++ b/internal/codeguard/checks/performance/performance_complexity_regression.go @@ -0,0 +1,120 @@ +package performance + +import ( + "fmt" + "go/ast" + "go/token" + "os" + "path/filepath" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +const complexityRegressionRuleID = "performance.complexity-regression" + +// complexityRegressionFindings warns when a change increases the maximum +// loop-nesting depth of an existing function relative to the diff base ref. +// It is a diff-only rule (like quality.coverage-delta): in full-scan mode, or +// when no diff scope is available, it stays silent. Coverage is Go-only in +// this first version; functions that do not exist at the base ref (new or +// renamed) are skipped, since there is no baseline to regress from. +func complexityRegressionFindings(env support.Context, target core.TargetConfig) []core.Finding { + if !toggleEnabled(env.Config.Checks.PerformanceRules.DetectComplexityRegression) { + return nil + } + if env.Mode != core.ScanModeDiff || env.DiffScope == nil || env.ReadBaseFile == nil { + return nil + } + switch support.NormalizedLanguage(target.Language) { + case "", "go": + default: + return nil + } + scope := env.DiffScope() + if len(scope) == 0 { + return nil + } + findings := make([]core.Finding, 0) + for _, rel := range sortedChangedPaths(scope) { + if !strings.HasSuffix(rel, ".go") { + continue + } + findings = append(findings, complexityRegressionFileFindings(env, target, rel, scope[rel])...) + } + return findings +} + +func complexityRegressionFileFindings(env support.Context, target core.TargetConfig, rel string, changed core.ChangedLineRanges) []core.Finding { + headData, err := os.ReadFile(filepath.Join(target.Path, filepath.FromSlash(rel))) //nolint:gosec // target path from config + rel path from the scan's own git diff + if err != nil { + // Deleted (or unreadable) file: nothing on the head side to regress. + return nil + } + headFset, headFile, err := support.ParseGoSource(env, rel, headData) + if err != nil { + // Unparseable Go is the quality section's problem (quality.parse-error). + return nil + } + baseData, err := env.ReadBaseFile(target, rel) + if err != nil { + // Added file: every function is new, so there is no base to compare. + return nil + } + baseDepths, err := baseFunctionLoopDepths(rel, baseData) + if err != nil { + return nil + } + scan := fileRegressionScan{fset: headFset, rel: rel, changed: changed, baseDepths: baseDepths} + findings := make([]core.Finding, 0) + for _, decl := range headFile.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + if finding, hit := scan.functionFinding(env, fn); hit { + findings = append(findings, finding) + } + } + return findings +} + +// fileRegressionScan carries the per-file comparison state: the head-revision +// fileset, the file's changed line ranges, and the base-revision depths. +type fileRegressionScan struct { + fset *token.FileSet + rel string + changed core.ChangedLineRanges + baseDepths map[string]int +} + +// functionFinding compares one head-revision function against its +// base-revision depth and returns a finding when the maximum loop-nesting +// depth increased. Functions outside the changed line ranges or absent from +// the base revision report no finding. +func (s fileRegressionScan) functionFinding(env support.Context, fn *ast.FuncDecl) (core.Finding, bool) { + start := s.fset.Position(fn.Pos()).Line + end := s.fset.Position(fn.End()).Line + if !changedRangesIntersect(s.changed, start, end) { + return core.Finding{}, false + } + baseDepth, existsInBase := s.baseDepths[goFunctionKey(fn)] + if !existsInBase { + return core.Finding{}, false + } + headDepth, deepest := goMaxLoopNesting(s.fset, fn.Body) + if headDepth <= baseDepth { + return core.Finding{}, false + } + line := deepest + if !s.changed.Contains(line) { + // Anchor the finding to a changed line inside the function so it + // survives diff-scope filtering even when the innermost loop line + // itself predates this change (e.g. an existing loop was wrapped). + line = firstChangedLineInSpan(s.changed, start, end) + } + return warnFinding(env, complexityRegressionRuleID, s.rel, line, 0, + fmt.Sprintf("function %s: loop nesting depth increased from %d to %d in this change; verify the added iteration is not over unbounded data", + goFunctionKey(fn), baseDepth, headDepth)), true +} diff --git a/internal/codeguard/checks/performance/performance_complexity_regression_depth.go b/internal/codeguard/checks/performance/performance_complexity_regression_depth.go new file mode 100644 index 0000000..7fb098d --- /dev/null +++ b/internal/codeguard/checks/performance/performance_complexity_regression_depth.go @@ -0,0 +1,131 @@ +package performance + +import ( + "go/ast" + "go/parser" + "go/token" + "sort" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// baseFunctionLoopDepths parses the base-ref revision of a file and returns +// the maximum loop-nesting depth per function key. The base blob is not part +// of the working tree, so it deliberately bypasses the shared parse cache. +func baseFunctionLoopDepths(rel string, data []byte) (map[string]int, error) { + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, rel, data, parser.SkipObjectResolution) + if err != nil { + return nil, err + } + depths := make(map[string]int) + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + depth, _ := goMaxLoopNesting(fset, fn.Body) + depths[goFunctionKey(fn)] = depth + } + return depths, nil +} + +// goFunctionKey identifies a function across revisions: methods are keyed as +// ReceiverType.Name so same-named methods on different types stay distinct. +func goFunctionKey(fn *ast.FuncDecl) string { + if fn.Recv != nil && len(fn.Recv.List) > 0 { + if recv := receiverBaseTypeName(fn.Recv.List[0].Type); recv != "" { + return recv + "." + fn.Name.Name + } + } + return fn.Name.Name +} + +func receiverBaseTypeName(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return receiverBaseTypeName(t.X) + case *ast.IndexExpr: // generic receiver: T[P] + return receiverBaseTypeName(t.X) + case *ast.IndexListExpr: // generic receiver: T[P1, P2] + return receiverBaseTypeName(t.X) + } + return "" +} + +// goMaxLoopNesting returns the maximum syntactic loop-nesting depth in a +// function body and the line of the first loop reaching that depth. Loops +// inside function literals count at their syntactic depth: a closure launched +// per iteration that itself loops still multiplies the iteration space. +func goMaxLoopNesting(fset *token.FileSet, body *ast.BlockStmt) (int, int) { + maxDepth := 0 + deepestLine := 0 + record := func(depth int, pos token.Pos) { + if depth > maxDepth { + maxDepth = depth + deepestLine = fset.Position(pos).Line + } + } + var walk func(node ast.Node, depth int) + walk = func(node ast.Node, depth int) { + ast.Inspect(node, func(child ast.Node) bool { + loopBody := goLoopBody(child) + if loopBody == nil { + return true + } + record(depth+1, child.Pos()) + walk(loopBody, depth+1) + return false + }) + } + walk(body, 0) + return maxDepth, deepestLine +} + +func changedRangesIntersect(changed core.ChangedLineRanges, start int, end int) bool { + if changed.AllChanged { + return true + } + for _, r := range changed.Ranges { + if r[0] <= end && r[1] >= start { + return true + } + } + return false +} + +// firstChangedLineInSpan returns the smallest changed line within +// [start, end], or start when the whole file counts as changed. +func firstChangedLineInSpan(changed core.ChangedLineRanges, start int, end int) int { + if changed.AllChanged { + return start + } + best := 0 + for _, r := range changed.Ranges { + if r[0] > end || r[1] < start { + continue + } + lo := r[0] + if lo < start { + lo = start + } + if best == 0 || lo < best { + best = lo + } + } + if best == 0 { + return start + } + return best +} + +func sortedChangedPaths(scope map[string]core.ChangedLineRanges) []string { + paths := make([]string, 0, len(scope)) + for path := range scope { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} diff --git a/internal/codeguard/checks/quality/quality_performance_go.go b/internal/codeguard/checks/performance/performance_go.go similarity index 87% rename from internal/codeguard/checks/quality/quality_performance_go.go rename to internal/codeguard/checks/performance/performance_go.go index d9d9d20..3542362 100644 --- a/internal/codeguard/checks/quality/quality_performance_go.go +++ b/internal/codeguard/checks/performance/performance_go.go @@ -1,4 +1,4 @@ -package quality +package performance import ( "bytes" @@ -33,6 +33,12 @@ var syncIOOperationsByImportPath = map[string]map[string]struct{}{ func goCorePerformanceFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File) []core.Finding { findings := make([]core.Finding, 0) + rules := env.Config.Checks.PerformanceRules + detectGoroutines := toggleEnabled(rules.DetectUnboundedConcurrency) + detectSyncIO := toggleEnabled(rules.DetectSyncIOInHandlers) + if !detectGoroutines && !detectSyncIO { + return findings + } httpAliases := importAliasesForPath(parsed, "net/http") syncIOAliases := syncIOAliases(parsed) @@ -48,18 +54,21 @@ func goCorePerformanceFindings(env support.Context, file string, fset *token.Fil stack = append(stack, n) switch node := n.(type) { case *ast.GoStmt: - if hasLoopAncestor(stack[:len(stack)-1]) { + if detectGoroutines && hasLoopAncestor(stack[:len(stack)-1]) { pos := fset.Position(node.Go) - findings = append(findings, warnFinding(env, "quality.unbounded-goroutines-in-loop", file, pos.Line, pos.Column, + findings = append(findings, warnFinding(env, "performance.unbounded-goroutines-in-loop", file, pos.Line, pos.Column, "goroutine launched inside a loop should be bounded or queued explicitly")) } case *ast.CallExpr: + if !detectSyncIO { + return true + } fn := enclosingFunc(stack[:len(stack)-1]) if fn == nil || !isLikelyHTTPHandler(fn, httpAliases) || !isSyncIOCall(node, syncIOAliases) { return true } pos := fset.Position(node.Pos()) - findings = append(findings, warnFinding(env, "quality.sync-io-in-request-path", file, pos.Line, pos.Column, + findings = append(findings, warnFinding(env, "performance.sync-io-in-request-path", file, pos.Line, pos.Column, "synchronous file I/O in an HTTP request path can add tail latency")) } return true diff --git a/internal/codeguard/checks/quality/quality_performance_go_alloc.go b/internal/codeguard/checks/performance/performance_go_alloc.go similarity index 95% rename from internal/codeguard/checks/quality/quality_performance_go_alloc.go rename to internal/codeguard/checks/performance/performance_go_alloc.go index 9258e90..37ac4cc 100644 --- a/internal/codeguard/checks/quality/quality_performance_go_alloc.go +++ b/internal/codeguard/checks/performance/performance_go_alloc.go @@ -1,4 +1,4 @@ -package quality +package performance import ( "fmt" @@ -27,7 +27,7 @@ type goAllocLoopScan struct { // loops legitimately skip. func goAllocInLoopFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File) []core.Finding { findings := make([]core.Finding, 0) - detectPrealloc := preallocToggleEnabled(env.Config.Checks.QualityRules.DetectPreallocInLoop) + detectPrealloc := preallocToggleEnabled(env.Config.Checks.PerformanceRules.DetectPreallocInLoop) for _, decl := range parsed.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Body == nil { @@ -86,7 +86,7 @@ func (scan goAllocLoopScan) assignFindings(assign *ast.AssignStmt, knowableBound func (scan goAllocLoopScan) finding(assign *ast.AssignStmt, message string) core.Finding { pos := scan.fset.Position(assign.Pos()) - return warnFinding(scan.env, "quality.go.alloc-in-loop", scan.file, pos.Line, pos.Column, message) + return warnFinding(scan.env, "performance.go.alloc-in-loop", scan.file, pos.Line, pos.Column, message) } func goStringGrowthMessage(assign *ast.AssignStmt) string { diff --git a/internal/codeguard/checks/quality/quality_performance_go_alloc_ast.go b/internal/codeguard/checks/performance/performance_go_alloc_ast.go similarity index 99% rename from internal/codeguard/checks/quality/quality_performance_go_alloc_ast.go rename to internal/codeguard/checks/performance/performance_go_alloc_ast.go index 8501c9e..ebcecf8 100644 --- a/internal/codeguard/checks/quality/quality_performance_go_alloc_ast.go +++ b/internal/codeguard/checks/performance/performance_go_alloc_ast.go @@ -1,4 +1,4 @@ -package quality +package performance import ( "go/ast" diff --git a/internal/codeguard/checks/performance/performance_go_calls.go b/internal/codeguard/checks/performance/performance_go_calls.go new file mode 100644 index 0000000..4e449c2 --- /dev/null +++ b/internal/codeguard/checks/performance/performance_go_calls.go @@ -0,0 +1,169 @@ +package performance + +import ( + "go/ast" + "go/token" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var regexCompileNames = map[string]struct{}{ + "Compile": {}, + "MustCompile": {}, + "CompilePOSIX": {}, + "MustCompilePOSIX": {}, +} + +// goLoopCallFindings flags loop bodies that repeat work belonging outside the +// loop (regex compilation, sleeps, leaked timers, accumulating defers) plus +// unbounded whole-input reads in loops or HTTP request paths. +func goLoopCallFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File) []core.Finding { + rules := env.Config.Checks.PerformanceRules + detectRegex := toggleEnabled(rules.DetectRegexCompileInLoop) + detectDefer := toggleEnabled(rules.DetectDeferInLoop) + detectSleep := toggleEnabled(rules.DetectSleepInLoop) + detectTimer := toggleEnabled(rules.DetectTimerLeaks) + detectReads := toggleEnabled(rules.DetectUnboundedReads) + if !detectRegex && !detectDefer && !detectSleep && !detectTimer && !detectReads { + return nil + } + + regexAliases := importAliasesForPath(parsed, "regexp") + timeAliases := importAliasesForPath(parsed, "time") + readAliases := importAliasesForPath(parsed, "io") + for alias := range importAliasesForPath(parsed, "io/ioutil") { + readAliases[alias] = struct{}{} + } + httpAliases := importAliasesForPath(parsed, "net/http") + + findings := make([]core.Finding, 0) + warn := func(ruleID string, pos token.Position, message string) { + findings = append(findings, warnFinding(env, ruleID, file, pos.Line, pos.Column, message)) + } + + stack := make([]ast.Node, 0, 32) + ast.Inspect(parsed, func(n ast.Node) bool { + if n == nil { + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + return false + } + stack = append(stack, n) + + switch node := n.(type) { + case *ast.DeferStmt: + // Defer scopes to the enclosing function: a defer inside a func + // literal launched from a loop runs per goroutine/function exit and + // does not accumulate, so the ancestor walk stops at the boundary. + if detectDefer && hasLoopAncestorWithinFunc(stack[:len(stack)-1]) { + warn("performance.go.defer-in-loop", fset.Position(node.Defer), + "defer inside a loop runs only at function exit, so deferred resources accumulate each iteration; release explicitly or extract the loop body into a function") + } + case *ast.CallExpr: + alias, name, ok := packageCall(node) + if !ok { + return true + } + inLoop := hasLoopAncestor(stack[:len(stack)-1]) + pos := fset.Position(node.Pos()) + switch { + case inLoop && detectRegex && aliasHas(regexAliases, alias) && nameIn(regexCompileNames, name) && literalPatternArg(node): + warn("performance.regex-compile-in-loop", pos, + "regular expression compiled inside a loop; compile it once before the loop or as a package-level variable") + case inLoop && detectSleep && aliasHas(timeAliases, alias) && name == "Sleep": + warn("performance.go.sleep-in-loop", pos, + "time.Sleep inside a loop usually marks polling; prefer a time.Ticker, a channel signal, or a backoff helper") + case inLoop && detectTimer && aliasHas(timeAliases, alias) && name == "After": + warn("performance.go.timer-leak-in-loop", pos, + "time.After inside a loop allocates a timer every iteration that is not collected until it fires; reuse a time.NewTimer or time.NewTicker") + case detectReads && aliasHas(readAliases, alias) && name == "ReadAll" && !readerIsLimited(node): + fn := enclosingFunc(stack[:len(stack)-1]) + if inLoop || (fn != nil && isLikelyHTTPHandler(fn, httpAliases)) { + warn("performance.unbounded-read", pos, + "ReadAll loads the entire input into memory; bound it with io.LimitReader or process the stream incrementally") + } + } + } + return true + }) + return findings +} + +// hasLoopAncestorWithinFunc reports a loop ancestor reached without crossing +// a function-literal boundary. +func hasLoopAncestorWithinFunc(stack []ast.Node) bool { + for i := len(stack) - 1; i >= 0; i-- { + switch stack[i].(type) { + case *ast.ForStmt, *ast.RangeStmt: + return true + case *ast.FuncLit, *ast.FuncDecl: + return false + } + } + return false +} + +// literalPatternArg reports whether the compile call's pattern argument is a +// string literal. A variable pattern usually changes per iteration (compiling +// config-supplied patterns in a loop over them), which is not the smell. +func literalPatternArg(call *ast.CallExpr) bool { + if len(call.Args) == 0 { + return false + } + lit, ok := call.Args[0].(*ast.BasicLit) + return ok && lit.Kind == token.STRING +} + +// readerIsLimited reports whether a ReadAll argument already applies a bound +// (io.LimitReader / io.LimitedReader / http.MaxBytesReader), so the +// recommended fix is not itself flagged. +func readerIsLimited(call *ast.CallExpr) bool { + limited := false + for _, arg := range call.Args { + ast.Inspect(arg, func(node ast.Node) bool { + switch value := node.(type) { + case *ast.SelectorExpr: + switch value.Sel.Name { + case "LimitReader", "LimitedReader", "MaxBytesReader": + limited = true + } + case *ast.Ident: + switch value.Name { + case "LimitReader", "LimitedReader", "MaxBytesReader": + limited = true + } + } + return !limited + }) + if limited { + break + } + } + return limited +} + +// packageCall unpacks a pkg.Func selector call into its package alias and +// function name; method calls on non-identifier receivers return ok=false. +func packageCall(call *ast.CallExpr) (alias string, name string, ok bool) { + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel { + return "", "", false + } + ident, isIdent := sel.X.(*ast.Ident) + if !isIdent { + return "", "", false + } + return ident.Name, sel.Sel.Name, true +} + +func aliasHas(aliases map[string]struct{}, alias string) bool { + _, ok := aliases[alias] + return ok +} + +func nameIn(names map[string]struct{}, name string) bool { + _, ok := names[name] + return ok +} diff --git a/internal/codeguard/checks/quality/quality_performance_go_loops.go b/internal/codeguard/checks/performance/performance_go_loops.go similarity index 78% rename from internal/codeguard/checks/quality/quality_performance_go_loops.go rename to internal/codeguard/checks/performance/performance_go_loops.go index b6936df..9c80cbe 100644 --- a/internal/codeguard/checks/quality/quality_performance_go_loops.go +++ b/internal/codeguard/checks/performance/performance_go_loops.go @@ -1,4 +1,4 @@ -package quality +package performance import ( "fmt" @@ -18,16 +18,19 @@ var goQueryMethodNames = map[string]struct{}{ "ExecContext": {}, } -func qualityToggleEnabled(value *bool) bool { +// toggleEnabled treats a nil rule toggle as enabled, matching the rest of the +// rule pack defaults. +func toggleEnabled(value *bool) bool { return value == nil || *value } func goPerformanceFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File) []core.Finding { findings := goCorePerformanceFindings(env, file, fset, parsed) - if qualityToggleEnabled(env.Config.Checks.QualityRules.DetectNPlusOneQuery) { + findings = append(findings, goLoopCallFindings(env, file, fset, parsed)...) + if toggleEnabled(env.Config.Checks.PerformanceRules.DetectNPlusOneQuery) { findings = append(findings, goNPlusOneFindings(env, file, fset, parsed)...) } - if qualityToggleEnabled(env.Config.Checks.QualityRules.DetectAllocInLoop) { + if toggleEnabled(env.Config.Checks.PerformanceRules.DetectAllocInLoop) { findings = append(findings, goAllocInLoopFindings(env, file, fset, parsed)...) } return findings @@ -69,7 +72,7 @@ func goNPlusOneFindings(env support.Context, file string, fset *token.FileSet, p return true } seen[line] = struct{}{} - findings = append(findings, warnFinding(env, "quality.n-plus-one-query", file, line, fset.Position(call.Pos()).Column, + findings = append(findings, warnFinding(env, "performance.n-plus-one-query", file, line, fset.Position(call.Pos()).Column, fmt.Sprintf("query call %s inside a loop suggests an N+1 query pattern; batch the query or hoist it out of the loop", sel.Sel.Name))) return true }) diff --git a/internal/codeguard/checks/performance/performance_python.go b/internal/codeguard/checks/performance/performance_python.go new file mode 100644 index 0000000..90f7076 --- /dev/null +++ b/internal/codeguard/checks/performance/performance_python.go @@ -0,0 +1,162 @@ +package performance + +import ( + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + pythonLoopStartPattern = regexp.MustCompile(`^\s*(?:for\s+.+\s+in\s+.+:|while\s+.+:)`) + pythonAsyncDefPattern = regexp.MustCompile(`^\s*async\s+def\s+`) + pythonQueryCallPattern = regexp.MustCompile(`\b(?:requests|httpx)\.(?:get|post|put|delete|patch|head)\s*\(|\bcursor\.execute\s*\(|\.execute\s*\(|\bsession\.query\s*\(`) + pythonSyncInAsyncCall = regexp.MustCompile(`\brequests\.\w+\s*\(|\burllib\.request\.urlopen\s*\(|\btime\.sleep\s*\(`) + // pythonRegexCompileCall requires a literal pattern: a variable argument + // usually varies per iteration, which is not the hoistable smell. + pythonRegexCompileCall = regexp.MustCompile(`\bre\.compile\s*\(\s*[frbu]{0,2}["']`) + // pythonStringConcat matches "name += ": a quote, f-string, or + // str(...) on the right-hand side keeps integer accumulators out. Augmented + // assignment to a variable initialized from a string literal (tracked by + // pythonStringAssign) is caught separately, covering out += line. + pythonStringConcat = regexp.MustCompile(`^\s*\w+\s*\+=\s*(?:[frbu]{0,2}["']|str\s*\()`) + pythonStringAssign = regexp.MustCompile(`^\s*(\w+)\s*=\s*[frbu]{0,2}["']`) + pythonAugmentedAssign = regexp.MustCompile(`^\s*(\w+)\s*\+=`) + pythonTaskCreateCall = regexp.MustCompile(`\basyncio\.(?:create_task|ensure_future)\s*\(`) + pythonUnboundedRead = regexp.MustCompile(`\.(?:read|readlines)\s*\(\s*\)`) + pythonConcurrencyHint = regexp.MustCompile(`Semaphore\s*\(|TaskGroup\s*\(|aiolimiter|anyio\.CapacityLimiter`) +) + +func pythonPerformanceFindings(env support.Context, file string, data []byte) []core.Finding { + source := strings.ReplaceAll(string(data), "\r\n", "\n") + scan := &pythonPerformanceScan{ + env: env, + file: file, + rules: env.Config.Checks.PerformanceRules, + limited: pythonConcurrencyHint.MatchString(source), + frameworks: newPythonFrameworkScan(env.Config.Checks.PerformanceRules, source), + } + // N+1 detection prefers the tree-sitter path (query-shaped text inside + // comments and string literals cannot match a call node); when the tree + // is unavailable the line scan below keeps its regex check. + if toggleEnabled(scan.rules.DetectNPlusOneQuery) { + if treeFindings, ok := pythonNPlusOneTreeFindings(env, file, source); ok { + scan.nPlusOneByTree = true + scan.findings = append(scan.findings, treeFindings...) + } + } + for idx, line := range strings.Split(source, "\n") { + scan.consumeLine(idx+1, line) + } + return scan.findings +} + +type pythonPerformanceScan struct { + env support.Context + file string + rules core.PerformanceRulesConfig + limited bool + // nPlusOneByTree records that the tree-sitter path already produced the + // performance.n-plus-one-query findings, so the regex check is skipped. + nPlusOneByTree bool + stringVars map[string]struct{} + loops []int + asyncDefs []int + frameworks *pythonFrameworkScan + findings []core.Finding +} + +func (s *pythonPerformanceScan) consumeLine(lineNo int, line string) { + if strings.TrimSpace(line) == "" { + return + } + if m := pythonStringAssign.FindStringSubmatch(line); m != nil { + if s.stringVars == nil { + s.stringVars = map[string]struct{}{} + } + s.stringVars[m[1]] = struct{}{} + } + indent := indentationWidth(line) + s.loops = popIndentRegions(s.loops, indent) + s.asyncDefs = popIndentRegions(s.asyncDefs, indent) + startsLoop := pythonLoopStartPattern.MatchString(line) + s.checkLine(lineNo, line, len(s.loops) > 0 || startsLoop, len(s.asyncDefs) > 0) + s.frameworks.observe(s, lineNo, line, indent, len(s.loops) > 0 || startsLoop) + if startsLoop { + s.loops = append(s.loops, indent) + } + if pythonAsyncDefPattern.MatchString(line) { + s.asyncDefs = append(s.asyncDefs, indent) + } +} + +func (s *pythonPerformanceScan) checkLine(lineNo int, line string, inLoop bool, inAsync bool) { + if inLoop && !s.nPlusOneByTree && toggleEnabled(s.rules.DetectNPlusOneQuery) && pythonQueryCallPattern.MatchString(line) { + s.addFinding("performance.n-plus-one-query", lineNo, pythonNPlusOneMessage) + } + if inAsync && toggleEnabled(s.rules.DetectSyncIOInHandlers) && pythonSyncInAsyncCall.MatchString(line) { + s.addFinding("performance.python.sync-io-in-async", lineNo, + "blocking call inside an async function stalls the event loop; use an async client or asyncio.sleep") + } + if !inLoop { + return + } + if toggleEnabled(s.rules.DetectRegexCompileInLoop) && pythonRegexCompileCall.MatchString(line) { + s.addFinding("performance.regex-compile-in-loop", lineNo, + "re.compile inside a loop recompiles the pattern every iteration; compile it once at module level or before the loop") + } + if toggleEnabled(s.rules.DetectAllocInLoop) && s.isStringConcat(line) { + s.addFinding("performance.string-concat-in-loop", lineNo, + "string built by += inside a loop copies the whole value each iteration; collect parts in a list and \"\".join them") + } + if toggleEnabled(s.rules.DetectUnboundedConcurrency) && !s.limited && pythonTaskCreateCall.MatchString(line) { + s.addFinding("performance.python.unbounded-concurrency", lineNo, + "asyncio task created inside a loop without a concurrency limit; bound it with asyncio.Semaphore or a TaskGroup") + } + if toggleEnabled(s.rules.DetectUnboundedReads) && pythonUnboundedRead.MatchString(line) { + s.addFinding("performance.unbounded-read", lineNo, + "unbounded read() inside a loop loads whole inputs into memory; read in chunks or iterate the stream") + } +} + +// isStringConcat reports += growth of a string: either a string-ish +// right-hand side, or an augmented assignment to a variable that was earlier +// initialized from a string literal. +func (s *pythonPerformanceScan) isStringConcat(line string) bool { + if pythonStringConcat.MatchString(line) { + return true + } + m := pythonAugmentedAssign.FindStringSubmatch(line) + if m == nil { + return false + } + _, isString := s.stringVars[m[1]] + return isString +} + +func (s *pythonPerformanceScan) addFinding(ruleID string, lineNo int, message string) { + s.findings = append(s.findings, warnFinding(s.env, ruleID, s.file, lineNo, 1, message)) +} + +func popIndentRegions(regions []int, indent int) []int { + for len(regions) > 0 && indent <= regions[len(regions)-1] { + regions = regions[:len(regions)-1] + } + return regions +} +func indentationWidth(line string) int { + width := 0 + for _, ch := range line { + if ch == ' ' { + width++ + continue + } + if ch == '\t' { + width += 4 + continue + } + break + } + return width +} diff --git a/internal/codeguard/checks/performance/performance_python_frameworks.go b/internal/codeguard/checks/performance/performance_python_frameworks.go new file mode 100644 index 0000000..6f6a426 --- /dev/null +++ b/internal/codeguard/checks/performance/performance_python_frameworks.go @@ -0,0 +1,163 @@ +package performance + +import ( + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// Framework-aware Python rules (performance_rules.detect_framework_patterns). +// Every rule is gated on file-level framework evidence — a Django import or +// `.objects.` manager usage for the Django rules, a SQLAlchemy import for the +// session rule — so plain-Python code never matches. +var ( + pythonDjangoEvidence = regexp.MustCompile(`(?m)^\s*(?:from\s+django[.\s]|import\s+django\b)|\.objects\.`) + pythonSQLAlchemyEvidence = regexp.MustCompile(`(?m)^\s*(?:from\s+sqlalchemy[.\s]|import\s+sqlalchemy\b)`) + pythonRelatedPrefetch = regexp.MustCompile(`\bselect_related\s*\(|\bprefetch_related\s*\(`) + pythonForLoopHeader = regexp.MustCompile(`^\s*for\s+(\w+)\s+in\s+(.+?):`) + pythonQuerysetAssign = regexp.MustCompile(`^\s*(\w+)\s*=\s*\S.*\.objects\.`) + // pythonAttrChain matches ..; the scan then requires + // to be an active queryset-loop variable, drops chains whose middle + // segment is `objects` (that is a manager call, covered by the ORM rule), + // and drops chains whose final segment is immediately called (scalar method + // calls like item.name.strip() are usually not relation loads). + pythonAttrChain = regexp.MustCompile(`\b(\w+)\.(\w+)\.(\w+)\b`) + // pythonReverseRelation matches ._set. — the Django reverse + // relation manager — which is a relation traversal even when called. + pythonReverseRelation = regexp.MustCompile(`\b(\w+)\.(\w+)_set\.`) + // pythonORMQueryCall and pythonSessionGetCall are deliberately disjoint + // from pythonQueryCallPattern (the generic n-plus-one rule): they cover + // only the ORM point-query shapes the generic pattern misses, so one line + // never reports under both rules. + pythonORMQueryCall = regexp.MustCompile(`\.objects\.(?:get|filter)\s*\(`) + pythonSessionGetCall = regexp.MustCompile(`\bsession\.get\s*\(`) +) + +// pythonFrameworkScan carries the framework-aware state for one file scan. A +// nil pointer (toggle disabled, or no framework evidence in the file) turns +// every hook into a no-op. +type pythonFrameworkScan struct { + django bool + sqlalchemy bool + // prefetched is true when select_related/prefetch_related appears anywhere + // in the file; the relation rule then stays quiet for the whole file. + prefetched bool + querysetVars map[string]struct{} + loopVars []pythonQuerysetLoopVar +} + +// pythonQuerysetLoopVar is an indent-delimited region in which name iterates a +// Django queryset. +type pythonQuerysetLoopVar struct { + indent int + name string +} + +func newPythonFrameworkScan(rules core.PerformanceRulesConfig, source string) *pythonFrameworkScan { + if !toggleEnabled(rules.DetectFrameworkPatterns) { + return nil + } + django := pythonDjangoEvidence.MatchString(source) + sqlalchemy := pythonSQLAlchemyEvidence.MatchString(source) + if !django && !sqlalchemy { + return nil + } + return &pythonFrameworkScan{ + django: django, + sqlalchemy: sqlalchemy, + prefetched: pythonRelatedPrefetch.MatchString(source), + } +} + +// observe is called once per non-blank line, after the generic checks, with +// the loop state the generic scan computed for the same line. +func (f *pythonFrameworkScan) observe(s *pythonPerformanceScan, lineNo int, line string, indent int, inLoop bool) { + if f == nil { + return + } + for len(f.loopVars) > 0 && indent <= f.loopVars[len(f.loopVars)-1].indent { + f.loopVars = f.loopVars[:len(f.loopVars)-1] + } + if m := pythonQuerysetAssign.FindStringSubmatch(line); m != nil { + if f.querysetVars == nil { + f.querysetVars = map[string]struct{}{} + } + f.querysetVars[m[1]] = struct{}{} + } + isLoopHeader := pythonLoopStartPattern.MatchString(line) + f.checkDjangoRelation(s, lineNo, line) + f.checkORMQueryInLoop(s, lineNo, line, inLoop, isLoopHeader) + if header := pythonForLoopHeader.FindStringSubmatch(line); header != nil && f.iterableIsQueryset(header[2]) { + f.loopVars = append(f.loopVars, pythonQuerysetLoopVar{indent: indent, name: header[1]}) + } +} + +// iterableIsQueryset reports whether a for-loop iterable expression looks like +// a Django queryset: an inline manager expression, or a variable previously +// assigned from one. +func (f *pythonFrameworkScan) iterableIsQueryset(expr string) bool { + if strings.Contains(expr, ".objects.") { + return true + } + _, tracked := f.querysetVars[strings.TrimSpace(expr)] + return tracked +} + +func (f *pythonFrameworkScan) isActiveLoopVar(name string) bool { + for _, loopVar := range f.loopVars { + if loopVar.name == name { + return true + } + } + return false +} + +// checkDjangoRelation implements performance.python.django-nplusone-relation: +// relation traversal on a queryset-loop variable in a file with Django +// evidence and no select_related/prefetch_related anywhere. +func (f *pythonFrameworkScan) checkDjangoRelation(s *pythonPerformanceScan, lineNo int, line string) { + if !f.django || f.prefetched || len(f.loopVars) == 0 { + return + } + for _, m := range pythonReverseRelation.FindAllStringSubmatch(line, -1) { + if f.isActiveLoopVar(m[1]) { + s.addFinding("performance.python.django-nplusone-relation", lineNo, + "reverse relation access on a queryset row inside the loop issues one query per row; load it up front with prefetch_related") + return + } + } + for _, m := range pythonAttrChain.FindAllStringSubmatchIndex(line, -1) { + base := line[m[2]:m[3]] + middle := line[m[4]:m[5]] + if !f.isActiveLoopVar(base) || middle == "objects" { + continue + } + if rest := strings.TrimLeft(line[m[1]:], " \t"); strings.HasPrefix(rest, "(") { + continue + } + s.addFinding("performance.python.django-nplusone-relation", lineNo, + "attribute access through a related object inside a queryset loop issues one query per row; load the relation up front with select_related or prefetch_related") + return + } +} + +// checkORMQueryInLoop implements performance.python.orm-query-in-loop: Django +// .objects.get/.objects.filter or SQLAlchemy session.get inside a loop body. +// Loop headers are exempt (the iterable there runs once, not per iteration), +// and any line the generic n-plus-one pattern already covers is skipped so +// the two rules stay disjoint. +func (f *pythonFrameworkScan) checkORMQueryInLoop(s *pythonPerformanceScan, lineNo int, line string, inLoop bool, isLoopHeader bool) { + if !inLoop || isLoopHeader || pythonQueryCallPattern.MatchString(line) { + return + } + if f.django && pythonORMQueryCall.MatchString(line) { + s.addFinding("performance.python.orm-query-in-loop", lineNo, + "Django ORM query inside a loop issues one query per iteration; batch it before the loop with in_bulk(), filter(pk__in=...), or values_list") + return + } + if f.sqlalchemy && pythonSessionGetCall.MatchString(line) { + s.addFinding("performance.python.orm-query-in-loop", lineNo, + "SQLAlchemy session.get inside a loop issues one query per iteration; fetch the rows in one query with an in_() filter before the loop") + } +} diff --git a/internal/codeguard/checks/performance/performance_python_tree.go b/internal/codeguard/checks/performance/performance_python_tree.go new file mode 100644 index 0000000..38a7f5b --- /dev/null +++ b/internal/codeguard/checks/performance/performance_python_tree.go @@ -0,0 +1,134 @@ +package performance + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// pythonNPlusOneMessage is shared verbatim by the tree and regex paths so the +// tree-sitter upgrade is invisible in output except for precision (same rule +// ID, same level, same message). +const pythonNPlusOneMessage = "query or request call inside a loop suggests an N+1 pattern; batch the work or hoist the call out of the loop" + +// pythonLoopQuery captures every for/while statement so call sites can be +// tested for loop containment by line span. The span includes the loop header +// line, matching the regex path (which flags `for row in cursor.execute(q):` +// on the header itself). +var pythonLoopQuery = support.CompileScriptQuery(` +(for_statement) @loop +(while_statement) @loop +`) + +// pythonQueryCallQuery captures method-style calls (`receiver.method(...)`) +// with the receiver/method split, the syntactic counterpart of +// pythonQueryCallPattern: because only real call nodes match, query-shaped +// text inside comments and string literals — a false-positive class the regex +// path cannot avoid — never fires. +var pythonQueryCallQuery = support.CompileScriptQuery(` +(call + function: (attribute + object: (_) @call.object + attribute: (identifier) @call.method)) +`) + +// pythonNPlusOneTreeFindings runs the tree-sitter path for +// performance.n-plus-one-query on one Python file. The boolean reports +// whether the tree path ran: false (parsers.treesitter off, grammar not +// embedded in this build, parse refusal, or query failure) means the caller +// must keep its regex scan, mirroring the fallback contract of +// security_typescript_tree.go. +func pythonNPlusOneTreeFindings(env support.Context, file string, source string) ([]core.Finding, bool) { + tree := support.ScriptSyntaxTree(env, file, source) + if tree == nil { + return nil, false + } + loopHits, err := tree.Query(pythonLoopQuery) + if err != nil { + return nil, false + } + loops := pythonLoopSpans(loopHits) + findings, ok := support.ScriptQueryFindings(env, file, tree, support.ScriptQuerySpec{ + Query: pythonQueryCallQuery, + RuleID: "performance.n-plus-one-query", + Level: "warn", + Message: pythonNPlusOneMessage, + Confidence: core.ConfidenceHigh, + Classify: func(hit support.QueryHit) (int, bool) { + return classifyPythonQueryCallHit(hit, loops) + }, + }) + if !ok { + return nil, false + } + return findings, true +} + +// pythonLoopSpans reduces loop captures to inclusive [start, end] line spans. +func pythonLoopSpans(hits []support.QueryHit) [][2]int { + spans := make([][2]int, 0, len(hits)) + for _, hit := range hits { + for _, capture := range hit.Captures { + if capture.Name == "loop" { + spans = append(spans, [2]int{capture.Line, capture.EndLine}) + } + } + } + return spans +} + +// classifyPythonQueryCallHit keeps a call when it sits inside a for/while +// statement and its callee matches the query-call shapes of +// pythonQueryCallPattern: requests/httpx HTTP verbs, any `.execute(...)` +// (cursor objects go by many names), and `session.query(...)`. +func classifyPythonQueryCallHit(hit support.QueryHit, loops [][2]int) (int, bool) { + object := hit.CaptureText("call.object") + method := "" + line := 0 + for _, capture := range hit.Captures { + if capture.Name == "call.method" { + method = capture.Text + line = capture.Line + } + } + if line == 0 || !lineInsideSpans(loops, line) { + return 0, false + } + if !isPythonQueryCallee(object, method) { + return 0, false + } + return line, true +} + +func lineInsideSpans(spans [][2]int, line int) bool { + for _, span := range spans { + if line >= span[0] && line <= span[1] { + return true + } + } + return false +} + +// isPythonQueryCallee mirrors pythonQueryCallPattern member by member so both +// paths agree on true positives; only the comment/string false-positive class +// differs. +func isPythonQueryCallee(object string, method string) bool { + switch method { + case "execute": + return true + case "get", "post", "put", "delete", "patch", "head": + return pythonReceiverIs(object, "requests") || pythonReceiverIs(object, "httpx") + case "query": + return pythonReceiverIs(object, "session") + default: + return false + } +} + +// pythonReceiverIs matches the receiver exactly or as the last member of a +// dotted chain (`db.session` counts as `session`, matching the regex path's +// `\bsession\.query` word boundary). +func pythonReceiverIs(object string, name string) bool { + return object == name || strings.HasSuffix(object, "."+name) +} diff --git a/internal/codeguard/checks/performance/performance_score.go b/internal/codeguard/checks/performance/performance_score.go new file mode 100644 index 0000000..dda1aae --- /dev/null +++ b/internal/codeguard/checks/performance/performance_score.go @@ -0,0 +1,129 @@ +package performance + +import ( + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// performanceScoreWeights assigns each performance rule a weight by family, +// mirroring aiSlopRuleWeights for the slop_score artifact. Weights order the +// families by typical production impact and are deliberately simple and +// stable so scores stay comparable across scans: +// +// 5 — query-in-loop (N+1): per-item round trips multiply latency directly +// 4 — blocking I/O in request/async paths: stalls a handler or event loop +// 4 — unbounded concurrency: loop-spawned goroutines/promises/tasks +// 3 — memory pressure: unbounded reads, leaked timers/listeners +// 2 — repeated loop work: regex compiles, defers, sleeps, serial awaits +// 1 — allocation churn: string growth and alloc-heavy loop bodies +var performanceScoreWeights = map[string]int{ + "performance.n-plus-one-query": 5, + + "performance.sync-io-in-request-path": 4, + "performance.typescript.sync-io-in-handler": 4, + "performance.javascript.sync-io-in-handler": 4, + "performance.python.sync-io-in-async": 4, + + "performance.unbounded-goroutines-in-loop": 4, + "performance.typescript.unbounded-concurrency": 4, + "performance.javascript.unbounded-concurrency": 4, + "performance.python.unbounded-concurrency": 4, + + "performance.unbounded-read": 3, + "performance.go.timer-leak-in-loop": 3, + "performance.typescript.timer-listener-leak": 3, + "performance.javascript.timer-listener-leak": 3, + + "performance.regex-compile-in-loop": 2, + "performance.go.defer-in-loop": 2, + "performance.go.sleep-in-loop": 2, + "performance.typescript.await-in-loop": 2, + "performance.javascript.await-in-loop": 2, + + "performance.go.alloc-in-loop": 1, + "performance.string-concat-in-loop": 1, +} + +// maybePutPerformanceScoreArtifact publishes the per-target performance_score +// artifact when the section produced findings, mirroring +// maybePutAISlopArtifact in checks/quality. +func maybePutPerformanceScoreArtifact(env support.Context, target core.TargetConfig, findings []core.Finding) { + if env.PutArtifact == nil { + return + } + artifact, ok := performanceScoreArtifact(target, findings) + if !ok { + return + } + recordPerformanceScoreHistory(env, &artifact) + env.PutArtifact(artifact) +} + +// performanceScoreArtifact computes the weighted score: each finding +// contributes its rule's family weight, and the total saturates at 100 via +// the same min(10*sum, 100) scaling the slop score uses. +func performanceScoreArtifact(target core.TargetConfig, findings []core.Finding) (core.Artifact, bool) { + componentCounts := map[string]int{} + signals := 0 + total := 0 + for _, finding := range findings { + weight, ok := performanceScoreWeights[finding.RuleID] + if !ok { + continue + } + componentCounts[finding.RuleID]++ + signals++ + total += weight + } + if signals == 0 { + return core.Artifact{}, false + } + componentIDs := make([]string, 0, len(componentCounts)) + for ruleID := range componentCounts { + componentIDs = append(componentIDs, ruleID) + } + sort.Strings(componentIDs) + components := make([]core.SlopScoreComponent, 0, len(componentIDs)) + for _, ruleID := range componentIDs { + weight := performanceScoreWeights[ruleID] + count := componentCounts[ruleID] + components = append(components, core.SlopScoreComponent{ + RuleID: ruleID, + Count: count, + Weight: weight, + Contribution: count * weight, + }) + } + language := support.NormalizedLanguage(target.Language) + if language == "" { + language = "go" + } + score := total * 10 + if score > 100 { + score = 100 + } + return support.NewPerformanceScoreArtifact( + "performance_score."+language+"."+performanceArtifactSafeID(target.Name), + language, + target.Path, + core.PerformanceScoreArtifact{ + Score: score, + Signals: signals, + Components: components, + }, + ), true +} + +// performanceArtifactSafeID mirrors quality.artifactSafeID for artifact ID +// segments derived from target names. +func performanceArtifactSafeID(value string) string { + replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", "_", "-") + out := strings.Trim(replacer.Replace(strings.ToLower(strings.TrimSpace(value))), "-") + if out == "" { + return "target" + } + return out +} diff --git a/internal/codeguard/checks/performance/performance_score_history.go b/internal/codeguard/checks/performance/performance_score_history.go new file mode 100644 index 0000000..5dfa5da --- /dev/null +++ b/internal/codeguard/checks/performance/performance_score_history.go @@ -0,0 +1,51 @@ +package performance + +import ( + "time" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" + runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support" +) + +// recordPerformanceScoreHistory persists the artifact's score to the +// per-scan trend file next to the cache and annotates the artifact with the +// previous score and delta when prior scans exist, mirroring +// recordSlopHistory in checks/quality. +func recordPerformanceScoreHistory(env support.Context, artifact *core.Artifact) { + if artifact == nil || artifact.PerformanceScore == nil { + return + } + rules := env.Config.Checks.PerformanceRules + if !toggleEnabled(rules.ScoreHistory) { + return + } + if env.Config.Cache.Enabled != nil && !*env.Config.Cache.Enabled { + return + } + path := runnersupport.PerfScoreHistoryPathForBase(env.Config.Cache.Path) + if path == "" { + return + } + entry := core.PerformanceHistoryEntry{ + Timestamp: performanceScanTimestamp(env), + Score: artifact.PerformanceScore.Score, + Signals: artifact.PerformanceScore.Signals, + Components: append([]core.SlopScoreComponent(nil), artifact.PerformanceScore.Components...), + } + previous, hasPrevious := runnersupport.AppendPerfScoreHistory(path, artifact.ID, entry, rules.ScoreHistoryLimit) + if !hasPrevious { + return + } + previousScore := previous.Score + delta := artifact.PerformanceScore.Score - previousScore + artifact.PerformanceScore.PreviousScore = &previousScore + artifact.PerformanceScore.Delta = &delta +} + +func performanceScanTimestamp(env support.Context) string { + if !env.ScanTime.IsZero() { + return env.ScanTime.UTC().Format(time.RFC3339) + } + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/internal/codeguard/checks/performance/performance_typescript.go b/internal/codeguard/checks/performance/performance_typescript.go new file mode 100644 index 0000000..0ca35f6 --- /dev/null +++ b/internal/codeguard/checks/performance/performance_typescript.go @@ -0,0 +1,183 @@ +package performance + +import ( + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + tsLoopStartPattern = regexp.MustCompile(`(?:^|[^\w$])(?:for|while)\s*\(|\.(?:forEach|map|flatMap)\s*\(`) + tsHandlerStartPattern = regexp.MustCompile(`\b(?:app|router|server|api|fastify)\.(?:get|post|put|delete|patch|all|use)\s*\(|export\s+(?:default\s+)?(?:async\s+)?function\s+handler\s*\(|export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(`) + tsQueryCallPattern = regexp.MustCompile(`\bfetch\s*\(|\baxios\b|\.query\s*\(|\.execute\s*\(|\.findOne\s*\(|\.findMany\s*\(|\.findUnique\s*\(|\.findFirst\s*\(`) + tsSyncCallPattern = regexp.MustCompile(`\b\w+Sync\s*\(`) + tsPromiseCreatePattern = regexp.MustCompile(`new\s+Promise\s*\(|\.push\s*\(\s*(?:fetch\s*\(|axios\b)`) + tsConcurrencyLimitHint = regexp.MustCompile(`p-limit|p-queue|pLimit\s*\(`) + tsAwaitPattern = regexp.MustCompile(`(?:^|[^\w$])await\s`) + tsForAwaitPattern = regexp.MustCompile(`\bfor\s+await\s*\(`) + // tsRegexCompilePattern requires a literal pattern (matched on the raw + // line, since the stripper blanks quotes): a variable argument usually + // varies per iteration, which is not the hoistable smell. + tsRegexCompilePattern = regexp.MustCompile(`new\s+RegExp\s*\(\s*["'` + "`" + `]`) + // tsStringConcat matches "name += ": a quote, backtick, or + // String(...) start keeps numeric accumulators out. It runs on the RAW + // line (the stripper blanks quote delimiters), guarded by the stripped + // line still containing += so comment text cannot match. Augmented + // assignment to a variable initialized from a string literal (tracked by + // tsStringInit) is caught separately, covering out += row + "\n". + tsStringConcat = regexp.MustCompile(`[\w$\]]\s*\+=\s*(?:["'` + "`" + `]|String\s*\()`) + tsStringInit = regexp.MustCompile(`\b(?:let|var)\s+([\w$]+)\s*(?::\s*string\s*)?=\s*["'` + "`" + `]`) + tsAugmentedAssign = regexp.MustCompile(`(?:^|[^\w$.])([\w$]+)\s*\+=`) + tsSetIntervalPattern = regexp.MustCompile(`\bsetInterval\s*\(`) + tsClearInterval = regexp.MustCompile(`\bclearInterval\s*\(`) + tsAddListenerPattern = regexp.MustCompile(`\baddEventListener\s*\(`) + tsRemoveListenerCall = regexp.MustCompile(`\bremoveEventListener\s*\(`) + tsAbortSignalListener = regexp.MustCompile(`\bsignal\s*[:=]`) +) + +func typeScriptPerformanceTargetFindings(env support.Context, target core.TargetConfig) []core.Finding { + findings := make([]core.Finding, 0) + env.VisitTargetFiles(target, isTypeScriptLikeFile, func(rel string, data []byte) { + findings = append(findings, typeScriptPerformanceFindings(env, rel, data)...) + }) + return findings +} + +func typeScriptPerformanceFindings(env support.Context, file string, data []byte) []core.Finding { + source := strings.ReplaceAll(string(data), "\r\n", "\n") + code := support.StripTypeScriptCommentsAndStrings(source) + scan := &tsPerformanceScan{ + env: env, + file: file, + limited: tsConcurrencyLimitHint.MatchString(source), + listenersCleaned: tsRemoveListenerCall.MatchString(code) || tsAbortSignalListener.MatchString(code), + rules: env.Config.Checks.PerformanceRules, + frameworks: newTSFrameworkScan(env.Config.Checks.PerformanceRules, source), + findings: make([]core.Finding, 0), + } + rawLines := strings.Split(source, "\n") + for idx, line := range strings.Split(code, "\n") { + scan.consumeLine(idx+1, line, rawLines[idx]) + } + // setInterval leaks are a file-level judgment: any clearInterval in the + // file counts as cleanup, so the findings are emitted after the pass. + if toggleEnabled(scan.rules.DetectTimerLeaks) && !scan.intervalsCleaned { + for _, lineNo := range scan.intervalLines { + scan.addFinding("performance.typescript.timer-listener-leak", "performance.javascript.timer-listener-leak", lineNo, + "setInterval without any clearInterval in the file keeps the callback and its captures alive forever; store the handle and clear it") + } + } + return scan.findings +} + +type tsPerformanceScan struct { + env support.Context + file string + limited bool + listenersCleaned bool + intervalsCleaned bool + intervalLines []int + stringVars map[string]struct{} + rules core.PerformanceRulesConfig + frameworks *tsFrameworkScan + depth int + loops []int + handlers []int + findings []core.Finding +} + +func (s *tsPerformanceScan) consumeLine(lineNo int, line string, rawLine string) { + if m := tsStringInit.FindStringSubmatch(rawLine); m != nil { + if s.stringVars == nil { + s.stringVars = map[string]struct{}{} + } + s.stringVars[m[1]] = struct{}{} + } + startsLoop := tsLoopStartPattern.MatchString(line) + startsHandler := tsHandlerStartPattern.MatchString(line) + s.checkLine(lineNo, line, rawLine, len(s.loops) > 0 || startsLoop, len(s.handlers) > 0 || startsHandler) + next := s.depth + strings.Count(line, "{") - strings.Count(line, "}") + s.frameworks.observe(s, lineNo, line, next) + if startsLoop && next > s.depth { + s.loops = append(s.loops, s.depth) + } + if startsHandler && next > s.depth { + s.handlers = append(s.handlers, s.depth) + } + for len(s.loops) > 0 && next <= s.loops[len(s.loops)-1] { + s.loops = s.loops[:len(s.loops)-1] + } + for len(s.handlers) > 0 && next <= s.handlers[len(s.handlers)-1] { + s.handlers = s.handlers[:len(s.handlers)-1] + } + s.depth = next +} + +func (s *tsPerformanceScan) checkLine(lineNo int, line string, rawLine string, inLoop bool, inHandler bool) { + if tsSetIntervalPattern.MatchString(line) { + s.intervalLines = append(s.intervalLines, lineNo) + } + if tsClearInterval.MatchString(line) { + s.intervalsCleaned = true + } + if inLoop && toggleEnabled(s.rules.DetectNPlusOneQuery) && tsQueryCallPattern.MatchString(line) { + s.addFinding("performance.n-plus-one-query", "performance.n-plus-one-query", lineNo, + "query or fetch call inside a loop suggests an N+1 pattern; batch requests or hoist the call out of the loop") + } + if inLoop && toggleEnabled(s.rules.DetectUnboundedConcurrency) && !s.limited && + !strings.Contains(line, "await ") && tsPromiseCreatePattern.MatchString(line) { + s.addFinding("performance.typescript.unbounded-concurrency", "performance.javascript.unbounded-concurrency", lineNo, + "promise created inside a loop without a concurrency limit; batch with Promise.all over chunks or use p-limit") + } + if inHandler && tsSyncCallPattern.MatchString(line) { + // The framework-aware express middleware rule takes precedence over + // the generic sync-io rule so a single line never reports twice. + if !s.frameworks.reportExpressSyncMiddleware(s, lineNo, line) && toggleEnabled(s.rules.DetectSyncIOInHandlers) { + s.addFinding("performance.typescript.sync-io-in-handler", "performance.javascript.sync-io-in-handler", lineNo, + "synchronous I/O call inside a request handler blocks the event loop; use the async API instead") + } + } + if !inLoop { + return + } + if toggleEnabled(s.rules.DetectAwaitInLoop) && !s.limited && + tsAwaitPattern.MatchString(line) && !tsForAwaitPattern.MatchString(line) { + s.addFinding("performance.typescript.await-in-loop", "performance.javascript.await-in-loop", lineNo, + "await inside a loop serializes independent work; collect the promises and await Promise.all over chunks") + } + if toggleEnabled(s.rules.DetectRegexCompileInLoop) && strings.Contains(line, "RegExp") && + tsRegexCompilePattern.MatchString(rawLine) { + s.addFinding("performance.regex-compile-in-loop", "performance.regex-compile-in-loop", lineNo, + "new RegExp inside a loop recompiles the pattern every iteration; hoist it out of the loop or use a literal") + } + if toggleEnabled(s.rules.DetectAllocInLoop) && strings.Contains(line, "+=") && s.isStringConcat(line, rawLine) { + s.addFinding("performance.string-concat-in-loop", "performance.string-concat-in-loop", lineNo, + "string built by += inside a loop copies the whole value each iteration; collect parts in an array and join them") + } + if toggleEnabled(s.rules.DetectTimerLeaks) && !s.listenersCleaned && tsAddListenerPattern.MatchString(line) { + s.addFinding("performance.typescript.timer-listener-leak", "performance.javascript.timer-listener-leak", lineNo, + "addEventListener inside a loop with no removeEventListener or AbortSignal in the file accumulates listeners; deduplicate or clean them up") + } +} + +// isStringConcat reports += growth of a string: either a string-ish +// right-hand side on the raw line, or an augmented assignment (on the +// stripped line, so comments cannot match) to a variable initialized from a +// string literal. +func (s *tsPerformanceScan) isStringConcat(line string, rawLine string) bool { + if tsStringConcat.MatchString(rawLine) { + return true + } + m := tsAugmentedAssign.FindStringSubmatch(line) + if m == nil { + return false + } + _, isString := s.stringVars[m[1]] + return isString +} + +func (s *tsPerformanceScan) addFinding(tsRuleID string, jsRuleID string, lineNo int, message string) { + s.findings = append(s.findings, warnFinding(s.env, support.RuleIDForScript(s.file, tsRuleID, jsRuleID), s.file, lineNo, 1, message)) +} diff --git a/internal/codeguard/checks/performance/performance_typescript_frameworks.go b/internal/codeguard/checks/performance/performance_typescript_frameworks.go new file mode 100644 index 0000000..f7819da --- /dev/null +++ b/internal/codeguard/checks/performance/performance_typescript_frameworks.go @@ -0,0 +1,135 @@ +package performance + +import ( + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// Framework-aware TypeScript/JavaScript rules +// (performance_rules.detect_framework_patterns). Every rule is gated on +// file-level framework evidence — an import/require of react or express — so +// non-framework code never matches. Import evidence is matched on the RAW +// source: the module name lives in a string literal that the stripper blanks. +var ( + tsReactImportEvidence = regexp.MustCompile(`from\s+["']react["']|require\(\s*["']react["']\s*\)`) + tsExpressImportEvidence = regexp.MustCompile(`from\s+["']express["']|require\(\s*["']express["']\s*\)`) + // tsComponentStart marks a React component or custom hook definition: a + // function/const whose name starts with a capital letter or `use`. + tsComponentStart = regexp.MustCompile(`\bfunction\s+(?:[A-Z]|use[A-Z])[\w$]*\s*\(|\bconst\s+(?:[A-Z]|use[A-Z])[\w$]*\s*(?::[^=]*)?=\s*(?:React\.memo\(\s*|memo\(\s*|forwardRef\(\s*)?(?:async\s+)?\(`) + // tsHookWrapperStart marks a memo/effect wrapper region; work inside it + // does not run on every render, so the render rule stays quiet there. + tsHookWrapperStart = regexp.MustCompile(`\buse(?:Memo|Callback|Effect|LayoutEffect)\s*\(`) + tsArrayChainCall = regexp.MustCompile(`\.(?:sort|filter|map)\s*\(`) + tsExpensiveCreate = regexp.MustCompile(`\bnew\s+Array\s*\(|\bJSON\.parse\s*\(`) + tsMiddlewareStart = regexp.MustCompile(`\b(?:app|router)\.use\s*\(`) + // tsCPUHeavySyncCall is the deliberate shortlist of CPU-heavy synchronous + // APIs (bcrypt, crypto KDFs, zlib, child_process); generic *Sync calls in + // handlers stay with performance.{ts,js}.sync-io-in-handler. Bare names + // cover both qualified (bcrypt.hashSync) and destructured (hashSync) call + // forms; the zlib alternative catches less common zlib *Sync helpers. + tsCPUHeavySyncCall = regexp.MustCompile(`\b(?:hashSync|compareSync|pbkdf2Sync|scryptSync|execSync|gzipSync|gunzipSync|deflateSync|inflateSync|brotliCompressSync|brotliDecompressSync)\s*\(|\bzlib\.\w+Sync\s*\(`) +) + +// tsFrameworkScan carries the framework-aware state for one file scan. A nil +// pointer (toggle disabled, or no framework evidence in the file) turns every +// hook into a no-op. Component regions are brace-delimited (function bodies); +// hook-wrapper and middleware regions are paren-delimited, because +// useMemo(() => expr, [deps]) and app.use(...) bodies may never open a brace. +type tsFrameworkScan struct { + react bool + express bool + parenDepth int + components []int + hookRegions []int + middlewares []int +} + +func newTSFrameworkScan(rules core.PerformanceRulesConfig, source string) *tsFrameworkScan { + if !toggleEnabled(rules.DetectFrameworkPatterns) { + return nil + } + react := tsReactImportEvidence.MatchString(source) + express := tsExpressImportEvidence.MatchString(source) + if !react && !express { + return nil + } + return &tsFrameworkScan{react: react, express: express} +} + +// observe is called once per line after the generic checks, with the brace +// depth before (s.depth) and after (next) the line, mirroring the region +// convention of the generic scan. +func (f *tsFrameworkScan) observe(s *tsPerformanceScan, lineNo int, line string, next int) { + if f == nil { + return + } + nextParen := f.parenDepth + strings.Count(line, "(") - strings.Count(line, ")") + startsComponent := f.react && tsComponentStart.MatchString(line) + startsHook := f.react && tsHookWrapperStart.MatchString(line) + f.checkReactRender(s, lineNo, line, startsComponent, startsHook) + if startsComponent && next > s.depth { + f.components = append(f.components, s.depth) + } + if startsHook && nextParen > f.parenDepth { + f.hookRegions = append(f.hookRegions, f.parenDepth) + } + if f.express && tsMiddlewareStart.MatchString(line) && nextParen > f.parenDepth { + f.middlewares = append(f.middlewares, f.parenDepth) + } + f.components = popNestedRegions(f.components, next) + f.hookRegions = popNestedRegions(f.hookRegions, nextParen) + f.middlewares = popNestedRegions(f.middlewares, nextParen) + f.parenDepth = nextParen +} + +// checkReactRender implements performance.{typescript,javascript}.react-expensive-render: +// inside a component (or custom hook) body, a chain of two or more array +// methods (.sort/.filter/.map) or an expensive construction (new Array, +// JSON.parse) that reruns on every render. Lines inside (or starting) a +// useMemo/useCallback/useEffect wrapper are exempt. +func (f *tsFrameworkScan) checkReactRender(s *tsPerformanceScan, lineNo int, line string, startsComponent bool, startsHook bool) { + if !f.react || (len(f.components) == 0 && !startsComponent) { + return + } + if len(f.hookRegions) > 0 || startsHook { + return + } + if len(tsArrayChainCall.FindAllString(line, -1)) >= 2 { + s.addFinding("performance.typescript.react-expensive-render", "performance.javascript.react-expensive-render", lineNo, + "array method chain in the component body reruns on every render; memoize it with useMemo or compute it outside the component") + return + } + if tsExpensiveCreate.MatchString(line) { + s.addFinding("performance.typescript.react-expensive-render", "performance.javascript.react-expensive-render", lineNo, + "expensive construction (new Array/JSON.parse) in the component body reruns on every render; wrap it in useMemo or hoist it out of the component") + } +} + +// reportExpressSyncMiddleware implements +// performance.{typescript,javascript}.express-sync-middleware: a CPU-heavy +// synchronous call inside an app.use/router.use middleware region in a file +// with express evidence. It returns true when it reported, so the caller can +// skip the generic sync-io rule and one line never reports twice. +func (f *tsFrameworkScan) reportExpressSyncMiddleware(s *tsPerformanceScan, lineNo int, line string) bool { + if f == nil || !f.express { + return false + } + if len(f.middlewares) == 0 && !tsMiddlewareStart.MatchString(line) { + return false + } + if !tsCPUHeavySyncCall.MatchString(line) { + return false + } + s.addFinding("performance.typescript.express-sync-middleware", "performance.javascript.express-sync-middleware", lineNo, + "CPU-heavy synchronous call inside Express middleware blocks the event loop for every request; use the async API (bcrypt.hash, crypto.pbkdf2, async zlib, exec) or move the work off the request path") + return true +} + +func popNestedRegions(regions []int, next int) []int { + for len(regions) > 0 && next <= regions[len(regions)-1] { + regions = regions[:len(regions)-1] + } + return regions +} diff --git a/internal/codeguard/checks/quality/quality.go b/internal/codeguard/checks/quality/quality.go index 204553c..ef93fe3 100644 --- a/internal/codeguard/checks/quality/quality.go +++ b/internal/codeguard/checks/quality/quality.go @@ -43,7 +43,6 @@ func languageQualityFindings(ctx context.Context, env support.Context, target co })...) case "typescript", "javascript", "ts", "tsx", "js", "jsx": findings = append(findings, typeScriptTargetFindings(ctx, env, target)...) - findings = append(findings, typeScriptPerformanceTargetFindings(env, target)...) case "rust", "rs": findings = append(findings, env.ScanTargetFiles(target, "quality", isRustFile, func(file string, data []byte) []core.Finding { return rustFindingsForFile(env, file, data) diff --git a/internal/codeguard/checks/quality/quality_ai_semantic.go b/internal/codeguard/checks/quality/quality_ai_semantic.go index 2ea6e0b..a79724a 100644 --- a/internal/codeguard/checks/quality/quality_ai_semantic.go +++ b/internal/codeguard/checks/quality/quality_ai_semantic.go @@ -6,38 +6,24 @@ import ( "strings" "github.com/devr-tools/codeguard/internal/codeguard/ai/semantic" + "github.com/devr-tools/codeguard/internal/codeguard/checks/semanticreview" "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" ) +// semanticFindings runs the shared command-backed semantic review and emits +// the quality.* verdicts. The request itself is built centrally in +// semanticreview.Options and is shared with the performance section (which +// emits the performance.* verdicts from the same cached response). func semanticFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { - if !semanticEligible(env) { + if !semanticreview.Enabled(env) { return nil } - command := semanticCommand(env.Config.AI) - if strings.TrimSpace(command) == "" { + opts := semanticreview.Options(env, target, "quality.") + if strings.TrimSpace(opts.Command) == "" { return []core.Finding{semanticRuntimeFinding(env, target, "semantic review is enabled but no semantic command is configured")} } - findings, err := semantic.Analyze(ctx, semantic.Options{ - Target: target, - Language: support.NormalizedLanguage(target.Language), - BaseRef: env.BaseRef, - DiffText: env.DiffText, - CachePath: semanticCachePath(env.Config.Cache), - Command: command, - Enabled: semanticEnabled(env), - CheckSelection: semanticCheckSelection(env.Config.AI.Semantic), - NewFinding: func(ruleID string, level string, path string, line int, message string) core.Finding { - return env.NewFinding(support.FindingInput{ - RuleID: ruleID, - Level: level, - Path: path, - Line: line, - Column: 1, - Message: message, - }) - }, - }) + findings, err := semantic.Analyze(ctx, opts) if err != nil { return []core.Finding{semanticRuntimeFinding(env, target, fmt.Sprintf("semantic review command failed for target %q: %v", target.Name, err))} } @@ -54,42 +40,3 @@ func semanticRuntimeFinding(env support.Context, _ core.TargetConfig, message st Message: message, }) } - -func semanticEligible(env support.Context) bool { - return semanticEnabled(env) -} - -func semanticEnabled(env support.Context) bool { - if env.Config.AI.Semantic.Enabled != nil { - return *env.Config.AI.Semantic.Enabled && aiRuntimeEnabled(env) - } - return aiRuntimeEnabled(env) && semantic.Enabled() -} - -func semanticCheckSelection(cfg core.AISemanticConfig) semantic.CheckSelection { - return semantic.CheckSelection{ - FunctionContract: cfg.FunctionContract == nil || *cfg.FunctionContract, - ContractDrift: cfg.ContractDrift == nil || *cfg.ContractDrift, - MisleadingErrorMessages: cfg.MisleadingErrorMessages == nil || *cfg.MisleadingErrorMessages, - TestBehaviorCoverage: cfg.TestBehaviorCoverage == nil || *cfg.TestBehaviorCoverage, - TestAdequacy: cfg.TestAdequacy == nil || *cfg.TestAdequacy, - } -} - -func semanticCommand(cfg core.AIConfig) string { - if strings.TrimSpace(cfg.Provider.Type) == "command" && strings.TrimSpace(cfg.Provider.Command) != "" { - return strings.TrimSpace(strings.Join(append([]string{cfg.Provider.Command}, cfg.Provider.Args...), " ")) - } - return semantic.Command() -} - -func aiRuntimeEnabled(env support.Context) bool { - return env.AIEnabled || semantic.Enabled() -} - -func semanticCachePath(cfg core.CacheConfig) string { - if cfg.Enabled != nil && !*cfg.Enabled { - return "" - } - return semantic.CachePathForBase(cfg.Path) -} diff --git a/internal/codeguard/checks/quality/quality_go.go b/internal/codeguard/checks/quality/quality_go.go index f8d3349..d52da8a 100644 --- a/internal/codeguard/checks/quality/quality_go.go +++ b/internal/codeguard/checks/quality/quality_go.go @@ -57,7 +57,6 @@ func goFindingsForFile(env support.Context, file string, data []byte) []core.Fin findings = append(findings, importFindings(env, file, fset, parsed)...) findings = append(findings, goFunctionFindings(env, file, fset, parsed)...) findings = append(findings, goAIQualityFindings(env, file, fset, parsed, data)...) - findings = append(findings, goPerformanceFindings(env, file, fset, parsed)...) return append(fileLengthFindingWithSignals(env, file, data, findings), findings...) } diff --git a/internal/codeguard/checks/quality/quality_performance_python.go b/internal/codeguard/checks/quality/quality_performance_python.go deleted file mode 100644 index cd04ec8..0000000 --- a/internal/codeguard/checks/quality/quality_performance_python.go +++ /dev/null @@ -1,87 +0,0 @@ -package quality - -import ( - "regexp" - "strings" - - "github.com/devr-tools/codeguard/internal/codeguard/checks/support" - "github.com/devr-tools/codeguard/internal/codeguard/core" -) - -var ( - pythonLoopStartPattern = regexp.MustCompile(`^\s*(?:for\s+.+\s+in\s+.+:|while\s+.+:)`) - pythonAsyncDefPattern = regexp.MustCompile(`^\s*async\s+def\s+`) - pythonQueryCallPattern = regexp.MustCompile(`\b(?:requests|httpx)\.(?:get|post|put|delete|patch|head)\s*\(|\bcursor\.execute\s*\(|\.execute\s*\(|\bsession\.query\s*\(`) - pythonSyncInAsyncCall = regexp.MustCompile(`\brequests\.\w+\s*\(|\burllib\.request\.urlopen\s*\(|\btime\.sleep\s*\(`) -) - -func pythonPerformanceFindings(env support.Context, file string, data []byte) []core.Finding { - scan := &pythonPerformanceScan{env: env, file: file, rules: env.Config.Checks.QualityRules} - for idx, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") { - scan.consumeLine(idx+1, line) - } - return scan.findings -} - -type pythonPerformanceScan struct { - env support.Context - file string - rules core.QualityRulesConfig - loops []int - asyncDefs []int - findings []core.Finding -} - -func (s *pythonPerformanceScan) consumeLine(lineNo int, line string) { - if strings.TrimSpace(line) == "" { - return - } - indent := indentationWidth(line) - s.loops = popIndentRegions(s.loops, indent) - s.asyncDefs = popIndentRegions(s.asyncDefs, indent) - startsLoop := pythonLoopStartPattern.MatchString(line) - s.checkLine(lineNo, line, len(s.loops) > 0 || startsLoop, len(s.asyncDefs) > 0) - if startsLoop { - s.loops = append(s.loops, indent) - } - if pythonAsyncDefPattern.MatchString(line) { - s.asyncDefs = append(s.asyncDefs, indent) - } -} - -func (s *pythonPerformanceScan) checkLine(lineNo int, line string, inLoop bool, inAsync bool) { - if inLoop && qualityToggleEnabled(s.rules.DetectNPlusOneQuery) && pythonQueryCallPattern.MatchString(line) { - s.addFinding("quality.n-plus-one-query", lineNo, - "query or request call inside a loop suggests an N+1 pattern; batch the work or hoist the call out of the loop") - } - if inAsync && qualityToggleEnabled(s.rules.DetectSyncIOInHandlers) && pythonSyncInAsyncCall.MatchString(line) { - s.addFinding("quality.python.sync-io-in-async", lineNo, - "blocking call inside an async function stalls the event loop; use an async client or asyncio.sleep") - } -} - -func (s *pythonPerformanceScan) addFinding(ruleID string, lineNo int, message string) { - s.findings = append(s.findings, warnFinding(s.env, ruleID, s.file, lineNo, 1, message)) -} - -func popIndentRegions(regions []int, indent int) []int { - for len(regions) > 0 && indent <= regions[len(regions)-1] { - regions = regions[:len(regions)-1] - } - return regions -} -func indentationWidth(line string) int { - width := 0 - for _, ch := range line { - if ch == ' ' { - width++ - continue - } - if ch == '\t' { - width += 4 - continue - } - break - } - return width -} diff --git a/internal/codeguard/checks/quality/quality_performance_typescript.go b/internal/codeguard/checks/quality/quality_performance_typescript.go deleted file mode 100644 index 47ebad9..0000000 --- a/internal/codeguard/checks/quality/quality_performance_typescript.go +++ /dev/null @@ -1,93 +0,0 @@ -package quality - -import ( - "regexp" - "strings" - - "github.com/devr-tools/codeguard/internal/codeguard/checks/support" - "github.com/devr-tools/codeguard/internal/codeguard/core" -) - -var ( - tsLoopStartPattern = regexp.MustCompile(`(?:^|[^\w$])(?:for|while)\s*\(|\.(?:forEach|map|flatMap)\s*\(`) - tsHandlerStartPattern = regexp.MustCompile(`\b(?:app|router|server|api|fastify)\.(?:get|post|put|delete|patch|all|use)\s*\(|export\s+(?:default\s+)?(?:async\s+)?function\s+handler\s*\(|export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(`) - tsQueryCallPattern = regexp.MustCompile(`\bfetch\s*\(|\baxios\b|\.query\s*\(|\.execute\s*\(|\.findOne\s*\(|\.findMany\s*\(|\.findUnique\s*\(|\.findFirst\s*\(`) - tsSyncCallPattern = regexp.MustCompile(`\b\w+Sync\s*\(`) - tsPromiseCreatePattern = regexp.MustCompile(`new\s+Promise\s*\(|\.push\s*\(\s*(?:fetch\s*\(|axios\b)`) - tsConcurrencyLimitHint = regexp.MustCompile(`p-limit|p-queue|pLimit\s*\(`) -) - -func typeScriptPerformanceTargetFindings(env support.Context, target core.TargetConfig) []core.Finding { - findings := make([]core.Finding, 0) - env.VisitTargetFiles(target, isTypeScriptLikeFile, func(rel string, data []byte) { - findings = append(findings, typeScriptPerformanceFindings(env, rel, data)...) - }) - return findings -} - -func typeScriptPerformanceFindings(env support.Context, file string, data []byte) []core.Finding { - source := strings.ReplaceAll(string(data), "\r\n", "\n") - code := support.StripTypeScriptCommentsAndStrings(source) - scan := &tsPerformanceScan{ - env: env, - file: file, - limited: tsConcurrencyLimitHint.MatchString(source), - rules: env.Config.Checks.QualityRules, - findings: make([]core.Finding, 0), - } - for idx, line := range strings.Split(code, "\n") { - scan.consumeLine(idx+1, line) - } - return scan.findings -} - -type tsPerformanceScan struct { - env support.Context - file string - limited bool - rules core.QualityRulesConfig - depth int - loops []int - handlers []int - findings []core.Finding -} - -func (s *tsPerformanceScan) consumeLine(lineNo int, line string) { - startsLoop := tsLoopStartPattern.MatchString(line) - startsHandler := tsHandlerStartPattern.MatchString(line) - s.checkLine(lineNo, line, len(s.loops) > 0 || startsLoop, len(s.handlers) > 0 || startsHandler) - next := s.depth + strings.Count(line, "{") - strings.Count(line, "}") - if startsLoop && next > s.depth { - s.loops = append(s.loops, s.depth) - } - if startsHandler && next > s.depth { - s.handlers = append(s.handlers, s.depth) - } - for len(s.loops) > 0 && next <= s.loops[len(s.loops)-1] { - s.loops = s.loops[:len(s.loops)-1] - } - for len(s.handlers) > 0 && next <= s.handlers[len(s.handlers)-1] { - s.handlers = s.handlers[:len(s.handlers)-1] - } - s.depth = next -} - -func (s *tsPerformanceScan) checkLine(lineNo int, line string, inLoop bool, inHandler bool) { - if inLoop && qualityToggleEnabled(s.rules.DetectNPlusOneQuery) && tsQueryCallPattern.MatchString(line) { - s.addFinding("quality.n-plus-one-query", "quality.n-plus-one-query", lineNo, - "query or fetch call inside a loop suggests an N+1 pattern; batch requests or hoist the call out of the loop") - } - if inLoop && qualityToggleEnabled(s.rules.DetectUnboundedConcurrency) && !s.limited && - !strings.Contains(line, "await ") && tsPromiseCreatePattern.MatchString(line) { - s.addFinding("quality.typescript.unbounded-concurrency", "quality.javascript.unbounded-concurrency", lineNo, - "promise created inside a loop without a concurrency limit; batch with Promise.all over chunks or use p-limit") - } - if inHandler && qualityToggleEnabled(s.rules.DetectSyncIOInHandlers) && tsSyncCallPattern.MatchString(line) { - s.addFinding("quality.typescript.sync-io-in-handler", "quality.javascript.sync-io-in-handler", lineNo, - "synchronous I/O call inside a request handler blocks the event loop; use the async API instead") - } -} - -func (s *tsPerformanceScan) addFinding(tsRuleID string, jsRuleID string, lineNo int, message string) { - s.findings = append(s.findings, warnFinding(s.env, support.RuleIDForScript(s.file, tsRuleID, jsRuleID), s.file, lineNo, 1, message)) -} diff --git a/internal/codeguard/checks/quality/quality_python.go b/internal/codeguard/checks/quality/quality_python.go index 63c6166..0a4d9d5 100644 --- a/internal/codeguard/checks/quality/quality_python.go +++ b/internal/codeguard/checks/quality/quality_python.go @@ -13,7 +13,6 @@ func pythonFindingsForFile(env support.Context, file string, data []byte) []core findings = append(findings, maintainabilityFindings(env, file, fn)...) } findings = append(findings, pythonAIQualityFindings(env, file, data)...) - findings = append(findings, pythonPerformanceFindings(env, file, data)...) return append(fileLengthFindingWithSignals(env, file, data, findings), findings...) } diff --git a/internal/codeguard/checks/semanticreview/semanticreview.go b/internal/codeguard/checks/semanticreview/semanticreview.go new file mode 100644 index 0000000..19bc204 --- /dev/null +++ b/internal/codeguard/checks/semanticreview/semanticreview.go @@ -0,0 +1,101 @@ +// Package semanticreview centralizes how check sections invoke the +// command-backed semantic review runtime (internal/codeguard/ai/semantic). +// +// The quality and performance sections share one combined request: both build +// byte-identical semantic.Options here (all lenses in one payload), so the +// verdict cache and the in-process single-flight in the semantic package +// collapse them into a single runtime call per scan, and each section +// demultiplexes the response by rule-id prefix via Options.EmitRule. Keep +// every input that feeds the request hash in this package — if sections +// computed options independently, a drifting field would silently double the +// LLM calls. +// +// This lives in its own package (not checks/support) because ai/semantic +// imports runner/support, which imports checks/support; adding the reverse +// edge would create an import cycle. +package semanticreview + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/ai/semantic" + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// Enabled reports whether the semantic review runtime is enabled for this +// scan: an explicit ai.semantic.enabled config wins; otherwise the +// CODEGUARD_SEMANTIC_CHECKS env gate applies. Either way the AI runtime +// itself must be on. +func Enabled(env support.Context) bool { + if env.Config.AI.Semantic.Enabled != nil { + return *env.Config.AI.Semantic.Enabled && aiRuntimeEnabled(env) + } + return aiRuntimeEnabled(env) && semantic.Enabled() +} + +func aiRuntimeEnabled(env support.Context) bool { + return env.AIEnabled || semantic.Enabled() +} + +// Options builds the semantic.Analyze options for one section. rulePrefix +// scopes emission ("quality." for the quality section, "performance." for the +// performance section); everything else is identical across callers by +// construction. +func Options(env support.Context, target core.TargetConfig, rulePrefix string) semantic.Options { + return semantic.Options{ + Target: target, + Language: support.NormalizedLanguage(target.Language), + BaseRef: env.BaseRef, + DiffText: env.DiffText, + CachePath: cachePath(env.Config.Cache), + Command: Command(env.Config.AI), + Enabled: Enabled(env), + CheckSelection: selection(env), + EmitRule: func(ruleID string) bool { + return strings.HasPrefix(ruleID, rulePrefix) + }, + NewFinding: func(ruleID string, level string, path string, line int, message string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: ruleID, + Level: level, + Path: path, + Line: line, + Column: 1, + Message: message, + }) + }, + } +} + +// Command resolves the runtime command: an ai.provider command wins, else the +// CODEGUARD_SEMANTIC_COMMAND env value. +func Command(cfg core.AIConfig) string { + if strings.TrimSpace(cfg.Provider.Type) == "command" && strings.TrimSpace(cfg.Provider.Command) != "" { + return strings.TrimSpace(strings.Join(append([]string{cfg.Provider.Command}, cfg.Provider.Args...), " ")) + } + return semantic.Command() +} + +// selection returns every lens active for this scan. The performance lens +// rides along only when checks.performance is enabled, which keeps requests +// (and therefore cache keys) byte-identical to previous releases when the +// performance section is off. +func selection(env support.Context) semantic.CheckSelection { + cfg := env.Config.AI.Semantic + return semantic.CheckSelection{ + FunctionContract: cfg.FunctionContract == nil || *cfg.FunctionContract, + ContractDrift: cfg.ContractDrift == nil || *cfg.ContractDrift, + MisleadingErrorMessages: cfg.MisleadingErrorMessages == nil || *cfg.MisleadingErrorMessages, + TestBehaviorCoverage: cfg.TestBehaviorCoverage == nil || *cfg.TestBehaviorCoverage, + TestAdequacy: cfg.TestAdequacy == nil || *cfg.TestAdequacy, + PerformanceReview: env.Config.Checks.Performance != nil && *env.Config.Checks.Performance, + } +} + +func cachePath(cfg core.CacheConfig) string { + if cfg.Enabled != nil && !*cfg.Enabled { + return "" + } + return semantic.CachePathForBase(cfg.Path) +} diff --git a/internal/codeguard/checks/support/artifacts.go b/internal/codeguard/checks/support/artifacts.go index 0fe5e97..ccd8a67 100644 --- a/internal/codeguard/checks/support/artifacts.go +++ b/internal/codeguard/checks/support/artifacts.go @@ -4,6 +4,7 @@ import "github.com/devr-tools/codeguard/internal/codeguard/core" const ArtifactKindDependencyGraph = "dependency_graph" const ArtifactKindSlopScore = "slop_score" +const ArtifactKindPerformanceScore = "performance_score" const ArtifactKindChangeRisk = "change_risk" const ArtifactKindRepoLegibility = "repo_legibility" @@ -54,6 +55,22 @@ func NewSlopScoreArtifact(id string, language string, target string, score core. } } +func NewPerformanceScoreArtifact(id string, language string, target string, score core.PerformanceScoreArtifact) core.Artifact { + components := make([]core.SlopScoreComponent, 0, len(score.Components)) + components = append(components, score.Components...) + return core.Artifact{ + ID: id, + Kind: ArtifactKindPerformanceScore, + Language: language, + Target: target, + PerformanceScore: &core.PerformanceScoreArtifact{ + Score: score.Score, + Signals: score.Signals, + Components: components, + }, + } +} + func NewRepoLegibilityArtifact(id string, target string, legibility core.RepoLegibilityArtifact) core.Artifact { components := make([]core.RepoLegibilityComponent, 0, len(legibility.Components)) components = append(components, legibility.Components...) diff --git a/internal/codeguard/checks/support/treeprovider.go b/internal/codeguard/checks/support/treeprovider.go index 5f3f308..1472137 100644 --- a/internal/codeguard/checks/support/treeprovider.go +++ b/internal/codeguard/checks/support/treeprovider.go @@ -18,13 +18,14 @@ const ( ScriptLangTypeScript ScriptLanguage = "typescript" ScriptLangTSX ScriptLanguage = "tsx" ScriptLangJavaScript ScriptLanguage = "javascript" + ScriptLangPython ScriptLanguage = "python" ) // ScriptLanguageForPath maps a file path onto the grammar that parses it: // .ts/.mts/.cts use the TypeScript grammar, .tsx the TSX grammar (JSX and // type annotations are grammatically incompatible, so upstream ships two), -// and .js/.jsx/.mjs/.cjs the JavaScript grammar (which includes JSX). It -// returns "" for non-script files. +// .js/.jsx/.mjs/.cjs the JavaScript grammar (which includes JSX), and .py +// the Python grammar. It returns "" for non-script files. func ScriptLanguageForPath(path string) ScriptLanguage { switch strings.ToLower(filepath.Ext(path)) { case ".ts", ".mts", ".cts": @@ -33,6 +34,8 @@ func ScriptLanguageForPath(path string) ScriptLanguage { return ScriptLangTSX case ".js", ".jsx", ".mjs", ".cjs": return ScriptLangJavaScript + case ".py": + return ScriptLangPython default: return "" } @@ -75,7 +78,18 @@ func ScriptSyntaxTree(env Context, file string, source string) *SyntaxTree { // scriptGrammar returns the gotreesitter language for a ScriptLanguage. The // grammar registry caches decoded grammars process-wide, so repeated lookups // are cheap after the first (which decodes the embedded blob). +// +// Each case here must have a matching grammar_subset_ build tag in the +// release tag set (Makefile GRAMMAR_TAGS and .goreleaser.yaml): under a +// grammar_subset build a grammar whose tag is absent is not registered or +// embedded, and its accessor PANICS rather than returning nil — hence the +// registry probe below, which turns a missing grammar into an error so every +// rule using it takes its regex fallback instead of losing the section to +// the safeRun panic recovery. func scriptGrammar(lang ScriptLanguage) (*gotreesitter.Language, error) { + if grammars.DetectLanguageByName(string(lang)) == nil { + return nil, fmt.Errorf("tree-sitter grammar %q is not embedded in this build", lang) + } var language *gotreesitter.Language switch lang { case ScriptLangTypeScript: @@ -84,6 +98,8 @@ func scriptGrammar(lang ScriptLanguage) (*gotreesitter.Language, error) { language = grammars.TsxLanguage() case ScriptLangJavaScript: language = grammars.JavascriptLanguage() + case ScriptLangPython: + language = grammars.PythonLanguage() default: return nil, fmt.Errorf("no tree-sitter grammar for script language %q", lang) } diff --git a/internal/codeguard/config/defaults.go b/internal/codeguard/config/defaults.go index 1d847ed..f538313 100644 --- a/internal/codeguard/config/defaults.go +++ b/internal/codeguard/config/defaults.go @@ -62,6 +62,7 @@ func applyCheckDefaults(cfg *core.Config, def core.Config) { cfg.Checks.Context = def.Checks.Context } applyQualityDefaults(&cfg.Checks.QualityRules, def.Checks.QualityRules) + applyPerformanceDefaults(&cfg.Checks.PerformanceRules) applyDesignDefaults(&cfg.Checks.DesignRules, def.Checks.DesignRules) applyPromptDefaults(&cfg.Checks.PromptRules, def.Checks.PromptRules) applyCIDefaults(&cfg.Checks.CIRules, def.Checks.CIRules) diff --git a/internal/codeguard/config/defaults_performance.go b/internal/codeguard/config/defaults_performance.go new file mode 100644 index 0000000..cfe6250 --- /dev/null +++ b/internal/codeguard/config/defaults_performance.go @@ -0,0 +1,23 @@ +package config + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// DefaultBenchmarkMaxRegressionPercent is the ns/op slowdown tolerated per +// benchmark before performance.benchmark-regression fires, when the config +// does not set performance_rules.benchmarks.max_regression_percent. +const DefaultBenchmarkMaxRegressionPercent = 20 + +// applyPerformanceMeasurementDefaults defaults the measurement-based +// performance gates (budgets and benchmark regression). Benchmarks stay off by +// default because they execute the scanned repository's own test code. +func applyPerformanceMeasurementDefaults(dst *core.PerformanceRulesConfig) { + defaultBoolPtr(&dst.Benchmarks.Enabled, false) + if dst.Benchmarks.MaxRegressionPercent == 0 { + dst.Benchmarks.MaxRegressionPercent = DefaultBenchmarkMaxRegressionPercent + } + for i := range dst.Budgets { + if dst.Budgets[i].Level == "" { + dst.Budgets[i].Level = "warn" + } + } +} diff --git a/internal/codeguard/config/defaults_rules.go b/internal/codeguard/config/defaults_rules.go index ad2ba60..639d3e0 100644 --- a/internal/codeguard/config/defaults_rules.go +++ b/internal/codeguard/config/defaults_rules.go @@ -8,16 +8,28 @@ func applyQualityDefaults(dst *core.QualityRulesConfig, def core.QualityRulesCon defaultInt(&dst.MaxParameters, def.MaxParameters) defaultInt(&dst.MaxCyclomaticComplexity, def.MaxCyclomaticComplexity) defaultInt(&dst.CloneTokenThreshold, def.CloneTokenThreshold) + defaultCommandMap(&dst.LanguageCommands, def.LanguageCommands) + applyAIChangeRiskDefaults(&dst.AIChangeRisk, def.AIChangeRisk) + applyCoverageDeltaDefaults(&dst.CoverageDelta) +} + +func applyPerformanceDefaults(dst *core.PerformanceRulesConfig) { applyDefaultBoolPtrs( &dst.DetectNPlusOneQuery, &dst.DetectAllocInLoop, &dst.DetectSyncIOInHandlers, &dst.DetectUnboundedConcurrency, + &dst.DetectRegexCompileInLoop, + &dst.DetectDeferInLoop, + &dst.DetectSleepInLoop, + &dst.DetectAwaitInLoop, + &dst.DetectTimerLeaks, + &dst.DetectUnboundedReads, + &dst.DetectComplexityRegression, + &dst.DetectFrameworkPatterns, ) defaultBoolPtr(&dst.DetectPreallocInLoop, false) - defaultCommandMap(&dst.LanguageCommands, def.LanguageCommands) - applyAIChangeRiskDefaults(&dst.AIChangeRisk, def.AIChangeRisk) - applyCoverageDeltaDefaults(&dst.CoverageDelta) + applyPerformanceMeasurementDefaults(dst) } func applyDesignDefaults(dst *core.DesignRulesConfig, def core.DesignRulesConfig) { diff --git a/internal/codeguard/config/example.go b/internal/codeguard/config/example.go index 0c68ebb..6406021 100644 --- a/internal/codeguard/config/example.go +++ b/internal/codeguard/config/example.go @@ -25,13 +25,19 @@ func exampleTargets() []core.TargetConfig { func exampleChecks() core.CheckConfig { return core.CheckConfig{ - Quality: true, - Design: true, + Quality: true, + Design: true, + // Performance is opt-in while the rules settle into their own section; + // they previously ran (enabled) inside quality under quality.* ids. The + // explicit false (vs nil) writes the key into generated configs so new + // users discover it, and suppresses the upgrade hint in scan output. + Performance: boolPtr(false), Security: true, Prompts: true, CI: true, SupplyChain: false, QualityRules: exampleQualityRules(), + PerformanceRules: examplePerformanceRules(), DesignRules: exampleDesignRules(), PromptRules: examplePromptRules(), CIRules: exampleCIRules(), diff --git a/internal/codeguard/config/example_rules.go b/internal/codeguard/config/example_rules.go index 7e7c4e5..6d67409 100644 --- a/internal/codeguard/config/example_rules.go +++ b/internal/codeguard/config/example_rules.go @@ -9,7 +9,6 @@ func exampleQualityRules() core.QualityRulesConfig { MaxParameters: 5, MaxCyclomaticComplexity: 10, CloneTokenThreshold: 60, - DetectPreallocInLoop: boolPtr(false), AIProvenance: core.AIProvenanceConfig{ Enabled: boolPtr(true), EnvVars: []string{"CODEGUARD_AI_ASSISTED"}, @@ -25,6 +24,12 @@ func exampleQualityRules() core.QualityRulesConfig { } } +func examplePerformanceRules() core.PerformanceRulesConfig { + return core.PerformanceRulesConfig{ + DetectPreallocInLoop: boolPtr(false), + } +} + func exampleDesignRules() core.DesignRulesConfig { return core.DesignRulesConfig{ RequireCmdThroughInternalCLI: boolPtr(true), diff --git a/internal/codeguard/config/io.go b/internal/codeguard/config/io.go index eb078a0..fdc74b3 100644 --- a/internal/codeguard/config/io.go +++ b/internal/codeguard/config/io.go @@ -68,6 +68,7 @@ func containConfigArtifactPaths(cfg *core.Config, baseDir string) error { {"baseline.path", &cfg.Baseline.Path}, {"cache.path", &cfg.Cache.Path}, {"ai.cache.path", &cfg.AI.Cache.Path}, + {"performance_rules.benchmarks.baseline_path", &cfg.Checks.PerformanceRules.Benchmarks.BaselinePath}, } { resolved, err := containedPath(baseDir, *a.path) if err != nil { diff --git a/internal/codeguard/config/validate.go b/internal/codeguard/config/validate.go index d7b2971..9fe14b0 100644 --- a/internal/codeguard/config/validate.go +++ b/internal/codeguard/config/validate.go @@ -26,6 +26,7 @@ func Validate(cfg core.Config) error { validateContextRules(cfg.Checks.ContextRules), validateCoverageDelta(cfg.Checks.QualityRules.CoverageDelta), validateGraphThresholds(cfg.Checks.DesignRules), + validatePerformanceRules(cfg.Checks.PerformanceRules), validateSecretsRules(cfg.Checks.SecurityRules.Secrets), validateParsers(cfg.Parsers), validateRulePacks(cfg.RulePacks), diff --git a/internal/codeguard/config/validate_performance.go b/internal/codeguard/config/validate_performance.go new file mode 100644 index 0000000..d53d772 --- /dev/null +++ b/internal/codeguard/config/validate_performance.go @@ -0,0 +1,101 @@ +package config + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// benchmarkPackagePattern restricts performance_rules.benchmarks.packages +// entries to plain relative Go package patterns. Because the entries are +// appended to codeguard's fixed `go test` invocation, the charset excludes +// anything that could smuggle a flag (leading '-'), an absolute path, or +// shell metacharacters; ".." segments are rejected separately so a pattern +// can never point outside the target. +var benchmarkPackagePattern = regexp.MustCompile(`^(\.|\./[A-Za-z0-9_./-]*)$`) + +func validatePerformanceRules(rules core.PerformanceRulesConfig) error { + for idx, budget := range rules.Budgets { + if err := validatePerformanceBudget(idx, budget); err != nil { + return err + } + } + return validatePerformanceBenchmarks(rules.Benchmarks) +} + +func validatePerformanceBudget(idx int, budget core.PerformanceBudgetConfig) error { + label := fmt.Sprintf("performance_rules.budgets[%d]", idx) + if strings.TrimSpace(budget.Name) == "" { + return fmt.Errorf("%s.name is required", label) + } + switch budget.Kind { + case core.PerformanceBudgetKindFileSize, core.PerformanceBudgetKindBundleStats: + default: + return fmt.Errorf("%s.kind must be %q or %q", label, core.PerformanceBudgetKindFileSize, core.PerformanceBudgetKindBundleStats) + } + if budget.MaxBytes <= 0 { + return fmt.Errorf("%s.max_bytes must be positive", label) + } + if err := validateBudgetPath(label, budget.Path); err != nil { + return err + } + if budget.Asset != "" && budget.Kind != core.PerformanceBudgetKindBundleStats { + return fmt.Errorf("%s.asset only applies to kind %q", label, core.PerformanceBudgetKindBundleStats) + } + switch budget.Level { + case "", "warn", "fail": + default: + return fmt.Errorf("%s.level must be \"warn\" or \"fail\"", label) + } + return nil +} + +// validateBudgetPath lexically rejects budget paths that could leave the +// target directory. The check package re-verifies containment at scan time +// (including symlink resolution); this validation just fails fast on configs +// that are wrong on their face. +func validateBudgetPath(label string, path string) error { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return fmt.Errorf("%s.path is required", label) + } + if filepath.IsAbs(trimmed) { + return fmt.Errorf("%s.path must be relative to the target directory", label) + } + for _, segment := range strings.Split(filepath.ToSlash(trimmed), "/") { + if segment == ".." { + return fmt.Errorf("%s.path must not contain \"..\" segments", label) + } + } + return nil +} + +func validatePerformanceBenchmarks(benchmarks core.PerformanceBenchmarksConfig) error { + if benchmarks.MaxRegressionPercent < 0 { + return fmt.Errorf("performance_rules.benchmarks.max_regression_percent must not be negative") + } + for idx, pkg := range benchmarks.Packages { + if !benchmarkPackagePattern.MatchString(pkg) { + return fmt.Errorf("performance_rules.benchmarks.packages[%d]: %q is not a valid relative package pattern (expected e.g. \"./...\" or \"./internal/...\")", idx, pkg) + } + if containsDotDotSegment(pkg) { + return fmt.Errorf("performance_rules.benchmarks.packages[%d]: %q must not contain \"..\" path segments", idx, pkg) + } + } + return nil +} + +// containsDotDotSegment reports whether the package pattern has a ".." path +// segment. Go's "..." wildcard is allowed; a bare ".." (or "../x") is not, +// since it would benchmark code outside the target directory. +func containsDotDotSegment(pkg string) bool { + for _, segment := range strings.Split(pkg, "/") { + if segment == ".." { + return true + } + } + return false +} diff --git a/internal/codeguard/core/config_performance_types.go b/internal/codeguard/core/config_performance_types.go new file mode 100644 index 0000000..bef668f --- /dev/null +++ b/internal/codeguard/core/config_performance_types.go @@ -0,0 +1,55 @@ +package core + +// PerformanceBudgetKindFileSize budgets the on-disk size of a file (or the +// summed size of a glob's matches); PerformanceBudgetKindBundleStats budgets +// sizes recorded in a bundler stats JSON file (esbuild metafile or webpack +// stats). +const ( + PerformanceBudgetKindFileSize = "file-size" + PerformanceBudgetKindBundleStats = "bundle-stats" +) + +// PerformanceBudgetConfig is one entry of performance_rules.budgets: a named +// size gate over a build artifact. Path is resolved relative to the target +// directory and must stay inside it. A missing artifact is reported as a warn +// finding, never a hard error, so budgets on optional build outputs (e.g. +// dist/ that only exists after a release build) stay usable. +type PerformanceBudgetConfig struct { + // Name identifies the budget in findings and must be non-empty. + Name string `json:"name" yaml:"name"` + // Kind is "file-size" or "bundle-stats". + Kind string `json:"kind" yaml:"kind"` + // Path is the target-relative artifact path. For "file-size" it may be a + // glob (the matched sizes are summed); for "bundle-stats" it names the + // stats JSON file. + Path string `json:"path" yaml:"path"` + // Asset (bundle-stats only) budgets a single named asset/output from the + // stats file instead of the total across all of them. + Asset string `json:"asset,omitempty" yaml:"asset,omitempty"` + // MaxBytes is the budget; it must be positive. + MaxBytes int64 `json:"max_bytes" yaml:"max_bytes"` + // Level is the finding level when the budget is exceeded: "warn" (default) + // or "fail". + Level string `json:"level,omitempty" yaml:"level,omitempty"` +} + +// PerformanceBenchmarksConfig tunes performance_rules.benchmarks, the +// benchmark-regression gate. It is off by default because it runs the target +// repository's own test binaries (go test -bench), which executes repository +// code. +type PerformanceBenchmarksConfig struct { + // Enabled turns the gate on. Defaults to false. + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // Packages lists the Go package patterns to benchmark (e.g. "./internal/..."). + // Full scans require an explicit list; diff scans default to the packages + // containing changed .go files. + Packages []string `json:"packages,omitempty" yaml:"packages,omitempty"` + // MaxRegressionPercent is the ns/op slowdown tolerated per benchmark before + // a finding is emitted. Defaults to 20. + MaxRegressionPercent float64 `json:"max_regression_percent,omitempty" yaml:"max_regression_percent,omitempty"` + // BaselinePath stores the benchmark baseline JSON. Defaults to a file + // derived from cache.path (e.g. .codeguard/cache.bench-baseline.json) and, + // like the other config-controlled artifact paths, must stay inside the + // config directory. + BaselinePath string `json:"baseline_path,omitempty" yaml:"baseline_path,omitempty"` +} diff --git a/internal/codeguard/core/config_rule_types.go b/internal/codeguard/core/config_rule_types.go index dc58ff1..2809f89 100644 --- a/internal/codeguard/core/config_rule_types.go +++ b/internal/codeguard/core/config_rule_types.go @@ -7,18 +7,74 @@ type QualityRulesConfig struct { MaxCyclomaticComplexity int `json:"max_cyclomatic_complexity" yaml:"max_cyclomatic_complexity"` CloneTokenThreshold int `json:"clone_token_threshold,omitempty" yaml:"clone_token_threshold,omitempty"` LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty" yaml:"language_commands,omitempty"` - DetectNPlusOneQuery *bool `json:"detect_n_plus_one_query,omitempty" yaml:"detect_n_plus_one_query,omitempty"` - DetectAllocInLoop *bool `json:"detect_alloc_in_loop,omitempty" yaml:"detect_alloc_in_loop,omitempty"` + AIProvenance AIProvenanceConfig `json:"ai_provenance,omitempty" yaml:"ai_provenance,omitempty"` + AIChangeRisk AIChangeRiskConfig `json:"ai_change_risk,omitempty" yaml:"ai_change_risk,omitempty"` + AIChecks AIChecksConfig `json:"ai_checks,omitempty" yaml:"ai_checks,omitempty"` + CoverageDelta CoverageDeltaConfig `json:"coverage_delta,omitempty" yaml:"coverage_delta,omitempty"` +} + +// PerformanceRulesConfig tunes the performance section (checks.performance). +// The detect_* toggles moved here from quality_rules when the performance +// rules were promoted out of the quality section; nil toggles default to +// enabled except detect_prealloc_in_loop. +type PerformanceRulesConfig struct { + // DetectNPlusOneQuery gates query/fetch-in-loop detection across languages. + DetectNPlusOneQuery *bool `json:"detect_n_plus_one_query,omitempty" yaml:"detect_n_plus_one_query,omitempty"` + // DetectAllocInLoop gates allocation-heavy loop detection: Go string growth + // and fmt.Sprintf accumulation, plus string concatenation in Python and + // TypeScript/JavaScript loops. + DetectAllocInLoop *bool `json:"detect_alloc_in_loop,omitempty" yaml:"detect_alloc_in_loop,omitempty"` // DetectPreallocInLoop gates the append-without-preallocation branch of - // quality.go.alloc-in-loop. Defaults to false: preallocating is a + // performance.go.alloc-in-loop. Defaults to false: preallocating is a // micro-optimization, and idiomatic accumulation loops legitimately skip it. - DetectPreallocInLoop *bool `json:"detect_prealloc_in_loop,omitempty" yaml:"detect_prealloc_in_loop,omitempty"` - DetectSyncIOInHandlers *bool `json:"detect_sync_io_in_handlers,omitempty" yaml:"detect_sync_io_in_handlers,omitempty"` - DetectUnboundedConcurrency *bool `json:"detect_unbounded_concurrency,omitempty" yaml:"detect_unbounded_concurrency,omitempty"` - AIProvenance AIProvenanceConfig `json:"ai_provenance,omitempty" yaml:"ai_provenance,omitempty"` - AIChangeRisk AIChangeRiskConfig `json:"ai_change_risk,omitempty" yaml:"ai_change_risk,omitempty"` - AIChecks AIChecksConfig `json:"ai_checks,omitempty" yaml:"ai_checks,omitempty"` - CoverageDelta CoverageDeltaConfig `json:"coverage_delta,omitempty" yaml:"coverage_delta,omitempty"` + DetectPreallocInLoop *bool `json:"detect_prealloc_in_loop,omitempty" yaml:"detect_prealloc_in_loop,omitempty"` + DetectSyncIOInHandlers *bool `json:"detect_sync_io_in_handlers,omitempty" yaml:"detect_sync_io_in_handlers,omitempty"` + // DetectUnboundedConcurrency gates goroutines-in-loop (Go), promise + // creation in loops (TS/JS), and asyncio task creation in loops (Python). + DetectUnboundedConcurrency *bool `json:"detect_unbounded_concurrency,omitempty" yaml:"detect_unbounded_concurrency,omitempty"` + // DetectRegexCompileInLoop flags regex compilation inside loop bodies + // (regexp.Compile/MustCompile, re.compile, new RegExp). + DetectRegexCompileInLoop *bool `json:"detect_regex_compile_in_loop,omitempty" yaml:"detect_regex_compile_in_loop,omitempty"` + // DetectDeferInLoop flags Go defer statements inside loop bodies, where + // they accumulate until function exit. + DetectDeferInLoop *bool `json:"detect_defer_in_loop,omitempty" yaml:"detect_defer_in_loop,omitempty"` + // DetectSleepInLoop flags time.Sleep inside Go loop bodies, which usually + // marks a poll that wants a ticker, channel, or backoff helper. + DetectSleepInLoop *bool `json:"detect_sleep_in_loop,omitempty" yaml:"detect_sleep_in_loop,omitempty"` + // DetectAwaitInLoop flags await inside TS/JS loop bodies, which serializes + // work that could run concurrently via Promise.all. + DetectAwaitInLoop *bool `json:"detect_await_in_loop,omitempty" yaml:"detect_await_in_loop,omitempty"` + // DetectTimerLeaks flags timer/listener leaks: time.After in Go loops, + // setInterval without clearInterval and addEventListener in TS/JS loops. + DetectTimerLeaks *bool `json:"detect_timer_leaks,omitempty" yaml:"detect_timer_leaks,omitempty"` + // DetectUnboundedReads flags whole-input reads without a size bound: + // io.ReadAll in Go handlers/loops, .read()/.readlines() in Python loops. + DetectUnboundedReads *bool `json:"detect_unbounded_reads,omitempty" yaml:"detect_unbounded_reads,omitempty"` + // DetectComplexityRegression gates the diff-only loop-nesting regression + // check: it compares each changed function's maximum loop-nesting depth + // against the diff base ref and warns on increases. Full scans are + // unaffected (the rule only activates in diff mode). + DetectComplexityRegression *bool `json:"detect_complexity_regression,omitempty" yaml:"detect_complexity_regression,omitempty"` + // DetectFrameworkPatterns gates the framework-aware rules: Django relation + // access and ORM point queries in Python loops (Django/SQLAlchemy), + // expensive per-render work in React components, and CPU-heavy synchronous + // calls in Express middleware. Each rule additionally requires file-level + // framework evidence (imports or obvious idioms), so non-framework code + // never matches. + DetectFrameworkPatterns *bool `json:"detect_framework_patterns,omitempty" yaml:"detect_framework_patterns,omitempty"` + // Budgets lists measured size gates over build artifacts (see + // PerformanceBudgetConfig); findings report as performance.budget. + Budgets []PerformanceBudgetConfig `json:"budgets,omitempty" yaml:"budgets,omitempty"` + // Benchmarks configures the opt-in benchmark-regression gate (see + // PerformanceBenchmarksConfig); findings report as + // performance.benchmark-regression. + Benchmarks PerformanceBenchmarksConfig `json:"benchmarks,omitempty" yaml:"benchmarks,omitempty"` + // ScoreHistory gates persistence of the performance_score trend next to + // the scan cache (nil = enabled, mirroring ai_checks.slop_history). + ScoreHistory *bool `json:"score_history,omitempty" yaml:"score_history,omitempty"` + // ScoreHistoryLimit caps retained performance_score history entries per + // target (0 = default limit). + ScoreHistoryLimit int `json:"score_history_limit,omitempty" yaml:"score_history_limit,omitempty"` } // AIChecksConfig toggles individual AI-quality heuristics. A nil pointer diff --git a/internal/codeguard/core/config_types.go b/internal/codeguard/core/config_types.go index ebeb7fc..2e6ce0a 100644 --- a/internal/codeguard/core/config_types.go +++ b/internal/codeguard/core/config_types.go @@ -50,6 +50,14 @@ type CheckConfig struct { Security bool `json:"security" yaml:"security"` Prompts bool `json:"prompts" yaml:"prompts"` CI bool `json:"ci" yaml:"ci"` + // Performance toggles the performance section (N+1 queries, alloc-heavy + // loops, blocking I/O in request paths, unbounded concurrency). Off by + // default while the rules settle into their new section; the rules + // themselves previously ran inside the quality section under quality.* ids. + // A nil pointer also runs as off, but marks the config as predating the + // section so the scan output can suggest enabling it; an explicit false + // silences that hint. + Performance *bool `json:"performance,omitempty" yaml:"performance,omitempty"` // SupplyChain toggles dependency-policy checks such as manifest hygiene, // lockfile drift, license policy, and SBOM-oriented validation. SupplyChain bool `json:"supply_chain,omitempty" yaml:"supply_chain,omitempty"` @@ -65,6 +73,7 @@ type CheckConfig struct { // PR regardless of the change under review. Context *bool `json:"context,omitempty" yaml:"context,omitempty"` QualityRules QualityRulesConfig `json:"quality_rules" yaml:"quality_rules"` + PerformanceRules PerformanceRulesConfig `json:"performance_rules,omitempty" yaml:"performance_rules,omitempty"` DesignRules DesignRulesConfig `json:"design_rules" yaml:"design_rules"` PromptRules PromptRulesConfig `json:"prompt_rules" yaml:"prompt_rules"` CIRules CIRulesConfig `json:"ci_rules" yaml:"ci_rules"` diff --git a/internal/codeguard/core/report_artifact_perf_types.go b/internal/codeguard/core/report_artifact_perf_types.go new file mode 100644 index 0000000..66bb583 --- /dev/null +++ b/internal/codeguard/core/report_artifact_perf_types.go @@ -0,0 +1,26 @@ +package core + +// Performance-score artifact and history types, mirroring the slop_score +// shapes so tooling can trend both metrics the same way. Components reuse +// SlopScoreComponent: its fields (rule_id/count/weight/contribution) are +// score-agnostic. + +// PerformanceScoreArtifact is the per-target performance score published by +// the performance section: a weighted count of its findings by rule family, +// saturating at 100. +type PerformanceScoreArtifact struct { + Score int `json:"score"` + Signals int `json:"signals"` + Components []SlopScoreComponent `json:"components,omitempty"` + PreviousScore *int `json:"previous_score,omitempty"` + Delta *int `json:"delta,omitempty"` +} + +// PerformanceHistoryEntry is one persisted performance-score observation for +// a target, recorded once per scan so trends can be reported over time. +type PerformanceHistoryEntry struct { + Timestamp string `json:"timestamp"` + Score int `json:"score"` + Signals int `json:"signals"` + Components []SlopScoreComponent `json:"components,omitempty"` +} diff --git a/internal/codeguard/core/report_artifact_types.go b/internal/codeguard/core/report_artifact_types.go index 8fcfe74..e0bc5ba 100644 --- a/internal/codeguard/core/report_artifact_types.go +++ b/internal/codeguard/core/report_artifact_types.go @@ -1,19 +1,20 @@ package core type Artifact struct { - ID string `json:"id"` - Kind string `json:"kind"` - Language string `json:"language,omitempty"` - Target string `json:"target,omitempty"` - DependencyGraph *DependencyGraphArtifact `json:"dependency_graph,omitempty"` - SupplyChain *SupplyChainArtifact `json:"supply_chain,omitempty"` - SlopScore *SlopScoreArtifact `json:"slop_score,omitempty"` - RuleStats *RuleStatsArtifact `json:"rule_stats,omitempty"` - ChangeRisk *ChangeRiskArtifact `json:"change_risk,omitempty"` - AIAnalysis *AIAnalysisArtifact `json:"ai_analysis,omitempty"` - AIFix *AIFixArtifact `json:"ai_fix,omitempty"` - ChangeImpact *ChangeImpactArtifact `json:"change_impact,omitempty"` - RepoLegibility *RepoLegibilityArtifact `json:"repo_legibility,omitempty"` + ID string `json:"id"` + Kind string `json:"kind"` + Language string `json:"language,omitempty"` + Target string `json:"target,omitempty"` + DependencyGraph *DependencyGraphArtifact `json:"dependency_graph,omitempty"` + SupplyChain *SupplyChainArtifact `json:"supply_chain,omitempty"` + SlopScore *SlopScoreArtifact `json:"slop_score,omitempty"` + PerformanceScore *PerformanceScoreArtifact `json:"performance_score,omitempty"` + RuleStats *RuleStatsArtifact `json:"rule_stats,omitempty"` + ChangeRisk *ChangeRiskArtifact `json:"change_risk,omitempty"` + AIAnalysis *AIAnalysisArtifact `json:"ai_analysis,omitempty"` + AIFix *AIFixArtifact `json:"ai_fix,omitempty"` + ChangeImpact *ChangeImpactArtifact `json:"change_impact,omitempty"` + RepoLegibility *RepoLegibilityArtifact `json:"repo_legibility,omitempty"` } type DependencyGraphArtifact struct { diff --git a/internal/codeguard/rules/catalog.go b/internal/codeguard/rules/catalog.go index 661315d..d99f06f 100644 --- a/internal/codeguard/rules/catalog.go +++ b/internal/codeguard/rules/catalog.go @@ -4,7 +4,11 @@ import "github.com/devr-tools/codeguard/internal/codeguard/core" var catalog = withSecurityOWASP(mergeRuleCatalogs( qualityCatalog, - qualityPerformanceCatalog, + performanceCatalog, + performanceRegressionCatalog, + performanceFrameworksCatalog, + performanceAICatalog, + performanceMeasuredCatalog, designCatalog, designGraphCatalog, securityCatalog, diff --git a/internal/codeguard/rules/catalog_fix_templates.go b/internal/codeguard/rules/catalog_fix_templates.go index 1e21a20..f5f4422 100644 --- a/internal/codeguard/rules/catalog_fix_templates.go +++ b/internal/codeguard/rules/catalog_fix_templates.go @@ -17,6 +17,11 @@ const ( var fixTemplates = mergeFixTemplates( qualityFixTemplates, qualityAIFixTemplates, + performanceFixTemplates, + performanceRegressionFixTemplates, + performanceFrameworkFixTemplates, + performanceAIFixTemplates, + performanceMeasuredFixTemplates, securityFixTemplates, securityLanguageFixTemplates, designFixTemplates, diff --git a/internal/codeguard/rules/catalog_fix_templates_performance.go b/internal/codeguard/rules/catalog_fix_templates_performance.go new file mode 100644 index 0000000..aebfb2d --- /dev/null +++ b/internal/codeguard/rules/catalog_fix_templates_performance.go @@ -0,0 +1,27 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceFixTemplates covers the performance section heuristics. +var performanceFixTemplates = map[string]core.FixTemplate{ + "performance.sync-io-in-request-path": {Kind: guided, Text: "Load the data once at startup or cache it instead of reading the filesystem per request.\n\nBefore:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tdata, _ := os.ReadFile(\"config.json\")\n\tw.Write(data)\n}\n\nAfter:\nvar config = mustLoadConfig(\"config.json\") // loaded once at startup\n\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tw.Write(config)\n}"}, + "performance.unbounded-goroutines-in-loop": {Kind: guided, Text: "Bound loop-driven concurrency with a worker pool, semaphore, or errgroup limit.\n\nBefore:\nfor _, job := range jobs {\n\tgo process(job)\n}\n\nAfter:\ngroup, ctx := errgroup.WithContext(ctx)\ngroup.SetLimit(8)\nfor _, job := range jobs {\n\tgroup.Go(func() error { return process(ctx, job) })\n}\nif err := group.Wait(); err != nil {\n\treturn err\n}"}, + "performance.n-plus-one-query": {Kind: guided, Text: "Batch the per-item lookups into one query issued before the loop.\n\nBefore:\nfor _, id := range ids {\n\tuser, _ := db.GetUser(ctx, id)\n\tusers = append(users, user)\n}\n\nAfter:\nusers, err := db.GetUsersByIDs(ctx, ids) // single batched query"}, + "performance.go.alloc-in-loop": {Kind: guided, Text: "Use strings.Builder for string accumulation and preallocate slice capacity before the loop.\n\nBefore:\nvar out string\nfor _, line := range lines {\n\tout += line + \"\\n\"\n}\n\nAfter:\nvar b strings.Builder\nfor _, line := range lines {\n\tb.WriteString(line)\n\tb.WriteByte('\\n')\n}\nout := b.String()"}, + "performance.typescript.sync-io-in-handler": {Kind: guided, Text: "Switch to the promise-based API inside request handlers so the event loop is not blocked.\n\nBefore:\napp.get(\"/report\", (req, res) => {\n const data = fs.readFileSync(\"report.json\", \"utf8\");\n res.send(data);\n});\n\nAfter:\napp.get(\"/report\", async (req, res) => {\n const data = await fs.promises.readFile(\"report.json\", \"utf8\");\n res.send(data);\n});"}, + "performance.javascript.sync-io-in-handler": {Kind: guided, Text: "Switch to the promise-based API inside request handlers so the event loop is not blocked.\n\nBefore:\napp.get(\"/report\", (req, res) => {\n res.send(fs.readFileSync(\"report.json\"));\n});\n\nAfter:\nconst fsp = require(\"fs/promises\");\napp.get(\"/report\", async (req, res) => {\n res.send(await fsp.readFile(\"report.json\"));\n});"}, + "performance.typescript.unbounded-concurrency": {Kind: guided, Text: "Process the work in bounded chunks or wrap calls with a concurrency limiter.\n\nBefore:\nfor (const url of urls) {\n promises.push(fetch(url));\n}\nawait Promise.all(promises);\n\nAfter:\nimport pLimit from \"p-limit\";\nconst limit = pLimit(8);\nawait Promise.all(urls.map((url) => limit(() => fetch(url))));"}, + "performance.javascript.unbounded-concurrency": {Kind: guided, Text: "Process the work in bounded chunks or wrap calls with a concurrency limiter.\n\nBefore:\nfor (const url of urls) {\n promises.push(fetch(url));\n}\nawait Promise.all(promises);\n\nAfter:\nfor (let i = 0; i < urls.length; i += 8) {\n await Promise.all(urls.slice(i, i + 8).map((url) => fetch(url)));\n}"}, + "performance.python.sync-io-in-async": {Kind: guided, Text: "Use async clients and asyncio.sleep inside async functions so the event loop is not blocked.\n\nBefore:\nasync def refresh():\n data = requests.get(url).json()\n time.sleep(1)\n\nAfter:\nasync def refresh():\n async with httpx.AsyncClient() as client:\n data = (await client.get(url)).json()\n await asyncio.sleep(1)"}, + "performance.regex-compile-in-loop": {Kind: deterministic, Text: "Compile the pattern once outside the loop and reuse it.\n\nBefore:\nfor _, line := range lines {\n\tre := regexp.MustCompile(`\\d+`)\n\tif re.MatchString(line) { ... }\n}\n\nAfter:\nvar digits = regexp.MustCompile(`\\d+`) // compiled once\n\nfor _, line := range lines {\n\tif digits.MatchString(line) { ... }\n}"}, + "performance.go.defer-in-loop": {Kind: guided, Text: "Release per-iteration resources explicitly, or extract the loop body into a function so the defer runs each iteration.\n\nBefore:\nfor _, path := range paths {\n\tf, _ := os.Open(path)\n\tdefer f.Close() // all files stay open until the function returns\n}\n\nAfter:\nfor _, path := range paths {\n\tif err := processFile(path); err != nil { ... }\n}\n\nfunc processFile(path string) error {\n\tf, err := os.Open(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close() // closes at the end of each iteration\n\t...\n}"}, + "performance.go.sleep-in-loop": {Kind: guided, Text: "Replace the polling sleep with a ticker, a channel signal, or backoff.\n\nBefore:\nfor {\n\tif ready() {\n\t\tbreak\n\t}\n\ttime.Sleep(100 * time.Millisecond)\n}\n\nAfter:\nticker := time.NewTicker(100 * time.Millisecond)\ndefer ticker.Stop()\nfor range ticker.C {\n\tif ready() {\n\t\tbreak\n\t}\n}\n// or better: have the producer signal readiness on a channel"}, + "performance.go.timer-leak-in-loop": {Kind: deterministic, Text: "Reuse one timer instead of allocating a new one per iteration with time.After.\n\nBefore:\nfor {\n\tselect {\n\tcase msg := <-inbox:\n\t\thandle(msg)\n\tcase <-time.After(time.Second): // leaks a timer per iteration\n\t\treturn\n\t}\n}\n\nAfter:\ntimer := time.NewTimer(time.Second)\ndefer timer.Stop()\nfor {\n\tselect {\n\tcase msg := <-inbox:\n\t\thandle(msg)\n\t\ttimer.Reset(time.Second)\n\tcase <-timer.C:\n\t\treturn\n\t}\n}"}, + "performance.unbounded-read": {Kind: guided, Text: "Bound the read or process the input as a stream.\n\nBefore:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tbody, _ := io.ReadAll(r.Body) // one oversized request = whole payload in memory\n}\n\nAfter:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap\n}\n// Python: read(65536) in a loop, or iterate the file object line by line"}, + "performance.string-concat-in-loop": {Kind: deterministic, Text: "Collect the parts and join once after the loop.\n\nBefore:\nout = \"\"\nfor row in rows:\n out += render(row)\n\nAfter:\nparts = [render(row) for row in rows]\nout = \"\".join(parts)\n// TS/JS: const parts: string[] = []; parts.push(render(row)); const out = parts.join(\"\")"}, + "performance.python.unbounded-concurrency": {Kind: guided, Text: "Bound loop-driven task creation with a semaphore or task group.\n\nBefore:\nfor url in urls:\n asyncio.create_task(fetch(url))\n\nAfter:\nsem = asyncio.Semaphore(8)\n\nasync def bounded_fetch(url):\n async with sem:\n return await fetch(url)\n\nasync with asyncio.TaskGroup() as group:\n for url in urls:\n group.create_task(bounded_fetch(url))"}, + "performance.typescript.await-in-loop": {Kind: guided, Text: "Run independent iterations concurrently with Promise.all.\n\nBefore:\nfor (const id of ids) {\n const user = await fetchUser(id); // serial round-trips\n users.push(user);\n}\n\nAfter:\nconst users = await Promise.all(ids.map((id) => fetchUser(id)));\n// keep the loop only when each iteration depends on the previous one"}, + "performance.javascript.await-in-loop": {Kind: guided, Text: "Run independent iterations concurrently with Promise.all.\n\nBefore:\nfor (const id of ids) {\n const user = await fetchUser(id); // serial round-trips\n users.push(user);\n}\n\nAfter:\nconst users = await Promise.all(ids.map((id) => fetchUser(id)));\n// keep the loop only when each iteration depends on the previous one"}, + "performance.typescript.timer-listener-leak": {Kind: guided, Text: "Store timer handles and clean up listeners on teardown.\n\nBefore:\nsetInterval(poll, 1000); // no clearInterval anywhere\nfor (const el of items) {\n el.addEventListener(\"click\", onClick);\n}\n\nAfter:\nconst handle = setInterval(poll, 1000);\nconst controller = new AbortController();\nfor (const el of items) {\n el.addEventListener(\"click\", onClick, { signal: controller.signal });\n}\n// on teardown:\nclearInterval(handle);\ncontroller.abort();"}, + "performance.javascript.timer-listener-leak": {Kind: guided, Text: "Store timer handles and clean up listeners on teardown.\n\nBefore:\nsetInterval(poll, 1000); // no clearInterval anywhere\nfor (const el of items) {\n el.addEventListener(\"click\", onClick);\n}\n\nAfter:\nconst handle = setInterval(poll, 1000);\nconst controller = new AbortController();\nfor (const el of items) {\n el.addEventListener(\"click\", onClick, { signal: controller.signal });\n}\n// on teardown:\nclearInterval(handle);\ncontroller.abort();"}, +} diff --git a/internal/codeguard/rules/catalog_fix_templates_performance_ai.go b/internal/codeguard/rules/catalog_fix_templates_performance_ai.go new file mode 100644 index 0000000..1675345 --- /dev/null +++ b/internal/codeguard/rules/catalog_fix_templates_performance_ai.go @@ -0,0 +1,9 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceAIFixTemplates covers the LLM-assisted performance lens. +var performanceAIFixTemplates = map[string]core.FixTemplate{ + "performance.ai.semantic-perf": {Kind: guided, Text: "Compute the expensive result once and reuse it: cache or memoize repeated calls, hoist invariant work out of the loop or request path, batch per-item lookups, or pick a data structure that matches the realistic input sizes.\n\nBefore:\nfor _, order := range orders {\n\trate, _ := fetchExchangeRate(order.Currency) // same currencies re-fetched per order\n\ttotal += order.Amount * rate\n}\n\nAfter:\nrates := map[string]float64{}\nfor _, order := range orders {\n\trate, ok := rates[order.Currency]\n\tif !ok {\n\t\trate, _ = fetchExchangeRate(order.Currency)\n\t\trates[order.Currency] = rate\n\t}\n\ttotal += order.Amount * rate\n}"}, + "performance.ai.semantic-runtime": {Kind: guided, Text: "Point the semantic review provider at an installed command that returns valid JSON, or disable semantic review (or the performance section) explicitly.\n\nBefore:\n{\n \"checks\": { \"performance\": true },\n \"ai\": {\n \"semantic\": { \"enabled\": true },\n \"provider\": { \"type\": \"command\", \"command\": \"missing-reviewer\" }\n }\n}\n\nAfter:\n{\n \"checks\": { \"performance\": true },\n \"ai\": {\n \"semantic\": { \"enabled\": true },\n \"provider\": { \"type\": \"command\", \"command\": \"/usr/local/bin/semantic-reviewer\" }\n }\n}\n// run the command by hand and fix any crash or malformed JSON it reports"}, +} diff --git a/internal/codeguard/rules/catalog_fix_templates_performance_frameworks.go b/internal/codeguard/rules/catalog_fix_templates_performance_frameworks.go new file mode 100644 index 0000000..e0ac204 --- /dev/null +++ b/internal/codeguard/rules/catalog_fix_templates_performance_frameworks.go @@ -0,0 +1,14 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceFrameworkFixTemplates covers the framework-aware performance +// rules (performance_rules.detect_framework_patterns). +var performanceFrameworkFixTemplates = map[string]core.FixTemplate{ + "performance.python.django-nplusone-relation": {Kind: guided, Text: "Load the relation with the initial query instead of per row.\n\nBefore:\nfor order in Order.objects.all():\n send(order.customer.email) # one query per order\n\nAfter:\nfor order in Order.objects.select_related(\"customer\"):\n send(order.customer.email) # customer joined in the initial query\n# reverse/many-to-many relations: Order.objects.prefetch_related(\"item_set\")"}, + "performance.python.orm-query-in-loop": {Kind: guided, Text: "Batch the per-iteration lookups into one query before the loop.\n\nBefore:\nfor pk in ids:\n articles.append(Article.objects.get(pk=pk))\n\nAfter:\nby_id = Article.objects.in_bulk(ids) # one query\narticles = [by_id[pk] for pk in ids]\n# or: Article.objects.filter(pk__in=ids)\n# SQLAlchemy: session.scalars(select(Article).where(Article.id.in_(ids)))"}, + "performance.typescript.react-expensive-render": {Kind: guided, Text: "Memoize per-render computation so it only reruns when its inputs change.\n\nBefore:\nfunction ItemList({ items }: Props) {\n const rows = items.filter((i) => i.active).map(render); // every render\n return
    {rows}
;\n}\n\nAfter:\nfunction ItemList({ items }: Props) {\n const rows = useMemo(() => items.filter((i) => i.active).map(render), [items]);\n return
    {rows}
;\n}"}, + "performance.javascript.react-expensive-render": {Kind: guided, Text: "Memoize per-render computation so it only reruns when its inputs change.\n\nBefore:\nfunction ItemList({ items }) {\n const rows = items.filter((i) => i.active).map(render); // every render\n return
    {rows}
;\n}\n\nAfter:\nfunction ItemList({ items }) {\n const rows = useMemo(() => items.filter((i) => i.active).map(render), [items]);\n return
    {rows}
;\n}"}, + "performance.typescript.express-sync-middleware": {Kind: guided, Text: "Use the async API inside middleware so the event loop is not blocked per request.\n\nBefore:\napp.use((req, res, next) => {\n req.token = bcrypt.hashSync(req.body.secret, 10); // blocks every request\n next();\n});\n\nAfter:\napp.use(async (req, res, next) => {\n req.token = await bcrypt.hash(req.body.secret, 10);\n next();\n});"}, + "performance.javascript.express-sync-middleware": {Kind: guided, Text: "Use the async API inside middleware so the event loop is not blocked per request.\n\nBefore:\napp.use((req, res, next) => {\n req.token = bcrypt.hashSync(req.body.secret, 10); // blocks every request\n next();\n});\n\nAfter:\napp.use(async (req, res, next) => {\n req.token = await bcrypt.hash(req.body.secret, 10);\n next();\n});"}, +} diff --git a/internal/codeguard/rules/catalog_fix_templates_performance_measured.go b/internal/codeguard/rules/catalog_fix_templates_performance_measured.go new file mode 100644 index 0000000..2ee6181 --- /dev/null +++ b/internal/codeguard/rules/catalog_fix_templates_performance_measured.go @@ -0,0 +1,10 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceMeasuredFixTemplates covers the measurement-based performance +// gates (budgets, benchmark regression). +var performanceMeasuredFixTemplates = map[string]core.FixTemplate{ + "performance.budget": {Kind: guided, Text: "Bring the artifact back under its byte budget, or raise the budget deliberately.\n\nTypical levers:\n- Bundles: split by route, lazy-load rarely used code, replace heavy dependencies, enable minification/tree-shaking.\n- Binaries: build with -ldflags=\"-s -w\", drop unused embeds.\n- Assets: compress (gzip/brotli/webp) or downscale.\n\nIf the growth is intentional, raise max_bytes for the entry in performance_rules.budgets and note why in the change."}, + "performance.benchmark-regression": {Kind: guided, Text: "Investigate the regressed benchmark before accepting the slowdown.\n\n1. Reproduce: go test -run='^$' -bench= -benchmem ./pkg\n2. Profile: add -cpuprofile=cpu.out -memprofile=mem.out and inspect with go tool pprof.\n3. Fix the hot path (avoid allocations in loops, hoist repeated work, use faster data structures).\n\nIf the new cost is intentional, delete the baseline file (performance_rules.benchmarks.baseline_path) so the next run records the new numbers as the baseline."}, +} diff --git a/internal/codeguard/rules/catalog_fix_templates_performance_regression.go b/internal/codeguard/rules/catalog_fix_templates_performance_regression.go new file mode 100644 index 0000000..b852546 --- /dev/null +++ b/internal/codeguard/rules/catalog_fix_templates_performance_regression.go @@ -0,0 +1,10 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceRegressionFixTemplates covers the diff-only performance +// regression rules. Kept separate from performanceFixTemplates so parallel +// additions to the performance family do not conflict. +var performanceRegressionFixTemplates = map[string]core.FixTemplate{ + "performance.complexity-regression": {Kind: guided, Text: "Restructure the added nesting so it does not multiply the iteration space, or confirm the inner collection is small and bounded.\n\nBefore:\nfunc UpdateAll(users []User, orders []Order) {\n\tfor _, user := range users {\n\t\tfor _, order := range orders { // new inner loop: O(users x orders)\n\t\t\tif order.UserID == user.ID {\n\t\t\t\tapply(user, order)\n\t\t\t}\n\t\t}\n\t}\n}\n\nAfter:\nfunc UpdateAll(users []User, orders []Order) {\n\tbyUser := make(map[string][]Order, len(orders))\n\tfor _, order := range orders {\n\t\tbyUser[order.UserID] = append(byUser[order.UserID], order)\n\t}\n\tfor _, user := range users {\n\t\tfor _, order := range byUser[user.ID] { // bounded per-user slice\n\t\t\tapply(user, order)\n\t\t}\n\t}\n}"}, +} diff --git a/internal/codeguard/rules/catalog_fix_templates_quality.go b/internal/codeguard/rules/catalog_fix_templates_quality.go index e031223..47ad0dd 100644 --- a/internal/codeguard/rules/catalog_fix_templates_quality.go +++ b/internal/codeguard/rules/catalog_fix_templates_quality.go @@ -3,39 +3,31 @@ package rules import "github.com/devr-tools/codeguard/internal/codeguard/core" // qualityFixTemplates covers the non-AI quality rules, including the -// TypeScript/JavaScript suppression mirrors and the performance heuristics. +// TypeScript/JavaScript suppression mirrors. The performance heuristics live +// in catalog_fix_templates_performance.go. var qualityFixTemplates = map[string]core.FixTemplate{ - "quality.gofmt": {Kind: deterministic, Text: "Run gofmt -w on the file and commit the formatted result.\n\nBefore:\nfunc main(){fmt.Println(\"hi\")}\n\nAfter:\nfunc main() {\n\tfmt.Println(\"hi\")\n}"}, - "quality.parse-error": {Kind: guided, Text: "Fix the syntax error the parser reports so the file parses cleanly.\n\nBefore:\nfunc main() {\n\tfmt.Println(\"hi\")\n// missing closing brace\n\nAfter:\nfunc main() {\n\tfmt.Println(\"hi\")\n}"}, - "quality.max-file-lines": {Kind: guided, Text: "Split the file into smaller files with one responsibility each.\n\nBefore:\n// handlers.go: routing, validation, persistence, and rendering in one 900-line file\n\nAfter:\n// handlers.go: HTTP routing only\n// validate.go: input validation\n// store.go: persistence\n// render.go: response rendering"}, - "quality.max-function-lines": {Kind: guided, Text: "Extract cohesive steps into named helpers until the function fits the configured limit.\n\nBefore:\nfunc handle(req Request) (Response, error) {\n\t// dozens of lines: validate, transform, persist, render\n}\n\nAfter:\nfunc handle(req Request) (Response, error) {\n\tinput, err := validate(req)\n\tif err != nil {\n\t\treturn Response{}, err\n\t}\n\trecord := transform(input)\n\tif err := persist(record); err != nil {\n\t\treturn Response{}, err\n\t}\n\treturn render(record), nil\n}"}, - "quality.max-parameters": {Kind: guided, Text: "Group related parameters into an options struct.\n\nBefore:\nfunc render(w io.Writer, title string, body string, width int, height int, theme string) error\n\nAfter:\ntype RenderOptions struct {\n\tTitle, Body string\n\tWidth, Height int\n\tTheme string\n}\n\nfunc render(w io.Writer, opts RenderOptions) error"}, - "quality.cyclomatic-complexity": {Kind: guided, Text: "Flatten branching with early returns, or replace branch ladders with table-driven dispatch.\n\nBefore:\nif ok {\n\tif valid {\n\t\tif ready {\n\t\t\tprocess()\n\t\t}\n\t}\n}\n\nAfter:\nif !ok || !valid || !ready {\n\treturn\n}\nprocess()"}, - "quality.duplicate-code": {Kind: guided, Text: "Extract the duplicated block into one shared helper and call it from every copy site.\n\nBefore:\n// the same parse-validate-normalize sequence repeated in create.go and update.go\n\nAfter:\nfunc normalizeInput(raw Input) (Input, error) {\n\t// shared implementation\n}\n// create.go and update.go both call normalizeInput"}, - "quality.dependency-direction": {Kind: guided, Text: "Move the shared logic into a reusable package so library code stops importing CLI internals.\n\nBefore:\n// internal/report/report.go\nimport \"example.com/app/internal/cli\" // library imports CLI details\n\nAfter:\n// move the shared helper from internal/cli into a reusable package\nimport \"example.com/app/internal/render\""}, - "quality.command-check": {Kind: guided, Text: "Run the configured quality command locally and fix each reported finding at its source.\n\nBefore:\n$ golangci-lint run\nstore.go:42:2: ineffectual assignment to err\n\nAfter:\n// handle or return err at store.go:42, then re-run until the command exits 0\n// adjust the configured command only if it does not fit the target"}, - "quality.sync-io-in-request-path": {Kind: guided, Text: "Load the data once at startup or cache it instead of reading the filesystem per request.\n\nBefore:\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tdata, _ := os.ReadFile(\"config.json\")\n\tw.Write(data)\n}\n\nAfter:\nvar config = mustLoadConfig(\"config.json\") // loaded once at startup\n\nfunc handler(w http.ResponseWriter, r *http.Request) {\n\tw.Write(config)\n}"}, - "quality.unbounded-goroutines-in-loop": {Kind: guided, Text: "Bound loop-driven concurrency with a worker pool, semaphore, or errgroup limit.\n\nBefore:\nfor _, job := range jobs {\n\tgo process(job)\n}\n\nAfter:\ngroup, ctx := errgroup.WithContext(ctx)\ngroup.SetLimit(8)\nfor _, job := range jobs {\n\tgroup.Go(func() error { return process(ctx, job) })\n}\nif err := group.Wait(); err != nil {\n\treturn err\n}"}, - "quality.coverage-delta": {Kind: guided, Text: "Add or extend tests so the changed lines are exercised.\n\nBefore:\n// the diff adds a new error branch in Process() that no test reaches\n\nAfter:\nfunc TestProcessRejectsEmptyInput(t *testing.T) {\n\tif _, err := Process(\"\"); err == nil {\n\t\tt.Fatal(\"expected error for empty input\")\n\t}\n}"}, - "quality.n-plus-one-query": {Kind: guided, Text: "Batch the per-item lookups into one query issued before the loop.\n\nBefore:\nfor _, id := range ids {\n\tuser, _ := db.GetUser(ctx, id)\n\tusers = append(users, user)\n}\n\nAfter:\nusers, err := db.GetUsersByIDs(ctx, ids) // single batched query"}, - "quality.go.alloc-in-loop": {Kind: guided, Text: "Use strings.Builder for string accumulation and preallocate slice capacity before the loop.\n\nBefore:\nvar out string\nfor _, line := range lines {\n\tout += line + \"\\n\"\n}\n\nAfter:\nvar b strings.Builder\nfor _, line := range lines {\n\tb.WriteString(line)\n\tb.WriteByte('\\n')\n}\nout := b.String()"}, - "quality.typescript.sync-io-in-handler": {Kind: guided, Text: "Switch to the promise-based API inside request handlers so the event loop is not blocked.\n\nBefore:\napp.get(\"/report\", (req, res) => {\n const data = fs.readFileSync(\"report.json\", \"utf8\");\n res.send(data);\n});\n\nAfter:\napp.get(\"/report\", async (req, res) => {\n const data = await fs.promises.readFile(\"report.json\", \"utf8\");\n res.send(data);\n});"}, - "quality.javascript.sync-io-in-handler": {Kind: guided, Text: "Switch to the promise-based API inside request handlers so the event loop is not blocked.\n\nBefore:\napp.get(\"/report\", (req, res) => {\n res.send(fs.readFileSync(\"report.json\"));\n});\n\nAfter:\nconst fsp = require(\"fs/promises\");\napp.get(\"/report\", async (req, res) => {\n res.send(await fsp.readFile(\"report.json\"));\n});"}, - "quality.typescript.unbounded-concurrency": {Kind: guided, Text: "Process the work in bounded chunks or wrap calls with a concurrency limiter.\n\nBefore:\nfor (const url of urls) {\n promises.push(fetch(url));\n}\nawait Promise.all(promises);\n\nAfter:\nimport pLimit from \"p-limit\";\nconst limit = pLimit(8);\nawait Promise.all(urls.map((url) => limit(() => fetch(url))));"}, - "quality.javascript.unbounded-concurrency": {Kind: guided, Text: "Process the work in bounded chunks or wrap calls with a concurrency limiter.\n\nBefore:\nfor (const url of urls) {\n promises.push(fetch(url));\n}\nawait Promise.all(promises);\n\nAfter:\nfor (let i = 0; i < urls.length; i += 8) {\n await Promise.all(urls.slice(i, i + 8).map((url) => fetch(url)));\n}"}, - "quality.python.sync-io-in-async": {Kind: guided, Text: "Use async clients and asyncio.sleep inside async functions so the event loop is not blocked.\n\nBefore:\nasync def refresh():\n data = requests.get(url).json()\n time.sleep(1)\n\nAfter:\nasync def refresh():\n async with httpx.AsyncClient() as client:\n data = (await client.get(url)).json()\n await asyncio.sleep(1)"}, - "quality.typescript.ts-ignore": {Kind: guided, Text: "Fix the underlying type error and delete the @ts-ignore suppression.\n\nBefore:\n// @ts-ignore\nconst id = user.id;\n\nAfter:\nif (user === undefined) {\n throw new Error(\"user is required\");\n}\nconst id = user.id;"}, - "quality.typescript.explicit-any": {Kind: guided, Text: "Replace any with a precise type, a generic constraint, or unknown plus narrowing.\n\nBefore:\nfunction parse(input: any): any {\n return JSON.parse(input);\n}\n\nAfter:\nfunction parse(input: string): T {\n return JSON.parse(input) as T;\n}"}, - "quality.typescript.ts-nocheck": {Kind: guided, Text: "Delete the file-level @ts-nocheck and fix the type errors it hides.\n\nBefore:\n// @ts-nocheck\nexport function total(items) {\n return items.reduce((sum, item) => sum + item.price, 0);\n}\n\nAfter:\nexport function total(items: LineItem[]): number {\n return items.reduce((sum, item) => sum + item.price, 0);\n}"}, - "quality.typescript.double-assertion": {Kind: guided, Text: "Replace the as unknown as chain with runtime validation or a narrower source type.\n\nBefore:\nconst user = payload as unknown as User;\n\nAfter:\nconst user = parseUser(payload); // validates the shape and returns User\nfunction parseUser(value: unknown): User {\n if (!isUser(value)) {\n throw new Error(\"invalid user payload\");\n }\n return value;\n}"}, - "quality.typescript.non-null-assertion": {Kind: guided, Text: "Prove nullability with a guard instead of asserting it away with !.\n\nBefore:\nconst name = user!.name;\n\nAfter:\nif (user === null) {\n throw new Error(\"user is required\");\n}\nconst name = user.name;"}, - "quality.typescript.ts-expect-error": {Kind: guided, Text: "Fix the underlying type error and delete the @ts-expect-error suppression.\n\nBefore:\n// @ts-expect-error\nconst id = user.id;\n\nAfter:\nif (user === undefined) {\n throw new Error(\"user is required\");\n}\nconst id = user.id;"}, - "quality.typescript.debugger-statement": {Kind: deterministic, Text: "Remove the committed debugger statement; use tests or structured logging instead.\n\nBefore:\nfunction onSubmit(data: FormData) {\n debugger;\n send(data);\n}\n\nAfter:\nfunction onSubmit(data: FormData) {\n send(data);\n}"}, - "quality.javascript.ts-ignore": {Kind: guided, Text: "Fix the underlying type error and delete the @ts-ignore suppression.\n\nBefore:\n// @ts-ignore\nconst id = user.id;\n\nAfter:\nif (user === undefined) {\n throw new Error(\"user is required\");\n}\nconst id = user.id;"}, - "quality.javascript.explicit-any": {Kind: guided, Text: "Replace any with a precise type, a generic constraint, or unknown plus narrowing.\n\nBefore:\n/** @param {any} input */\nfunction parse(input) {\n return JSON.parse(input);\n}\n\nAfter:\n/** @param {string} input @returns {Config} */\nfunction parse(input) {\n return JSON.parse(input);\n}"}, - "quality.javascript.ts-nocheck": {Kind: guided, Text: "Delete the file-level @ts-nocheck and fix the type errors it hides.\n\nBefore:\n// @ts-nocheck\n/** @param {Array<{price: number}>} items */\nfunction total(items) {\n return items.reduce((sum, item) => sum + item.price, 0);\n}\n\nAfter:\n/** @param {Array<{price: number}>} items @returns {number} */\nfunction total(items) {\n return items.reduce((sum, item) => sum + item.price, 0);\n}"}, - "quality.javascript.double-assertion": {Kind: guided, Text: "Replace the JSDoc cast chain with runtime validation of the value's shape.\n\nBefore:\nconst user = /** @type {User} */ (/** @type {unknown} */ (payload));\n\nAfter:\nconst user = parseUser(payload); // validates the shape and returns User\nfunction parseUser(value) {\n if (!isUser(value)) {\n throw new Error(\"invalid user payload\");\n }\n return value;\n}"}, - "quality.javascript.non-null-assertion": {Kind: guided, Text: "Prove nullability with a guard instead of asserting it away with !.\n\nBefore:\nconst name = user!.name;\n\nAfter:\nif (user === null) {\n throw new Error(\"user is required\");\n}\nconst name = user.name;"}, - "quality.javascript.ts-expect-error": {Kind: guided, Text: "Fix the underlying type error and delete the @ts-expect-error suppression.\n\nBefore:\n// @ts-expect-error\nconst id = user.id;\n\nAfter:\nif (user === undefined) {\n throw new Error(\"user is required\");\n}\nconst id = user.id;"}, - "quality.javascript.debugger-statement": {Kind: deterministic, Text: "Remove the committed debugger statement; use tests or structured logging instead.\n\nBefore:\nfunction onSubmit(data) {\n debugger;\n send(data);\n}\n\nAfter:\nfunction onSubmit(data) {\n send(data);\n}"}, + "quality.gofmt": {Kind: deterministic, Text: "Run gofmt -w on the file and commit the formatted result.\n\nBefore:\nfunc main(){fmt.Println(\"hi\")}\n\nAfter:\nfunc main() {\n\tfmt.Println(\"hi\")\n}"}, + "quality.parse-error": {Kind: guided, Text: "Fix the syntax error the parser reports so the file parses cleanly.\n\nBefore:\nfunc main() {\n\tfmt.Println(\"hi\")\n// missing closing brace\n\nAfter:\nfunc main() {\n\tfmt.Println(\"hi\")\n}"}, + "quality.max-file-lines": {Kind: guided, Text: "Split the file into smaller files with one responsibility each.\n\nBefore:\n// handlers.go: routing, validation, persistence, and rendering in one 900-line file\n\nAfter:\n// handlers.go: HTTP routing only\n// validate.go: input validation\n// store.go: persistence\n// render.go: response rendering"}, + "quality.max-function-lines": {Kind: guided, Text: "Extract cohesive steps into named helpers until the function fits the configured limit.\n\nBefore:\nfunc handle(req Request) (Response, error) {\n\t// dozens of lines: validate, transform, persist, render\n}\n\nAfter:\nfunc handle(req Request) (Response, error) {\n\tinput, err := validate(req)\n\tif err != nil {\n\t\treturn Response{}, err\n\t}\n\trecord := transform(input)\n\tif err := persist(record); err != nil {\n\t\treturn Response{}, err\n\t}\n\treturn render(record), nil\n}"}, + "quality.max-parameters": {Kind: guided, Text: "Group related parameters into an options struct.\n\nBefore:\nfunc render(w io.Writer, title string, body string, width int, height int, theme string) error\n\nAfter:\ntype RenderOptions struct {\n\tTitle, Body string\n\tWidth, Height int\n\tTheme string\n}\n\nfunc render(w io.Writer, opts RenderOptions) error"}, + "quality.cyclomatic-complexity": {Kind: guided, Text: "Flatten branching with early returns, or replace branch ladders with table-driven dispatch.\n\nBefore:\nif ok {\n\tif valid {\n\t\tif ready {\n\t\t\tprocess()\n\t\t}\n\t}\n}\n\nAfter:\nif !ok || !valid || !ready {\n\treturn\n}\nprocess()"}, + "quality.duplicate-code": {Kind: guided, Text: "Extract the duplicated block into one shared helper and call it from every copy site.\n\nBefore:\n// the same parse-validate-normalize sequence repeated in create.go and update.go\n\nAfter:\nfunc normalizeInput(raw Input) (Input, error) {\n\t// shared implementation\n}\n// create.go and update.go both call normalizeInput"}, + "quality.dependency-direction": {Kind: guided, Text: "Move the shared logic into a reusable package so library code stops importing CLI internals.\n\nBefore:\n// internal/report/report.go\nimport \"example.com/app/internal/cli\" // library imports CLI details\n\nAfter:\n// move the shared helper from internal/cli into a reusable package\nimport \"example.com/app/internal/render\""}, + "quality.command-check": {Kind: guided, Text: "Run the configured quality command locally and fix each reported finding at its source.\n\nBefore:\n$ golangci-lint run\nstore.go:42:2: ineffectual assignment to err\n\nAfter:\n// handle or return err at store.go:42, then re-run until the command exits 0\n// adjust the configured command only if it does not fit the target"}, + "quality.coverage-delta": {Kind: guided, Text: "Add or extend tests so the changed lines are exercised.\n\nBefore:\n// the diff adds a new error branch in Process() that no test reaches\n\nAfter:\nfunc TestProcessRejectsEmptyInput(t *testing.T) {\n\tif _, err := Process(\"\"); err == nil {\n\t\tt.Fatal(\"expected error for empty input\")\n\t}\n}"}, + "quality.typescript.ts-ignore": {Kind: guided, Text: "Fix the underlying type error and delete the @ts-ignore suppression.\n\nBefore:\n// @ts-ignore\nconst id = user.id;\n\nAfter:\nif (user === undefined) {\n throw new Error(\"user is required\");\n}\nconst id = user.id;"}, + "quality.typescript.explicit-any": {Kind: guided, Text: "Replace any with a precise type, a generic constraint, or unknown plus narrowing.\n\nBefore:\nfunction parse(input: any): any {\n return JSON.parse(input);\n}\n\nAfter:\nfunction parse(input: string): T {\n return JSON.parse(input) as T;\n}"}, + "quality.typescript.ts-nocheck": {Kind: guided, Text: "Delete the file-level @ts-nocheck and fix the type errors it hides.\n\nBefore:\n// @ts-nocheck\nexport function total(items) {\n return items.reduce((sum, item) => sum + item.price, 0);\n}\n\nAfter:\nexport function total(items: LineItem[]): number {\n return items.reduce((sum, item) => sum + item.price, 0);\n}"}, + "quality.typescript.double-assertion": {Kind: guided, Text: "Replace the as unknown as chain with runtime validation or a narrower source type.\n\nBefore:\nconst user = payload as unknown as User;\n\nAfter:\nconst user = parseUser(payload); // validates the shape and returns User\nfunction parseUser(value: unknown): User {\n if (!isUser(value)) {\n throw new Error(\"invalid user payload\");\n }\n return value;\n}"}, + "quality.typescript.non-null-assertion": {Kind: guided, Text: "Prove nullability with a guard instead of asserting it away with !.\n\nBefore:\nconst name = user!.name;\n\nAfter:\nif (user === null) {\n throw new Error(\"user is required\");\n}\nconst name = user.name;"}, + "quality.typescript.ts-expect-error": {Kind: guided, Text: "Fix the underlying type error and delete the @ts-expect-error suppression.\n\nBefore:\n// @ts-expect-error\nconst id = user.id;\n\nAfter:\nif (user === undefined) {\n throw new Error(\"user is required\");\n}\nconst id = user.id;"}, + "quality.typescript.debugger-statement": {Kind: deterministic, Text: "Remove the committed debugger statement; use tests or structured logging instead.\n\nBefore:\nfunction onSubmit(data: FormData) {\n debugger;\n send(data);\n}\n\nAfter:\nfunction onSubmit(data: FormData) {\n send(data);\n}"}, + "quality.javascript.ts-ignore": {Kind: guided, Text: "Fix the underlying type error and delete the @ts-ignore suppression.\n\nBefore:\n// @ts-ignore\nconst id = user.id;\n\nAfter:\nif (user === undefined) {\n throw new Error(\"user is required\");\n}\nconst id = user.id;"}, + "quality.javascript.explicit-any": {Kind: guided, Text: "Replace any with a precise type, a generic constraint, or unknown plus narrowing.\n\nBefore:\n/** @param {any} input */\nfunction parse(input) {\n return JSON.parse(input);\n}\n\nAfter:\n/** @param {string} input @returns {Config} */\nfunction parse(input) {\n return JSON.parse(input);\n}"}, + "quality.javascript.ts-nocheck": {Kind: guided, Text: "Delete the file-level @ts-nocheck and fix the type errors it hides.\n\nBefore:\n// @ts-nocheck\n/** @param {Array<{price: number}>} items */\nfunction total(items) {\n return items.reduce((sum, item) => sum + item.price, 0);\n}\n\nAfter:\n/** @param {Array<{price: number}>} items @returns {number} */\nfunction total(items) {\n return items.reduce((sum, item) => sum + item.price, 0);\n}"}, + "quality.javascript.double-assertion": {Kind: guided, Text: "Replace the JSDoc cast chain with runtime validation of the value's shape.\n\nBefore:\nconst user = /** @type {User} */ (/** @type {unknown} */ (payload));\n\nAfter:\nconst user = parseUser(payload); // validates the shape and returns User\nfunction parseUser(value) {\n if (!isUser(value)) {\n throw new Error(\"invalid user payload\");\n }\n return value;\n}"}, + "quality.javascript.non-null-assertion": {Kind: guided, Text: "Prove nullability with a guard instead of asserting it away with !.\n\nBefore:\nconst name = user!.name;\n\nAfter:\nif (user === null) {\n throw new Error(\"user is required\");\n}\nconst name = user.name;"}, + "quality.javascript.ts-expect-error": {Kind: guided, Text: "Fix the underlying type error and delete the @ts-expect-error suppression.\n\nBefore:\n// @ts-expect-error\nconst id = user.id;\n\nAfter:\nif (user === undefined) {\n throw new Error(\"user is required\");\n}\nconst id = user.id;"}, + "quality.javascript.debugger-statement": {Kind: deterministic, Text: "Remove the committed debugger statement; use tests or structured logging instead.\n\nBefore:\nfunction onSubmit(data) {\n debugger;\n send(data);\n}\n\nAfter:\nfunction onSubmit(data) {\n send(data);\n}"}, } diff --git a/internal/codeguard/rules/catalog_performance.go b/internal/codeguard/rules/catalog_performance.go new file mode 100644 index 0000000..b81a92d --- /dev/null +++ b/internal/codeguard/rules/catalog_performance.go @@ -0,0 +1,228 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceCatalog covers the performance section. These rules previously +// lived in the quality section under quality.* ids (see +// retiredPerformanceRuleIDs for the mapping). +var performanceCatalog = map[string]core.RuleMetadata{ + "performance.n-plus-one-query": { + ID: "performance.n-plus-one-query", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage( + core.RuleLanguageGo, + core.RuleLanguagePython, + core.RuleLanguageTypeScript, + core.RuleLanguageJavaScript, + ), + Title: "N+1 query in loop", + Description: "Warns when a database query or remote fetch call runs inside a loop body, suggesting an N+1 access pattern.", + HowToFix: "Batch the lookups into one query, prefetch the data before the loop, or use a bulk API.", + }, + "performance.go.alloc-in-loop": { + ID: "performance.go.alloc-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + Title: "Allocation-heavy loop", + Description: "Warns when a loop grows a string by concatenation or accumulates fmt.Sprintf output (performance_rules.detect_alloc_in_loop, on by default). When performance_rules.detect_prealloc_in_loop is enabled (off by default), also warns when a loop appends to a slice without preallocated capacity despite a knowable bound.", + HowToFix: "Use strings.Builder for string accumulation and preallocate slice capacity with make(len 0, cap n) before the loop.", + }, + "performance.sync-io-in-request-path": { + ID: "performance.sync-io-in-request-path", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + Title: "Synchronous I/O in request path", + Description: "Warns when a likely Go HTTP handler performs synchronous file I/O directly in the request path.", + HowToFix: "Move the I/O out of the request path, preload or cache the data, or switch to an architecture that avoids per-request filesystem access.", + }, + "performance.unbounded-goroutines-in-loop": { + ID: "performance.unbounded-goroutines-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + Title: "Goroutines launched from loops", + Description: "Warns when Go code launches goroutines from inside loops without any visible bounding mechanism.", + HowToFix: "Use a worker pool, semaphore, errgroup limit, or explicit queue so loop-driven concurrency stays bounded.", + }, + "performance.typescript.sync-io-in-handler": { + ID: "performance.typescript.sync-io-in-handler", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "TypeScript sync I/O in handler", + Description: "Warns when a synchronous *Sync call runs inside an HTTP request handler, blocking the event loop.", + HowToFix: "Switch to the promise-based API (fs.promises, async exec) inside request handlers.", + }, + "performance.javascript.sync-io-in-handler": { + ID: "performance.javascript.sync-io-in-handler", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "JavaScript sync I/O in handler", + Description: "Warns when a synchronous *Sync call runs inside an HTTP request handler, blocking the event loop.", + HowToFix: "Switch to the promise-based API (fs.promises, async exec) inside request handlers.", + }, + "performance.typescript.unbounded-concurrency": { + ID: "performance.typescript.unbounded-concurrency", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "TypeScript unbounded concurrency", + Description: "Warns when promises are created inside a loop without batching or a concurrency limiter.", + HowToFix: "Process the work in chunks with Promise.all or wrap calls with a limiter such as p-limit.", + }, + "performance.javascript.unbounded-concurrency": { + ID: "performance.javascript.unbounded-concurrency", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "JavaScript unbounded concurrency", + Description: "Warns when promises are created inside a loop without batching or a concurrency limiter.", + HowToFix: "Process the work in chunks with Promise.all or wrap calls with a limiter such as p-limit.", + }, + "performance.python.sync-io-in-async": { + ID: "performance.python.sync-io-in-async", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "Python blocking call in async function", + Description: "Warns when requests, urllib, or time.sleep calls run inside an async def body, blocking the event loop.", + HowToFix: "Use an async HTTP client (httpx.AsyncClient, aiohttp) and asyncio.sleep inside async functions.", + }, + "performance.regex-compile-in-loop": { + ID: "performance.regex-compile-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage( + core.RuleLanguageGo, + core.RuleLanguagePython, + core.RuleLanguageTypeScript, + core.RuleLanguageJavaScript, + ), + Title: "Regex compiled in loop", + Description: "Warns when a regular expression is compiled inside a loop body (regexp.Compile/MustCompile, re.compile, new RegExp), repeating the compilation cost every iteration (performance_rules.detect_regex_compile_in_loop).", + HowToFix: "Compile the pattern once before the loop or at package/module level and reuse it.", + }, + "performance.go.defer-in-loop": { + ID: "performance.go.defer-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + Title: "Defer inside loop", + Description: "Warns when a defer statement sits inside a loop body: deferred calls run only at function exit, so resources accumulate for every iteration (performance_rules.detect_defer_in_loop).", + HowToFix: "Release the resource explicitly at the end of the iteration, or extract the loop body into a function so the defer runs per iteration.", + }, + "performance.go.sleep-in-loop": { + ID: "performance.go.sleep-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + Title: "Sleep inside loop", + Description: "Warns when time.Sleep runs inside a loop body, which usually marks a poll that burns latency (performance_rules.detect_sleep_in_loop).", + HowToFix: "Replace the poll with a time.Ticker, a channel or sync.Cond signal, or an exponential-backoff helper.", + }, + "performance.go.timer-leak-in-loop": { + ID: "performance.go.timer-leak-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + Title: "Timer leaked in loop", + Description: "Warns when time.After is called inside a loop: each call allocates a timer that is not garbage-collected until it fires, so tight loops leak timers (performance_rules.detect_timer_leaks).", + HowToFix: "Create one time.NewTimer or time.NewTicker before the loop and reuse it (Reset per iteration), stopping it when done.", + }, + "performance.unbounded-read": { + ID: "performance.unbounded-read", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage( + core.RuleLanguageGo, + core.RuleLanguagePython, + ), + Title: "Unbounded read into memory", + Description: "Warns when an entire input is read into memory without a size bound: io.ReadAll in Go HTTP handlers or loops, and .read()/.readlines() with no size argument in Python loops. One oversized input then costs the whole payload in memory (performance_rules.detect_unbounded_reads).", + HowToFix: "Bound the read (io.LimitReader, read(n)) or process the input as a stream instead of materializing it.", + }, + "performance.string-concat-in-loop": { + ID: "performance.string-concat-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.FixedRuleLanguageCoverage( + core.RuleLanguagePython, + core.RuleLanguageTypeScript, + core.RuleLanguageJavaScript, + ), + Title: "String concatenation in loop", + Description: "Warns when a string grows by += inside a loop in Python or TypeScript/JavaScript, copying the accumulated value every iteration (performance_rules.detect_alloc_in_loop; the Go equivalent is performance.go.alloc-in-loop).", + HowToFix: "Collect the parts in a list/array and join them once after the loop.", + }, + "performance.python.unbounded-concurrency": { + ID: "performance.python.unbounded-concurrency", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "Python unbounded concurrency", + Description: "Warns when asyncio.create_task or asyncio.ensure_future runs inside a loop with no visible concurrency limit (Semaphore, TaskGroup, or a limiter library) in the file (performance_rules.detect_unbounded_concurrency).", + HowToFix: "Bound the fan-out with an asyncio.Semaphore, an asyncio.TaskGroup, or a limiter such as aiolimiter.", + }, + "performance.typescript.await-in-loop": { + ID: "performance.typescript.await-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "TypeScript await in loop", + Description: "Warns when await runs inside a loop body, serializing work that could run concurrently; for await streams are exempt (performance_rules.detect_await_in_loop).", + HowToFix: "Collect the promises and await Promise.all (chunked or via a limiter) when the iterations are independent.", + }, + "performance.javascript.await-in-loop": { + ID: "performance.javascript.await-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "JavaScript await in loop", + Description: "Warns when await runs inside a loop body, serializing work that could run concurrently; for await streams are exempt (performance_rules.detect_await_in_loop).", + HowToFix: "Collect the promises and await Promise.all (chunked or via a limiter) when the iterations are independent.", + }, + "performance.typescript.timer-listener-leak": { + ID: "performance.typescript.timer-listener-leak", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "TypeScript leaked timer or listener", + Description: "Warns when setInterval has no matching clearInterval anywhere in the file, or addEventListener runs inside a loop with no removeEventListener/AbortSignal in the file — both keep callbacks and their captures alive indefinitely (performance_rules.detect_timer_leaks).", + HowToFix: "Store the interval handle and clearInterval it on teardown; deregister listeners with removeEventListener or pass an AbortSignal.", + }, + "performance.javascript.timer-listener-leak": { + ID: "performance.javascript.timer-listener-leak", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "JavaScript leaked timer or listener", + Description: "Warns when setInterval has no matching clearInterval anywhere in the file, or addEventListener runs inside a loop with no removeEventListener/AbortSignal in the file — both keep callbacks and their captures alive indefinitely (performance_rules.detect_timer_leaks).", + HowToFix: "Store the interval handle and clearInterval it on teardown; deregister listeners with removeEventListener or pass an AbortSignal.", + }, +} + +// RetiredPerformanceRuleIDs maps the retired quality.* ids of the performance +// rules to their performance.* replacements. The scan never consults this — +// there is deliberately no runtime aliasing — but `codeguard doctor` uses it +// to flag stale waivers and configs after the migration. +func RetiredPerformanceRuleIDs() map[string]string { + return map[string]string{ + "quality.n-plus-one-query": "performance.n-plus-one-query", + "quality.go.alloc-in-loop": "performance.go.alloc-in-loop", + "quality.sync-io-in-request-path": "performance.sync-io-in-request-path", + "quality.unbounded-goroutines-in-loop": "performance.unbounded-goroutines-in-loop", + "quality.typescript.sync-io-in-handler": "performance.typescript.sync-io-in-handler", + "quality.javascript.sync-io-in-handler": "performance.javascript.sync-io-in-handler", + "quality.typescript.unbounded-concurrency": "performance.typescript.unbounded-concurrency", + "quality.javascript.unbounded-concurrency": "performance.javascript.unbounded-concurrency", + "quality.python.sync-io-in-async": "performance.python.sync-io-in-async", + } +} diff --git a/internal/codeguard/rules/catalog_performance_ai.go b/internal/codeguard/rules/catalog_performance_ai.go new file mode 100644 index 0000000..7a4bf28 --- /dev/null +++ b/internal/codeguard/rules/catalog_performance_ai.go @@ -0,0 +1,40 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceAICatalog covers the optional LLM-assisted performance lens of +// the command-backed semantic review runtime. The lens shares one semantic +// request with the quality section's lenses; its verdicts are routed into the +// performance section by rule id. +var performanceAICatalog = map[string]core.RuleMetadata{ + "performance.ai.semantic-perf": { + ID: "performance.ai.semantic-perf", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelCommandDriven, + LanguageCoverage: core.FixedRuleLanguageCoverage( + core.RuleLanguageGo, + core.RuleLanguagePython, + core.RuleLanguageTypeScript, + core.RuleLanguageJavaScript, + ), + Title: "AI-assisted performance review", + Description: "Warns when optional LLM-assisted semantic review finds a performance concern in changed functions that static rules cannot judge: repeated expensive calls that want caching or memoization, algorithmic complexity out of line with plausible input sizes, or obviously redundant work across the change.", + HowToFix: "Cache or memoize the repeated expensive work, hoist it out of the loop or request path, batch the calls, or switch to an algorithm suited to the realistic input sizes.", + }, + "performance.ai.semantic-runtime": { + ID: "performance.ai.semantic-runtime", + Section: "Performance", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelCommandDriven, + LanguageCoverage: core.FixedRuleLanguageCoverage( + core.RuleLanguageGo, + core.RuleLanguagePython, + core.RuleLanguageTypeScript, + core.RuleLanguageJavaScript, + ), + Title: "Semantic performance review runtime failure", + Description: "Fails when the AI-assisted performance lens was enabled but the configured semantic command was missing, crashed, or returned invalid output, so the performance section would otherwise lose semantic coverage silently.", + HowToFix: "Configure a valid semantic command, then fix any runtime or JSON response errors so semantic review can run deterministically.", + }, +} diff --git a/internal/codeguard/rules/catalog_performance_frameworks.go b/internal/codeguard/rules/catalog_performance_frameworks.go new file mode 100644 index 0000000..18d0a0d --- /dev/null +++ b/internal/codeguard/rules/catalog_performance_frameworks.go @@ -0,0 +1,65 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceFrameworksCatalog covers the framework-aware performance rules +// (performance_rules.detect_framework_patterns, on by default within the +// opt-in performance section). Every rule is additionally gated on file-level +// framework evidence — imports or obvious idioms — so code that does not use +// the framework never matches. +var performanceFrameworksCatalog = map[string]core.RuleMetadata{ + "performance.python.django-nplusone-relation": { + ID: "performance.python.django-nplusone-relation", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "Django relation access in queryset loop", + Description: "Warns when a loop over a Django queryset accesses a related object (item.relation.attr or item.relation_set.*) in a file with Django evidence and no select_related/prefetch_related anywhere, issuing one extra query per row (performance_rules.detect_framework_patterns).", + HowToFix: "Load the relation with the initial query: queryset.select_related(\"relation\") for foreign keys and one-to-ones, queryset.prefetch_related(\"relation_set\") for reverse and many-to-many relations.", + }, + "performance.python.orm-query-in-loop": { + ID: "performance.python.orm-query-in-loop", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "ORM point query in loop", + Description: "Warns when a Django .objects.get/.objects.filter call or a SQLAlchemy session.get call runs inside a loop body, issuing one query per iteration. Covers only the ORM call shapes the generic performance.n-plus-one-query pattern misses, so a line never reports under both rules (performance_rules.detect_framework_patterns).", + HowToFix: "Batch the lookups before the loop: Model.objects.in_bulk(ids) or .filter(pk__in=ids) in Django, one query with an in_() filter in SQLAlchemy.", + }, + "performance.typescript.react-expensive-render": { + ID: "performance.typescript.react-expensive-render", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "TypeScript expensive work in React render", + Description: "Warns when a React component body (file imports react; function named with a capital letter or use* prefix) chains two or more array methods (.sort/.filter/.map) or constructs via new Array/JSON.parse outside a useMemo/useCallback/useEffect wrapper, redoing the work on every render (performance_rules.detect_framework_patterns).", + HowToFix: "Wrap the computation in useMemo with the right dependency array, or hoist it out of the component so it runs once.", + }, + "performance.javascript.react-expensive-render": { + ID: "performance.javascript.react-expensive-render", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "JavaScript expensive work in React render", + Description: "Warns when a React component body (file imports react; function named with a capital letter or use* prefix) chains two or more array methods (.sort/.filter/.map) or constructs via new Array/JSON.parse outside a useMemo/useCallback/useEffect wrapper, redoing the work on every render (performance_rules.detect_framework_patterns).", + HowToFix: "Wrap the computation in useMemo with the right dependency array, or hoist it out of the component so it runs once.", + }, + "performance.typescript.express-sync-middleware": { + ID: "performance.typescript.express-sync-middleware", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "TypeScript CPU-heavy sync call in Express middleware", + Description: "Warns when a known CPU-heavy synchronous API (bcrypt.hashSync/compareSync, crypto.pbkdf2Sync/scryptSync, zlib *Sync, child_process.execSync) runs inside an app.use/router.use middleware region in a file with express evidence, blocking the event loop on every request. Takes precedence over performance.typescript.sync-io-in-handler on the same line (performance_rules.detect_framework_patterns).", + HowToFix: "Use the asynchronous variant (bcrypt.hash, crypto.pbkdf2, promisified zlib, exec) or move the CPU-heavy work off the request path (queue or worker thread).", + }, + "performance.javascript.express-sync-middleware": { + ID: "performance.javascript.express-sync-middleware", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "JavaScript CPU-heavy sync call in Express middleware", + Description: "Warns when a known CPU-heavy synchronous API (bcrypt.hashSync/compareSync, crypto.pbkdf2Sync/scryptSync, zlib *Sync, child_process.execSync) runs inside an app.use/router.use middleware region in a file with express evidence, blocking the event loop on every request. Takes precedence over performance.javascript.sync-io-in-handler on the same line (performance_rules.detect_framework_patterns).", + HowToFix: "Use the asynchronous variant (bcrypt.hash, crypto.pbkdf2, promisified zlib, exec) or move the CPU-heavy work off the request path (queue or worker thread).", + }, +} diff --git a/internal/codeguard/rules/catalog_performance_measured.go b/internal/codeguard/rules/catalog_performance_measured.go new file mode 100644 index 0000000..f94216b --- /dev/null +++ b/internal/codeguard/rules/catalog_performance_measured.go @@ -0,0 +1,28 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceMeasuredCatalog covers the measurement-based performance gates: +// artifact size budgets and benchmark regression. Unlike the pattern rules in +// performanceCatalog, these compare real measurements (file sizes, bundler +// stats, go test -bench timings) against configured limits. +var performanceMeasuredCatalog = map[string]core.RuleMetadata{ + "performance.budget": { + ID: "performance.budget", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + Title: "Performance budget exceeded", + Description: "Compares artifact sizes against the budgets configured in performance_rules.budgets: on-disk file or glob-total sizes (kind file-size) and bundler stats totals or per-asset sizes from an esbuild metafile or webpack stats JSON (kind bundle-stats). A missing artifact reports as a warn finding, never a hard error; a budget entry may set level fail to gate the scan.", + HowToFix: "Shrink the artifact below the budget (trim dependencies, split bundles, strip debug info, compress assets) or, if the growth is intentional, raise max_bytes for the budget entry.", + }, + "performance.benchmark-regression": { + ID: "performance.benchmark-regression", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelCommandDriven, + Title: "Benchmark regression", + Description: "Runs go test -run=^$ -bench=. -benchmem over the configured packages and warns when a benchmark's ns/op regresses beyond performance_rules.benchmarks.max_regression_percent relative to the stored baseline. The first run writes the baseline and reports nothing. Off by default because it executes the repository's own test code (performance_rules.benchmarks.enabled).", + HowToFix: "Profile the regressed benchmark (go test -bench= -cpuprofile) and fix the slowdown, or refresh the baseline by deleting the baseline file if the new cost is accepted.", + }, +} diff --git a/internal/codeguard/rules/catalog_performance_regression.go b/internal/codeguard/rules/catalog_performance_regression.go new file mode 100644 index 0000000..c3b5db1 --- /dev/null +++ b/internal/codeguard/rules/catalog_performance_regression.go @@ -0,0 +1,18 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +// performanceRegressionCatalog covers the diff-only performance regression +// rules. Kept separate from performanceCatalog so parallel additions to the +// performance family do not conflict. +var performanceRegressionCatalog = map[string]core.RuleMetadata{ + "performance.complexity-regression": { + ID: "performance.complexity-regression", + Section: "Performance", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelGoNative, + Title: "Loop-nesting complexity regression", + Description: "Warns in diff scans when a changed function's maximum loop-nesting depth increased relative to the diff base ref (performance_rules.detect_complexity_regression, on by default). Functions that do not exist at the base ref are skipped, and full scans are unaffected: the rule only activates in diff mode.", + HowToFix: "Verify the added loop nesting does not iterate over unbounded data; hoist invariant work out of the inner loop, batch lookups, or restructure the iteration (e.g. index by key first) so the nesting does not multiply the input size.", + }, +} diff --git a/internal/codeguard/rules/catalog_quality.go b/internal/codeguard/rules/catalog_quality.go index 7dc4300..a8828bb 100644 --- a/internal/codeguard/rules/catalog_quality.go +++ b/internal/codeguard/rules/catalog_quality.go @@ -129,24 +129,6 @@ var qualityCatalog = map[string]core.RuleMetadata{ Description: "Fails when a configured language-specific quality command exits non-zero.", HowToFix: "Fix the reported issue from the command output or adjust the configured command if it does not fit the target.", }, - "quality.sync-io-in-request-path": { - ID: "quality.sync-io-in-request-path", - Section: "Code Quality", - DefaultLevel: "warn", - ExecutionModel: core.RuleExecutionModelGoNative, - Title: "Synchronous I/O in request path", - Description: "Warns when a likely Go HTTP handler performs synchronous file I/O directly in the request path.", - HowToFix: "Move the I/O out of the request path, preload or cache the data, or switch to an architecture that avoids per-request filesystem access.", - }, - "quality.unbounded-goroutines-in-loop": { - ID: "quality.unbounded-goroutines-in-loop", - Section: "Code Quality", - DefaultLevel: "warn", - ExecutionModel: core.RuleExecutionModelGoNative, - Title: "Goroutines launched from loops", - Description: "Warns when Go code launches goroutines from inside loops without any visible bounding mechanism.", - HowToFix: "Use a worker pool, semaphore, errgroup limit, or explicit queue so loop-driven concurrency stays bounded.", - }, "quality.ai.swallowed-error": { ID: "quality.ai.swallowed-error", Section: "Code Quality", diff --git a/internal/codeguard/rules/catalog_quality_performance.go b/internal/codeguard/rules/catalog_quality_performance.go deleted file mode 100644 index b85ff61..0000000 --- a/internal/codeguard/rules/catalog_quality_performance.go +++ /dev/null @@ -1,75 +0,0 @@ -package rules - -import "github.com/devr-tools/codeguard/internal/codeguard/core" - -var qualityPerformanceCatalog = map[string]core.RuleMetadata{ - "quality.n-plus-one-query": { - ID: "quality.n-plus-one-query", - Section: "Code Quality", - DefaultLevel: "warn", - ExecutionModel: core.RuleExecutionModelLanguageAgnostic, - LanguageCoverage: core.FixedRuleLanguageCoverage( - core.RuleLanguageGo, - core.RuleLanguagePython, - core.RuleLanguageTypeScript, - core.RuleLanguageJavaScript, - ), - Title: "N+1 query in loop", - Description: "Warns when a database query or remote fetch call runs inside a loop body, suggesting an N+1 access pattern.", - HowToFix: "Batch the lookups into one query, prefetch the data before the loop, or use a bulk API.", - }, - "quality.go.alloc-in-loop": { - ID: "quality.go.alloc-in-loop", - Section: "Code Quality", - DefaultLevel: "warn", - ExecutionModel: core.RuleExecutionModelGoNative, - Title: "Allocation-heavy loop", - Description: "Warns when a loop grows a string by concatenation or accumulates fmt.Sprintf output (quality_rules.detect_alloc_in_loop, on by default). When quality_rules.detect_prealloc_in_loop is enabled (off by default), also warns when a loop appends to a slice without preallocated capacity despite a knowable bound.", - HowToFix: "Use strings.Builder for string accumulation and preallocate slice capacity with make(len 0, cap n) before the loop.", - }, - "quality.typescript.sync-io-in-handler": { - ID: "quality.typescript.sync-io-in-handler", - Section: "Code Quality", - DefaultLevel: "warn", - ExecutionModel: core.RuleExecutionModelLanguageAgnostic, - Title: "TypeScript sync I/O in handler", - Description: "Warns when a synchronous *Sync call runs inside an HTTP request handler, blocking the event loop.", - HowToFix: "Switch to the promise-based API (fs.promises, async exec) inside request handlers.", - }, - "quality.javascript.sync-io-in-handler": { - ID: "quality.javascript.sync-io-in-handler", - Section: "Code Quality", - DefaultLevel: "warn", - ExecutionModel: core.RuleExecutionModelLanguageAgnostic, - Title: "JavaScript sync I/O in handler", - Description: "Warns when a synchronous *Sync call runs inside an HTTP request handler, blocking the event loop.", - HowToFix: "Switch to the promise-based API (fs.promises, async exec) inside request handlers.", - }, - "quality.typescript.unbounded-concurrency": { - ID: "quality.typescript.unbounded-concurrency", - Section: "Code Quality", - DefaultLevel: "warn", - ExecutionModel: core.RuleExecutionModelLanguageAgnostic, - Title: "TypeScript unbounded concurrency", - Description: "Warns when promises are created inside a loop without batching or a concurrency limiter.", - HowToFix: "Process the work in chunks with Promise.all or wrap calls with a limiter such as p-limit.", - }, - "quality.javascript.unbounded-concurrency": { - ID: "quality.javascript.unbounded-concurrency", - Section: "Code Quality", - DefaultLevel: "warn", - ExecutionModel: core.RuleExecutionModelLanguageAgnostic, - Title: "JavaScript unbounded concurrency", - Description: "Warns when promises are created inside a loop without batching or a concurrency limiter.", - HowToFix: "Process the work in chunks with Promise.all or wrap calls with a limiter such as p-limit.", - }, - "quality.python.sync-io-in-async": { - ID: "quality.python.sync-io-in-async", - Section: "Code Quality", - DefaultLevel: "warn", - ExecutionModel: core.RuleExecutionModelLanguageAgnostic, - Title: "Python blocking call in async function", - Description: "Warns when requests, urllib, or time.sleep calls run inside an async def body, blocking the event loop.", - HowToFix: "Use an async HTTP client (httpx.AsyncClient, aiohttp) and asyncio.sleep inside async functions.", - }, -} diff --git a/internal/codeguard/runner/benchregression/baseline.go b/internal/codeguard/runner/benchregression/baseline.go new file mode 100644 index 0000000..3391528 --- /dev/null +++ b/internal/codeguard/runner/benchregression/baseline.go @@ -0,0 +1,107 @@ +package benchregression + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// baselineVersion guards the on-disk format; a mismatched file is treated as +// missing so a format change re-baselines instead of mis-comparing (mirrors +// runner/support slop_history.go). +const baselineVersion = 1 + +// BaselineEntry is one stored benchmark measurement. +type BaselineEntry struct { + NsPerOp float64 `json:"ns_per_op"` + BytesPerOp float64 `json:"bytes_per_op,omitempty"` + AllocsPerOp float64 `json:"allocs_per_op,omitempty"` +} + +type baselineFile struct { + Version int `json:"version"` + Benchmarks map[string]BaselineEntry `json:"benchmarks"` +} + +// BaselinePathForBase derives the default benchmark-baseline path from the +// scan cache path (".codeguard/cache.json" -> ".codeguard/cache.bench-baseline.json"), +// mirroring the slop-history naming convention so the file lands in the +// already-contained cache directory. +func BaselinePathForBase(base string) string { + trimmed := strings.TrimSpace(base) + if trimmed == "" { + return "" + } + ext := filepath.Ext(trimmed) + if ext == "" { + return trimmed + ".bench-baseline" + } + return strings.TrimSuffix(trimmed, ext) + ".bench-baseline" + ext +} + +// LoadBaseline reads the stored baseline. ok is false when the file is +// missing, unreadable, or from another format version — callers then treat +// the current run as the first one and write a fresh baseline. +func LoadBaseline(path string) (map[string]BaselineEntry, bool) { + if strings.TrimSpace(path) == "" { + return nil, false + } + data, err := os.ReadFile(path) //nolint:gosec // config-contained baseline artifact path + if err != nil { + return nil, false + } + var file baselineFile + if err := json.Unmarshal(data, &file); err != nil || file.Version != baselineVersion || file.Benchmarks == nil { + return nil, false + } + return file.Benchmarks, true +} + +// WriteBaseline persists results as the new baseline. +func WriteBaseline(path string, results []Result) error { + benchmarks := make(map[string]BaselineEntry, len(results)) + for _, result := range results { + benchmarks[result.Name] = entryFromResult(result) + } + return saveBaseline(path, benchmarks) +} + +// MergeNewBenchmarks adds results whose names are absent from baseline and +// persists the merged file. Existing entries are never overwritten: the +// baseline must stay stable, otherwise a regressed run would silently become +// the new normal on the next scan. It returns whether anything was added. +func MergeNewBenchmarks(path string, baseline map[string]BaselineEntry, results []Result) (bool, error) { + added := false + for _, result := range results { + if _, ok := baseline[result.Name]; ok { + continue + } + baseline[result.Name] = entryFromResult(result) + added = true + } + if !added { + return false, nil + } + return true, saveBaseline(path, baseline) +} + +func entryFromResult(result Result) BaselineEntry { + return BaselineEntry{ + NsPerOp: result.NsPerOp, + BytesPerOp: result.BytesPerOp, + AllocsPerOp: result.AllocsPerOp, + } +} + +func saveBaseline(path string, benchmarks map[string]BaselineEntry) error { + payload := baselineFile{Version: baselineVersion, Benchmarks: benchmarks} + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} diff --git a/internal/codeguard/runner/benchregression/benchregression.go b/internal/codeguard/runner/benchregression/benchregression.go new file mode 100644 index 0000000..df8661a --- /dev/null +++ b/internal/codeguard/runner/benchregression/benchregression.go @@ -0,0 +1,116 @@ +// Package benchregression runs `go test -bench` for the performance section's +// benchmark-regression gate and provides the pure parser, comparator, and +// baseline persistence around it. It deliberately lives beside the govulncheck +// runner rather than inside checks/performance: the subprocess plumbing +// (bounded output, contained timeout) mirrors the shell-out template in +// runner/govulncheck, and keeping ParseOutput/Compare as pure functions in a +// leaf package lets tests exercise them without ever executing a benchmark. +// +// The `go` binary here is codeguard's own fixed tool invocation (like git): +// the command name is never config-supplied, so it does not pass through +// trust.GuardConfigCommand. Only the package patterns come from configuration, +// and those are charset-validated (config/validate_performance.go) plus +// re-checked here so a pattern can never smuggle a flag or a path outside the +// target. +package benchregression + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +// maxOutputBytes caps how much benchmark output is buffered so a runaway or +// malicious test binary cannot exhaust memory (mirrors runner/govulncheck). +const maxOutputBytes = 16 << 20 // 16 MiB + +// runTimeout bounds a single benchmark run. Benchmarks default to ~1s of +// measurement per benchmark plus compilation, so a stuck run is capped well +// before it stalls a CI job indefinitely. +const runTimeout = 10 * time.Minute + +// limitedWriter drops bytes beyond its budget while recording that truncation +// occurred, letting one writer back both stdout and stderr under a shared cap. +type limitedWriter struct { + w *bytes.Buffer + remaining int + truncated bool +} + +func (l *limitedWriter) Write(p []byte) (int, error) { + if l.remaining <= 0 { + l.truncated = true + return len(p), nil + } + if len(p) > l.remaining { + l.w.Write(p[:l.remaining]) + l.remaining = 0 + l.truncated = true + return len(p), nil + } + n, err := l.w.Write(p) + l.remaining -= n + return n, err +} + +// RunBenchmarks executes `go test -run=^$ -bench=. -benchmem ` in +// dir with a contained timeout and bounded output, returning the combined +// stdout+stderr text. A non-zero exit is not fatal by itself when benchmark +// lines were produced (a failing unrelated package still yields usable +// results); callers get both the output and the error and decide. +func RunBenchmarks(ctx context.Context, dir string, packages []string) (string, error) { + if len(packages) == 0 { + return "", fmt.Errorf("no benchmark packages configured") + } + args := []string{"test", "-run=^$", "-bench=.", "-benchmem"} + for _, pkg := range packages { + // Defense in depth on top of config validation: a package argument must + // never be able to act as a flag or leave the target directory. + if err := validatePackagePattern(pkg); err != nil { + return "", err + } + args = append(args, pkg) + } + ctx, cancel := context.WithTimeout(ctx, runTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "go", args...) //nolint:gosec // fixed `go` binary from PATH; package args validated above + cmd.Dir = dir + var buf bytes.Buffer + limited := &limitedWriter{w: &buf, remaining: maxOutputBytes} + cmd.Stdout = limited + cmd.Stderr = limited + err := cmd.Run() + if limited.truncated { + return "", fmt.Errorf("benchmark output exceeded %d bytes", maxOutputBytes) + } + text := buf.String() + if err != nil { + return text, fmt.Errorf("go test -bench failed: %w", err) + } + return text, nil +} + +// validatePackagePattern rejects package arguments that could be interpreted +// as flags or escape the working directory. It mirrors the config-time +// validation so programmatically-built configs get the same guarantee. +func validatePackagePattern(pkg string) error { + trimmed := strings.TrimSpace(pkg) + if trimmed == "" || trimmed != pkg { + return fmt.Errorf("invalid benchmark package pattern %q", pkg) + } + if strings.HasPrefix(pkg, "-") { + return fmt.Errorf("benchmark package pattern %q must not begin with '-'", pkg) + } + if pkg != "." && !strings.HasPrefix(pkg, "./") { + return fmt.Errorf("benchmark package pattern %q must be relative (start with \"./\")", pkg) + } + for _, segment := range strings.Split(pkg, "/") { + if segment == ".." { + return fmt.Errorf("benchmark package pattern %q must not contain \"..\" segments", pkg) + } + } + return nil +} diff --git a/internal/codeguard/runner/benchregression/compare.go b/internal/codeguard/runner/benchregression/compare.go new file mode 100644 index 0000000..ef44c78 --- /dev/null +++ b/internal/codeguard/runner/benchregression/compare.go @@ -0,0 +1,39 @@ +package benchregression + +import "sort" + +// Regression records one benchmark whose ns/op grew beyond the tolerated +// percentage relative to the stored baseline. +type Regression struct { + Name string + BaselineNsPerOp float64 + CurrentNsPerOp float64 + Percent float64 +} + +// Compare returns the benchmarks in current whose ns/op regressed more than +// maxRegressionPercent against the baseline, sorted by name for deterministic +// output. Benchmarks absent from the baseline (new benchmarks) and baseline +// entries with a non-positive ns/op are skipped: there is nothing meaningful +// to compare against. +func Compare(baseline map[string]BaselineEntry, current []Result, maxRegressionPercent float64) []Regression { + regressions := make([]Regression, 0) + for _, result := range current { + entry, ok := baseline[result.Name] + if !ok || entry.NsPerOp <= 0 { + continue + } + percent := (result.NsPerOp - entry.NsPerOp) / entry.NsPerOp * 100 + if percent <= maxRegressionPercent { + continue + } + regressions = append(regressions, Regression{ + Name: result.Name, + BaselineNsPerOp: entry.NsPerOp, + CurrentNsPerOp: result.NsPerOp, + Percent: percent, + }) + } + sort.Slice(regressions, func(i, j int) bool { return regressions[i].Name < regressions[j].Name }) + return regressions +} diff --git a/internal/codeguard/runner/benchregression/parse.go b/internal/codeguard/runner/benchregression/parse.go new file mode 100644 index 0000000..2c52582 --- /dev/null +++ b/internal/codeguard/runner/benchregression/parse.go @@ -0,0 +1,99 @@ +package benchregression + +import ( + "strconv" + "strings" +) + +// Result is one parsed benchmark line from standard `go test -bench` output, +// e.g. "BenchmarkX-8 1000 1234 ns/op 456 B/op 7 allocs/op". Name has the +// -GOMAXPROCS suffix stripped so baselines survive a core-count change. +type Result struct { + Name string `json:"name"` + Iterations int64 `json:"iterations"` + NsPerOp float64 `json:"ns_per_op"` + BytesPerOp float64 `json:"bytes_per_op"` + AllocsPerOp float64 `json:"allocs_per_op"` +} + +// ParseOutput extracts benchmark results from go test -bench output text, +// ignoring every non-benchmark line (goos/goarch headers, PASS/ok trailers, +// compiler noise). Malformed benchmark lines are skipped rather than failing +// the run. When the same benchmark name appears more than once (e.g. two +// packages defining the same benchmark), the last occurrence wins. +func ParseOutput(text string) []Result { + results := make([]Result, 0) + index := map[string]int{} + for _, line := range strings.Split(text, "\n") { + result, ok := parseBenchmarkLine(line) + if !ok { + continue + } + if at, seen := index[result.Name]; seen { + results[at] = result + continue + } + index[result.Name] = len(results) + results = append(results, result) + } + return results +} + +// parseBenchmarkLine parses a single "Benchmark[-N] +// ..." line. The value/unit pairs after the iteration count are +// interpreted by unit so extra custom metrics (b.ReportMetric) do not break +// parsing. +func parseBenchmarkLine(line string) (Result, bool) { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 4 || !strings.HasPrefix(fields[0], "Benchmark") { + return Result{}, false + } + // "Benchmark" alone is not a benchmark name; require Benchmark. + if fields[0] == "Benchmark" { + return Result{}, false + } + iterations, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return Result{}, false + } + result := Result{Name: normalizeBenchmarkName(fields[0]), Iterations: iterations} + sawNsPerOp := false + for i := 2; i+1 < len(fields); i += 2 { + value, err := strconv.ParseFloat(fields[i], 64) + if err != nil { + return Result{}, false + } + switch fields[i+1] { + case "ns/op": + result.NsPerOp = value + sawNsPerOp = true + case "B/op": + result.BytesPerOp = value + case "allocs/op": + result.AllocsPerOp = value + } + } + if !sawNsPerOp { + return Result{}, false + } + return result, true +} + +// normalizeBenchmarkName strips the trailing -GOMAXPROCS suffix go test +// appends ("BenchmarkX-8" -> "BenchmarkX") so results stay comparable across +// machines with different core counts. Sub-benchmark separators ("/") are +// preserved. +func normalizeBenchmarkName(name string) string { + dash := strings.LastIndex(name, "-") + if dash <= 0 { + return name + } + suffix := name[dash+1:] + if suffix == "" { + return name + } + if _, err := strconv.Atoi(suffix); err != nil { + return name + } + return name[:dash] +} diff --git a/internal/codeguard/runner/checks/registry.go b/internal/codeguard/runner/checks/registry.go index 29f995e..b7e30e6 100644 --- a/internal/codeguard/runner/checks/registry.go +++ b/internal/codeguard/runner/checks/registry.go @@ -7,6 +7,7 @@ import ( ciCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/ci" contractsCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/contracts" designCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/design" + performanceCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/performance" promptsCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/prompts" qualityCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/quality" securityCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/security" @@ -45,6 +46,18 @@ var sectionRegistry = []sectionDef{ return qualityCheck.Run(ctx, checkEnv) }, }, + { + id: "performance", + name: "Performance", + // nil means "config predates the section" and runs as off; the scan + // output separately suggests enabling it (see cli.executeScan). + enabled: func(sc runnersupport.Context) bool { + return sc.Cfg.Checks.Performance != nil && *sc.Cfg.Checks.Performance + }, + run: func(ctx context.Context, _ runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult { + return performanceCheck.Run(ctx, checkEnv) + }, + }, { id: "design", name: "Design", diff --git a/internal/codeguard/runner/runner.go b/internal/codeguard/runner/runner.go index 5cbf806..df763a4 100644 --- a/internal/codeguard/runner/runner.go +++ b/internal/codeguard/runner/runner.go @@ -79,6 +79,19 @@ func LoadSlopHistory(path string) map[string][]core.SlopHistoryEntry { return runnersupport.LoadSlopHistory(path) } +// PerfScoreHistoryPath derives the performance-score history file path for a +// config. +func PerfScoreHistoryPath(cfg core.Config) string { + config.ApplyDefaults(&cfg) + return runnersupport.PerfScoreHistoryPathForBase(cfg.Cache.Path) +} + +// LoadPerfScoreHistory reads the persisted performance-score trend, keyed by +// artifact ID. +func LoadPerfScoreHistory(path string) map[string][]core.PerformanceHistoryEntry { + return runnersupport.LoadPerfScoreHistory(path) +} + // RuleStatsHistoryPath derives the rule-stats history file path for a config. func RuleStatsHistoryPath(cfg core.Config) string { config.ApplyDefaults(&cfg) diff --git a/internal/codeguard/runner/support/cache_helpers.go b/internal/codeguard/runner/support/cache_helpers.go index 3180153..35b4c87 100644 --- a/internal/codeguard/runner/support/cache_helpers.go +++ b/internal/codeguard/runner/support/cache_helpers.go @@ -46,16 +46,17 @@ func SectionConfigHashes(cfg core.Config, catalog map[string]core.RuleMetadata, checks := cfg.Checks return map[string]string{ // quality reads both QualityRules and DesignRules, and its AI-quality - // findings depend on the AI config. quality and security both include - // cfg.Parsers because toggling parsers.treesitter changes their - // per-file script findings. - "quality": sectionFingerprint(prefix, "quality", catalog, cfg.AI, checks.QualityRules, checks.DesignRules, cfg.Parsers), - "design": sectionFingerprint(prefix, "design", catalog, checks.DesignRules), - "security": sectionFingerprint(prefix, "security", catalog, checks.SecurityRules, cfg.Parsers), - "prompts": sectionFingerprint(prefix, "prompts", catalog, checks.PromptRules), - "ci": sectionFingerprint(prefix, "ci", catalog, checks.CIRules), - "contracts": sectionFingerprint(prefix, "contracts", catalog, checks.ContractRules), - "": sectionFingerprint(prefix, "all", catalog, cfg.AI, checks, cfg.Parsers), + // findings depend on the AI config. quality, performance, and security + // include cfg.Parsers because toggling parsers.treesitter changes + // their per-file script findings. + "quality": sectionFingerprint(prefix, "quality", catalog, cfg.AI, checks.QualityRules, checks.DesignRules, cfg.Parsers), + "performance": sectionFingerprint(prefix, "performance", catalog, checks.PerformanceRules, cfg.Parsers), + "design": sectionFingerprint(prefix, "design", catalog, checks.DesignRules), + "security": sectionFingerprint(prefix, "security", catalog, checks.SecurityRules, cfg.Parsers), + "prompts": sectionFingerprint(prefix, "prompts", catalog, checks.PromptRules), + "ci": sectionFingerprint(prefix, "ci", catalog, checks.CIRules), + "contracts": sectionFingerprint(prefix, "contracts", catalog, checks.ContractRules), + "": sectionFingerprint(prefix, "all", catalog, cfg.AI, checks, cfg.Parsers), } } @@ -68,7 +69,7 @@ func sectionConfigFamily(sectionID string) string { sectionID = sectionID[:i] } switch sectionID { - case "quality", "design", "security", "prompts", "ci", "contracts": + case "quality", "performance", "design", "security", "prompts", "ci", "contracts": return sectionID default: return "" diff --git a/internal/codeguard/runner/support/perf_score_history.go b/internal/codeguard/runner/support/perf_score_history.go new file mode 100644 index 0000000..07bc4e3 --- /dev/null +++ b/internal/codeguard/runner/support/perf_score_history.go @@ -0,0 +1,90 @@ +package support + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +const perfScoreHistoryVersion = 1 + +// DefaultPerfScoreHistoryLimit caps how many scans are retained per target +// key, mirroring DefaultSlopHistoryLimit. +const DefaultPerfScoreHistoryLimit = 100 + +type perfScoreHistoryFile struct { + Version int `json:"version"` + Entries map[string][]core.PerformanceHistoryEntry `json:"entries"` +} + +// PerfScoreHistoryPathForBase derives the performance-score history file path +// from the scan cache path, mirroring SlopHistoryPathForBase. +func PerfScoreHistoryPathForBase(base string) string { + trimmed := strings.TrimSpace(base) + if trimmed == "" { + return "" + } + ext := filepath.Ext(trimmed) + if ext == "" { + return trimmed + ".perf-history" + } + return strings.TrimSuffix(trimmed, ext) + ".perf-history" + ext +} + +// LoadPerfScoreHistory reads the persisted performance-score history keyed by +// artifact ID. A missing or unreadable file yields an empty history. +func LoadPerfScoreHistory(path string) map[string][]core.PerformanceHistoryEntry { + if strings.TrimSpace(path) == "" { + return map[string][]core.PerformanceHistoryEntry{} + } + data, err := os.ReadFile(path) //nolint:gosec // config-supplied perf-history cache path + if err != nil { + return map[string][]core.PerformanceHistoryEntry{} + } + var file perfScoreHistoryFile + if err := json.Unmarshal(data, &file); err != nil || file.Version != perfScoreHistoryVersion || file.Entries == nil { + return map[string][]core.PerformanceHistoryEntry{} + } + return file.Entries +} + +// AppendPerfScoreHistory records a new observation for key, trims the history +// to limit entries, and persists the file. It returns the previous entry, if +// one existed, so callers can report score deltas. +func AppendPerfScoreHistory(path string, key string, entry core.PerformanceHistoryEntry, limit int) (core.PerformanceHistoryEntry, bool) { + if strings.TrimSpace(path) == "" || strings.TrimSpace(key) == "" { + return core.PerformanceHistoryEntry{}, false + } + if limit <= 0 { + limit = DefaultPerfScoreHistoryLimit + } + entries := LoadPerfScoreHistory(path) + history := entries[key] + var previous core.PerformanceHistoryEntry + hasPrevious := len(history) > 0 + if hasPrevious { + previous = history[len(history)-1] + } + history = append(history, entry) + if len(history) > limit { + history = history[len(history)-limit:] + } + entries[key] = history + savePerfScoreHistory(path, entries) + return previous, hasPrevious +} + +func savePerfScoreHistory(path string, entries map[string][]core.PerformanceHistoryEntry) { + payload := perfScoreHistoryFile{Version: perfScoreHistoryVersion, Entries: entries} + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return + } + _ = os.WriteFile(path, append(data, '\n'), 0o600) +} diff --git a/pkg/codeguard/sdk_baseline.go b/pkg/codeguard/sdk_baseline.go index 8f7fd6a..20e6b5c 100644 --- a/pkg/codeguard/sdk_baseline.go +++ b/pkg/codeguard/sdk_baseline.go @@ -16,6 +16,18 @@ func LoadSlopHistory(path string) map[string][]SlopHistoryEntry { return runner.LoadSlopHistory(path) } +// PerfScoreHistoryPath derives the performance-score history file path for a +// config. +func PerfScoreHistoryPath(cfg Config) string { + return runner.PerfScoreHistoryPath(cfg) +} + +// LoadPerfScoreHistory reads the persisted performance-score trend, keyed by +// artifact ID. +func LoadPerfScoreHistory(path string) map[string][]PerformanceHistoryEntry { + return runner.LoadPerfScoreHistory(path) +} + func BaselineEntriesFromReport(rep Report) []BaselineEntry { return runner.BaselineEntriesFromReport(rep) } diff --git a/pkg/codeguard/sdk_types_config_performance.go b/pkg/codeguard/sdk_types_config_performance.go new file mode 100644 index 0000000..7017053 --- /dev/null +++ b/pkg/codeguard/sdk_types_config_performance.go @@ -0,0 +1,7 @@ +package codeguard + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +type PerformanceRulesConfig = core.PerformanceRulesConfig +type PerformanceBudgetConfig = core.PerformanceBudgetConfig +type PerformanceBenchmarksConfig = core.PerformanceBenchmarksConfig diff --git a/pkg/codeguard/sdk_types_runtime_report.go b/pkg/codeguard/sdk_types_runtime_report.go index bdca75c..b555860 100644 --- a/pkg/codeguard/sdk_types_runtime_report.go +++ b/pkg/codeguard/sdk_types_runtime_report.go @@ -14,6 +14,8 @@ type ( ChangeRiskArtifact = core.ChangeRiskArtifact ChangeRiskComponent = core.ChangeRiskComponent SlopHistoryEntry = core.SlopHistoryEntry + PerformanceScoreArtifact = core.PerformanceScoreArtifact + PerformanceHistoryEntry = core.PerformanceHistoryEntry RuleStatsArtifact = core.RuleStatsArtifact RuleStatsEntry = core.RuleStatsEntry RuleStatsHistoryEntry = core.RuleStatsHistoryEntry diff --git a/tests/checks/performance_ai_semantic_test.go b/tests/checks/performance_ai_semantic_test.go new file mode 100644 index 0000000..60235a9 --- /dev/null +++ b/tests/checks/performance_ai_semantic_test.go @@ -0,0 +1,220 @@ +package checks_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// performanceAISemanticConfig enables both the quality and performance +// sections plus the verdict cache, mirroring the quality semantic harness. +func performanceAISemanticConfig(dir string, name string) codeguard.Config { + cfg := qualityAISemanticConfig(dir, name) + cfg.Checks.Performance = boolPtr(true) + return cfg +} + +func performanceSemanticDiff() string { + return stringsJoin( + "diff --git a/service.go b/service.go", + "--- a/service.go", + "+++ b/service.go", + "@@ -1,4 +1,5 @@", + " package sample", + " ", + "+// BuildUser loads settings per call.", + " func BuildUser() error {", + " \treturn nil", + " }", + ) +} + +const performanceSemanticVerdicts = `{"verdicts":[` + + `{"rule_id":"performance.ai.semantic-perf","path":"service.go","line":4,"message":"the same settings file is re-read and re-parsed on every call; load it once and cache the result"},` + + `{"rule_id":"quality.ai.contract-drift","path":"service.go","line":3,"message":"comment describes settings loading the implementation does not perform"}]}` + +func TestPerformanceSemanticFindingLandsInPerformanceSection(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") + counterPath := filepath.Join(dir, "semantic-calls.txt") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticScript(counterPath, performanceSemanticVerdicts)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + cfg := performanceAISemanticConfig(dir, "performance-ai-semantic") + for i := 0; i < 2; i++ { + report, err := codeguard.RunPatch(context.Background(), cfg, performanceSemanticDiff()) + if err != nil { + t.Fatalf("run patch %d: %v", i, err) + } + assertFindingRulePresent(t, report, "Performance", "performance.ai.semantic-perf") + assertFindingLevel(t, report, "Performance", "performance.ai.semantic-perf", "warn") + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.contract-drift") + assertSectionRuleAbsent(t, report, "Performance", "quality.ai.contract-drift") + assertSectionRuleAbsent(t, report, "Code Quality", "performance.ai.semantic-perf") + } + + // One combined request serves both sections and both scans: the quality + // and performance lenses share a single runtime invocation (in-process + // single-flight on the first scan, verdict cache on the second). + assertFileEquals(t, counterPath, "1") +} + +func TestPerformanceSemanticSkippedWhenPerformanceSectionIsOff(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, performanceSemanticVerdicts)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + cfg := performanceAISemanticConfig(dir, "performance-ai-semantic-off") + cfg.Checks.Performance = nil + + report, err := codeguard.RunPatch(context.Background(), cfg, performanceSemanticDiff()) + if err != nil { + t.Fatalf("run patch: %v", err) + } + + for _, section := range report.Sections { + if section.Name == "Performance" { + t.Fatal("performance section ran despite checks.performance being off") + } + } + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.contract-drift") + assertSectionRuleAbsent(t, report, "Code Quality", "performance.ai.semantic-perf") + assertFileEquals(t, counterPath, "1") + + // With the performance section off, the semantic request must be + // byte-identical to previous releases: no performance lens rides along. + data, err := os.ReadFile(requestPath) + if err != nil { + t.Fatalf("read request: %v", err) + } + if strings.Contains(string(data), "performance.ai.semantic-perf") { + t.Fatal("semantic request includes the performance lens while checks.performance is off") + } +} + +func TestPerformanceSemanticRunsWithQualitySectionDisabled(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, performanceSemanticVerdicts)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + cfg := performanceAISemanticConfig(dir, "performance-ai-semantic-only") + cfg.Checks.Quality = false + + report, err := codeguard.RunPatch(context.Background(), cfg, performanceSemanticDiff()) + if err != nil { + t.Fatalf("run patch: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.ai.semantic-perf") + assertSectionRuleAbsent(t, report, "Performance", "quality.ai.contract-drift") + for _, section := range report.Sections { + if section.Name == "Code Quality" { + t.Fatal("quality section ran despite checks.quality being false") + } + } + assertFileEquals(t, counterPath, "1") + assertPerformanceLensRequested(t, requestPath) +} + +type capturedSemanticRequest struct { + Checks []struct { + RuleID string `json:"rule_id"` + } `json:"checks"` + Prompt struct { + RuleInstructions []struct { + RuleID string `json:"rule_id"` + Focus string `json:"focus"` + Avoid []string `json:"avoid"` + } `json:"rule_instructions"` + } `json:"prompt"` +} + +// assertPerformanceLensRequested checks that the captured semantic request +// carries the performance check spec plus its structured prompt instruction. +func assertPerformanceLensRequested(t *testing.T, requestPath string) { + t.Helper() + data, err := os.ReadFile(requestPath) + if err != nil { + t.Fatalf("read request: %v", err) + } + var req capturedSemanticRequest + if err := json.Unmarshal(data, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + assertPerformanceCheckSpecPresent(t, req) + for _, instruction := range req.Prompt.RuleInstructions { + if instruction.RuleID != "performance.ai.semantic-perf" { + continue + } + if !strings.Contains(instruction.Focus, "cached or memoized") { + t.Fatalf("performance prompt focus = %q, want caching/memoization guidance", instruction.Focus) + } + if !strings.Contains(strings.Join(instruction.Avoid, " "), "micro-optimizations") { + t.Fatalf("performance prompt avoid list = %#v, want micro-optimization exclusion", instruction.Avoid) + } + return + } + t.Fatal("semantic request prompt missing the performance rule instruction") +} + +func assertPerformanceCheckSpecPresent(t *testing.T, req capturedSemanticRequest) { + t.Helper() + for _, check := range req.Checks { + if check.RuleID == "performance.ai.semantic-perf" { + return + } + } + t.Fatalf("semantic request checks missing performance lens: %#v", req.Checks) +} + +func TestPerformanceSemanticEmitsRuntimeFindingWhenCommandIsMissing(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + + cfg := performanceAISemanticConfig(dir, "performance-ai-semantic-runtime-missing") + cfg.Checks.Quality = false + + report, err := codeguard.RunPatch(context.Background(), cfg, performanceSemanticDiff()) + if err != nil { + t.Fatalf("run patch: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.ai.semantic-runtime") + assertFindingLevel(t, report, "Performance", "performance.ai.semantic-runtime", "fail") +} + +func assertSectionRuleAbsent(t *testing.T, report codeguard.Report, section string, ruleID string) { + t.Helper() + for _, result := range report.Sections { + if result.Name != section { + continue + } + for _, finding := range result.Findings { + if finding.RuleID == ruleID { + t.Fatalf("section %q unexpectedly contains rule %q", section, ruleID) + } + } + } +} diff --git a/tests/checks/performance_benchmarks_test.go b/tests/checks/performance_benchmarks_test.go new file mode 100644 index 0000000..d9c3304 --- /dev/null +++ b/tests/checks/performance_benchmarks_test.go @@ -0,0 +1,217 @@ +package checks_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/runner/benchregression" + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// fakeBenchOutput mimics real `go test -bench` output: headers, sub-benchmark +// names, GOMAXPROCS suffixes, float ns/op values, -benchmem columns, custom +// metrics, and trailer noise. The parser must ignore everything that is not a +// benchmark result line. +const fakeBenchOutput = `goos: darwin +goarch: arm64 +pkg: example.com/sample +cpu: Apple M3 +BenchmarkEncode-8 1000000 1234 ns/op 456 B/op 7 allocs/op +BenchmarkDecode/small-8 500000 2500.5 ns/op 1024 B/op 12 allocs/op +BenchmarkTiny-8 1000000000 0.5000 ns/op +BenchmarkWithMetric-8 200000 9000 ns/op 42.0 widgets/op 128 B/op 3 allocs/op +Benchmark +--- FAIL: BenchmarkBroken +PASS +ok example.com/sample 3.210s +` + +func TestBenchmarkOutputParser(t *testing.T) { + results := benchregression.ParseOutput(fakeBenchOutput) + byName := map[string]benchregression.Result{} + for _, result := range results { + byName[result.Name] = result + } + if len(results) != 4 { + t.Fatalf("parsed %d results, want 4: %+v", len(results), results) + } + encode := byName["BenchmarkEncode"] + if encode.NsPerOp != 1234 || encode.BytesPerOp != 456 || encode.AllocsPerOp != 7 || encode.Iterations != 1000000 { + t.Fatalf("BenchmarkEncode parsed wrong: %+v", encode) + } + if sub := byName["BenchmarkDecode/small"]; sub.NsPerOp != 2500.5 { + t.Fatalf("sub-benchmark parsed wrong: %+v", sub) + } + if tiny := byName["BenchmarkTiny"]; tiny.NsPerOp != 0.5 { + t.Fatalf("float ns/op parsed wrong: %+v", tiny) + } + if custom := byName["BenchmarkWithMetric"]; custom.NsPerOp != 9000 || custom.BytesPerOp != 128 { + t.Fatalf("custom-metric line parsed wrong: %+v", custom) + } +} + +func TestBenchmarkComparator(t *testing.T) { + baseline := map[string]benchregression.BaselineEntry{ + "BenchmarkStable": {NsPerOp: 1000}, + "BenchmarkRegressed": {NsPerOp: 1000}, + "BenchmarkImproved": {NsPerOp: 1000}, + "BenchmarkZero": {NsPerOp: 0}, + } + current := []benchregression.Result{ + {Name: "BenchmarkStable", NsPerOp: 1100}, // +10%: within the 20% threshold + {Name: "BenchmarkRegressed", NsPerOp: 1500}, // +50%: regression + {Name: "BenchmarkImproved", NsPerOp: 500}, // faster: never a finding + {Name: "BenchmarkZero", NsPerOp: 100}, // zero baseline: skipped + {Name: "BenchmarkNew", NsPerOp: 9999}, // absent from baseline: skipped + } + regressions := benchregression.Compare(baseline, current, 20) + if len(regressions) != 1 { + t.Fatalf("got %d regressions, want 1: %+v", len(regressions), regressions) + } + got := regressions[0] + if got.Name != "BenchmarkRegressed" || got.Percent != 50 || got.BaselineNsPerOp != 1000 || got.CurrentNsPerOp != 1500 { + t.Fatalf("regression fields wrong: %+v", got) + } +} + +func TestBenchmarkBaselineRoundTripAndMerge(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.bench-baseline.json") + results := []benchregression.Result{{Name: "BenchmarkA", NsPerOp: 100, BytesPerOp: 8, AllocsPerOp: 1}} + if err := benchregression.WriteBaseline(path, results); err != nil { + t.Fatalf("write baseline: %v", err) + } + baseline, ok := benchregression.LoadBaseline(path) + if !ok || baseline["BenchmarkA"].NsPerOp != 100 { + t.Fatalf("baseline roundtrip failed: ok=%v %+v", ok, baseline) + } + + // Merging adds new names but never overwrites existing entries: a regressed + // run must not silently become the new baseline. + added, err := benchregression.MergeNewBenchmarks(path, baseline, []benchregression.Result{ + {Name: "BenchmarkA", NsPerOp: 999}, + {Name: "BenchmarkB", NsPerOp: 50}, + }) + if err != nil || !added { + t.Fatalf("merge: added=%v err=%v", added, err) + } + merged, ok := benchregression.LoadBaseline(path) + if !ok || merged["BenchmarkA"].NsPerOp != 100 || merged["BenchmarkB"].NsPerOp != 50 { + t.Fatalf("merge result wrong: %+v", merged) + } + + if _, ok := benchregression.LoadBaseline(filepath.Join(t.TempDir(), "missing.json")); ok { + t.Fatal("missing baseline unexpectedly loaded") + } +} + +func TestBenchmarkBaselinePathDerivedFromCachePath(t *testing.T) { + if got := benchregression.BaselinePathForBase(".codeguard/cache.json"); got != ".codeguard/cache.bench-baseline.json" { + t.Fatalf("derived baseline path = %q", got) + } + if got := benchregression.BaselinePathForBase(""); got != "" { + t.Fatalf("empty base should derive empty path, got %q", got) + } +} + +func benchmarksConfig(name string, dir string, benchmarks codeguard.PerformanceBenchmarksConfig) codeguard.Config { + cfg := performanceConfig(name, dir, "go") + cfg.Checks.PerformanceRules.Benchmarks = benchmarks + return cfg +} + +func TestPerformanceBenchmarksFullScanRequiresExplicitPackages(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "lib.go"), "package sample\n") + + enabled := true + report, err := codeguard.Run(context.Background(), benchmarksConfig("bench-no-packages", dir, codeguard.PerformanceBenchmarksConfig{ + Enabled: &enabled, + })) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Performance", "warn") + assertFindingRulePresent(t, report, "Performance", "performance.benchmark-regression") + assertFindingMessageContains(t, report, "performance.benchmark-regression", "explicit performance_rules.benchmarks.packages") +} + +func assertFindingMessageContains(t *testing.T, report codeguard.Report, ruleID string, want string) { + t.Helper() + for _, section := range report.Sections { + for _, finding := range section.Findings { + if finding.RuleID == ruleID && strings.Contains(finding.Message, want) { + return + } + } + } + t.Fatalf("no %s finding containing %q", ruleID, want) +} + +func TestPerformanceBenchmarksPackageValidation(t *testing.T) { + dir := t.TempDir() + for _, pkg := range []string{"-bench=evil", "/abs/path", "../outside", "./ok; rm -rf", "pkg"} { + cfg := benchmarksConfig("bench-validate", dir, codeguard.PerformanceBenchmarksConfig{Packages: []string{pkg}}) + if err := codeguard.ValidateConfig(cfg); err == nil { + t.Fatalf("package pattern %q unexpectedly validated", pkg) + } + } + cfg := benchmarksConfig("bench-validate-ok", dir, codeguard.PerformanceBenchmarksConfig{Packages: []string{".", "./...", "./internal/..."}}) + if err := codeguard.ValidateConfig(cfg); err != nil { + t.Fatalf("valid package patterns rejected: %v", err) + } +} + +// TestPerformanceBenchmarkRegressionEndToEnd runs the real gate against a +// trivial one-benchmark module: the first scan seeds the baseline and reports +// nothing; after the baseline is doctored to a near-zero ns/op, the second +// scan reports a regression. This is the only test that actually executes +// `go test -bench`; everything else goes through the pure parser/comparator. +func TestPerformanceBenchmarkRegressionEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("runs real go benchmarks") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skipf("go binary unavailable: %v", err) + } + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/benchsample\n\ngo 1.21\n") + writeFile(t, filepath.Join(dir, "bench_test.go"), `package benchsample + +import "testing" + +func BenchmarkNoop(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = i + } +} +`) + baselinePath := filepath.Join(dir, ".codeguard", "bench-baseline.json") + enabled := true + cfg := benchmarksConfig("bench-end-to-end", dir, codeguard.PerformanceBenchmarksConfig{ + Enabled: &enabled, + Packages: []string{"."}, + BaselinePath: baselinePath, + }) + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("first run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.benchmark-regression") + if _, statErr := os.Stat(baselinePath); statErr != nil { + t.Fatalf("first run did not write the baseline: %v", statErr) + } + + // Doctor the baseline to an impossible speed so the second run regresses. + writeFile(t, baselinePath, `{"version": 1, "benchmarks": {"BenchmarkNoop": {"ns_per_op": 0.0000001}}}`) + report, err = codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("second run: %v", err) + } + assertSectionStatus(t, report, "Performance", "warn") + assertFindingMessageContains(t, report, "performance.benchmark-regression", "BenchmarkNoop regressed") +} diff --git a/tests/checks/performance_budgets_stats_test.go b/tests/checks/performance_budgets_stats_test.go new file mode 100644 index 0000000..57f88cd --- /dev/null +++ b/tests/checks/performance_budgets_stats_test.go @@ -0,0 +1,60 @@ +package checks_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestPerformanceBudgetBundleStatsEsbuildTotal(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "meta.json"), `{ + "outputs": { + "dist/app.js": {"bytes": 90000}, + "dist/vendor.js": {"bytes": 60000} + } +}`) + + report, err := codeguard.Run(context.Background(), budgetConfig("budget-esbuild", dir, []codeguard.PerformanceBudgetConfig{ + {Name: "bundle-total", Kind: "bundle-stats", Path: "meta.json", MaxBytes: 100000}, + })) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Performance", "warn") + findBudgetFindingMessage(t, report, "total 150000 bytes") +} + +func TestPerformanceBudgetBundleStatsWebpackPerAsset(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "stats.json"), `{ + "assets": [ + {"name": "main.js", "size": 120000}, + {"name": "vendor.js", "size": 30000} + ] +}`) + + cfg := budgetConfig("budget-webpack-asset", dir, []codeguard.PerformanceBudgetConfig{ + {Name: "main-bundle", Kind: "bundle-stats", Path: "stats.json", Asset: "main.js", MaxBytes: 100000}, + {Name: "vendor-bundle", Kind: "bundle-stats", Path: "stats.json", Asset: "vendor.js", MaxBytes: 100000}, + }) + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Performance", "warn") + findBudgetFindingMessage(t, report, `asset "main.js" is 120000 bytes`) + for _, section := range report.Sections { + if section.Name != "Performance" { + continue + } + for _, finding := range section.Findings { + if strings.Contains(finding.Message, "vendor-bundle") { + t.Fatalf("under-budget asset unexpectedly reported: %s", finding.Message) + } + } + } +} diff --git a/tests/checks/performance_budgets_test.go b/tests/checks/performance_budgets_test.go new file mode 100644 index 0000000..25f7002 --- /dev/null +++ b/tests/checks/performance_budgets_test.go @@ -0,0 +1,172 @@ +package checks_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func budgetConfig(name string, dir string, budgets []codeguard.PerformanceBudgetConfig) codeguard.Config { + cfg := performanceConfig(name, dir, "go") + cfg.Checks.PerformanceRules.Budgets = budgets + return cfg +} + +// findBudgetFindingMessage returns the first performance.budget finding +// message containing want, failing the test when none matches. +func findBudgetFindingMessage(t *testing.T, report codeguard.Report, want string) { + t.Helper() + for _, section := range report.Sections { + if section.Name != "Performance" { + continue + } + for _, finding := range section.Findings { + if finding.RuleID == "performance.budget" && strings.Contains(finding.Message, want) { + return + } + } + } + t.Fatalf("no performance.budget finding containing %q", want) +} + +func TestPerformanceBudgetFileSizeUnderBudgetPasses(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dist", "app.bin"), strings.Repeat("x", 100)) + + report, err := codeguard.Run(context.Background(), budgetConfig("budget-under", dir, []codeguard.PerformanceBudgetConfig{ + {Name: "binary", Kind: "file-size", Path: "dist/app.bin", MaxBytes: 200}, + })) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Performance", "pass") + assertFindingRuleAbsent(t, report, "Performance", "performance.budget") +} + +func TestPerformanceBudgetFileSizeOverBudgetFailLevel(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dist", "app.bin"), strings.Repeat("x", 500)) + + report, err := codeguard.Run(context.Background(), budgetConfig("budget-over-fail", dir, []codeguard.PerformanceBudgetConfig{ + {Name: "binary", Kind: "file-size", Path: "dist/app.bin", MaxBytes: 100, Level: "fail"}, + })) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Performance", "fail") + assertFindingRulePresent(t, report, "Performance", "performance.budget") + findBudgetFindingMessage(t, report, "totals 500 bytes") +} + +func TestPerformanceBudgetGlobSumsMatches(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dist", "a.js"), strings.Repeat("a", 300)) + writeFile(t, filepath.Join(dir, "dist", "b.js"), strings.Repeat("b", 300)) + + report, err := codeguard.Run(context.Background(), budgetConfig("budget-glob", dir, []codeguard.PerformanceBudgetConfig{ + {Name: "bundles", Kind: "file-size", Path: "dist/*.js", MaxBytes: 500}, + })) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Performance", "warn") + findBudgetFindingMessage(t, report, "totals 600 bytes") +} + +func TestPerformanceBudgetMissingArtifactWarnsOnly(t *testing.T) { + dir := t.TempDir() + + // level fail must not apply to a missing artifact: absence is a warn-level + // diagnostic, never a hard failure (dist/ may simply not be built here). + report, err := codeguard.Run(context.Background(), budgetConfig("budget-missing", dir, []codeguard.PerformanceBudgetConfig{ + {Name: "binary", Kind: "file-size", Path: "dist/app.bin", MaxBytes: 100, Level: "fail"}, + })) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Performance", "warn") + findBudgetFindingMessage(t, report, "not found") +} + +// TestPerformanceBudgetRelativeTargetPath guards a real containment bug: with +// a relative target path (the repository self-scan uses "."), EvalSymlinks +// keeps matches relative, and comparing them against the absolute canonical +// root falsely reported every artifact as escaping. +func TestPerformanceBudgetRelativeTargetPath(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dist", "app.bin"), strings.Repeat("x", 300)) + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + relDir, err := filepath.Rel(cwd, dir) + if err != nil { + t.Skipf("temp dir not relativizable from %s: %v", cwd, err) + } + + report, err := codeguard.Run(context.Background(), budgetConfig("budget-relative-target", relDir, []codeguard.PerformanceBudgetConfig{ + {Name: "binary", Kind: "file-size", Path: "dist/app.bin", MaxBytes: 100}, + })) + if err != nil { + t.Fatalf("run: %v", err) + } + assertSectionStatus(t, report, "Performance", "warn") + findBudgetFindingMessage(t, report, "totals 300 bytes") +} + +func TestPerformanceBudgetPathEscapeRejectedByValidation(t *testing.T) { + dir := t.TempDir() + cfg := budgetConfig("budget-escape", dir, []codeguard.PerformanceBudgetConfig{ + {Name: "escape", Kind: "file-size", Path: "../outside.bin", MaxBytes: 100}, + }) + if err := codeguard.ValidateConfig(cfg); err == nil || !strings.Contains(err.Error(), "..") { + t.Fatalf("expected validation error for escaping budget path, got %v", err) + } +} + +func TestPerformanceBudgetValidationRejectsBadEntries(t *testing.T) { + dir := t.TempDir() + cases := []struct { + label string + budget codeguard.PerformanceBudgetConfig + want string + }{ + {"empty name", codeguard.PerformanceBudgetConfig{Kind: "file-size", Path: "a", MaxBytes: 1}, "name is required"}, + {"unknown kind", codeguard.PerformanceBudgetConfig{Name: "x", Kind: "zip-size", Path: "a", MaxBytes: 1}, "kind must be"}, + {"non-positive max_bytes", codeguard.PerformanceBudgetConfig{Name: "x", Kind: "file-size", Path: "a", MaxBytes: 0}, "max_bytes must be positive"}, + {"absolute path", codeguard.PerformanceBudgetConfig{Name: "x", Kind: "file-size", Path: "/etc/passwd", MaxBytes: 1}, "must be relative"}, + {"asset on file-size", codeguard.PerformanceBudgetConfig{Name: "x", Kind: "file-size", Path: "a", Asset: "b", MaxBytes: 1}, "asset only applies"}, + {"bad level", codeguard.PerformanceBudgetConfig{Name: "x", Kind: "file-size", Path: "a", MaxBytes: 1, Level: "error"}, "level must be"}, + } + for _, tc := range cases { + cfg := budgetConfig("budget-validate", dir, []codeguard.PerformanceBudgetConfig{tc.budget}) + err := codeguard.ValidateConfig(cfg) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("%s: expected error containing %q, got %v", tc.label, tc.want, err) + } + } +} + +func TestPerformanceBudgetSymlinkEscapeRejected(t *testing.T) { + outside := t.TempDir() + writeFile(t, filepath.Join(outside, "big.bin"), strings.Repeat("x", 1000)) + dir := t.TempDir() + if err := os.Symlink(filepath.Join(outside, "big.bin"), filepath.Join(dir, "app.bin")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + report, err := codeguard.Run(context.Background(), budgetConfig("budget-symlink-escape", dir, []codeguard.PerformanceBudgetConfig{ + {Name: "binary", Kind: "file-size", Path: "app.bin", MaxBytes: 1, Level: "fail"}, + })) + if err != nil { + t.Fatalf("run: %v", err) + } + // The symlinked artifact resolves outside the target: the budget is skipped + // with a warn diagnostic instead of measuring (or failing on) the foreign file. + assertSectionStatus(t, report, "Performance", "warn") + findBudgetFindingMessage(t, report, "resolves outside the target directory") +} diff --git a/tests/checks/performance_complexity_regression_test.go b/tests/checks/performance_complexity_regression_test.go new file mode 100644 index 0000000..c25ffd9 --- /dev/null +++ b/tests/checks/performance_complexity_regression_test.go @@ -0,0 +1,213 @@ +package checks_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// initComplexityRepo builds a git repo whose main branch holds baseSource in +// main.go, then leaves the worktree on a feature branch so tests can write +// the changed revision and diff against main. +func initComplexityRepo(t *testing.T, baseSource string) string { + t.Helper() + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "main.go"), baseSource) + runGit(t, dir, "init", "-b", "main") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "CodeGuard Test") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "base") + runGit(t, dir, "checkout", "-b", "feature") + return dir +} + +func complexityRegressionConfig(dir string) codeguard.Config { + cfg := performanceConfig("performance-complexity-regression", dir, "go") + cfg.Cache.Enabled = boolPtr(false) + return cfg +} + +func runPerformanceDiffScan(t *testing.T, cfg codeguard.Config) codeguard.Report { + t.Helper() + report, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{ + Mode: codeguard.ScanModeDiff, + BaseRef: "main", + }) + if err != nil { + t.Fatalf("diff scan: %v", err) + } + return report +} + +func complexityRegressionFindings(report codeguard.Report) []codeguard.Finding { + findings := make([]codeguard.Finding, 0) + for _, section := range report.Sections { + for _, finding := range section.Findings { + if finding.RuleID == "performance.complexity-regression" { + findings = append(findings, finding) + } + } + } + return findings +} + +const ( + complexitySingleLoopBase = `package sample + +func UpdateAll(values []int) int { + total := 0 + for _, value := range values { + total += value + } + return total +} +` + + complexityNestedLoopHead = `package sample + +func UpdateAll(values []int) int { + total := 0 + for _, value := range values { + for step := 0; step < value; step++ { + total += step + } + } + return total +} +` +) + +func TestComplexityRegressionWarnsOnNestingDepthIncrease(t *testing.T) { + dir := initComplexityRepo(t, complexitySingleLoopBase) + writeFile(t, filepath.Join(dir, "main.go"), complexityNestedLoopHead) + + report := runPerformanceDiffScan(t, complexityRegressionConfig(dir)) + + findings := complexityRegressionFindings(report) + if len(findings) != 1 { + t.Fatalf("complexity-regression findings = %d, want 1: %+v", len(findings), findings) + } + finding := findings[0] + if finding.Level != "warn" { + t.Fatalf("finding level = %q, want warn", finding.Level) + } + if finding.Path != "main.go" { + t.Fatalf("finding path = %q, want main.go", finding.Path) + } + want := "function UpdateAll: loop nesting depth increased from 1 to 2 in this change" + if !strings.Contains(finding.Message, want) { + t.Fatalf("message = %q, want it to contain %q", finding.Message, want) + } + if finding.Line <= 0 { + t.Fatalf("finding line = %d, want a positive changed line", finding.Line) + } +} + +func TestComplexityRegressionMatchesMethodsByReceiver(t *testing.T) { + dir := initComplexityRepo(t, `package sample + +type Store struct{ items []int } + +func (s *Store) UpdateAll() int { + total := 0 + for _, item := range s.items { + total += item + } + return total +} +`) + writeFile(t, filepath.Join(dir, "main.go"), `package sample + +type Store struct{ items []int } + +func (s *Store) UpdateAll() int { + total := 0 + for round := 0; round < 2; round++ { + for _, item := range s.items { + total += item * round + } + } + return total +} +`) + + report := runPerformanceDiffScan(t, complexityRegressionConfig(dir)) + + findings := complexityRegressionFindings(report) + if len(findings) != 1 { + t.Fatalf("complexity-regression findings = %d, want 1: %+v", len(findings), findings) + } + want := "function Store.UpdateAll: loop nesting depth increased from 1 to 2" + if !strings.Contains(findings[0].Message, want) { + t.Fatalf("message = %q, want it to contain %q", findings[0].Message, want) + } +} + +func TestComplexityRegressionSkipsFunctionsNotInBase(t *testing.T) { + dir := initComplexityRepo(t, complexitySingleLoopBase) + // A brand-new function with nested loops has no base version to regress + // from; the existing function is untouched. + writeFile(t, filepath.Join(dir, "main.go"), complexitySingleLoopBase+` +func Pairs(values []int) int { + count := 0 + for _, left := range values { + for _, right := range values { + if left < right { + count++ + } + } + } + return count +} +`) + + report := runPerformanceDiffScan(t, complexityRegressionConfig(dir)) + + if findings := complexityRegressionFindings(report); len(findings) != 0 { + t.Fatalf("expected no complexity-regression findings for new functions, got %+v", findings) + } +} + +func TestComplexityRegressionSilentWhenDepthUnchanged(t *testing.T) { + dir := initComplexityRepo(t, complexityNestedLoopHead) + // Touch a line inside the nested loop without changing the nesting depth. + writeFile(t, filepath.Join(dir, "main.go"), strings.Replace( + complexityNestedLoopHead, "total += step", "total += step + 1", 1)) + + report := runPerformanceDiffScan(t, complexityRegressionConfig(dir)) + + if findings := complexityRegressionFindings(report); len(findings) != 0 { + t.Fatalf("expected no complexity-regression findings when depth is unchanged, got %+v", findings) + } +} + +func TestComplexityRegressionSilentInFullScan(t *testing.T) { + dir := initComplexityRepo(t, complexitySingleLoopBase) + writeFile(t, filepath.Join(dir, "main.go"), complexityNestedLoopHead) + + report, err := codeguard.Run(context.Background(), complexityRegressionConfig(dir)) + if err != nil { + t.Fatalf("full scan: %v", err) + } + + if findings := complexityRegressionFindings(report); len(findings) != 0 { + t.Fatalf("expected the rule to stay silent in full-scan mode, got %+v", findings) + } +} + +func TestComplexityRegressionToggleDisablesRule(t *testing.T) { + dir := initComplexityRepo(t, complexitySingleLoopBase) + writeFile(t, filepath.Join(dir, "main.go"), complexityNestedLoopHead) + + cfg := complexityRegressionConfig(dir) + cfg.Checks.PerformanceRules.DetectComplexityRegression = boolPtr(false) + report := runPerformanceDiffScan(t, cfg) + + if findings := complexityRegressionFindings(report); len(findings) != 0 { + t.Fatalf("expected no findings with detect_complexity_regression disabled, got %+v", findings) + } +} diff --git a/tests/checks/performance_frameworks_python_test.go b/tests/checks/performance_frameworks_python_test.go new file mode 100644 index 0000000..faef9f9 --- /dev/null +++ b/tests/checks/performance_frameworks_python_test.go @@ -0,0 +1,142 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// Framework-aware rules (performance_rules.detect_framework_patterns): each +// rule is gated on file-level framework evidence, so every positive fixture +// carries an import/idiom and every negative test proves either the evidence +// gate or the rule's exemption. + +func TestPerformanceCheckWarnsForDjangoRelationAccessInQuerysetLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "emails.py"), + "from django.db import models\n\nfrom app.models import Order\n\n\ndef send_receipts():\n for order in Order.objects.all():\n send(order.customer.email)\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-django-relation", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.python.django-nplusone-relation") +} + +func TestPerformanceCheckWarnsForDjangoReverseRelationOnTrackedQuerysetVar(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "counts.py"), + "from django.db import models\n\nfrom app.models import User\n\n\ndef order_counts():\n users = User.objects.filter(active=True)\n counts = {}\n for user in users:\n counts[user.pk] = user.order_set.count()\n return counts\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-django-reverse", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.python.django-nplusone-relation") +} + +func TestPerformanceCheckSkipsDjangoRelationWhenSelectRelatedPresent(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "emails.py"), + "from django.db import models\n\nfrom app.models import Order\n\n\ndef send_receipts():\n for order in Order.objects.select_related(\"customer\"):\n send(order.customer.email)\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-django-prefetched", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.python.django-nplusone-relation") +} + +func TestPerformanceCheckSkipsRelationChainsWithoutDjangoEvidence(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "plain.py"), + "def send_all(orders):\n for order in orders:\n send(order.customer.email)\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-no-django", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.python.django-nplusone-relation") +} + +func TestPerformanceCheckSkipsScalarMethodChainsInQuerysetLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "names.py"), + "from django.db import models\n\nfrom app.models import User\n\n\ndef names():\n out = []\n for user in User.objects.all():\n out.append(user.name.strip())\n return out\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-scalar-chain", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.python.django-nplusone-relation") +} + +func TestPerformanceCheckWarnsForDjangoORMQueryInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "articles.py"), + "from django.db import models\n\nfrom app.models import Article\n\n\ndef load(ids):\n out = []\n for pk in ids:\n out.append(Article.objects.get(pk=pk))\n return out\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-orm-loop", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.python.orm-query-in-loop") + // Disjointness: the generic n-plus-one pattern does not cover + // .objects.get, so the line must not double-report. + assertFindingRuleAbsent(t, report, "Performance", "performance.n-plus-one-query") +} + +func TestPerformanceCheckWarnsForSQLAlchemySessionGetInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "rows.py"), + "from sqlalchemy.orm import Session\n\nfrom app.models import User\n\n\ndef load(session, ids):\n rows = []\n for pk in ids:\n rows.append(session.get(User, pk))\n return rows\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-sqlalchemy-loop", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.python.orm-query-in-loop") +} + +func TestPerformanceCheckSkipsRequestsSessionGetInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "pages.py"), + "import requests\n\n\ndef load(urls):\n session = requests.Session()\n out = []\n for url in urls:\n out.append(session.get(url))\n return out\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-requests-session", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + // session.get without SQLAlchemy evidence is an HTTP session, not the ORM. + assertFindingRuleAbsent(t, report, "Performance", "performance.python.orm-query-in-loop") +} + +func TestPerformanceCheckSkipsORMQueryOutsideLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "single.py"), + "from django.db import models\n\nfrom app.models import Article\n\n\ndef load_one(pk):\n return Article.objects.get(pk=pk)\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-orm-no-loop", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.python.orm-query-in-loop") +} + +func TestPerformanceCheckFrameworkPatternsToggleDisablesRules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "emails.py"), + "from django.db import models\n\nfrom app.models import Order\n\n\ndef send_receipts():\n for order in Order.objects.all():\n send(order.customer.email)\n refresh(Order.objects.get(pk=order.pk))\n") + + cfg := performanceConfig("performance-py-frameworks-off", dir, "python") + cfg.Checks.PerformanceRules.DetectFrameworkPatterns = boolPtr(false) + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.python.django-nplusone-relation") + assertFindingRuleAbsent(t, report, "Performance", "performance.python.orm-query-in-loop") +} diff --git a/tests/checks/performance_frameworks_scripts_test.go b/tests/checks/performance_frameworks_scripts_test.go new file mode 100644 index 0000000..445325d --- /dev/null +++ b/tests/checks/performance_frameworks_scripts_test.go @@ -0,0 +1,104 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// Framework-aware TypeScript/JavaScript rules +// (performance_rules.detect_framework_patterns): React render-cost and +// Express middleware tests, including the negative cases that prove the +// evidence gates and exemptions hold. + +func TestPerformanceCheckWarnsForReactExpensiveRender(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "ItemList.tsx"), + "import React from \"react\";\n\nexport function ItemList({ items }: Props) {\n const rows = items.filter((i) => i.active).map((i) => render(i));\n return
    {rows}
;\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-react-render", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.typescript.react-expensive-render") +} + +func TestPerformanceCheckWarnsForReactJSONParseInComponentBody(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "Config.tsx"), + "import React from \"react\";\n\nexport function ConfigPanel({ raw }: Props) {\n const config = JSON.parse(raw);\n return
{config.name}
;\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-react-parse", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.typescript.react-expensive-render") +} + +func TestPerformanceCheckSkipsReactRenderWorkInsideUseMemo(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "ItemList.tsx"), + "import React, { useMemo } from \"react\";\n\nexport function ItemList({ items }: Props) {\n const rows = useMemo(\n () => items.filter((i) => i.active).map((i) => render(i)),\n [items],\n );\n return
    {rows}
;\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-react-memoized", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.react-expensive-render") +} + +func TestPerformanceCheckSkipsArrayChainsWithoutReactEvidence(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "transform.ts"), + "export function BuildList(items: Item[]) {\n return items.filter((i) => i.active).map((i) => render(i));\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-no-react", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.react-expensive-render") +} + +func TestPerformanceCheckWarnsForExpressSyncMiddleware(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "server.ts"), + "import express from \"express\";\nimport bcrypt from \"bcrypt\";\n\nconst app = express();\n\napp.use((req, res, next) => {\n req.hash = bcrypt.hashSync(req.body.password, 10);\n next();\n});\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-express-sync", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.typescript.express-sync-middleware") + // Precedence: the specific middleware rule replaces the generic sync-io + // finding on the same line, so the line never reports twice. + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.sync-io-in-handler") +} + +func TestPerformanceCheckKeepsGenericSyncIOForNonCPUHeavyMiddlewareCalls(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "server.ts"), + "import express from \"express\";\nimport fs from \"fs\";\n\nconst app = express();\n\napp.use((req, res, next) => {\n res.locals.config = fs.readFileSync(\"config.json\", \"utf8\");\n next();\n});\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-express-fs", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.express-sync-middleware") + assertFindingRulePresent(t, report, "Performance", "performance.typescript.sync-io-in-handler") +} + +func TestPerformanceCheckSkipsSyncMiddlewareWithoutExpressEvidence(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "server.ts"), + "import bcrypt from \"bcrypt\";\n\nconst app = createApp();\n\napp.use((req, res, next) => {\n req.hash = bcrypt.hashSync(req.body.password, 10);\n next();\n});\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-no-express", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.express-sync-middleware") + // Without express evidence the generic sync-io rule still applies. + assertFindingRulePresent(t, report, "Performance", "performance.typescript.sync-io-in-handler") +} diff --git a/tests/checks/performance_score_test.go b/tests/checks/performance_score_test.go new file mode 100644 index 0000000..1613cae --- /dev/null +++ b/tests/checks/performance_score_test.go @@ -0,0 +1,194 @@ +package checks_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// writePerformanceScoreFixture produces one N+1 finding (family weight 5) +// and one string-concat-in-loop finding (family weight 1), so the expected +// score is min(10*(5+1), 100) = 60 with 2 signals. +func writePerformanceScoreFixture(t *testing.T, dir string) { + t.Helper() + writeFile(t, filepath.Join(dir, "service.py"), `def render(items, cursor): + out = "" + for item in items: + row = cursor.execute("SELECT name FROM users WHERE id = ?") + out += str(row) + return out +`) +} + +func performanceScoreConfig(dir string, name string) codeguard.Config { + cfg := performanceConfig(name, dir, "python") + enabled := true + cfg.Cache = codeguard.CacheConfig{ + Enabled: &enabled, + Path: filepath.Join(dir, ".codeguard", "cache.json"), + } + return cfg +} + +func runPerformanceScoreScan(t *testing.T, cfg codeguard.Config, label string) *codeguard.PerformanceScoreArtifact { + t.Helper() + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("%s run: %v", label, err) + } + return findPerformanceScoreArtifact(t, report) +} + +func findPerformanceScoreArtifact(t *testing.T, report codeguard.Report) *codeguard.PerformanceScoreArtifact { + t.Helper() + for _, artifact := range report.Artifacts { + if artifact.Kind == "performance_score" && artifact.PerformanceScore != nil { + return artifact.PerformanceScore + } + } + t.Fatalf("expected performance_score artifact, got %#v", report.Artifacts) + return nil +} + +func TestPerformanceScoreArtifactComputesWeightedScore(t *testing.T) { + dir := t.TempDir() + writePerformanceScoreFixture(t, dir) + cfg := performanceScoreConfig(dir, "performance-score") + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + found := false + for _, artifact := range report.Artifacts { + if artifact.Kind != "performance_score" { + continue + } + found = true + if artifact.ID != "performance_score.python.repo" { + t.Errorf("artifact ID = %q, want performance_score.python.repo", artifact.ID) + } + score := artifact.PerformanceScore + if score == nil { + t.Fatal("performance_score artifact has no payload") + } + if score.Score != 60 { + t.Errorf("score = %d, want 60 (min(10*(5+1), 100))", score.Score) + } + if score.Signals != 2 { + t.Errorf("signals = %d, want 2", score.Signals) + } + if len(score.Components) != 2 { + t.Fatalf("components = %#v, want 2 entries", score.Components) + } + nPlusOne := score.Components[0] + concat := score.Components[1] + if nPlusOne.RuleID != "performance.n-plus-one-query" || nPlusOne.Weight != 5 || nPlusOne.Count != 1 || nPlusOne.Contribution != 5 { + t.Errorf("unexpected n-plus-one component: %#v", nPlusOne) + } + if concat.RuleID != "performance.string-concat-in-loop" || concat.Weight != 1 || concat.Count != 1 || concat.Contribution != 1 { + t.Errorf("unexpected string-concat component: %#v", concat) + } + } + if !found { + t.Fatalf("expected performance_score artifact, got %#v", report.Artifacts) + } +} + +func TestPerformanceScoreAbsentWithoutFindings(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.py"), `def render(items): + return [item for item in items] +`) + + report, err := codeguard.Run(context.Background(), performanceScoreConfig(dir, "performance-score-clean")) + if err != nil { + t.Fatalf("run: %v", err) + } + for _, artifact := range report.Artifacts { + if artifact.Kind == "performance_score" { + t.Fatalf("expected no performance_score artifact on a clean target, got %#v", artifact) + } + } +} + +func TestPerformanceScoreHistoryRecordsTrendAndDelta(t *testing.T) { + dir := t.TempDir() + writePerformanceScoreFixture(t, dir) + cfg := performanceScoreConfig(dir, "performance-score-history") + + first := runPerformanceScoreScan(t, cfg, "first") + if first.PreviousScore != nil || first.Delta != nil { + t.Fatalf("first scan should have no previous score, got %#v", first) + } + + historyPath := codeguard.PerfScoreHistoryPath(cfg) + if _, err := os.Stat(historyPath); err != nil { + t.Fatalf("expected history file at %s: %v", historyPath, err) + } + + second := runPerformanceScoreScan(t, cfg, "second") + if second.PreviousScore == nil || second.Delta == nil { + t.Fatalf("second scan should report previous score and delta, got %#v", second) + } + if *second.PreviousScore != first.Score { + t.Fatalf("previous score = %d, want %d", *second.PreviousScore, first.Score) + } + if *second.Delta != second.Score-first.Score { + t.Fatalf("delta = %d, want %d", *second.Delta, second.Score-first.Score) + } + + history := codeguard.LoadPerfScoreHistory(historyPath) + if len(history) == 0 { + t.Fatal("expected non-empty performance-score history") + } + for key, entries := range history { + if len(entries) != 2 { + t.Fatalf("history[%s] entries = %d, want 2", key, len(entries)) + } + for _, entry := range entries { + if entry.Timestamp == "" || entry.Score <= 0 || entry.Signals <= 0 || len(entry.Components) == 0 { + t.Fatalf("incomplete history entry: %#v", entry) + } + } + } +} + +func TestPerformanceScoreHistoryHonorsToggle(t *testing.T) { + dir := t.TempDir() + writePerformanceScoreFixture(t, dir) + cfg := performanceScoreConfig(dir, "performance-score-history-toggle") + disabled := false + cfg.Checks.PerformanceRules.ScoreHistory = &disabled + + if _, err := codeguard.Run(context.Background(), cfg); err != nil { + t.Fatalf("run: %v", err) + } + if _, err := os.Stat(codeguard.PerfScoreHistoryPath(cfg)); !os.IsNotExist(err) { + t.Fatalf("expected no history file when disabled, stat err = %v", err) + } +} + +func TestPerformanceScoreHistoryCapsEntries(t *testing.T) { + dir := t.TempDir() + writePerformanceScoreFixture(t, dir) + cfg := performanceScoreConfig(dir, "performance-score-history-cap") + cfg.Checks.PerformanceRules.ScoreHistoryLimit = 2 + + for i := 0; i < 3; i++ { + if _, err := codeguard.Run(context.Background(), cfg); err != nil { + t.Fatalf("run %d: %v", i, err) + } + } + + history := codeguard.LoadPerfScoreHistory(codeguard.PerfScoreHistoryPath(cfg)) + for key, entries := range history { + if len(entries) != 2 { + t.Fatalf("history[%s] entries = %d, want capped at 2", key, len(entries)) + } + } +} diff --git a/tests/checks/performance_scripts_test.go b/tests/checks/performance_scripts_test.go new file mode 100644 index 0000000..2cb08be --- /dev/null +++ b/tests/checks/performance_scripts_test.go @@ -0,0 +1,206 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestPerformanceCheckWarnsForTypeScriptFetchInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "users.ts"), + "export async function loadUsers(ids: string[]) {\n const users = [];\n for (const id of ids) {\n const res = await fetch(`/api/users/${id}`);\n users.push(await res.json());\n }\n return users;\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-nplusone", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.n-plus-one-query") +} + +func TestPerformanceCheckWarnsForTypeScriptSyncIOInHandler(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "server.ts"), + "import fs from \"fs\";\nimport express from \"express\";\n\nconst app = express();\n\napp.get(\"/report\", (req, res) => {\n const data = fs.readFileSync(\"report.txt\", \"utf8\");\n res.send(data);\n});\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-sync-io", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.typescript.sync-io-in-handler") +} + +func TestPerformanceCheckWarnsForTypeScriptUnboundedConcurrency(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "warm.ts"), + "export function warm(urls: string[]) {\n const tasks = [];\n for (const url of urls) {\n tasks.push(fetch(url));\n }\n return Promise.all(tasks);\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-unbounded", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.typescript.unbounded-concurrency") +} + +func TestPerformanceCheckSkipsTypeScriptPerformanceSmellsOutsideRegions(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "clean.ts"), + "import fs from \"fs\";\n\nconst config = fs.readFileSync(\"config.json\", \"utf8\");\n\nexport async function loadUser(id: string) {\n const res = await fetch(`/api/users/${id}`);\n return res.json();\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-clean", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Performance", "performance.n-plus-one-query") + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.sync-io-in-handler") + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.unbounded-concurrency") +} + +func TestPerformanceCheckSkipsTypeScriptUnboundedConcurrencyWithPLimit(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "warm.ts"), + "import pLimit from \"p-limit\";\n\nconst limit = pLimit(4);\n\nexport function warm(urls: string[]) {\n const tasks = [];\n for (const url of urls) {\n tasks.push(limit(() => fetch(url)));\n }\n return Promise.all(tasks);\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-plimit", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.unbounded-concurrency") +} + +func TestPerformanceCheckWarnsForPythonRequestsInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "loader.py"), + "import requests\n\n\ndef load(ids):\n out = []\n for item in ids:\n out.append(requests.get(\"https://example.com/api/%s\" % item))\n return out\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-nplusone", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.n-plus-one-query") +} + +func TestPerformanceCheckWarnsForPythonBlockingCallInAsync(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "pause.py"), + "import time\n\n\nasync def pause():\n time.sleep(1)\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-sync-async", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.python.sync-io-in-async") +} + +func TestPerformanceCheckWarnsForTypeScriptAwaitInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "serial.ts"), + "export async function loadUsers(ids: string[]) {\n const users = [];\n for (const id of ids) {\n const user = await loadUser(id);\n users.push(user);\n }\n return users;\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-await-loop", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.typescript.await-in-loop") +} + +func TestPerformanceCheckSkipsForAwaitStreams(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "stream.ts"), + "export async function drain(stream: AsyncIterable) {\n for await (const chunk of stream) {\n consume(chunk);\n }\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-for-await", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.await-in-loop") +} + +func TestPerformanceCheckWarnsForTypeScriptRegexAndConcatInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "render.ts"), + "export function render(rows: string[]) {\n let out = \"\";\n for (const row of rows) {\n const re = new RegExp(\"[0-9]+\");\n if (re.test(row)) {\n out += row + \"\\n\";\n }\n }\n return out;\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-regex-concat", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.regex-compile-in-loop") + assertFindingRulePresent(t, report, "Performance", "performance.string-concat-in-loop") +} + +func TestPerformanceCheckWarnsForTypeScriptTimerAndListenerLeaks(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "poll.ts"), + "export function start(items: Element[]) {\n setInterval(refresh, 1000);\n for (const el of items) {\n el.addEventListener(\"click\", onClick);\n }\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-leaks", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.typescript.timer-listener-leak") +} + +func TestPerformanceCheckSkipsCleanedUpTimersAndListeners(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "poll.ts"), + "export function start(items: Element[]) {\n const handle = setInterval(refresh, 1000);\n const controller = new AbortController();\n for (const el of items) {\n el.addEventListener(\"click\", onClick, { signal: controller.signal });\n }\n return () => {\n clearInterval(handle);\n controller.abort();\n };\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-ts-leaks-cleaned", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.typescript.timer-listener-leak") +} + +func TestPerformanceCheckWarnsForPythonRegexConcatTasksReads(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "batch.py"), + "import asyncio\nimport re\n\n\nasync def process(files, urls, lines):\n out = \"\"\n for line in lines:\n pattern = re.compile(r\"\\d+\")\n if pattern.match(line):\n out += line\n for url in urls:\n asyncio.create_task(fetch(url))\n for f in files:\n data = f.read()\n use(data)\n return out\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-batch", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.regex-compile-in-loop") + assertFindingRulePresent(t, report, "Performance", "performance.string-concat-in-loop") + assertFindingRulePresent(t, report, "Performance", "performance.python.unbounded-concurrency") + assertFindingRulePresent(t, report, "Performance", "performance.unbounded-read") +} + +func TestPerformanceCheckSkipsBoundedPythonPatterns(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "clean_batch.py"), + "import asyncio\n\nsem = asyncio.Semaphore(8)\n\n\nasync def process(files, urls, counts):\n total = 0\n for n in counts:\n total += n\n for url in urls:\n asyncio.create_task(bounded_fetch(url))\n for f in files:\n data = f.read(65536)\n use(data)\n return total\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-bounded", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.string-concat-in-loop") + assertFindingRuleAbsent(t, report, "Performance", "performance.python.unbounded-concurrency") + assertFindingRuleAbsent(t, report, "Performance", "performance.unbounded-read") +} + +func TestPerformanceCheckSkipsPythonPerformanceSmellsOutsideRegions(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "clean.py"), + "import time\n\nimport requests\n\n\ndef load_once(url):\n return requests.get(url)\n\n\ndef sleepy():\n time.sleep(1)\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-py-clean", dir, "python")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Performance", "performance.n-plus-one-query") + assertFindingRuleAbsent(t, report, "Performance", "performance.python.sync-io-in-async") +} diff --git a/tests/checks/performance_test.go b/tests/checks/performance_test.go new file mode 100644 index 0000000..a31cb3c --- /dev/null +++ b/tests/checks/performance_test.go @@ -0,0 +1,389 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// performanceConfig enables only the performance section (which is opt-in by +// default) so section assertions are isolated from the other check families. +func performanceConfig(name string, dir string, language string) codeguard.Config { + cfg := codeguard.ExampleConfig() + cfg.Name = name + cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: language}} + cfg.Checks.Performance = boolPtr(true) + cfg.Checks.Quality = false + cfg.Checks.Design = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + return cfg +} + +func TestPerformanceCheckOffByDefault(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "worker.go"), `package sample + +func dispatch(items []int) { + for _, item := range items { + go func(value int) { + _ = value + }(item) + } +} +`) + + cfg := performanceConfig("performance-default-off", dir, "go") + cfg.Checks.Performance = nil + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + for _, section := range report.Sections { + if section.Name == "Performance" { + t.Fatal("performance section ran despite checks.performance being false") + } + } +} + +func TestPerformanceCheckWarnsForSyncIOInRequestPath(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "handler.go"), `package sample + +import ( + "net/http" + "os" +) + +func handle(w http.ResponseWriter, r *http.Request) { + _, _ = os.ReadFile("payload.json") + _, _ = w.Write([]byte("ok")) +} +`) + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-sync-io-request-path", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Performance", "warn") + assertFindingRulePresent(t, report, "Performance", "performance.sync-io-in-request-path") +} + +func TestPerformanceCheckWarnsForGoroutinesInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "worker.go"), `package sample + +func dispatch(items []int) { + for _, item := range items { + go func(value int) { + _ = value + }(item) + } +} +`) + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-unbounded-goroutines", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Performance", "warn") + assertFindingRulePresent(t, report, "Performance", "performance.unbounded-goroutines-in-loop") +} + +func TestPerformanceCheckGoTogglesGateGoRules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "worker.go"), `package sample + +import ( + "net/http" + "os" +) + +func handle(w http.ResponseWriter, r *http.Request) { + _, _ = os.ReadFile("payload.json") + for range []int{1, 2} { + go func() {}() + } +} +`) + + off := false + cfg := performanceConfig("performance-go-toggles-off", dir, "go") + cfg.Checks.PerformanceRules.DetectUnboundedConcurrency = &off + cfg.Checks.PerformanceRules.DetectSyncIOInHandlers = &off + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Performance", "performance.unbounded-goroutines-in-loop") + assertFindingRuleAbsent(t, report, "Performance", "performance.sync-io-in-request-path") +} + +func TestPerformanceCheckWarnsForGoQueryInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "repo.go"), + "package repo\n\nimport \"database/sql\"\n\nfunc UpdateAll(db *sql.DB, ids []int) error {\n\tfor _, id := range ids {\n\t\tif _, err := db.Exec(\"UPDATE items SET done = 1 WHERE id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-nplusone", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.n-plus-one-query") +} + +func TestPerformanceCheckSkipsGoQueryOutsideLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "repo.go"), + "package repo\n\nimport \"database/sql\"\n\nfunc UpdateOne(db *sql.DB, id int) error {\n\t_, err := db.Exec(\"UPDATE items SET done = 1 WHERE id = ?\", id)\n\treturn err\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-nplusone-neg", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Performance", "performance.n-plus-one-query") +} + +func TestPerformanceCheckWarnsForGoAllocInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "report.go"), + "package report\n\nimport \"fmt\"\n\nfunc Describe(items []string) string {\n\tout := \"\"\n\tfor _, item := range items {\n\t\tout += fmt.Sprintf(\"- %s\\n\", item)\n\t}\n\treturn out\n}\n\nfunc Gather(items []string) []string {\n\tvar values []string\n\tfor _, item := range items {\n\t\tvalues = append(values, item)\n\t}\n\treturn values\n}\n") + + on := true + cfg := performanceConfig("performance-go-alloc", dir, "go") + cfg.Checks.PerformanceRules.DetectPreallocInLoop = &on + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.go.alloc-in-loop") +} + +func TestPerformanceCheckWarnsForAppendWithoutPreallocWhenEnabled(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "report.go"), + "package report\n\nfunc Gather(items []string) []string {\n\tvar values []string\n\tfor _, item := range items {\n\t\tvalues = append(values, item)\n\t}\n\treturn values\n}\n") + + on := true + cfg := performanceConfig("performance-go-prealloc-on", dir, "go") + cfg.Checks.PerformanceRules.DetectPreallocInLoop = &on + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Performance", "performance.go.alloc-in-loop") +} + +func TestPerformanceCheckPreallocInLoopDefaultOff(t *testing.T) { + appendDir := t.TempDir() + writeFile(t, filepath.Join(appendDir, "report.go"), + "package report\n\nfunc Gather(items []string) []string {\n\tvar values []string\n\tfor _, item := range items {\n\t\tvalues = append(values, item)\n\t}\n\treturn values\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-prealloc-default", appendDir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.go.alloc-in-loop") + + concatDir := t.TempDir() + writeFile(t, filepath.Join(concatDir, "report.go"), + "package report\n\nfunc Describe(items []string) string {\n\tout := \"\"\n\tfor _, item := range items {\n\t\tout += \"- \" + item\n\t}\n\treturn out\n}\n") + + report, err = codeguard.Run(context.Background(), performanceConfig("performance-go-concat-default", concatDir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.go.alloc-in-loop") +} + +func TestPerformanceCheckSkipsPreallocatedAppendInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "report.go"), + "package report\n\nfunc Gather(items []string) []string {\n\tvalues := make([]string, 0, len(items))\n\tfor _, item := range items {\n\t\tvalues = append(values, item)\n\t}\n\treturn values\n}\n") + + on := true + cfg := performanceConfig("performance-go-alloc-neg", dir, "go") + cfg.Checks.PerformanceRules.DetectPreallocInLoop = &on + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Performance", "performance.go.alloc-in-loop") +} + +func TestPerformanceCheckAllocInLoopToggleOff(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "report.go"), + "package report\n\nimport \"fmt\"\n\nfunc Describe(items []string) string {\n\tout := \"\"\n\tfor _, item := range items {\n\t\tout += fmt.Sprintf(\"- %s\\n\", item)\n\t}\n\treturn out\n}\n") + + off := false + cfg := performanceConfig("performance-go-alloc-off", dir, "go") + cfg.Checks.PerformanceRules.DetectAllocInLoop = &off + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Performance", "performance.go.alloc-in-loop") +} + +func TestPerformanceCheckWarnsForRegexCompileInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "match.go"), + "package match\n\nimport \"regexp\"\n\nfunc CountDigits(lines []string) int {\n\ttotal := 0\n\tfor _, line := range lines {\n\t\tre := regexp.MustCompile(`[0-9]+`)\n\t\tif re.MatchString(line) {\n\t\t\ttotal++\n\t\t}\n\t}\n\treturn total\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-regex-loop", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.regex-compile-in-loop") +} + +func TestPerformanceCheckSkipsHoistedRegexCompile(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "match.go"), + "package match\n\nimport \"regexp\"\n\nvar digits = regexp.MustCompile(`[0-9]+`)\n\nfunc CountDigits(lines []string) int {\n\ttotal := 0\n\tfor _, line := range lines {\n\t\tif digits.MatchString(line) {\n\t\t\ttotal++\n\t\t}\n\t}\n\treturn total\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-regex-hoisted", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.regex-compile-in-loop") +} + +func TestPerformanceCheckSkipsVariablePatternRegexCompileInLoop(t *testing.T) { + // Compiling N different config-supplied patterns in a loop over the + // patterns is not the hoistable smell; only literal patterns flag. + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "match.go"), + "package match\n\nimport \"regexp\"\n\nfunc CompileAll(patterns []string) []*regexp.Regexp {\n\tout := make([]*regexp.Regexp, 0, len(patterns))\n\tfor _, pattern := range patterns {\n\t\tre, err := regexp.Compile(pattern)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, re)\n\t}\n\treturn out\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-regex-variable", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.regex-compile-in-loop") +} + +func TestPerformanceCheckSkipsDeferInsideGoroutineLiteral(t *testing.T) { + // defer scopes to the enclosing function: defer wg.Done() inside a + // goroutine launched from a loop runs per goroutine and never accumulates. + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pool.go"), + "package pool\n\nimport \"sync\"\n\nfunc RunAll(jobs []func()) {\n\tvar wg sync.WaitGroup\n\tfor _, job := range jobs {\n\t\twg.Add(1)\n\t\tgo func(run func()) {\n\t\t\tdefer wg.Done()\n\t\t\trun()\n\t\t}(job)\n\t}\n\twg.Wait()\n}\n") + + off := false + cfg := performanceConfig("performance-go-defer-goroutine", dir, "go") + cfg.Checks.PerformanceRules.DetectUnboundedConcurrency = &off + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.go.defer-in-loop") +} + +func TestPerformanceCheckWarnsForDeferInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "files.go"), + "package files\n\nimport \"os\"\n\nfunc ReadAllFiles(paths []string) {\n\tfor _, path := range paths {\n\t\tf, err := os.Open(path)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tdefer f.Close()\n\t}\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-defer-loop", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.go.defer-in-loop") +} + +func TestPerformanceCheckWarnsForSleepAndTimerInLoop(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "poll.go"), + "package poll\n\nimport \"time\"\n\nfunc WaitReady(ready func() bool) {\n\tfor !ready() {\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n}\n\nfunc Drain(inbox chan int) {\n\tfor {\n\t\tselect {\n\t\tcase <-inbox:\n\t\tcase <-time.After(time.Second):\n\t\t\treturn\n\t\t}\n\t}\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-sleep-timer", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.go.sleep-in-loop") + assertFindingRulePresent(t, report, "Performance", "performance.go.timer-leak-in-loop") +} + +func TestPerformanceCheckWarnsForUnboundedReadInHandler(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "handler.go"), + "package api\n\nimport (\n\t\"io\"\n\t\"net/http\"\n)\n\nfunc Upload(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\treturn\n\t}\n\t_, _ = w.Write(body)\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-unbounded-read", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRulePresent(t, report, "Performance", "performance.unbounded-read") +} + +func TestPerformanceCheckSkipsLimitedReadInHandler(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "handler.go"), + "package api\n\nimport (\n\t\"io\"\n\t\"net/http\"\n)\n\nfunc Upload(w http.ResponseWriter, r *http.Request) {\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))\n\tif err != nil {\n\t\treturn\n\t}\n\t_, _ = w.Write(body)\n}\n") + + report, err := codeguard.Run(context.Background(), performanceConfig("performance-go-limited-read", dir, "go")) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.unbounded-read") +} + +func TestPerformanceCheckNewGoTogglesOff(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "poll.go"), + "package poll\n\nimport (\n\t\"regexp\"\n\t\"time\"\n)\n\nfunc Scan(lines []string) {\n\tfor _, line := range lines {\n\t\t_ = regexp.MustCompile(`x`).MatchString(line)\n\t\ttime.Sleep(time.Millisecond)\n\t\tdefer func() {}()\n\t\t<-time.After(time.Millisecond)\n\t}\n}\n") + + off := false + cfg := performanceConfig("performance-go-new-toggles-off", dir, "go") + cfg.Checks.PerformanceRules.DetectRegexCompileInLoop = &off + cfg.Checks.PerformanceRules.DetectSleepInLoop = &off + cfg.Checks.PerformanceRules.DetectDeferInLoop = &off + cfg.Checks.PerformanceRules.DetectTimerLeaks = &off + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + assertFindingRuleAbsent(t, report, "Performance", "performance.regex-compile-in-loop") + assertFindingRuleAbsent(t, report, "Performance", "performance.go.sleep-in-loop") + assertFindingRuleAbsent(t, report, "Performance", "performance.go.defer-in-loop") + assertFindingRuleAbsent(t, report, "Performance", "performance.go.timer-leak-in-loop") +} + +func TestQualityCheckNoLongerEmitsPerformanceRules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "repo.go"), + "package repo\n\nimport \"database/sql\"\n\nfunc UpdateAll(db *sql.DB, ids []int) error {\n\tfor _, id := range ids {\n\t\tif _, err := db.Exec(\"UPDATE items SET done = 1 WHERE id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n") + + cfg := performanceConfig("quality-no-perf-rules", dir, "go") + cfg.Checks.Performance = boolPtr(false) + cfg.Checks.Quality = true + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.n-plus-one-query") + assertFindingRuleAbsent(t, report, "Code Quality", "performance.n-plus-one-query") +} diff --git a/tests/checks/python_treesitter_test.go b/tests/checks/python_treesitter_test.go new file mode 100644 index 0000000..3c79366 --- /dev/null +++ b/tests/checks/python_treesitter_test.go @@ -0,0 +1,143 @@ +package checks_test + +import ( + "context" + "path/filepath" + "reflect" + "sort" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// The Python N+1 differential tests pin both engines for +// performance.n-plus-one-query: the tree-sitter path (parsers.treesitter +// "auto") must report only real query calls inside loops, while the regex +// path ("off" — the fallback lever for the tree path, since Python has no +// Node semantic engine to disable) keeps its pinned false positives on +// query-shaped text inside comments and string literals. + +const pythonNPlusOneFixture = `import requests + + +def fetch_rows(items, cursor): + results = [] + for item in items: + row = cursor.execute("SELECT name FROM users WHERE id = ?") + results.append(row) + return results + + +def build_batch_notes(items): + notes = [] + for item in items: + # batching hint: cursor.execute(BULK_SQL) once supported + notes.append("cursor.execute(%s)" % item) + return notes + + +def poll(url): + while True: + response = requests.get(url) + return response +` + +// Fixture line numbers (1-based). +const ( + pythonNPlusOneExecuteLine = 7 // true positive: cursor.execute inside for + pythonNPlusOneCommentLine = 15 // regex FP: query text inside a comment + pythonNPlusOneStringLine = 16 // regex FP: query text inside a string literal + pythonNPlusOneGetLine = 22 // true positive: requests.get inside while +) + +const pythonNPlusOneWantMessage = "query or request call inside a loop suggests an N+1 pattern; batch the work or hoist the call out of the loop" + +func pythonTreesitterConfig(dir string, mode string) codeguard.Config { + cfg := performanceConfig("python-treesitter-differential", dir, "python") + disabled := false + cfg.Cache = codeguard.CacheConfig{Enabled: &disabled} + if mode != "" { + cfg.Parsers = codeguard.ParsersConfig{TreeSitter: mode} + } + return cfg +} + +// scanPythonNPlusOne returns the performance.n-plus-one-query finding lines +// plus each line's confidence, asserting the shared rule ID/message contract +// along the way. +func scanPythonNPlusOne(t *testing.T, dir string, mode string) ([]int, map[int]string) { + t.Helper() + report, err := codeguard.Run(context.Background(), pythonTreesitterConfig(dir, mode)) + if err != nil { + t.Fatalf("scan (mode %q): %v", mode, err) + } + lines := make([]int, 0, 4) + confidence := map[int]string{} + for _, section := range report.Sections { + for _, finding := range section.Findings { + if finding.RuleID != "performance.n-plus-one-query" { + continue + } + if finding.Message != pythonNPlusOneWantMessage { + t.Errorf("mode %q line %d message = %q, want %q", mode, finding.Line, finding.Message, pythonNPlusOneWantMessage) + } + lines = append(lines, finding.Line) + confidence[finding.Line] = finding.Confidence + } + } + sort.Ints(lines) + return lines, confidence +} + +// TestPythonNPlusOneTreeDifferential proves the tree path is strictly more +// precise than the regex fallback on the same fixture: both report the two +// genuine query calls in loops, and only the regex path also flags the +// query-shaped comment and string-literal lines. +func TestPythonNPlusOneTreeDifferential(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.py"), pythonNPlusOneFixture) + + truePositives := []int{pythonNPlusOneExecuteLine, pythonNPlusOneGetLine} + + treeLines, treeConfidence := scanPythonNPlusOne(t, dir, "auto") + if !reflect.DeepEqual(treeLines, truePositives) { + t.Errorf("tree path lines = %v, want exactly the true positives %v", treeLines, truePositives) + } + for line, level := range treeConfidence { + if level != "high" { + t.Errorf("tree path line %d confidence = %q, want high", line, level) + } + } + + regexLines, regexConfidence := scanPythonNPlusOne(t, dir, "off") + wantRegex := []int{pythonNPlusOneExecuteLine, pythonNPlusOneCommentLine, pythonNPlusOneStringLine, pythonNPlusOneGetLine} + sort.Ints(wantRegex) + if !reflect.DeepEqual(regexLines, wantRegex) { + t.Errorf("regex path lines = %v, want true positives plus pinned comment/string FPs %v", regexLines, wantRegex) + } + for line, level := range regexConfidence { + if level == "high" { + t.Errorf("regex path line %d unexpectedly reports confidence high", line) + } + } +} + +// TestPythonNPlusOneTreeOffMatchesDefault locks the fallback lever: leaving +// parsers unset must behave exactly like an explicit "off" for Python +// targets, so the tree path stays opt-in. +func TestPythonNPlusOneTreeOffMatchesDefault(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.py"), pythonNPlusOneFixture) + + defaultReport, err := codeguard.Run(context.Background(), pythonTreesitterConfig(dir, "")) + if err != nil { + t.Fatalf("scan (default): %v", err) + } + offReport, err := codeguard.Run(context.Background(), pythonTreesitterConfig(dir, "off")) + if err != nil { + t.Fatalf("scan (off): %v", err) + } + if !reflect.DeepEqual(defaultReport.Sections, offReport.Sections) { + t.Fatalf("explicit parsers.treesitter=off diverges from the default configuration\ndefault: %+v\noff: %+v", defaultReport.Sections, offReport.Sections) + } +} diff --git a/tests/checks/quality_performance_scripts_test.go b/tests/checks/quality_performance_scripts_test.go deleted file mode 100644 index 8177608..0000000 --- a/tests/checks/quality_performance_scripts_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package checks_test - -import ( - "context" - "path/filepath" - "testing" - - "github.com/devr-tools/codeguard/pkg/codeguard" -) - -func TestQualityCheckWarnsForTypeScriptFetchInLoop(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "src", "users.ts"), - "export async function loadUsers(ids: string[]) {\n const users = [];\n for (const id of ids) {\n const res = await fetch(`/api/users/${id}`);\n users.push(await res.json());\n }\n return users;\n}\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-ts-nplusone", dir, "typescript")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRulePresent(t, report, "Code Quality", "quality.n-plus-one-query") -} - -func TestQualityCheckWarnsForTypeScriptSyncIOInHandler(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "src", "server.ts"), - "import fs from \"fs\";\nimport express from \"express\";\n\nconst app = express();\n\napp.get(\"/report\", (req, res) => {\n const data = fs.readFileSync(\"report.txt\", \"utf8\");\n res.send(data);\n});\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-ts-sync-io", dir, "typescript")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRulePresent(t, report, "Code Quality", "quality.typescript.sync-io-in-handler") -} - -func TestQualityCheckWarnsForTypeScriptUnboundedConcurrency(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "src", "warm.ts"), - "export function warm(urls: string[]) {\n const tasks = [];\n for (const url of urls) {\n tasks.push(fetch(url));\n }\n return Promise.all(tasks);\n}\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-ts-unbounded", dir, "typescript")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRulePresent(t, report, "Code Quality", "quality.typescript.unbounded-concurrency") -} - -func TestQualityCheckSkipsTypeScriptPerformanceSmellsOutsideRegions(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "src", "clean.ts"), - "import fs from \"fs\";\n\nconst config = fs.readFileSync(\"config.json\", \"utf8\");\n\nexport async function loadUser(id: string) {\n const res = await fetch(`/api/users/${id}`);\n return res.json();\n}\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-ts-clean", dir, "typescript")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRuleAbsent(t, report, "Code Quality", "quality.n-plus-one-query") - assertFindingRuleAbsent(t, report, "Code Quality", "quality.typescript.sync-io-in-handler") - assertFindingRuleAbsent(t, report, "Code Quality", "quality.typescript.unbounded-concurrency") -} - -func TestQualityCheckSkipsTypeScriptUnboundedConcurrencyWithPLimit(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "src", "warm.ts"), - "import pLimit from \"p-limit\";\n\nconst limit = pLimit(4);\n\nexport function warm(urls: string[]) {\n const tasks = [];\n for (const url of urls) {\n tasks.push(limit(() => fetch(url)));\n }\n return Promise.all(tasks);\n}\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-ts-plimit", dir, "typescript")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRuleAbsent(t, report, "Code Quality", "quality.typescript.unbounded-concurrency") -} - -func TestQualityCheckWarnsForPythonRequestsInLoop(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "app", "loader.py"), - "import requests\n\n\ndef load(ids):\n out = []\n for item in ids:\n out.append(requests.get(\"https://example.com/api/%s\" % item))\n return out\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-py-nplusone", dir, "python")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRulePresent(t, report, "Code Quality", "quality.n-plus-one-query") -} - -func TestQualityCheckWarnsForPythonBlockingCallInAsync(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "app", "pause.py"), - "import time\n\n\nasync def pause():\n time.sleep(1)\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-py-sync-async", dir, "python")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRulePresent(t, report, "Code Quality", "quality.python.sync-io-in-async") -} - -func TestQualityCheckSkipsPythonPerformanceSmellsOutsideRegions(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "app", "clean.py"), - "import time\n\nimport requests\n\n\ndef load_once(url):\n return requests.get(url)\n\n\ndef sleepy():\n time.sleep(1)\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-py-clean", dir, "python")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRuleAbsent(t, report, "Code Quality", "quality.n-plus-one-query") - assertFindingRuleAbsent(t, report, "Code Quality", "quality.python.sync-io-in-async") -} diff --git a/tests/checks/quality_performance_test.go b/tests/checks/quality_performance_test.go deleted file mode 100644 index 8392d6f..0000000 --- a/tests/checks/quality_performance_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package checks_test - -import ( - "context" - "path/filepath" - "testing" - - "github.com/devr-tools/codeguard/pkg/codeguard" -) - -func TestQualityCheckWarnsForSyncIOInRequestPath(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "handler.go"), `package sample - -import ( - "net/http" - "os" -) - -func handle(w http.ResponseWriter, r *http.Request) { - _, _ = os.ReadFile("payload.json") - _, _ = w.Write([]byte("ok")) -} -`) - - cfg := codeguard.ExampleConfig() - cfg.Name = "quality-sync-io-request-path" - cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: "go"}} - cfg.Checks.Quality = true - cfg.Checks.Security = false - cfg.Checks.Design = false - cfg.Checks.Prompts = false - cfg.Checks.CI = false - - report, err := codeguard.Run(context.Background(), cfg) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertSectionStatus(t, report, "Code Quality", "warn") - assertFindingRulePresent(t, report, "Code Quality", "quality.sync-io-in-request-path") -} - -func TestQualityCheckWarnsForGoroutinesInLoop(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "worker.go"), `package sample - -func dispatch(items []int) { - for _, item := range items { - go func(value int) { - _ = value - }(item) - } -} -`) - - cfg := codeguard.ExampleConfig() - cfg.Name = "quality-unbounded-goroutines" - cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: "go"}} - cfg.Checks.Quality = true - cfg.Checks.Security = false - cfg.Checks.Design = false - cfg.Checks.Prompts = false - cfg.Checks.CI = false - - report, err := codeguard.Run(context.Background(), cfg) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertSectionStatus(t, report, "Code Quality", "warn") - assertFindingRulePresent(t, report, "Code Quality", "quality.unbounded-goroutines-in-loop") -} - -func qualityPerfConfig(name string, dir string, language string) codeguard.Config { - cfg := codeguard.ExampleConfig() - cfg.Name = name - cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: language}} - cfg.Checks.Quality = true - cfg.Checks.Design = false - cfg.Checks.Security = false - cfg.Checks.Prompts = false - cfg.Checks.CI = false - return cfg -} - -func TestQualityCheckWarnsForGoQueryInLoop(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "repo.go"), - "package repo\n\nimport \"database/sql\"\n\nfunc UpdateAll(db *sql.DB, ids []int) error {\n\tfor _, id := range ids {\n\t\tif _, err := db.Exec(\"UPDATE items SET done = 1 WHERE id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-go-nplusone", dir, "go")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRulePresent(t, report, "Code Quality", "quality.n-plus-one-query") -} - -func TestQualityCheckSkipsGoQueryOutsideLoop(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "repo.go"), - "package repo\n\nimport \"database/sql\"\n\nfunc UpdateOne(db *sql.DB, id int) error {\n\t_, err := db.Exec(\"UPDATE items SET done = 1 WHERE id = ?\", id)\n\treturn err\n}\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-go-nplusone-neg", dir, "go")) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRuleAbsent(t, report, "Code Quality", "quality.n-plus-one-query") -} - -func TestQualityCheckWarnsForGoAllocInLoop(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "report.go"), - "package report\n\nimport \"fmt\"\n\nfunc Describe(items []string) string {\n\tout := \"\"\n\tfor _, item := range items {\n\t\tout += fmt.Sprintf(\"- %s\\n\", item)\n\t}\n\treturn out\n}\n\nfunc Gather(items []string) []string {\n\tvar values []string\n\tfor _, item := range items {\n\t\tvalues = append(values, item)\n\t}\n\treturn values\n}\n") - - on := true - cfg := qualityPerfConfig("quality-go-alloc", dir, "go") - cfg.Checks.QualityRules.DetectPreallocInLoop = &on - - report, err := codeguard.Run(context.Background(), cfg) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRulePresent(t, report, "Code Quality", "quality.go.alloc-in-loop") -} - -func TestQualityCheckWarnsForAppendWithoutPreallocWhenEnabled(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "report.go"), - "package report\n\nfunc Gather(items []string) []string {\n\tvar values []string\n\tfor _, item := range items {\n\t\tvalues = append(values, item)\n\t}\n\treturn values\n}\n") - - on := true - cfg := qualityPerfConfig("quality-go-prealloc-on", dir, "go") - cfg.Checks.QualityRules.DetectPreallocInLoop = &on - - report, err := codeguard.Run(context.Background(), cfg) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRulePresent(t, report, "Code Quality", "quality.go.alloc-in-loop") -} - -func TestQualityCheckPreallocInLoopDefaultOff(t *testing.T) { - appendDir := t.TempDir() - writeFile(t, filepath.Join(appendDir, "report.go"), - "package report\n\nfunc Gather(items []string) []string {\n\tvar values []string\n\tfor _, item := range items {\n\t\tvalues = append(values, item)\n\t}\n\treturn values\n}\n") - - report, err := codeguard.Run(context.Background(), qualityPerfConfig("quality-go-prealloc-default", appendDir, "go")) - if err != nil { - t.Fatalf("run: %v", err) - } - assertFindingRuleAbsent(t, report, "Code Quality", "quality.go.alloc-in-loop") - - concatDir := t.TempDir() - writeFile(t, filepath.Join(concatDir, "report.go"), - "package report\n\nfunc Describe(items []string) string {\n\tout := \"\"\n\tfor _, item := range items {\n\t\tout += \"- \" + item\n\t}\n\treturn out\n}\n") - - report, err = codeguard.Run(context.Background(), qualityPerfConfig("quality-go-concat-default", concatDir, "go")) - if err != nil { - t.Fatalf("run: %v", err) - } - assertFindingRulePresent(t, report, "Code Quality", "quality.go.alloc-in-loop") -} - -func TestQualityCheckSkipsPreallocatedAppendInLoop(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "report.go"), - "package report\n\nfunc Gather(items []string) []string {\n\tvalues := make([]string, 0, len(items))\n\tfor _, item := range items {\n\t\tvalues = append(values, item)\n\t}\n\treturn values\n}\n") - - on := true - cfg := qualityPerfConfig("quality-go-alloc-neg", dir, "go") - cfg.Checks.QualityRules.DetectPreallocInLoop = &on - - report, err := codeguard.Run(context.Background(), cfg) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRuleAbsent(t, report, "Code Quality", "quality.go.alloc-in-loop") -} - -func TestQualityCheckAllocInLoopToggleOff(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "report.go"), - "package report\n\nimport \"fmt\"\n\nfunc Describe(items []string) string {\n\tout := \"\"\n\tfor _, item := range items {\n\t\tout += fmt.Sprintf(\"- %s\\n\", item)\n\t}\n\treturn out\n}\n") - - off := false - cfg := qualityPerfConfig("quality-go-alloc-off", dir, "go") - cfg.Checks.QualityRules.DetectAllocInLoop = &off - - report, err := codeguard.Run(context.Background(), cfg) - if err != nil { - t.Fatalf("run: %v", err) - } - - assertFindingRuleAbsent(t, report, "Code Quality", "quality.go.alloc-in-loop") -} diff --git a/tests/cli/doctor_rule_health_test.go b/tests/cli/doctor_rule_health_test.go index 9faf0dc..5f86d3e 100644 --- a/tests/cli/doctor_rule_health_test.go +++ b/tests/cli/doctor_rule_health_test.go @@ -53,6 +53,12 @@ func TestRunDoctorWaiverHealth(t *testing.T) { wantLine: "[WARN] waiver:quality.not-a-real-rule:", wantMessage: "matches no catalog rule", }, + { + name: "retired_performance_waiver_gets_migration_hint", + waivers: `{"rule": "quality.n-plus-one-query"}`, + wantLine: "[WARN] waiver:quality.n-plus-one-query:", + wantMessage: "rule moved to the performance section as performance.n-plus-one-query", + }, { name: "expired_waiver_warns", waivers: `{"rule": "quality.gofmt", "expires_on": "2020-01-01"}`, diff --git a/tests/cli/report_perf_test.go b/tests/cli/report_perf_test.go new file mode 100644 index 0000000..6bf376f --- /dev/null +++ b/tests/cli/report_perf_test.go @@ -0,0 +1,116 @@ +package cli_test + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/cli" + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +// setupPerfHistoryReportFixture writes a Python fixture repo with an N+1 +// pattern plus config, and seeds two performance scans so the report command +// has a recorded trend. It returns the config path. +func setupPerfHistoryReportFixture(t *testing.T, dir string) string { + t.Helper() + repo := filepath.Join(dir, "repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + source := `def fetch(items, cursor): + rows = [] + for item in items: + rows.append(cursor.execute("SELECT name FROM users WHERE id = ?")) + return rows +` + if err := os.WriteFile(filepath.Join(repo, "service.py"), []byte(source), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + cachePath := filepath.Join(dir, ".codeguard", "cache.json") + configPath := filepath.Join(dir, "codeguard.json") + config := fmt.Sprintf(`{ + "name": "report-perf-history", + "targets": [{"name": "repo", "path": %q, "language": "python"}], + "checks": {"performance": true, "quality": false, "design": false, "security": false, "prompts": false, "ci": false}, + "output": {"format": "json"}, + "cache": {"enabled": true, "path": %q} +}`, repo, cachePath) + if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := codeguard.LoadConfigFile(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + for i := 0; i < 2; i++ { + if _, err := codeguard.Run(context.Background(), cfg); err != nil { + t.Fatalf("scan %d: %v", i, err) + } + } + return configPath +} + +func TestRunReportPrintsPerfHistoryTrend(t *testing.T) { + configPath := setupPerfHistoryReportFixture(t, t.TempDir()) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := cli.Run([]string{"report", "-perf-history", "-config", configPath}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("report exit code = %d, stderr = %s", code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, "performance_score.python.repo") { + t.Fatalf("expected history key in output, got:\n%s", output) + } + if !strings.Contains(output, "score") || !strings.Contains(output, "(+0)") { + t.Fatalf("expected score trend with delta, got:\n%s", output) + } + if !strings.Contains(output, "performance.n-plus-one-query=1") { + t.Fatalf("expected component breakdown, got:\n%s", output) + } +} + +func TestRunReportHandlesMissingPerfHistory(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "codeguard.json") + config := fmt.Sprintf(`{ + "name": "report-perf-empty", + "targets": [{"name": "repo", "path": %q, "language": "python"}], + "checks": {"performance": false, "quality": false, "design": false, "security": false, "prompts": false, "ci": false}, + "output": {"format": "json"}, + "cache": {"enabled": true, "path": %q} +}`, dir, filepath.Join(dir, ".codeguard", "cache.json")) + if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := cli.Run([]string{"report", "-perf-history", "-config", configPath}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("report exit code = %d, stderr = %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "no performance-score history recorded") { + t.Fatalf("expected empty-history message, got: %s", stdout.String()) + } +} + +func TestRunReportRejectsBothModeFlags(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := cli.Run([]string{"report", "-slop-history", "-perf-history"}, strings.NewReader(""), &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit 1, got %d", code) + } + if !strings.Contains(stderr.String(), "only one mode flag") { + t.Fatalf("expected single-mode error, got: %s", stderr.String()) + } +} diff --git a/tests/cli/scan_test.go b/tests/cli/scan_test.go index c927b61..3020fd8 100644 --- a/tests/cli/scan_test.go +++ b/tests/cli/scan_test.go @@ -68,6 +68,61 @@ func TestRunInteractiveScanUsesPromptedBaseRef(t *testing.T) { } } +// writeHintScanConfig writes a minimal all-checks-off scan config; checksJSON +// is the raw JSON of the "checks" object so tests control exactly which keys +// are present (the performance hint keys off key absence, not value). +func writeHintScanConfig(t *testing.T, dir string, checksJSON string) string { + t.Helper() + configPath := filepath.Join(dir, "codeguard.json") + config := `{ + "name": "performance-hint", + "targets": [{"name": "repo", "path": ".", "language": "go"}], + "checks": ` + checksJSON + `, + "output": {"format": "text"} +}` + if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil { + t.Fatalf("write main.go: %v", err) + } + return configPath +} + +func TestRunScanSuggestsPerformanceSectionWhenKeyAbsent(t *testing.T) { + dir := t.TempDir() + configPath := writeHintScanConfig(t, dir, + `{"quality": false, "design": false, "security": false, "prompts": false, "ci": false}`) + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "-config", configPath}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("scan exit code = %d, stderr = %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "predates the performance check section") { + t.Fatalf("expected performance upgrade hint for config without the key, got:\n%s", stdout.String()) + } +} + +func TestRunScanStaysSilentWhenPerformanceKeyExplicit(t *testing.T) { + for _, value := range []string{"true", "false"} { + t.Run("performance_"+value, func(t *testing.T) { + dir := t.TempDir() + configPath := writeHintScanConfig(t, dir, + `{"quality": false, "design": false, "security": false, "prompts": false, "ci": false, "performance": `+value+`}`) + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "-config", configPath}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("scan exit code = %d, stderr = %s", code, stderr.String()) + } + if strings.Contains(stdout.String(), "predates the performance check section") { + t.Fatalf("expected no upgrade hint for explicit performance: %s, got:\n%s", value, stdout.String()) + } + }) + } +} + var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) func stripANSI(value string) string {