diff --git a/README.md b/README.md index 85f55ec..2e7dc48 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ codeguard doctor -config codeguard.yaml codeguard scan -config codeguard.yaml codeguard scan -config codeguard.yaml -folder ./internal/codeguard codeguard scan -folder ./internal/codeguard -profile startup +codeguard scan -folder ./internal/codeguard -profile startup -set checks.quality=true -set output.format=json codeguard scan-history codeguard waivers audit -config codeguard.yaml codeguard rules @@ -106,6 +107,20 @@ If you point `-config` at a directory such as `.codeguard`, `codeguard` will loo Use `codeguard scan -folder ` to scan only one folder. `-path ` is accepted as an alias. If no config file exists and you did not pass `-config`, folder scans use CodeGuard's built-in default config; add `-profile startup`, `-profile strict`, `-profile enterprise`, or `-profile ai-safe` to choose a default profile. +Use repeatable `-set key=value` flags to override config values from the terminal without writing a temporary config file. Keys use dotted YAML paths and are validated against the typed CodeGuard config; mistyped fields fail before the scan runs. String lists accept comma-separated values or a JSON string array. + +```bash +codeguard scan -folder . \ + -profile startup \ + -set targets[0].language=go \ + -set checks.quality=true \ + -set checks.security=false \ + -set checks.quality_rules.dead_code.enabled=true \ + -set checks.quality_rules.dead_code.mode=toolchain \ + -set checks.quality_rules.dead_code.go.entrypoints=./cmd/codeguard \ + -set output.format=json +``` + For agent bootstrap flows, the shortest useful commands are: ```bash diff --git a/docs/ai-quality.md b/docs/ai-quality.md index b7c330b..ae0223b 100644 --- a/docs/ai-quality.md +++ b/docs/ai-quality.md @@ -88,6 +88,10 @@ All HTTP providers (OpenAI-compatible and Anthropic, in triage and the shared ru - warns when mock setup strongly outweighs behavior assertions - Dead-code detection is heuristic: - currently focuses on obvious constant-condition branches such as `if false` and `if (false)` + - compiler, linker, semantic, graph, bundler, or toolchain-backed dead-code evidence is intentionally tracked by the separate `quality.dead-code.toolchain` rule, not by `quality.ai.dead-code` + - for TypeScript and JavaScript targets, `quality.dead-code.toolchain` uses configured TypeScript/JavaScript projects plus optional trusted esbuild metafile, webpack stats, and Rollup/Vite rendered-length reports instead of invoking package-manager or bundler commands itself + - for Python targets, `quality.dead-code.toolchain` can combine conservative static import reachability with trusted vulture-style text or JSON reports, but it does not execute Python modules or analyzers + - exported APIs and dynamic references such as reflection, plugin loading, generated registration, build tags, cgo callbacks, C ABI callbacks, virtual dispatch, templates, macros, decorators, dynamic imports, framework conventions, and string-named handlers require conservative handling because they can be used without direct static references; C++ linker map or `nm`-style artifact ingestion provides stronger evidence than graph-only reachability when configured - Semantic review is opt-in: - can be enabled through the normal AI runtime or through `CODEGUARD_SEMANTIC_CHECKS=1` - scopes itself to changed files from diff or patch input, or from a git diff against the configured base ref during full scans, plus a small set of nearby test files diff --git a/docs/checks.md b/docs/checks.md index 83ec756..1d67a45 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -28,7 +28,7 @@ This glossary is the quick map of every built-in check family and the main subse | Check family | Report section | Config key | Main subsections / rule themes | | --- | --- | --- | --- | -| Quality | `Code Quality` | `checks.quality` | formatting and parseability; maintainability thresholds; file/function size; cyclomatic complexity; clone detection; language-specific quality rules; local-quality precision for naming, function shape, error handling, and defensive programming; TypeScript/JavaScript type-safety rules; AI-failure-mode checks; semantic review; changed-line coverage; C++ formatter/compiler validation | +| Quality | `Code Quality` | `checks.quality` | formatting and parseability; maintainability thresholds; file/function size; cyclomatic complexity; clone detection; language-specific quality rules; local-quality precision for naming, function shape, error handling, and defensive programming; TypeScript/JavaScript type-safety rules; AI-failure-mode checks; semantic review; changed-line coverage; opt-in toolchain dead-code analysis; C++ formatter/compiler validation | | Performance | `Performance` | `checks.performance` | N+1 query/fetch patterns; allocation-heavy loops; repeated work in loops; blocking I/O in request paths; unbounded concurrency; sequential await; timer/listener leaks; unbounded whole-input reads; framework-aware performance smells; rebuild-cascade analysis; complexity regression; size budgets; build regression; benchmark regression | | Reliability | `Reliability` | `checks.reliability` | missing outbound timeouts; unbounded retries; retries without backoff/jitter; non-idempotent retries; missing cancellation propagation; unbounded work; missing concurrency limits; resource leaks; hidden partial failures; missing graceful shutdown; swallowed errors; lost error context; recoverable panics/exceptions | | Data Correctness | `Data Correctness` | `checks.data` | read-modify-write races; missing transaction boundaries; external side effects inside transactions; non-idempotent consumers; missing deduplication; unsafe dual writes; missing outbox strategy; unstable pagination; unbounded reads; exactly-once assumptions; cache writes without TTL/policy | @@ -398,7 +398,7 @@ Current inference behavior: | Family | Go | C++ | Python | TypeScript | Rust | Java | C# | Ruby | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Quality | `gofmt`, parseability, maintainability thresholds | maintainability thresholds across sources, headers, templates, and modules; optional `clang-format` and sanitized `clang++` validation | maintainability thresholds | maintainability thresholds, `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, `explicit any`, double assertions, non-null assertions, `debugger` statements | maintainability thresholds | maintainability thresholds | maintainability thresholds | maintainability thresholds | +| Quality | `gofmt`, parseability, maintainability thresholds, opt-in toolchain dead-code evidence | maintainability thresholds across sources, headers, templates, and modules; optional `clang-format` and sanitized `clang++` validation | maintainability thresholds | maintainability thresholds, `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, `explicit any`, double assertions, non-null assertions, `debugger` statements | maintainability thresholds | maintainability thresholds | maintainability thresholds | maintainability thresholds | | Design | package boundary rules, generic package names, declarations per file, methods per type, interface size, graph reachability/stability/impact | include and named-module cycles, reachability, stability, graph impact, generic filenames, declarations per file, method counts, contract surface checks, boundary-policy enforcement | public/private and entrypoint coupling, import cycles, generic module names, methods per type, protocol size | generic module names, max methods per class, max members per interface/object type, graph resolution through `tsconfig` paths, package `imports`, and workspace package exports | module cycles, graph impact, generic module names, methods per type, trait size | import cycles and graph impact | - | - | | Security | insecure TLS, shell execution review, optional `govulncheck` | insecure TLS, shell execution review, unsafe C string APIs, taint flow, SSRF | insecure TLS, shell execution review, dynamic code | insecure TLS, shell execution review, dynamic code, string timer execution, wildcard `postMessage`, Node `vm` execution, unsafe HTML sinks | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review, dynamic code | | Reliability | missing timeouts, cancellation gaps, retry policy gaps, non-idempotent retry evidence, unbounded goroutines/work, resource cleanup, swallowed/lost errors, recoverable panic, graceful shutdown | retry policy gaps, non-idempotent retry evidence, unbounded thread/task launch, raw allocation cleanup gaps, generic runtime throws | missing HTTP timeouts, retry policy gaps, non-idempotent retry evidence, unbounded asyncio work, swallowed exceptions, generic raises, resource cleanup | missing timeout/abort evidence, promise/HTTP work in loops, retry policy gaps, non-idempotent retry evidence, swallowed catches, generic throws | - | - | - | - | @@ -432,6 +432,7 @@ Purpose: - Go formatting and parse validation - Maintainability thresholds - Cyclomatic complexity checks +- Optional compiler/toolchain-backed dead-code analysis Config keys: @@ -469,6 +470,7 @@ Current behavior: - explicit C++ targets can opt into `clang-format` and sanitized `clang++ -fsyntax-only` validation through `quality_rules.cpp_tooling` - TypeScript and JavaScript targets also warn on `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, explicit `any`, double assertions, non-null assertions, and committed `debugger` statements - can run language-specific quality commands based on `targets[].language` +- can opt into `quality.dead-code.toolchain` findings backed by language-specific compiler, linker, semantic, or import-graph evidence; this is distinct from heuristic `quality.ai.dead-code` and graph-level `design.unreachable-module` AI provenance example: @@ -531,6 +533,197 @@ Language command example: } ``` +### Toolchain dead-code analysis + +`quality.dead-code.toolchain` is the opt-in rule for compiler, linker, semantic, +or import-graph evidence that code is unreachable from configured entrypoints. +The current implementation supports Go, Rust, C++, Python, TypeScript, and +JavaScript with different evidence models. +It is separate from: + +- `quality.ai.dead-code`, which is a fast heuristic for obvious AI-style + leftovers such as constant-false branches and unused private helpers. +- `design.unreachable-module`, which reasons about module/file reachability from + configured architecture entrypoints, not declaration-level toolchain usage. + +Intended config shape: + +```yaml +checks: + quality: true + quality_rules: + dead_code: + enabled: true + mode: toolchain + level: warn + go: + packages: + - ./... + entrypoints: + - ./cmd/codeguard + linker: true + ignore_paths: + - internal/generated/** + cpp: + entrypoints: + - src/main.cpp + reports: + - build/app.map + - build/nm.txt + graph: true + ignore_paths: + - src/generated/** + rust: + crates: + - crates/app + packages: + - app + reports: + - target/debug/app.nm + ignore_paths: + - src/generated/** + python: + entrypoints: + - app/__main__.py + reports: + - .codeguard/vulture.txt + ignore_paths: + - migrations/** + typescript: + projects: + - tsconfig.json + reports: + - dist/meta.json + ignore_paths: + - src/generated/** + javascript: + projects: + - jsconfig.json + reports: + - dist/webpack-stats.json + ignore_paths: + - generated/** +``` + +The same keys can be supplied from the CLI with repeatable `-set key=value` +overrides, which is useful for agent-driven scans that should not create a +temporary config file: + +```bash +codeguard scan -folder ./app \ + -profile startup \ + -set targets[0].language=typescript \ + -set checks.quality_rules.dead_code.enabled=true \ + -set checks.quality_rules.dead_code.mode=toolchain \ + -set checks.quality_rules.dead_code.typescript.projects=tsconfig.json \ + -set checks.quality_rules.dead_code.typescript.reports=.codeguard/esbuild-meta.json +``` + +Behavior and precision: + +- Findings warn by default and should report `quality.dead-code.toolchain`. +- The rule only runs when explicitly enabled. The Go implementation uses + `go build -gcflags=all=-l -ldflags=-dumpdep` over configured entrypoint + packages, with a temporary build cache, to collect linker dependency evidence. +- High-confidence findings are private package-level functions or entire + non-main packages absent from the configured linked binaries. +- The Rust implementation uses fixed `cargo check --message-format=json` + invocations with `rustc` `dead_code` and `unreachable_code` lints promoted + through `RUSTFLAGS`. It can also ingest trusted text symbol reports listed in + `quality_rules.dead_code.rust.reports`, such as `nm --demangle`, + `llvm-nm --demangle`, or linker-map extracts. Artifact findings only report + private top-level Rust functions when the same module has symbol evidence and + the candidate function is absent from the report. +- Rust artifact evidence is conservative. It skips public APIs, generics, + inline-marked functions, proc macros, cfg/test-only code, runtime exports, + plugin registries, generated/vendor paths, tests, examples, and benches by + default. Optimized or stripped artifacts can inline or remove reachable + functions, so use an unstripped debug or carefully configured release artifact + when relying on symbol reports for cleanup. +- The C++ implementation first ingests configured trusted linker artifacts from + `quality_rules.dead_code.cpp.reports`. GNU/LLD/MSVC-style discard lines, + Apple/Mach-O dead-strip map lines, and section-garbage-collection output can + produce high-confidence findings for discarded translation units or local + symbols. `nm`/`dumpbin`-style live symbol tables are used to suppress + graph-only false positives for linked library APIs. +- When C++ linker artifacts are not configured, or only provide partial live + evidence, the C++ implementation falls back to target-local `#include` and + C++20 named-module graph evidence from configured entrypoint files. + Unreachable named modules are medium-confidence findings; ordinary + translation units are low-confidence because C++ link steps can connect + `.cpp` files without include edges. +- The Python implementation uses static import reachability from configured + entrypoint modules/files. It can also ingest trusted vulture-style text or + JSON reports listed under `quality_rules.dead_code.python.reports`; low + confidence report entries are ignored. Static findings are low-confidence + because dynamic import, framework discovery, decorators, and packaging + exports can create runtime reachability that static imports do not show. +- The TypeScript and JavaScript implementation uses configured + `tsconfig.json`/`jsconfig.json` project files when + `quality_rules.dead_code.typescript.projects` or + `quality_rules.dead_code.javascript.projects` is set; otherwise it falls back + to normal project discovery. +- The TypeScript and JavaScript analysis enables `noUnusedLocals`, + `noUnusedParameters`, and unreachable-code diagnostics for the semantic pass. + It can also ingest trusted local esbuild metafile JSON, webpack stats JSON, + and Rollup/Vite chunk summaries with per-module `renderedLength` values + listed under `reports`, reporting only explicit zero-byte tree-shaking, + zero-rendered-length, or orphan-module evidence after generated, test, story, + package-entrypoint, framework-convention, side-effect, and configured + ignored-path guards. Vite manifests alone are treated as live-output metadata, + not dead-code proof. +- Exported declarations are lower-confidence unless the repository config + declares a complete consumer surface. Public libraries may intentionally + export APIs that no in-repo caller references. +- Dynamic references can hide legitimate use. Treat reflection, `init` + registration, plugin loading, generated dependency injection, build tags, + cgo callbacks, `//go:linkname`, Rust cfg/test-only code, proc macros, C ABI + callbacks, virtual dispatch, templates, macros, plugin registries, Python + decorators/importlib, TypeScript dynamic imports, framework file conventions, + package exports, and string-named handlers as caveats that require either + conservative skipping, reduced confidence, or an explicit waiver. +- Deleting a finding should be verified with normal package tests and, for + binary entrypoints, a rebuild of the affected command. C++ graph-only + findings still need linker map, `nm`, or section-garbage-collection evidence + before large removals. + +Safe artifact examples: + +```bash +# Go: CodeGuard invokes a fixed go build command itself when the rule is enabled. +codeguard scan -config .codeguard/codeguard.yaml + +# Rust: generate an unstripped local symbol report for configured reports. +cargo build +nm --demangle target/debug/my-binary > .codeguard/rust-symbols.txt + +# C++: collect live symbols and, when available, linker garbage-collection logs. +cmake --build build +nm -C build/my-binary > .codeguard/cpp-symbols.txt +clang++ -Wl,--print-gc-sections ... 2> .codeguard/cpp-gc-sections.txt +dumpbin /symbols build\my-binary.exe > .codeguard/cpp-dumpbin-symbols.txt + +# TypeScript/JavaScript: emit trusted bundler metadata without letting CodeGuard +# execute the bundler. +esbuild src/index.ts --bundle --metafile=.codeguard/esbuild-meta.json +webpack --json > .codeguard/webpack-stats.json +# Rollup/Vite reports must include per-module renderedLength, not only manifest +# entries for live outputs. + +# Python: import vulture output or a trusted JSON report generated by CI. +vulture app > .codeguard/vulture.txt +``` + +Reviewer checklist: + +- Confirm `quality_rules.dead_code.enabled` is intentionally set; the toolchain + rule is opt-in. +- Prefer artifact-backed findings for deletion PRs. Treat graph-only or static + import findings as cleanup leads unless normal tests and rebuilds also pass. +- Keep reports target-relative and generated by the project's trusted build + pipeline. CodeGuard reads these files but does not execute C++ linkers, + Rust symbol tools, Python analyzers, package managers, or bundlers for them. + ### C++ Clang tooling Explicit C++ targets can enable built-in formatter and compiler validation without placing executable command lines in `compile_commands.json`: diff --git a/docs/features.md b/docs/features.md index 99bfc23..d153dde 100644 --- a/docs/features.md +++ b/docs/features.md @@ -11,6 +11,7 @@ This page lists the current `codeguard` feature surface and the main config entr - local-quality precision heuristics for naming, function shape, error handling, defensive programming, and maintainability deltas where the active build includes them - structural smell heuristics such as god object, feature envy, middle man, message chains, data clumps, and switch-on-type - AI-quality heuristics such as swallowed errors, narrative comments, hallucinated imports, dead code, over-mocked tests, idiom drift, semantic review, provenance policy, and change-risk rollups + - opt-in toolchain dead-code evidence for Go, Rust, C++, Python, TypeScript, and JavaScript through compiler diagnostics, linker/symbol artifacts, static import graphs, and trusted bundler reports - changed-line coverage gating in diff mode - opt-in `clang-format` and sanitized `clang++ -fsyntax-only` validation backed by safe `compile_commands.json` metadata - `design` diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 7fd6568..427c5e1 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -46,6 +46,8 @@ func runValidate(args []string, stdout io.Writer, stderr io.Writer) int { fs.SetOutput(stderr) configPath := fs.String("config", service.DefaultConfigPath(), "config file or directory path") profile := fs.String("profile", "", "optional policy profile override") + var overrides configOverrideValues + fs.Var(&overrides, "set", "override a config value after loading config, repeatable; use dotted YAML paths like checks.quality=false") if ok, code := parseFlags(fs, args, stderr); !ok { return code } @@ -54,6 +56,10 @@ func runValidate(args []string, stdout io.Writer, stderr io.Writer) int { if !ok { return exitError } + if overrideErr := applyConfigOverrides(&cfg, overrides); overrideErr != nil { + _, _ = fmt.Fprintf(stderr, "invalid config override: %v\n", overrideErr) + return exitError + } if err := service.ValidateConfig(cfg); err != nil { _, _ = fmt.Fprintf(stderr, "invalid config: %v\n", err) return exitError @@ -104,6 +110,10 @@ func runScan(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) if trimmedFormat := strings.TrimSpace(*format); trimmedFormat != "" { cfg.Output.Format = trimmedFormat } + if err := applyConfigOverrides(&cfg, *flags.overrides); err != nil { + _, _ = fmt.Fprintf(stderr, "invalid config override: %v\n", err) + return exitError + } if err := executeScan(stdout, cfg, scanMode, strings.TrimSpace(*inputs.baseRef), targetPath, *enableAI); err != nil { _, _ = fmt.Fprintf(stderr, "scan failed: %v\n", err) @@ -119,6 +129,8 @@ func runValidatePatch(args []string, stdin io.Reader, stdout io.Writer, stderr i format := fs.String("format", "", "optional output format override: text, json, sarif, github, cyclonedx") enableAI := fs.Bool("ai", false, "enable optional AI-assisted analysis") profile := fs.String("profile", "", "optional policy profile override") + var overrides configOverrideValues + fs.Var(&overrides, "set", "override a config value after loading config, repeatable; use dotted YAML paths like checks.quality=false") if ok, code := parseFlags(fs, args, stderr); !ok { return code } @@ -140,6 +152,10 @@ func runValidatePatch(args []string, stdin io.Reader, stdout io.Writer, stderr i if trimmedFormat := strings.TrimSpace(*format); trimmedFormat != "" { cfg.Output.Format = trimmedFormat } + if overrideErr := applyConfigOverrides(&cfg, overrides); overrideErr != nil { + _, _ = fmt.Fprintf(stderr, "invalid config override: %v\n", overrideErr) + return exitError + } report, err := service.RunWithOptions(context.Background(), cfg, service.ScanOptions{ Mode: service.ScanModeDiff, @@ -176,6 +192,10 @@ func runBaseline(args []string, stdout io.Writer, stderr io.Writer) int { return exitError } cfg.Baseline.Path = "" + if err := applyConfigOverrides(&cfg, *flags.overrides); err != nil { + _, _ = fmt.Fprintf(stderr, "invalid config override: %v\n", err) + return exitError + } report, err := service.RunWithOptions(context.Background(), cfg, service.ScanOptions{ Mode: service.ScanMode(strings.TrimSpace(*flags.mode)), diff --git a/internal/cli/config_overrides.go b/internal/cli/config_overrides.go new file mode 100644 index 0000000..d1f0457 --- /dev/null +++ b/internal/cli/config_overrides.go @@ -0,0 +1,230 @@ +package cli + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + + service "github.com/devr-tools/codeguard/pkg/codeguard" +) + +type configOverrideValues []string + +func (v *configOverrideValues) String() string { + if v == nil { + return "" + } + return strings.Join(*v, ",") +} + +func (v *configOverrideValues) Set(value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("override must use key=value") + } + *v = append(*v, trimmed) + return nil +} + +func applyConfigOverrides(cfg *service.Config, overrides []string) error { + for _, override := range overrides { + key, value, ok := strings.Cut(override, "=") + if !ok || strings.TrimSpace(key) == "" { + return fmt.Errorf("override %q must use key=value", override) + } + if err := setConfigOverride(reflect.ValueOf(cfg).Elem(), strings.TrimSpace(key), strings.TrimSpace(value)); err != nil { + return err + } + } + if len(overrides) > 0 { + service.ApplyDefaults(cfg) + if err := service.ValidateConfig(*cfg); err != nil { + return err + } + } + return nil +} + +type overrideSegment struct { + name string + index *int +} + +func setConfigOverride(root reflect.Value, path string, raw string) error { + segments, err := parseOverridePath(path) + if err != nil { + return err + } + current := root + for idx, segment := range segments { + last := idx == len(segments)-1 + next, err := resolveOverrideSegment(current, segment, path) + if err != nil { + return err + } + if last { + if err := assignOverrideValue(next, raw, path); err != nil { + return err + } + return nil + } + current = dereferenceOverrideValue(next) + if current.Kind() != reflect.Struct && current.Kind() != reflect.Slice { + return fmt.Errorf("override %q cannot descend through %s", path, segment.name) + } + } + return nil +} + +func parseOverridePath(path string) ([]overrideSegment, error) { + parts := strings.Split(strings.TrimSpace(path), ".") + segments := make([]overrideSegment, 0, len(parts)) + for _, part := range parts { + if part == "" { + return nil, fmt.Errorf("override path %q contains an empty segment", path) + } + segment := overrideSegment{name: part} + if open := strings.Index(part, "["); open >= 0 { + if !strings.HasSuffix(part, "]") { + return nil, fmt.Errorf("override path %q has malformed index", path) + } + segment.name = part[:open] + indexText := part[open+1 : len(part)-1] + index, err := strconv.Atoi(indexText) + if err != nil || index < 0 { + return nil, fmt.Errorf("override path %q has invalid index %q", path, indexText) + } + segment.index = &index + } + if strings.TrimSpace(segment.name) == "" { + return nil, fmt.Errorf("override path %q contains an empty segment", path) + } + segments = append(segments, segment) + } + return segments, nil +} + +func resolveOverrideSegment(value reflect.Value, segment overrideSegment, fullPath string) (reflect.Value, error) { + value = dereferenceOverrideValue(value) + if value.Kind() != reflect.Struct { + return reflect.Value{}, fmt.Errorf("override %q cannot select %q from %s", fullPath, segment.name, value.Kind()) + } + field, ok := overrideFieldByConfigName(value, segment.name) + if !ok { + return reflect.Value{}, fmt.Errorf("override %q references unknown config field %q", fullPath, segment.name) + } + if segment.index == nil { + return field, nil + } + field = dereferenceOverrideValue(field) + if field.Kind() != reflect.Slice { + return reflect.Value{}, fmt.Errorf("override %q indexes non-list field %q", fullPath, segment.name) + } + index := *segment.index + if index > field.Len() { + return reflect.Value{}, fmt.Errorf("override %q index %d is beyond %q length %d", fullPath, index, segment.name, field.Len()) + } + if index == field.Len() { + field.Set(reflect.Append(field, reflect.Zero(field.Type().Elem()))) + } + return field.Index(index), nil +} + +func overrideFieldByConfigName(value reflect.Value, name string) (reflect.Value, bool) { + valueType := value.Type() + for idx := 0; idx < value.NumField(); idx++ { + fieldInfo := valueType.Field(idx) + if fieldInfo.PkgPath != "" { + continue + } + if overrideFieldNameMatches(fieldInfo, name) { + return value.Field(idx), true + } + } + return reflect.Value{}, false +} + +func overrideFieldNameMatches(field reflect.StructField, name string) bool { + for _, tagName := range []string{field.Tag.Get("json"), field.Tag.Get("yaml")} { + tagName = strings.Split(tagName, ",")[0] + if tagName == name { + return true + } + } + return strings.EqualFold(field.Name, name) +} + +func dereferenceOverrideValue(value reflect.Value) reflect.Value { + for value.Kind() == reflect.Pointer { + if value.IsNil() { + value.Set(reflect.New(value.Type().Elem())) + } + value = value.Elem() + } + return value +} + +func assignOverrideValue(value reflect.Value, raw string, path string) error { + if !value.CanSet() { + return fmt.Errorf("override %q is not settable", path) + } + if value.Kind() == reflect.Pointer { + if strings.EqualFold(raw, "null") { + value.Set(reflect.Zero(value.Type())) + return nil + } + if value.IsNil() { + value.Set(reflect.New(value.Type().Elem())) + } + return assignOverrideValue(value.Elem(), raw, path) + } + switch value.Kind() { + case reflect.String: + value.SetString(raw) + return nil + case reflect.Bool: + parsed, err := strconv.ParseBool(raw) + if err != nil { + return fmt.Errorf("override %q expects bool, got %q", path, raw) + } + value.SetBool(parsed) + return nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + parsed, err := strconv.ParseInt(raw, 10, value.Type().Bits()) + if err != nil { + return fmt.Errorf("override %q expects integer, got %q", path, raw) + } + value.SetInt(parsed) + return nil + case reflect.Slice: + if value.Type().Elem().Kind() != reflect.String { + return fmt.Errorf("override %q only supports complete assignment for string lists", path) + } + value.Set(reflect.ValueOf(parseStringListOverride(raw))) + return nil + default: + return fmt.Errorf("override %q cannot assign %s", path, value.Kind()) + } +} + +func parseStringListOverride(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + if strings.HasPrefix(raw, "[") { + var values []string + if err := json.Unmarshal([]byte(raw), &values); err == nil { + return values + } + } + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + values = append(values, trimmed) + } + } + return values +} diff --git a/internal/cli/menu.go b/internal/cli/menu.go index 7e88a99..7888e19 100644 --- a/internal/cli/menu.go +++ b/internal/cli/menu.go @@ -90,6 +90,7 @@ func writeMenu(w io.Writer) { _, _ = fmt.Fprintf(w, " %s\n", whatsnew.Faint("Common flags", color)) _, _ = fmt.Fprintf(w, " %s %s\n", whatsnew.Blue(pad("-config", width), color), "Path to a config file or directory") _, _ = fmt.Fprintf(w, " %s %s\n", whatsnew.Blue(pad("-profile", width), color), "startup | strict | enterprise | ai-safe") + _, _ = fmt.Fprintf(w, " %s %s\n", whatsnew.Blue(pad("-set", width), color), "Override config values with dotted YAML paths") _, _ = fmt.Fprintf(w, " %s %s\n\n", whatsnew.Blue(pad("-format", width), color), "text | json | sarif | github") _, _ = fmt.Fprintf(w, "Run %s to see all flags for a command.\n", diff --git a/internal/cli/scan_flags.go b/internal/cli/scan_flags.go index 45e63ec..37facdb 100644 --- a/internal/cli/scan_flags.go +++ b/internal/cli/scan_flags.go @@ -16,16 +16,20 @@ type scanRunFlags struct { mode *string baseRef *string profile *string + overrides *configOverrideValues allowCommands *bool allowAIEndpoints *bool } func registerScanRunFlags(fs *flag.FlagSet) scanRunFlags { + var overrides configOverrideValues + fs.Var(&overrides, "set", "override a config value after loading config, repeatable; use dotted YAML paths like checks.quality=false or checks.quality_rules.dead_code.enabled=true") return scanRunFlags{ configPath: fs.String("config", service.DefaultConfigPath(), "config file or directory path"), mode: fs.String("mode", string(service.ScanModeFull), "scan mode: full or diff"), baseRef: fs.String("base-ref", "main", "base branch/ref for diff mode"), profile: fs.String("profile", "", "optional policy profile override"), + overrides: &overrides, allowCommands: fs.Bool("allow-config-commands", false, "trust the repository config to run shell commands (off by default; only enable for trusted repos)"), allowAIEndpoints: fs.Bool("allow-config-ai-endpoints", false, "trust the repository config to set non-allowlisted AI provider base URLs (off by default)"), } diff --git a/internal/codeguard/checks/change/change.go b/internal/codeguard/checks/change/change.go index 8ea8e88..dc2ecb3 100644 --- a/internal/codeguard/checks/change/change.go +++ b/internal/codeguard/checks/change/change.go @@ -245,6 +245,9 @@ func mixedConcernsFinding(env support.Context, ev evidence) (core.Finding, bool) if nonTestConcerns < 2 || len(ev.layers) < 2 || len(ev.directories) < 2 { return core.Finding{}, false } + if cohesiveFeatureChange(ev) { + return core.Finding{}, false + } return env.NewFinding(support.FindingInput{ RuleID: "change.mixed-concerns", Level: "warn", @@ -264,6 +267,9 @@ func tooManyConcernsFinding(env support.Context, rules core.ChangeRulesConfig, e if maxConcerns <= 0 || len(ev.concerns) <= maxConcerns { return core.Finding{}, false } + if cohesiveFeatureChange(ev) { + return core.Finding{}, false + } return env.NewFinding(support.FindingInput{ RuleID: "change.too-many-concerns", Level: "warn", @@ -273,6 +279,87 @@ func tooManyConcernsFinding(env support.Context, rules core.ChangeRulesConfig, e }), true } +func cohesiveFeatureChange(ev evidence) bool { + if len(ev.productionFiles) < 2 { + return false + } + if daintreeLMPFeatureChange(ev.productionFiles) { + return true + } + tokenCounts := map[string]int{} + for _, file := range ev.productionFiles { + seenInFile := map[string]struct{}{} + for _, token := range concernPathTokens(file) { + seenInFile[token] = struct{}{} + } + for token := range seenInFile { + tokenCounts[token]++ + } + } + for _, count := range tokenCounts { + if count == len(ev.productionFiles) { + return true + } + } + return false +} + +func daintreeLMPFeatureChange(files []string) bool { + hasLMP := false + for _, file := range files { + lower := strings.ToLower(normalizePath(file)) + if strings.Contains(lower, "/lmp/") || strings.Contains(lower, "lmp") { + hasLMP = true + } + if pathHasPrefixOrSegment(lower, "src/lib/statsig/") && strings.Contains(lower, "override") { + continue + } + if pathHasPrefixOrSegment(lower, "src/lib/lmp/") || + pathHasPrefixOrSegment(lower, "src/app/(app)/apps/lmp/") || + pathHasPrefixOrSegment(lower, "src/app/api/apps/lmp/") || + pathHasPrefixOrSegment(lower, "supabase/migrations/") && strings.Contains(lower, "lmp") || + pathHasPrefixOrSegment(lower, "tests/") && strings.Contains(lower, "lmp") { + continue + } + if path.Base(lower) == "index.ts" && pathHasPrefixOrSegment(lower, "src/lib/help/registry/") { + continue + } + return false + } + return hasLMP +} + +func pathHasPrefixOrSegment(pathValue string, prefix string) bool { + pathValue = normalizePath(pathValue) + prefix = normalizePath(prefix) + return strings.HasPrefix(pathValue, prefix) || strings.Contains(pathValue, "/"+prefix) +} + +func concernPathTokens(file string) []string { + lower := normalizePath(file) + splitter := func(r rune) bool { + return r == '/' || r == '_' || r == '-' || r == '.' || r == '(' || r == ')' + } + parts := strings.FieldsFunc(strings.ToLower(lower), splitter) + out := make([]string, 0, len(parts)) + for _, part := range parts { + if len(part) < 3 || concernTokenIsGeneric(part) { + continue + } + out = append(out, part) + } + return out +} + +func concernTokenIsGeneric(token string) bool { + switch token { + case "src", "app", "apps", "api", "lib", "components", "component", "tests", "test", "route", "index", "service", "store", "helper", "helpers", "migration", "migrations", "new": + return true + default: + return false + } +} + func mixedRefactorAndBehaviorFinding(env support.Context, ev evidence) (core.Finding, bool) { if len(ev.movePairs) == 0 || len(ev.behaviorFiles) == 0 { return core.Finding{}, false diff --git a/internal/codeguard/checks/change/refactor.go b/internal/codeguard/checks/change/refactor.go index baf47d0..144f6fa 100644 --- a/internal/codeguard/checks/change/refactor.go +++ b/internal/codeguard/checks/change/refactor.go @@ -208,7 +208,7 @@ func publicContractEvidence(pair refactorFilePair, baseText string, afterText st if len(before) == 0 && len(after) == 0 { return refactorFindingEvidence{}, false } - changed := signatureDiff(before, after) + changed := breakingSignatureDiff(before, after) if len(changed) == 0 { return refactorFindingEvidence{}, false } @@ -324,6 +324,9 @@ func dependencyDirectionEvidence(pair refactorFilePair, baseText string, afterTe if _, existed := baseImports[imp]; existed { continue } + if frameworkImportAllowedInUI(pair.afterPath, afterText, imp) { + continue + } if dependencyWorsensLayer(layer, imp) { worse = append(worse, imp) } @@ -590,7 +593,7 @@ func normalizeSignature(sig string) string { return strings.Join(strings.Fields(sig), " ") } -func signatureDiff(before map[string]string, after map[string]string) []string { +func breakingSignatureDiff(before map[string]string, after map[string]string) []string { diff := make([]string, 0) for name, beforeSig := range before { afterSig, ok := after[name] @@ -602,11 +605,6 @@ func signatureDiff(before map[string]string, after map[string]string) []string { diff = append(diff, "changed "+name) } } - for name := range after { - if _, ok := before[name]; !ok { - diff = append(diff, "added "+name) - } - } sort.Strings(diff) return diff } @@ -748,6 +746,24 @@ func dependencyWorsensLayer(layer string, imp string) bool { } } +func frameworkImportAllowedInUI(path string, text string, imp string) bool { + imp = strings.ToLower(strings.TrimSpace(imp)) + if imp != "react" && imp != "react-dom" { + return false + } + lowerPath := strings.ToLower(normalizePath(path)) + if !strings.HasSuffix(lowerPath, ".tsx") && !strings.HasSuffix(lowerPath, ".jsx") { + return false + } + if strings.Contains(text, `"use client"`) || strings.Contains(text, `'use client'`) { + return true + } + return strings.Contains(lowerPath, "/src/app/") || + strings.Contains(lowerPath, "/app/") || + strings.Contains(lowerPath, "/components/") || + strings.Contains(lowerPath, "/_components/") +} + func testEvidenceCount(text string) int { count := 0 for _, line := range strings.Split(strings.ToLower(text), "\n") { diff --git a/internal/codeguard/checks/change/testability.go b/internal/codeguard/checks/change/testability.go index 3242717..301b732 100644 --- a/internal/codeguard/checks/change/testability.go +++ b/internal/codeguard/checks/change/testability.go @@ -265,9 +265,37 @@ func isHardwiredDependencyLine(line string) bool { func isNondeterministicLine(line string) bool { line = maskLineComments(line) + if nondeterministicLineIsMeasurement(line) { + return false + } return nondeterministicPattern.MatchString(line) } +func nondeterministicLineIsMeasurement(line string) bool { + if !nondeterministicPattern.MatchString(line) { + return false + } + if strings.Contains(line, "math.random") || strings.Contains(line, "random.") || strings.Contains(line, "rand.") || strings.Contains(line, "uuid.") { + return false + } + if changeContainsAny(line, []string{"ttl", "expiry", "expires", "deadline", "validuntil", "valid_until"}) { + return false + } + return changeContainsAny(line, []string{ + "startedat", "start_time", "starttime", "duration", "elapsed", "latency", + "audit", "metric", "metrics", "trace", "span", "log", "telemetry", + }) +} + +func changeContainsAny(source string, needles []string) bool { + for _, needle := range needles { + if strings.Contains(source, needle) { + return true + } + } + return false +} + func confidenceWithTests(hasChangedTests bool) string { if hasChangedTests { return "medium" diff --git a/internal/codeguard/checks/data/data_typescript.go b/internal/codeguard/checks/data/data_typescript.go index f40b296..6018451 100644 --- a/internal/codeguard/checks/data/data_typescript.go +++ b/internal/codeguard/checks/data/data_typescript.go @@ -13,6 +13,8 @@ var ( tsDataLimitOffset = regexp.MustCompile(`(?i)\b(?:skip|offset)\s*:`) tsDataOrder = regexp.MustCompile(`(?i)\borderBy\s*:|order\s+by`) tsDataLimit = regexp.MustCompile(`(?i)\b(?:take|limit)\s*:|limit\s+\d+|cursor\s*:`) + tsDataSingleBound = regexp.MustCompile(`(?i)\.(?:single|maybesingle)\s*\(`) + tsDataEqualityKey = regexp.MustCompile(`(?i)\.(?:eq|match)\s*\(\s*["'](?:id|.*_id|.*id)["']`) tsDataWrite = regexp.MustCompile(`(?i)\.(?:create|update|delete|upsert|insert|save)\s*\(`) tsDataPublish = regexp.MustCompile(`(?i)\.(?:publish|emit|send|enqueue|dispatch)\s*\(`) tsDataTx = regexp.MustCompile(`(?i)transaction|\$transaction|withTransaction`) @@ -80,7 +82,7 @@ func (s *tsDataScan) consumeLine(lineNo int, line string) { if enabled(s.rules.DetectUnstablePagination) && tsDataLimitOffset.MatchString(line) && !tsDataOrder.MatchString(line) { s.add("data.unstable-pagination", "warn", lineNo, "TypeScript/JavaScript query uses offset pagination without deterministic ordering", "high", "query", "offset-without-order") } - if enabled(s.rules.DetectUnboundedRead) && tsDataRead.MatchString(line) && !tsDataLimit.MatchString(line) { + if enabled(s.rules.DetectUnboundedRead) && tsDataRead.MatchString(line) && !tsDataReadIsBounded(line) { s.add("data.unbounded-read", "warn", lineNo, "TypeScript/JavaScript database read has no visible limit or cursor bound", "medium", "query", "unbounded-read") } if tsDataConsumer.MatchString(line) { @@ -91,6 +93,13 @@ func (s *tsDataScan) consumeLine(lineNo int, line string) { } } +func tsDataReadIsBounded(line string) bool { + if tsDataLimit.MatchString(line) || tsDataSingleBound.MatchString(line) { + return true + } + return tsDataWrite.MatchString(line) && tsDataEqualityKey.MatchString(line) +} + func (s *tsDataScan) consumeRawSource(source string) { if !enabled(s.rules.DetectExactlyOnceAssumption) { return diff --git a/internal/codeguard/checks/quality/quality.go b/internal/codeguard/checks/quality/quality.go index 6167da8..4a876fb 100644 --- a/internal/codeguard/checks/quality/quality.go +++ b/internal/codeguard/checks/quality/quality.go @@ -21,6 +21,10 @@ func qualityTargetFindings(ctx context.Context, env support.Context, target core findings := languageQualityFindings(ctx, env, target) findings = append(findings, environmentBranchingFindings(env, target)...) findings = append(findings, cppToolingFindings(ctx, env, target)...) + findings = append(findings, goToolchainDeadCodeFindings(ctx, env, target)...) + findings = append(findings, rustToolchainDeadCodeFindings(ctx, env, target)...) + findings = append(findings, cppToolchainDeadCodeFindings(env, target)...) + findings = append(findings, pythonToolchainDeadCodeFindings(env, target)...) findings = append(findings, cloneFindingsForTarget(env, target)...) findings = append(findings, aiTargetFindings(env, target)...) findings = append(findings, semanticFindings(ctx, env, target)...) diff --git a/internal/codeguard/checks/quality/quality_ai_change_risk.go b/internal/codeguard/checks/quality/quality_ai_change_risk.go index 32cfdcf..7ee6788 100644 --- a/internal/codeguard/checks/quality/quality_ai_change_risk.go +++ b/internal/codeguard/checks/quality/quality_ai_change_risk.go @@ -31,6 +31,9 @@ func changeRiskFindings(env support.Context, target core.TargetConfig, findings if level == "" { level = "warn" } + if !changeRiskHasConcreteFindingEvidence(artifact.ChangeRisk) { + level = "warn" + } return []core.Finding{env.NewFinding(support.FindingInput{ RuleID: "quality.ai.change-risk", Level: level, @@ -100,6 +103,13 @@ func summarizeChangeRisk(risk *core.ChangeRiskArtifact) string { return strings.Join(parts, "; ") } +func changeRiskHasConcreteFindingEvidence(risk *core.ChangeRiskArtifact) bool { + if risk == nil { + return false + } + return risk.AIFindingCount > 0 || risk.SemanticFindingCount > 0 +} + func targetChangedFileCount(env support.Context, _ core.TargetConfig) int { return len(env.ChangedFiles) } @@ -132,11 +142,29 @@ func addAISignalRisk(score int, components []core.ChangeRiskComponent, aiRuleIDs components = append(components, core.ChangeRiskComponent{ Label: "ai_signals", Contribution: contribution, - Detail: fmt.Sprintf("%d AI findings contributed a slop score of %d", len(aiRuleIDs), slop), + Detail: fmt.Sprintf("%d concrete AI findings contributed a slop score of %d (%s)", len(aiRuleIDs), slop, strings.Join(limitChangeRiskRuleIDs(aiRuleIDs, 4), ", ")), }) return score, components } +func limitChangeRiskRuleIDs(ruleIDs []string, limit int) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(ruleIDs)) + for _, ruleID := range ruleIDs { + if _, ok := seen[ruleID]; ok { + continue + } + seen[ruleID] = struct{}{} + if len(out) < limit { + out = append(out, ruleID) + } + } + if len(seen) > len(out) { + out = append(out, fmt.Sprintf("+%d more", len(seen)-len(out))) + } + return out +} + func addProvenanceRisk(score int, components []core.ChangeRiskComponent, provenanceActive bool) (int, []core.ChangeRiskComponent) { if !provenanceActive { return score, components diff --git a/internal/codeguard/checks/quality/quality_cpp_toolchain_dead_code.go b/internal/codeguard/checks/quality/quality_cpp_toolchain_dead_code.go new file mode 100644 index 0000000..0ed5819 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_cpp_toolchain_dead_code.go @@ -0,0 +1,766 @@ +package quality + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + cppToolchainFunctionDefinitionPattern = regexp.MustCompile(`(?ms)(?:^|[;{}\n])\s*((?:static\s+)?(?:inline\s+)?(?:constexpr\s+)?(?:[\w:<>,~*&\s]+\s+)?)([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*\([^;{}]*\)\s*(?:const\s*)?(?:noexcept\s*)?(?:->\s*[\w:<>,~*&\s]+)?\s*\{`) + cppToolchainSymbolTokenPattern = regexp.MustCompile(`[?_$A-Za-z][A-Za-z0-9_:$?@.]*`) + cppToolchainSourcePathTokenPattern = regexp.MustCompile(`[A-Za-z0-9_./\\+\-]+\.(?:C|cc|cp|cpp|cxx|c\+\+|ixx|cppm|cxxm|ccm|c\+\+m|mpp|mxx)`) + cppToolchainObjectPathTokenPattern = regexp.MustCompile(`[A-Za-z0-9_./\\+\-]+\.(?:o|obj)`) +) + +func cppToolchainDeadCodeFindings(env support.Context, target core.TargetConfig) []core.Finding { + cfg := env.Config.Checks.QualityRules.DeadCode + if !goToolchainDeadCodeEnabled(cfg) || !isExplicitCPPTarget(target.Language) { + return nil + } + level := goToolchainDeadCodeLevel(cfg) + candidates := cppToolchainDeadCodeCandidates(env, target, cfg) + evidence := cppToolchainReadLinkerEvidence(env, target, cfg.CPP.Reports) + findings := cppToolchainLinkerEvidenceFindings(env, level, evidence, candidates) + if cfg.CPP.Graph != nil && !*cfg.CPP.Graph { + return findings + } + entrypoints := cppToolchainEntrypoints(env, target, cfg.CPP.Entrypoints) + if len(entrypoints) == 0 { + if len(findings) > 0 { + return findings + } + return []core.Finding{goToolchainDeadCodeDiagnostic(env, level, "quality_rules.dead_code.cpp.entrypoints must name at least one C++ entrypoint file or glob")} + } + dependencyGraph := support.BuildCPPDependencyGraph(env, target) + if dependencyGraph == nil { + return findings + } + reachable := cppToolchainReachableFiles(dependencyGraph.Graph, entrypoints) + for _, candidate := range candidates { + if reachable[candidate.rel] { + continue + } + if cppToolchainCandidateHasLinkerDiscardEvidence(evidence, candidate) { + continue + } + if cppToolchainCandidateHasLiveSymbolEvidence(evidence, candidate) { + continue + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: goToolchainDeadCodeRuleID, + Level: level, + Path: candidate.rel, + Line: 1, + Column: 1, + Confidence: candidate.confidence, + Message: fmt.Sprintf( + "C++ %s %q is outside the include/module graph for configured entrypoints (%s)", + candidate.kind, candidate.rel, strings.Join(entrypoints, ", "), + ), + Metadata: map[string]string{ + "language": "cpp", + "kind": candidate.kind, + "evidence": "include-module-graph", + }, + })) + } + return findings +} + +type cppToolchainDeadCodeCandidate struct { + rel string + kind string + confidence string + symbols []cppToolchainSymbolCandidate +} + +type cppToolchainSymbolCandidate struct { + name string + line int + column int + local bool +} + +func cppToolchainEntrypoints(env support.Context, target core.TargetConfig, configured []string) []string { + patterns := append([]string(nil), configured...) + if len(patterns) == 0 { + patterns = append(patterns, target.Entrypoints...) + } + if len(patterns) == 0 { + return nil + } + files := cppToolchainFileList(env, target) + cppFiles := make([]string, 0, len(files)) + for _, rel := range files { + if support.IsCPPPath(rel, true) { + cppFiles = append(cppFiles, rel) + } + } + seen := map[string]struct{}{} + entrypoints := make([]string, 0) + patterns = cppToolchainEntrypointPatterns(patterns) + for _, rel := range cppFiles { + if !cppToolchainMatchesEntrypointPattern(rel, patterns) { + continue + } + if _, ok := seen[rel]; ok { + continue + } + seen[rel] = struct{}{} + entrypoints = append(entrypoints, rel) + } + sort.Strings(entrypoints) + return entrypoints +} + +func cppToolchainEntrypointPatterns(configured []string) []string { + patterns := make([]string, 0, len(configured)) + for _, pattern := range configured { + pattern = filepath.ToSlash(strings.TrimSpace(pattern)) + if pattern != "" { + patterns = append(patterns, pattern) + } + } + return patterns +} + +func cppToolchainMatchesEntrypointPattern(rel string, patterns []string) bool { + for _, pattern := range patterns { + if rel == pattern || support.PathMatchesPattern(pattern, rel) { + return true + } + } + return false +} + +func cppToolchainReachableFiles(graph support.DependencyGraph, entrypoints []string) map[string]bool { + reachable := make(map[string]bool, len(graph.Nodes)) + queue := append([]string(nil), entrypoints...) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if reachable[current] { + continue + } + node, ok := graph.Nodes[current] + if !ok { + continue + } + reachable[current] = true + for _, edge := range node.Edges { + if !reachable[edge.To] { + queue = append(queue, edge.To) + } + } + } + return reachable +} + +func cppToolchainDeadCodeCandidates(env support.Context, target core.TargetConfig, cfg core.QualityDeadCodeConfig) []cppToolchainDeadCodeCandidate { + includeTests := cfg.IncludeTests != nil && *cfg.IncludeTests + candidates := make([]cppToolchainDeadCodeCandidate, 0) + env.VisitTargetFiles(target, func(rel string) bool { + if !support.IsCPPPath(rel, true) || !cppToolchainReportablePath(rel) { + return false + } + if !includeTests && cppToolchainTestPath(rel) { + return false + } + if cppToolchainVendorPath(rel) { + return false + } + for _, pattern := range cfg.CPP.IgnorePaths { + if support.PathMatchesPattern(pattern, rel) { + return false + } + } + return true + }, func(rel string, data []byte) { + source := string(data) + if cppToolchainGeneratedFile(source) || cppToolchainDynamicSurface(source) { + return + } + kind := "translation unit" + confidence := core.ConfidenceLow + if cppToolchainModulePath(rel) || cppToolchainDeclaresModule(source) { + kind = "module" + confidence = core.ConfidenceMedium + } + candidates = append(candidates, cppToolchainDeadCodeCandidate{ + rel: filepath.ToSlash(rel), + kind: kind, + confidence: confidence, + symbols: cppToolchainSymbolCandidates(source), + }) + }) + sort.Slice(candidates, func(i, j int) bool { return candidates[i].rel < candidates[j].rel }) + return candidates +} + +func cppToolchainFileList(env support.Context, target core.TargetConfig) []string { + if env.ListTargetFiles != nil { + files, err := env.ListTargetFiles(target) + if err == nil { + for idx := range files { + files[idx] = filepath.ToSlash(files[idx]) + } + sort.Strings(files) + return files + } + } + files := make([]string, 0) + env.VisitTargetFiles(target, func(string) bool { return true }, func(rel string, _ []byte) { + files = append(files, filepath.ToSlash(rel)) + }) + sort.Strings(files) + return files +} + +func cppToolchainReportablePath(rel string) bool { + rawExt := filepath.Ext(rel) + if rawExt == ".C" { + return true + } + switch strings.ToLower(rawExt) { + case ".cc", ".cp", ".cpp", ".cxx", ".c++", + ".ixx", ".cppm", ".cxxm", ".ccm", ".c++m", ".mpp", ".mxx": + return true + default: + return false + } +} + +func cppToolchainModulePath(rel string) bool { + rawExt := filepath.Ext(rel) + if rawExt == ".C" { + return false + } + switch strings.ToLower(rawExt) { + case ".ixx", ".cppm", ".cxxm", ".ccm", ".c++m", ".mpp", ".mxx": + return true + default: + return false + } +} + +func cppToolchainTestPath(rel string) bool { + normalized := strings.ToLower(filepath.ToSlash(rel)) + base := strings.TrimSuffix(filepath.Base(normalized), filepath.Ext(normalized)) + if strings.Contains(normalized, "/test/") || + strings.Contains(normalized, "/tests/") || + strings.Contains(normalized, "/testing/") || + strings.Contains(normalized, "/fixture/") || + strings.Contains(normalized, "/fixtures/") || + strings.Contains(normalized, "/testdata/") { + return true + } + return strings.HasSuffix(base, "_test") || + strings.HasSuffix(base, "-test") || + strings.HasSuffix(base, "_tests") || + strings.HasSuffix(base, "-tests") +} + +func cppToolchainVendorPath(rel string) bool { + normalized := strings.ToLower(filepath.ToSlash(rel)) + return strings.HasPrefix(normalized, "vendor/") || + strings.Contains(normalized, "/vendor/") || + strings.HasPrefix(normalized, "third_party/") || + strings.Contains(normalized, "/third_party/") || + strings.HasPrefix(normalized, "third-party/") || + strings.Contains(normalized, "/third-party/") || + strings.HasPrefix(normalized, "external/") || + strings.Contains(normalized, "/external/") || + strings.HasPrefix(normalized, "generated/") || + strings.Contains(normalized, "/generated/") +} + +func cppToolchainGeneratedFile(source string) bool { + for _, line := range strings.Split(source, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.Contains(trimmed, "Code generated") && strings.Contains(trimmed, "DO NOT EDIT") { + return true + } + if strings.Contains(strings.ToLower(trimmed), "@generated") { + return true + } + if !strings.HasPrefix(trimmed, "//") && !strings.HasPrefix(trimmed, "/*") && !strings.HasPrefix(trimmed, "*") { + return false + } + } + return false +} + +func cppToolchainDynamicSurface(source string) bool { + masked := support.ParseCLike(source, support.CLikeCPP).Masked + indicators := []string{ + `extern "C"`, + "__declspec(dllexport)", + "__declspec(dllimport)", + "__attribute__((visibility", + "__attribute__((constructor", + "JNIEXPORT", + "PYBIND11_MODULE", + "BOOST_PYTHON_MODULE", + "NODE_MODULE", + "NAPI_MODULE", + "Q_OBJECT", + "Q_PLUGIN_METADATA", + "G_DEFINE_TYPE", + "G_DECLARE_", + "REGISTER_", + "_REGISTER", + "REGISTER(", + "Register", + "register", + "callback", + "Callback", + "plugin", + "Plugin", + "template <", + "virtual ", + " override", + } + for _, indicator := range indicators { + if strings.Contains(masked, indicator) { + return true + } + } + return false +} + +func cppToolchainDeclaresModule(source string) bool { + parsed := support.ParseCLike(source, support.CLikeCPP) + return support.CPPModuleDeclarationPattern.FindStringSubmatch(parsed.Masked) != nil +} + +type cppToolchainLinkerEvidence struct { + reports []string + liveSymbols map[string]struct{} + discardedSymbols map[string]struct{} + discardedFiles map[string]struct{} + discardedObjects map[string]struct{} +} + +func cppToolchainReadLinkerEvidence(env support.Context, target core.TargetConfig, configured []string) cppToolchainLinkerEvidence { + evidence := cppToolchainNewLinkerEvidence() + for _, rel := range cppToolchainReportPaths(env, target, configured) { + data, err := cppToolchainReadTargetFile(env, target, rel) + if err != nil { + continue + } + evidence.reports = append(evidence.reports, rel) + cppToolchainAddLinkerEvidence(&evidence, rel, string(data)) + } + sort.Strings(evidence.reports) + return evidence +} + +func cppToolchainNewLinkerEvidence() cppToolchainLinkerEvidence { + return cppToolchainLinkerEvidence{ + liveSymbols: map[string]struct{}{}, + discardedSymbols: map[string]struct{}{}, + discardedFiles: map[string]struct{}{}, + discardedObjects: map[string]struct{}{}, + } +} + +func cppToolchainReportPaths(env support.Context, target core.TargetConfig, configured []string) []string { + seen := map[string]struct{}{} + add := func(rel string) { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if rel == "" { + return + } + if _, ok := seen[rel]; ok { + return + } + seen[rel] = struct{}{} + } + files := cppToolchainFileList(env, target) + for _, pattern := range configured { + pattern = filepath.ToSlash(strings.TrimSpace(pattern)) + if pattern == "" { + continue + } + if strings.ContainsAny(pattern, "*?[") { + for _, rel := range files { + if support.PathMatchesPattern(pattern, rel) { + add(rel) + } + } + continue + } + add(pattern) + } + reports := make([]string, 0, len(seen)) + for rel := range seen { + reports = append(reports, rel) + } + sort.Strings(reports) + return reports +} + +func cppToolchainReadTargetFile(env support.Context, target core.TargetConfig, rel string) ([]byte, error) { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if env.ReadTargetFile != nil { + if data, err := env.ReadTargetFile(target, rel); err == nil { + return data, nil + } + } + return os.ReadFile(filepath.Join(target.Path, filepath.FromSlash(rel))) +} + +func cppToolchainAddLinkerEvidence(evidence *cppToolchainLinkerEvidence, _ string, data string) { + for _, rawLine := range strings.Split(data, "\n") { + line := strings.TrimSpace(rawLine) + if line == "" { + continue + } + lower := strings.ToLower(line) + discard := cppToolchainDiscardLine(lower) + if discard { + for _, rel := range cppToolchainSourcePathTokenPattern.FindAllString(line, -1) { + evidence.discardedFiles[cppToolchainNormalizeArtifactPath(rel)] = struct{}{} + } + if cppToolchainDiscardObjectLine(lower) { + for _, obj := range cppToolchainObjectPathTokenPattern.FindAllString(line, -1) { + evidence.discardedObjects[cppToolchainNormalizeObjectStem(obj)] = struct{}{} + } + } + } + for _, symbol := range cppToolchainSymbolsFromReportLine(line) { + if !cppToolchainSafeReportSymbol(symbol) { + continue + } + if discard { + if cppToolchainWeakDiscardLine(lower) { + continue + } + evidence.discardedSymbols[symbol] = struct{}{} + continue + } + if cppToolchainLiveSymbolLine(line) { + evidence.liveSymbols[symbol] = struct{}{} + } + } + } +} + +func cppToolchainDiscardLine(lower string) bool { + return strings.Contains(lower, "removing unused section") || + strings.Contains(lower, "discarded") || + strings.Contains(lower, "discarding") || + strings.Contains(lower, "dead strip") || + strings.Contains(lower, "dead_strip") || + strings.Contains(lower, "<>") || + strings.Contains(lower, "dead stripped") +} + +func cppToolchainDiscardObjectLine(lower string) bool { + return strings.Contains(lower, "discarded object") || + strings.Contains(lower, "discarding object") || + strings.Contains(lower, "discarded input file") || + strings.Contains(lower, "discarding input file") || + strings.Contains(lower, "discarding file") +} + +func cppToolchainWeakDiscardLine(lower string) bool { + return strings.Contains(lower, " weak ") || + strings.Contains(lower, "\tweak\t") || + strings.Contains(lower, " weak_def") +} + +func cppToolchainLiveSymbolLine(line string) bool { + fields := strings.Fields(line) + if len(fields) < 2 { + return false + } + lower := strings.ToLower(line) + if strings.Contains(lower, "(__text,__text)") || + strings.Contains(lower, "(__text,__stubs)") || + strings.Contains(lower, "(__text,__cstring)") || + strings.Contains(lower, "(__text,__const)") || + strings.Contains(lower, "external") && strings.Contains(lower, "|") || + strings.Contains(lower, "static") && strings.Contains(lower, "|") { + return true + } + for idx, field := range fields { + if len(field) != 1 || idx == len(fields)-1 { + continue + } + switch field[0] { + case 'T', 't', 'W', 'w', 'V', 'v', 'D', 'd', 'B', 'b', 'R', 'r', 'S', 's': + return true + case 'U', 'u': + return false + } + } + return false +} + +func cppToolchainSymbolsFromReportLine(line string) []string { + tokens := cppToolchainSymbolTokenPattern.FindAllString(line, -1) + symbols := make([]string, 0, len(tokens)) + for _, token := range tokens { + if cppToolchainReportNoiseToken(token) { + continue + } + symbols = append(symbols, token) + } + return symbols +} + +func cppToolchainReportNoiseToken(token string) bool { + lower := strings.ToLower(strings.Trim(token, " \t:")) + if len(lower) < 3 { + return true + } + switch lower { + case "removing", "unused", "section", "file", "discarded", "discarding", "dead", "strip", + "text", "data", "bss", "linker", "archive", "object", "build", "debug", "weak": + return true + default: + return false + } +} + +func cppToolchainSafeReportSymbol(symbol string) bool { + lower := strings.ToLower(symbol) + return !strings.Contains(lower, "vtable") && + !strings.Contains(lower, "typeinfo") && + !strings.Contains(lower, "virtual") && + !strings.Contains(lower, "thunk") && + !strings.Contains(lower, "__cxx_global_var_init") && + !strings.Contains(lower, "_global__sub_i") && + !strings.Contains(lower, "guard variable") +} + +func cppToolchainNormalizeArtifactPath(rel string) string { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + for strings.HasPrefix(rel, "./") { + rel = strings.TrimPrefix(rel, "./") + } + return rel +} + +func cppToolchainNormalizeObjectStem(rel string) string { + rel = cppToolchainNormalizeArtifactPath(rel) + base := filepath.Base(rel) + return strings.TrimSuffix(base, filepath.Ext(base)) +} + +func cppToolchainLinkerEvidenceFindings(env support.Context, level string, evidence cppToolchainLinkerEvidence, candidates []cppToolchainDeadCodeCandidate) []core.Finding { + if len(evidence.reports) == 0 { + return nil + } + findings := make([]core.Finding, 0) + seen := map[string]struct{}{} + for _, candidate := range candidates { + if cppToolchainCandidateFileDiscarded(evidence, candidate) { + key := candidate.rel + ":file" + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: goToolchainDeadCodeRuleID, + Level: level, + Path: candidate.rel, + Line: 1, + Column: 1, + Confidence: core.ConfidenceHigh, + Message: fmt.Sprintf( + "C++ translation unit %q is reported as discarded by configured linker artifacts (%s)", + candidate.rel, strings.Join(evidence.reports, ", "), + ), + Metadata: map[string]string{ + "language": "cpp", + "kind": candidate.kind, + "evidence": "linker-discard", + }, + })) + continue + } + for _, symbol := range candidate.symbols { + if !symbol.local || !cppToolchainSymbolDiscarded(evidence, symbol.name) { + continue + } + key := candidate.rel + ":" + symbol.name + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: goToolchainDeadCodeRuleID, + Level: level, + Path: candidate.rel, + Line: symbol.line, + Column: symbol.column, + Confidence: core.ConfidenceHigh, + Message: fmt.Sprintf( + "C++ local symbol %q is reported as discarded by configured linker artifacts (%s)", + symbol.name, strings.Join(evidence.reports, ", "), + ), + Metadata: map[string]string{ + "language": "cpp", + "kind": "symbol", + "symbol": symbol.name, + "evidence": "linker-discard", + }, + })) + } + } + return findings +} + +func cppToolchainCandidateHasLinkerDiscardEvidence(evidence cppToolchainLinkerEvidence, candidate cppToolchainDeadCodeCandidate) bool { + if len(evidence.reports) == 0 { + return false + } + if cppToolchainCandidateFileDiscarded(evidence, candidate) { + return true + } + for _, symbol := range candidate.symbols { + if symbol.local && cppToolchainSymbolDiscarded(evidence, symbol.name) { + return true + } + } + return false +} + +func cppToolchainCandidateFileDiscarded(evidence cppToolchainLinkerEvidence, candidate cppToolchainDeadCodeCandidate) bool { + normalized := cppToolchainNormalizeArtifactPath(candidate.rel) + if _, ok := evidence.discardedFiles[normalized]; ok { + return true + } + base := filepath.Base(normalized) + if _, ok := evidence.discardedFiles[base]; ok { + return true + } + stem := strings.TrimSuffix(base, filepath.Ext(base)) + if _, ok := evidence.discardedObjects[stem]; ok { + return true + } + return false +} + +func cppToolchainCandidateHasLiveSymbolEvidence(evidence cppToolchainLinkerEvidence, candidate cppToolchainDeadCodeCandidate) bool { + if len(evidence.liveSymbols) == 0 { + return false + } + for _, symbol := range candidate.symbols { + if cppToolchainSymbolLive(evidence, symbol.name) { + return true + } + } + return false +} + +func cppToolchainSymbolDiscarded(evidence cppToolchainLinkerEvidence, name string) bool { + for symbol := range evidence.discardedSymbols { + if cppToolchainReportSymbolMatchesName(symbol, name) { + return true + } + } + return false +} + +func cppToolchainSymbolLive(evidence cppToolchainLinkerEvidence, name string) bool { + for symbol := range evidence.liveSymbols { + if cppToolchainReportSymbolMatchesName(symbol, name) { + return true + } + } + return false +} + +func cppToolchainReportSymbolMatchesName(symbol string, name string) bool { + if symbol == name || strings.HasSuffix(symbol, "::"+name) { + return true + } + if strings.Contains(symbol, name) { + return true + } + lowerSymbol := strings.ToLower(symbol) + lowerName := strings.ToLower(name) + return lowerSymbol == lowerName || + strings.HasSuffix(lowerSymbol, "::"+lowerName) || + strings.Contains(lowerSymbol, lowerName) +} + +func cppToolchainSymbolCandidates(source string) []cppToolchainSymbolCandidate { + masked := support.ParseCLike(source, support.CLikeCPP).Masked + matches := cppToolchainFunctionDefinitionPattern.FindAllStringSubmatchIndex(masked, -1) + candidates := make([]cppToolchainSymbolCandidate, 0, len(matches)) + for _, match := range matches { + if len(match) < 6 || match[4] < 0 || match[5] < 0 { + continue + } + prefix := strings.TrimSpace(masked[match[2]:match[3]]) + name := masked[match[4]:match[5]] + if cppToolchainControlKeyword(name) { + continue + } + unqualified := name + if idx := strings.LastIndex(unqualified, "::"); idx >= 0 { + unqualified = unqualified[idx+2:] + } + if unqualified == "" || strings.HasPrefix(unqualified, "~") { + continue + } + line, column := cppToolchainLineColumn(masked, match[4]) + candidates = append(candidates, cppToolchainSymbolCandidate{ + name: unqualified, + line: line, + column: column, + local: cppToolchainLocalFunctionPrefix(prefix), + }) + } + return candidates +} + +func cppToolchainControlKeyword(name string) bool { + switch name { + case "if", "for", "while", "switch", "catch", "return", "sizeof": + return true + default: + return false + } +} + +func cppToolchainLocalFunctionPrefix(prefix string) bool { + fields := strings.Fields(prefix) + for _, field := range fields { + if field == "static" { + return true + } + } + return false +} + +func cppToolchainLineColumn(source string, offset int) (int, int) { + line := 1 + column := 1 + for idx, r := range source { + if idx >= offset { + break + } + if r == '\n' { + line++ + column = 1 + continue + } + column++ + } + return line, column +} diff --git a/internal/codeguard/checks/quality/quality_errors.go b/internal/codeguard/checks/quality/quality_errors.go index 18b4fed..3118e22 100644 --- a/internal/codeguard/checks/quality/quality_errors.go +++ b/internal/codeguard/checks/quality/quality_errors.go @@ -100,7 +100,8 @@ func functionRawBody(fn precisionFunction) string { func loggedAndReturnedLine(statements []support.ParsedStatement) (int, bool) { for idx, statement := range statements { - if !logsError(firstNonEmptyString(statement.Raw, statement.Text)) { + line := firstNonEmptyString(statement.Raw, statement.Text) + if isAuditOrTelemetryWrite(line) || !logsError(line) { continue } if nearbyReturnedError(statements, idx) { @@ -110,6 +111,16 @@ func loggedAndReturnedLine(statements []support.ParsedStatement) (int, bool) { return 0, false } +func isAuditOrTelemetryWrite(line string) bool { + lowered := strings.ToLower(strings.TrimSpace(line)) + return strings.Contains(lowered, "audit(") || + strings.Contains(lowered, "recordaudit") || + strings.Contains(lowered, "writeaudit") || + strings.Contains(lowered, "telemetry.") || + strings.Contains(lowered, "event.") || + strings.Contains(lowered, "events.") +} + func nearbyReturnedError(statements []support.ParsedStatement, idx int) bool { for lookahead := idx; lookahead < len(statements) && lookahead <= idx+4; lookahead++ { line := strings.TrimSpace(firstNonEmptyString(statements[lookahead].Raw, statements[lookahead].Text)) diff --git a/internal/codeguard/checks/quality/quality_go_toolchain_dead_code.go b/internal/codeguard/checks/quality/quality_go_toolchain_dead_code.go new file mode 100644 index 0000000..6efe6d2 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_go_toolchain_dead_code.go @@ -0,0 +1,495 @@ +package quality + +import ( + "bytes" + "context" + "fmt" + "go/ast" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +const ( + goToolchainDeadCodeRuleID = "quality.dead-code.toolchain" + goToolchainCommandOutputLimit = 1 << 20 +) + +type goToolchainCandidate struct { + rel string + importPath string + name string + symbol string + line int + column int +} + +func goToolchainDeadCodeFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { + cfg := env.Config.Checks.QualityRules.DeadCode + if !goToolchainDeadCodeEnabled(cfg) || !isGoQualityTarget(target) { + return nil + } + if cfg.Go.Linker != nil && !*cfg.Go.Linker { + return nil + } + level := goToolchainDeadCodeLevel(cfg) + entrypoints := goToolchainEntrypoints(env, target, cfg.Go.Entrypoints) + if len(entrypoints) == 0 { + return []core.Finding{goToolchainDeadCodeDiagnostic(env, level, "quality_rules.dead_code.go.entrypoints must name at least one Go entrypoint package or file")} + } + modulePath := support.GoModulePath(target.Path) + if modulePath == "" { + return []core.Finding{goToolchainDeadCodeDiagnostic(env, level, "go.mod was not found for Go toolchain dead-code analysis")} + } + candidates, packages := goToolchainCandidates(env, target, modulePath, cfg.Go.IgnorePaths, cfg.Go.Packages) + linked, err := linkedGoSymbols(ctx, target.Path, entrypoints) + if err != nil { + return []core.Finding{goToolchainDeadCodeDiagnostic(env, level, fmt.Sprintf("Go linker dependency analysis failed: %v", err))} + } + findings := make([]core.Finding, 0) + findings = append(findings, goToolchainUnreachablePackageFindings(env, level, packages, linked, entrypoints)...) + for _, candidate := range candidates { + if !goToolchainPackageLinked(candidate.importPath, linked) || linked[candidate.symbol] { + continue + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: goToolchainDeadCodeRuleID, + Level: level, + Path: candidate.rel, + Line: candidate.line, + Column: candidate.column, + Confidence: core.ConfidenceHigh, + Message: fmt.Sprintf( + "private Go function %q is absent from linker dependency graphs for configured entrypoints (%s)", + candidate.name, strings.Join(entrypoints, ", "), + ), + Metadata: map[string]string{ + "symbol": candidate.symbol, + "import_path": candidate.importPath, + }, + })) + } + return findings +} + +func goToolchainDeadCodeEnabled(cfg core.QualityDeadCodeConfig) bool { + if cfg.Enabled == nil || !*cfg.Enabled { + return false + } + switch strings.ToLower(strings.TrimSpace(cfg.Mode)) { + case "", "toolchain": + return true + default: + return false + } +} + +func goToolchainDeadCodeLevel(cfg core.QualityDeadCodeConfig) string { + switch strings.ToLower(strings.TrimSpace(cfg.Level)) { + case "fail": + return "fail" + default: + return "warn" + } +} + +func goToolchainDeadCodeDiagnostic(env support.Context, level string, message string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: goToolchainDeadCodeRuleID, + Level: level, + Message: message, + }) +} + +func isGoQualityTarget(target core.TargetConfig) bool { + switch support.NormalizedLanguage(target.Language) { + case "", "go": + return true + default: + return false + } +} + +func goToolchainEntrypoints(env support.Context, target core.TargetConfig, configured []string) []string { + patterns := append([]string(nil), configured...) + if len(patterns) == 0 { + patterns = append(patterns, target.Entrypoints...) + } + seen := map[string]struct{}{} + entrypoints := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + for _, pkg := range goToolchainPackagesForEntrypoint(env, target, pattern) { + if _, ok := seen[pkg]; ok { + continue + } + seen[pkg] = struct{}{} + entrypoints = append(entrypoints, pkg) + } + } + sort.Strings(entrypoints) + return entrypoints +} + +func goToolchainPackagesForEntrypoint(env support.Context, target core.TargetConfig, pattern string) []string { + trimmed := filepath.ToSlash(strings.TrimSpace(pattern)) + if trimmed == "" { + return nil + } + if strings.HasPrefix(trimmed, ".") || strings.Contains(trimmed, "...") { + return []string{trimmed} + } + if strings.HasSuffix(trimmed, ".go") || strings.ContainsAny(trimmed, "*?[") { + files := goTargetFileList(env, target) + seen := map[string]struct{}{} + packages := make([]string, 0) + for _, rel := range files { + if !strings.HasSuffix(rel, ".go") || !support.PathMatchesPattern(trimmed, rel) { + continue + } + pkg := goPackagePattern(path.Dir(filepath.ToSlash(rel))) + if _, ok := seen[pkg]; ok { + continue + } + seen[pkg] = struct{}{} + packages = append(packages, pkg) + } + sort.Strings(packages) + return packages + } + return []string{goPackagePattern(trimmed)} +} + +func goPackagePattern(dir string) string { + dir = strings.Trim(filepath.ToSlash(dir), "/") + if dir == "" || dir == "." { + return "." + } + return "./" + dir +} + +func goTargetFileList(env support.Context, target core.TargetConfig) []string { + if env.ListTargetFiles != nil { + files, err := env.ListTargetFiles(target) + if err == nil { + return files + } + } + files := make([]string, 0) + env.VisitTargetFiles(target, func(string) bool { return true }, func(rel string, _ []byte) { + files = append(files, rel) + }) + return files +} + +type goToolchainPackage struct { + importPath string + rel string + directiveSensitive bool +} + +func goToolchainCandidates(env support.Context, target core.TargetConfig, modulePath string, ignorePaths []string, packagePatterns []string) ([]goToolchainCandidate, []goToolchainPackage) { + candidates := make([]goToolchainCandidate, 0) + packagesByImportPath := map[string]goToolchainPackage{} + env.VisitTargetFiles(target, func(rel string) bool { + if !strings.HasSuffix(rel, ".go") || strings.HasSuffix(rel, "_test.go") { + return false + } + if goToolchainPathIgnored(rel) { + return false + } + if !goToolchainPackagePatternAllows(rel, packagePatterns) { + return false + } + for _, pattern := range ignorePaths { + if support.PathMatchesPattern(pattern, rel) { + return false + } + } + return true + }, func(rel string, data []byte) { + if goToolchainGeneratedFile(data) { + return + } + fset, parsed, err := support.ParseGoSource(env, rel, data) + if err != nil { + return + } + if parsed.Name != nil && parsed.Name.Name == "main" { + return + } + importPath := goImportPathForFile(modulePath, rel) + if _, ok := packagesByImportPath[importPath]; !ok { + packagesByImportPath[importPath] = goToolchainPackage{ + importPath: importPath, + rel: rel, + } + } + if goToolchainDirectiveSensitive(data) { + pkg := packagesByImportPath[importPath] + pkg.directiveSensitive = true + packagesByImportPath[importPath] = pkg + return + } + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || !goToolchainFunctionEligible(parsed, fn) { + continue + } + pos := fset.Position(fn.Name.Pos()) + candidates = append(candidates, goToolchainCandidate{ + rel: rel, + importPath: importPath, + name: fn.Name.Name, + symbol: goToolchainSymbol(parsed, importPath, fn.Name.Name), + line: pos.Line, + column: pos.Column, + }) + } + }) + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].rel != candidates[j].rel { + return candidates[i].rel < candidates[j].rel + } + return candidates[i].name < candidates[j].name + }) + packages := make([]goToolchainPackage, 0, len(packagesByImportPath)) + for _, pkg := range packagesByImportPath { + packages = append(packages, pkg) + } + sort.Slice(packages, func(i, j int) bool { return packages[i].importPath < packages[j].importPath }) + return candidates, packages +} + +func goToolchainPackagePatternAllows(rel string, patterns []string) bool { + if len(patterns) == 0 { + return true + } + pkgPattern := goPackagePattern(path.Dir(filepath.ToSlash(rel))) + for _, pattern := range patterns { + trimmed := filepath.ToSlash(strings.TrimSpace(pattern)) + switch { + case trimmed == "", trimmed == "./...": + return true + case strings.HasSuffix(trimmed, "/..."): + prefix := strings.TrimSuffix(trimmed, "/...") + if pkgPattern == prefix || strings.HasPrefix(pkgPattern, prefix+"/") { + return true + } + case pkgPattern == trimmed || support.PathMatchesPattern(strings.TrimPrefix(trimmed, "./")+"/**", path.Dir(filepath.ToSlash(rel))): + return true + } + } + return false +} + +func goToolchainUnreachablePackageFindings(env support.Context, level string, packages []goToolchainPackage, linked map[string]bool, entrypoints []string) []core.Finding { + findings := make([]core.Finding, 0) + for _, pkg := range packages { + if pkg.directiveSensitive || goToolchainPackageLinked(pkg.importPath, linked) { + continue + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: goToolchainDeadCodeRuleID, + Level: level, + Path: pkg.rel, + Line: 1, + Column: 1, + Confidence: core.ConfidenceHigh, + Message: fmt.Sprintf( + "Go package %q is absent from linker dependency graphs for configured entrypoints (%s)", + pkg.importPath, strings.Join(entrypoints, ", "), + ), + Metadata: map[string]string{"import_path": pkg.importPath}, + })) + } + return findings +} + +func goToolchainPackageLinked(importPath string, linked map[string]bool) bool { + prefix := importPath + "." + for symbol := range linked { + if strings.HasPrefix(symbol, prefix) { + return true + } + } + return false +} + +func goToolchainFunctionEligible(parsed *ast.File, fn *ast.FuncDecl) bool { + if fn.Recv != nil || fn.Name == nil { + return false + } + name := fn.Name.Name + if name == "init" || name == "main" || name == "TestMain" || !startsLowercase(name) { + return false + } + if parsed != nil && parsed.Name != nil && parsed.Name.Name == "main" { + return false + } + return true +} + +func goToolchainGeneratedFile(data []byte) bool { + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.Contains(trimmed, "Code generated") && strings.Contains(trimmed, "DO NOT EDIT") { + return true + } + if !strings.HasPrefix(trimmed, "//") && !strings.HasPrefix(trimmed, "/*") && !strings.HasPrefix(trimmed, "*") { + return false + } + } + return false +} + +func goToolchainPathIgnored(rel string) bool { + normalized := strings.ToLower(filepath.ToSlash(rel)) + return strings.HasPrefix(normalized, "vendor/") || + strings.Contains(normalized, "/vendor/") || + strings.HasPrefix(normalized, "third_party/") || + strings.Contains(normalized, "/third_party/") || + strings.HasPrefix(normalized, "third-party/") || + strings.Contains(normalized, "/third-party/") || + strings.HasPrefix(normalized, "testdata/") || + strings.Contains(normalized, "/testdata/") +} + +func goToolchainDirectiveSensitive(data []byte) bool { + source := string(data) + return strings.Contains(source, "//go:linkname") || + strings.Contains(source, "//export ") || + strings.Contains(source, "import \"C\"") +} + +func goImportPathForFile(modulePath string, rel string) string { + dir := path.Dir(filepath.ToSlash(rel)) + if dir == "." { + return modulePath + } + return modulePath + "/" + dir +} + +func goToolchainSymbol(parsed *ast.File, importPath string, name string) string { + if parsed != nil && parsed.Name != nil && parsed.Name.Name == "main" { + return "main." + name + } + return importPath + "." + name +} + +func linkedGoSymbols(ctx context.Context, targetDir string, entrypoints []string) (map[string]bool, error) { + tmpDir, err := os.MkdirTemp("", "codeguard-go-dead-code-*") + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(tmpDir) }() + linked := map[string]bool{} + env := goToolchainCommandEnv(tmpDir) + for idx, entrypoint := range entrypoints { + outputPath := filepath.Join(tmpDir, fmt.Sprintf("entrypoint-%d", idx)) + output, err := runGoToolchainCommand(ctx, targetDir, env, "go", "build", "-gcflags=all=-l", "-ldflags=-dumpdep", "-o", outputPath, entrypoint) + if err != nil { + if strings.TrimSpace(output) != "" { + return nil, fmt.Errorf("%s: %w: %s", entrypoint, err, strings.TrimSpace(output)) + } + return nil, fmt.Errorf("%s: %w", entrypoint, err) + } + for _, symbol := range parseGoLinkerDumpSymbols(output) { + linked[symbol] = true + } + } + return linked, nil +} + +func goToolchainCommandEnv(tmpDir string) []string { + env := make([]string, 0, len(os.Environ())+3) + for _, item := range os.Environ() { + if strings.HasPrefix(item, "GOROOT=") || strings.HasPrefix(item, "GOCACHE=") { + continue + } + env = append(env, item) + } + env = append(env, + "GOCACHE="+filepath.Join(tmpDir, "gocache"), + "GO111MODULE=on", + ) + return env +} + +func runGoToolchainCommand(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // fixed Go tool invocation; config only supplies validated package patterns + cmd.Dir = dir + cmd.Env = env + var buf bytes.Buffer + limited := newGoToolchainLimitedWriter(&buf, goToolchainCommandOutputLimit) + cmd.Stdout = limited + cmd.Stderr = limited + err := cmd.Run() + if limited.Truncated() { + return "", fmt.Errorf("%s output exceeded %d bytes", name, goToolchainCommandOutputLimit) + } + return buf.String(), err +} + +type goToolchainLimitedWriter struct { + buf *bytes.Buffer + limit int + truncated bool +} + +func newGoToolchainLimitedWriter(buf *bytes.Buffer, limit int) *goToolchainLimitedWriter { + return &goToolchainLimitedWriter{buf: buf, limit: limit} +} + +func (w *goToolchainLimitedWriter) Write(p []byte) (int, error) { + if w.truncated { + return len(p), nil + } + remaining := w.limit - w.buf.Len() + if remaining <= 0 { + w.truncated = true + return len(p), nil + } + if len(p) > remaining { + _, _ = w.buf.Write(p[:remaining]) + w.truncated = true + return len(p), nil + } + _, _ = w.buf.Write(p) + return len(p), nil +} + +func (w *goToolchainLimitedWriter) Truncated() bool { + return w.truncated +} + +func parseGoLinkerDumpSymbols(output string) []string { + seen := map[string]struct{}{} + for _, raw := range strings.Split(output, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "# ") { + continue + } + for _, part := range strings.Split(line, " -> ") { + symbol := strings.TrimSpace(part) + if symbol == "" || strings.HasPrefix(symbol, "go:") || strings.HasPrefix(symbol, "type:") { + continue + } + seen[symbol] = struct{}{} + } + } + symbols := make([]string, 0, len(seen)) + for symbol := range seen { + symbols = append(symbols, symbol) + } + sort.Strings(symbols) + return symbols +} diff --git a/internal/codeguard/checks/quality/quality_observability.go b/internal/codeguard/checks/quality/quality_observability.go index 90a8f6c..0826368 100644 --- a/internal/codeguard/checks/quality/quality_observability.go +++ b/internal/codeguard/checks/quality/quality_observability.go @@ -205,6 +205,9 @@ func criticalKind(file string, line string, rules core.ObservabilityRulesConfig) func hasInstrumentationEvidence(source string, rules core.ObservabilityRulesConfig) bool { lower := strings.ToLower(source) + if containsAny(lower, []string{"withtrackedroute", "trackedaction", "withtracing", "withmetrics", "withinstrumentation"}) { + return true + } for _, pattern := range rules.InstrumentationEvidencePatterns { pattern = strings.ToLower(strings.TrimSpace(pattern)) if pattern != "" && strings.Contains(lower, pattern) { diff --git a/internal/codeguard/checks/quality/quality_precision_duplication.go b/internal/codeguard/checks/quality/quality_precision_duplication.go index 312a1ea..f7eaaa1 100644 --- a/internal/codeguard/checks/quality/quality_precision_duplication.go +++ b/internal/codeguard/checks/quality/quality_precision_duplication.go @@ -13,23 +13,7 @@ import ( var domainKnowledgeLiteralPattern = regexp.MustCompile(`"([^"]{2,80})"|'([^']{2,80})'|\b\d+(?:\.\d+)?\b`) func parsedDuplicatedKnowledgeFindings(env support.Context, file string, parsed *support.ParsedFile) []core.Finding { - if isQualityFixturePath(file) { - return nil - } - if strings.HasPrefix(strings.ToLower(file), ".buildkite/") || isSeedOrScriptSourcePath(file) { - return nil - } - seen := map[string]int{} - for _, statement := range parsed.Module.Statements { - for _, literal := range domainKnowledgeLiterals(statement.Raw) { - if first, exists := seen[literal]; exists { - return []core.Finding{precisionWarnFinding(env, qualityDuplicatedKnowledgeRuleID, file, statement.Line, - fmt.Sprintf("business literal %s is duplicated near line %d; centralize shared domain knowledge", literal, first), core.ConfidenceLow)} - } - seen[literal] = statement.Line - } - } - return nil + return sourceDuplicatedKnowledgeFindings(env, file, parsed.Source) } func sourceDuplicatedKnowledgeFindings(env support.Context, file string, source string) []core.Finding { @@ -42,12 +26,22 @@ func sourceDuplicatedKnowledgeFindings(env support.Context, file string, source seen := map[string]int{} lowerFile := strings.ToLower(file) pythonSource := strings.HasSuffix(lowerFile, ".py") || strings.HasSuffix(lowerFile, ".pyi") + enumOrTypeBlock := false for idx, line := range strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n") { trimmed := strings.TrimSpace(line) pythonComment := pythonSource && strings.HasPrefix(trimmed, "#") if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || pythonComment { continue } + if duplicatedKnowledgeLineStartsEnumOrTypeBlock(trimmed) { + enumOrTypeBlock = true + } + if enumOrTypeBlock || duplicatedKnowledgeLineIsEnumOrTypeLiteral(trimmed) { + if duplicatedKnowledgeLineEndsEnumOrTypeBlock(trimmed) { + enumOrTypeBlock = false + } + continue + } for _, literal := range domainKnowledgeLiterals(line) { if first, exists := seen[literal]; exists { return []core.Finding{precisionWarnFinding(env, qualityDuplicatedKnowledgeRuleID, file, idx+1, @@ -59,6 +53,33 @@ func sourceDuplicatedKnowledgeFindings(env support.Context, file string, source return nil } +func duplicatedKnowledgeLineStartsEnumOrTypeBlock(line string) bool { + lowered := strings.ToLower(line) + return strings.Contains(lowered, "z.enum(") || + strings.Contains(lowered, "enum ") && strings.Contains(line, "{") || + strings.HasPrefix(lowered, "export type ") && strings.Contains(line, "=") && !strings.Contains(line, ";") || + strings.HasPrefix(lowered, "type ") && strings.Contains(line, "=") && !strings.Contains(line, ";") +} + +func duplicatedKnowledgeLineEndsEnumOrTypeBlock(line string) bool { + trimmed := strings.TrimSpace(line) + return strings.Contains(trimmed, "]);") || + strings.Contains(trimmed, "];") || + strings.Contains(trimmed, "};") || + strings.HasSuffix(trimmed, ";") +} + +func duplicatedKnowledgeLineIsEnumOrTypeLiteral(line string) bool { + lowered := strings.ToLower(line) + if strings.Contains(lowered, "z.enum(") || strings.Contains(lowered, " as const") { + return true + } + if strings.Contains(line, "|") && containsAny(lowered, []string{"type ", "=", `"`, `'`}) { + return true + } + return regexp.MustCompile(`^\s*["'][A-Za-z0-9_-]+["']\s*,?\s*$`).MatchString(line) +} + func domainKnowledgeLiterals(line string) []string { if duplicatedKnowledgeLineIsDisplayOnly(line) || duplicatedKnowledgeLineIsStructural(line) { return nil diff --git a/internal/codeguard/checks/quality/quality_precision_frameworks.go b/internal/codeguard/checks/quality/quality_precision_frameworks.go index c185d0d..47fbba7 100644 --- a/internal/codeguard/checks/quality/quality_precision_frameworks.go +++ b/internal/codeguard/checks/quality/quality_precision_frameworks.go @@ -7,7 +7,7 @@ import ( ) func isFrameworkOrchestrationBoundary(file string, fn precisionFunction) bool { - return isFrameworkCommandBoundary(file, fn.Name) || isNestJSRequestBoundary(file) + return isFrameworkCommandBoundary(file, fn.Name) || isTrackedRouteBoundary(file, fn) || isNestJSRequestBoundary(file) } func isFrameworkConventionalAmbiguousName(file string, fn precisionFunction, name string) bool { @@ -25,3 +25,22 @@ func isFrameworkConventionalAmbiguousName(file string, fn precisionFunction, nam func isNestJSRequestBoundary(file string) bool { return isScriptLikeSourcePath(file) && support.IsNestJSBoundaryPath(file) } + +func isTrackedRouteBoundary(file string, fn precisionFunction) bool { + if !isScriptLikeSourcePath(file) || !isNextRouteFile(file) { + return false + } + loweredName := strings.ToLower(strings.Trim(fn.Name, "_$")) + if isHTTPMethodName(fn.Name) { + return true + } + if strings.HasSuffix(loweredName, "handler") { + for _, method := range []string{"get", "post", "put", "patch", "delete", "head", "options"} { + if strings.HasPrefix(loweredName, method) { + return true + } + } + } + body := strings.ToLower(fn.Body) + return strings.Contains(body, "nextresponse.") || strings.Contains(body, "response.json(") +} diff --git a/internal/codeguard/checks/quality/quality_precision_mutation_targets.go b/internal/codeguard/checks/quality/quality_precision_mutation_targets.go index af98257..a15e3d9 100644 --- a/internal/codeguard/checks/quality/quality_precision_mutation_targets.go +++ b/internal/codeguard/checks/quality/quality_precision_mutation_targets.go @@ -464,7 +464,11 @@ func isFrameworkCommandBoundary(file string, name string) bool { if !isHTTPMethodName(name) { return false } - normalized := strings.ReplaceAll(file, "\\", "/") + return isNextRouteFile(file) +} + +func isNextRouteFile(file string) bool { + normalized := strings.ToLower(strings.ReplaceAll(file, "\\", "/")) return strings.HasSuffix(normalized, "/route.ts") || strings.HasSuffix(normalized, "/route.tsx") || strings.HasSuffix(normalized, "/route.js") || strings.HasSuffix(normalized, "/route.jsx") } diff --git a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go index 1e2726a..1c9fa8d 100644 --- a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go +++ b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go @@ -30,7 +30,7 @@ const ( ) var ( - commandFunctionPrefixPattern = regexp.MustCompile(`^(add|allocate|append|assign|bulk|cancel|capture|choose|clear|cleanup|close|copy|create|delete|disable|do|emit|enable|exchange|expose|export|fill|flip|flash|handle|hydrate|insert|link|log|move|mutate|note|notify|open|persist|publish|record|recompute|remember|remove|reset|retire|revert|save|send|set|store|submit|toggle|transfer|trigger|update|upsert|upload|walk|write)`) + commandFunctionPrefixPattern = regexp.MustCompile(`^(add|allocate|append|apply|assign|bulk|cancel|capture|choose|clear|cleanup|close|copy|create|delete|disable|do|emit|enable|exchange|expose|export|fill|flip|flash|handle|hydrate|insert|link|log|move|mutate|note|notify|open|persist|publish|record|recompute|remember|remove|reset|retire|revert|rollback|save|send|set|store|submit|toggle|transfer|trigger|update|upsert|upload|walk|write)`) readCallPattern = regexp.MustCompile(`(?i)(^|[.>:\-_])(count|fetch|find|get|list|load|lookup|query|read|select|search)([A-Z_:\-.]|$)`) identifierTokenPattern = regexp.MustCompile(`[A-Za-z_$][A-Za-z0-9_$]*`) roleSuffixPattern = regexp.MustCompile(`(?i)(manager|helper|util|utils|service|processor)$`) diff --git a/internal/codeguard/checks/quality/quality_python_toolchain_dead_code.go b/internal/codeguard/checks/quality/quality_python_toolchain_dead_code.go new file mode 100644 index 0000000..c1f3998 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_python_toolchain_dead_code.go @@ -0,0 +1,787 @@ +package quality + +import ( + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + pythonToolchainImportPattern = regexp.MustCompile(`(?m)^\s*import\s+([A-Za-z_][\w.]*)(?:\s+as\s+\w+)?`) + pythonToolchainFromPattern = regexp.MustCompile(`(?m)^\s*from\s+([A-Za-z_][\w.]*|\.+[A-Za-z_][\w.]*)\s+import\s+`) + pythonToolchainFromDotImportPattern = regexp.MustCompile(`(?m)^\s*from\s+(\.+)\s+import\s+([A-Za-z_][\w]*(?:\s+as\s+\w+)?(?:\s*,\s*[A-Za-z_][\w]*(?:\s+as\s+\w+)?)*)`) + pythonToolchainDynamicImportPattern = regexp.MustCompile(`(?:importlib\.import_module|__import__)\(\s*["']([A-Za-z_][\w.]*)["']`) + pythonToolchainUnknownDynamicPattern = regexp.MustCompile(`(?:importlib\.import_module|__import__)\s*\(\s*([^'"\s])`) + pythonToolchainEntryPointValuePattern = regexp.MustCompile(`["']([A-Za-z_][\w.]*)(?::[A-Za-z_][\w.]*)?["']`) + pythonToolchainAllAssignmentPattern = regexp.MustCompile(`(?s)__all__(?:\s*:[^=]+)?\s*=\s*(?:\[[^\]]*\]|\([^\)]*\)|\{[^\}]*\}|[^\n]+)`) + pythonToolchainVultureTextPattern = regexp.MustCompile(`^(.+\.py):(\d+):\s+unused\s+([A-Za-z_][\w -]*)\s+['"]?([^'"]*)['"]?(?:\s+\((\d+)% confidence\))?`) +) + +type pythonToolchainModule struct { + module string + rel string + edges []string + live bool + dynamicImport bool +} + +func pythonToolchainDeadCodeFindings(env support.Context, target core.TargetConfig) []core.Finding { + cfg := env.Config.Checks.QualityRules.DeadCode + if !goToolchainDeadCodeEnabled(cfg) || !isPythonQualityTarget(target) { + return nil + } + level := goToolchainDeadCodeLevel(cfg) + modules := pythonToolchainModules(env, target, cfg) + findings := pythonToolchainReportFindings(env, target, cfg, modules, level) + entrypoints := pythonToolchainEntrypoints(env, target, cfg.Python.Entrypoints) + if len(entrypoints) == 0 { + if len(findings) > 0 { + return findings + } + return []core.Finding{goToolchainDeadCodeDiagnostic(env, goToolchainDeadCodeLevel(cfg), "quality_rules.dead_code.python.entrypoints must name at least one Python entrypoint file, module, or glob")} + } + reachable := pythonToolchainReachable(modules, entrypoints) + for _, module := range pythonToolchainSortedModules(modules) { + node := modules[module] + if reachable[module] || node.live { + continue + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: goToolchainDeadCodeRuleID, + Level: level, + Path: node.rel, + Line: 1, + Column: 1, + Confidence: core.ConfidenceLow, + Message: fmt.Sprintf( + "Python module %q is outside the static import graph for configured entrypoints (%s)", + module, strings.Join(entrypoints, ", "), + ), + Metadata: map[string]string{ + "language": "python", + "module": module, + }, + })) + } + return findings +} + +type pythonToolchainReportIssue struct { + Path string + Line int + Kind string + Name string + Confidence int + Source string +} + +func pythonToolchainReportFindings(env support.Context, target core.TargetConfig, cfg core.QualityDeadCodeConfig, modules map[string]pythonToolchainModule, level string) []core.Finding { + issues := pythonToolchainReportIssues(env, target, cfg) + if len(issues) == 0 { + return nil + } + findings := make([]core.Finding, 0, len(issues)) + seen := map[string]struct{}{} + for _, issue := range issues { + rel := filepath.ToSlash(issue.Path) + moduleName := pythonToolchainModuleName(rel) + node, ok := modules[moduleName] + if !ok || node.live || pythonToolchainReportIssueIgnored(rel, issue, cfg) { + continue + } + key := fmt.Sprintf("%s:%d:%s:%s", rel, issue.Line, issue.Kind, issue.Name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: goToolchainDeadCodeRuleID, + Level: level, + Path: rel, + Line: issue.Line, + Column: 1, + Confidence: core.ConfidenceHigh, + Message: fmt.Sprintf( + "Python trusted dead-code report %q marks %s %q as unused", + issue.Source, issue.Kind, issue.Name, + ), + Metadata: map[string]string{ + "language": "python", + "module": moduleName, + "kind": issue.Kind, + "evidence": "trusted-report", + }, + })) + } + return findings +} + +func pythonToolchainReportIssues(env support.Context, target core.TargetConfig, cfg core.QualityDeadCodeConfig) []pythonToolchainReportIssue { + issues := make([]pythonToolchainReportIssue, 0) + for _, report := range cfg.Python.Reports { + rel := filepath.ToSlash(strings.TrimSpace(report)) + if rel == "" || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, "../") { + continue + } + data, ok := pythonToolchainReadReport(env, target, rel) + if !ok { + continue + } + issues = append(issues, pythonToolchainJSONReportIssues(rel, data)...) + issues = append(issues, pythonToolchainVultureTextIssues(rel, string(data))...) + } + sort.Slice(issues, func(i, j int) bool { + if issues[i].Path != issues[j].Path { + return issues[i].Path < issues[j].Path + } + if issues[i].Line != issues[j].Line { + return issues[i].Line < issues[j].Line + } + return issues[i].Name < issues[j].Name + }) + return issues +} + +func pythonToolchainReadReport(env support.Context, target core.TargetConfig, rel string) ([]byte, bool) { + if env.ReadTargetFile != nil { + data, err := env.ReadTargetFile(target, rel) + if err == nil { + return data, true + } + } + data, err := os.ReadFile(filepath.Join(target.Path, filepath.FromSlash(rel))) + return data, err == nil +} + +func pythonToolchainJSONReportIssues(source string, data []byte) []pythonToolchainReportIssue { + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return nil + } + records := pythonToolchainJSONRecords(raw) + issues := make([]pythonToolchainReportIssue, 0, len(records)) + for _, record := range records { + issue, ok := pythonToolchainJSONReportIssue(source, record) + if ok { + issues = append(issues, issue) + } + } + return issues +} + +func pythonToolchainJSONRecords(raw any) []map[string]any { + switch value := raw.(type) { + case []any: + records := make([]map[string]any, 0, len(value)) + for _, item := range value { + if record, ok := item.(map[string]any); ok { + records = append(records, record) + } + } + return records + case map[string]any: + for _, key := range []string{"items", "issues", "findings", "unused"} { + if list, ok := value[key].([]any); ok { + return pythonToolchainJSONRecords(list) + } + } + return []map[string]any{value} + default: + return nil + } +} + +func pythonToolchainJSONReportIssue(source string, record map[string]any) (pythonToolchainReportIssue, bool) { + rel := pythonToolchainReportString(record, "path", "file", "filename", "module_path") + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if rel == "" || !strings.HasSuffix(rel, ".py") || strings.HasPrefix(rel, "../") || filepath.IsAbs(rel) { + return pythonToolchainReportIssue{}, false + } + kind := strings.ToLower(pythonToolchainReportString(record, "kind", "type", "code")) + message := strings.ToLower(pythonToolchainReportString(record, "message", "reason", "description")) + if !strings.Contains(kind+" "+message, "unused") && !strings.Contains(kind+" "+message, "dead") { + return pythonToolchainReportIssue{}, false + } + name := pythonToolchainReportString(record, "name", "symbol", "object") + if strings.TrimSpace(name) == "" { + name = strings.TrimSpace(kind) + } + confidence := pythonToolchainReportInt(record, "confidence", "confidence_percent") + return pythonToolchainReportIssue{ + Path: rel, + Line: max(1, pythonToolchainReportInt(record, "line", "lineno", "line_number")), + Kind: pythonToolchainReportKind(kind, message), + Name: name, + Confidence: confidence, + Source: source, + }, true +} + +func pythonToolchainVultureTextIssues(source string, data string) []pythonToolchainReportIssue { + issues := make([]pythonToolchainReportIssue, 0) + for _, line := range strings.Split(data, "\n") { + match := pythonToolchainVultureTextPattern.FindStringSubmatch(strings.TrimSpace(line)) + if match == nil { + continue + } + lineNumber, _ := strconv.Atoi(match[2]) + confidence, _ := strconv.Atoi(match[5]) + issues = append(issues, pythonToolchainReportIssue{ + Path: filepath.ToSlash(strings.TrimSpace(match[1])), + Line: max(1, lineNumber), + Kind: pythonToolchainReportKind(strings.TrimSpace(match[3]), ""), + Name: strings.TrimSpace(match[4]), + Confidence: confidence, + Source: source, + }) + } + return issues +} + +func pythonToolchainReportIssueIgnored(rel string, issue pythonToolchainReportIssue, cfg core.QualityDeadCodeConfig) bool { + if issue.Confidence > 0 && issue.Confidence < 80 { + return true + } + if !strings.HasSuffix(rel, ".py") || pythonToolchainTestPath(rel) { + return true + } + for _, pattern := range cfg.Python.IgnorePaths { + if support.PathMatchesPattern(pattern, rel) { + return true + } + } + return false +} + +func pythonToolchainReportString(record map[string]any, keys ...string) string { + for _, key := range keys { + value, ok := record[key] + if !ok { + continue + } + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + } + } + return "" +} + +func pythonToolchainReportInt(record map[string]any, keys ...string) int { + for _, key := range keys { + value, ok := record[key] + if !ok { + continue + } + switch typed := value.(type) { + case float64: + return int(typed) + case int: + return typed + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(typed, "%"))) + if err == nil { + return parsed + } + } + } + return 0 +} + +func pythonToolchainReportKind(kind string, message string) string { + text := strings.ToLower(kind + " " + message) + switch { + case strings.Contains(text, "class"): + return "class" + case strings.Contains(text, "method"): + return "method" + case strings.Contains(text, "function"): + return "function" + case strings.Contains(text, "import"): + return "import" + case strings.Contains(text, "module"), strings.Contains(text, "file"): + return "module" + default: + return strings.TrimSpace(kind) + } +} + +func isPythonQualityTarget(target core.TargetConfig) bool { + switch support.NormalizedLanguage(target.Language) { + case "python", "py": + return true + default: + return false + } +} + +func pythonToolchainEntrypoints(env support.Context, target core.TargetConfig, configured []string) []string { + patterns := append([]string(nil), configured...) + if len(patterns) == 0 { + patterns = append(patterns, target.Entrypoints...) + } + files := goTargetFileList(env, target) + seen := map[string]struct{}{} + entrypoints := make([]string, 0) + for _, pattern := range patterns { + trimmed := filepath.ToSlash(strings.TrimSpace(pattern)) + if trimmed == "" { + continue + } + if strings.HasSuffix(trimmed, ".py") || strings.ContainsAny(trimmed, "*?[") { + for _, rel := range files { + if strings.HasSuffix(rel, ".py") && support.PathMatchesPattern(trimmed, rel) { + module := pythonToolchainModuleName(rel) + if _, ok := seen[module]; !ok { + seen[module] = struct{}{} + entrypoints = append(entrypoints, module) + } + } + } + continue + } + module := strings.Trim(strings.ReplaceAll(trimmed, "/", "."), ".") + if module != "" { + if _, ok := seen[module]; !ok { + seen[module] = struct{}{} + entrypoints = append(entrypoints, module) + } + } + } + for _, module := range pythonToolchainDiscoveredEntrypoints(env, target) { + if _, ok := seen[module]; ok { + continue + } + seen[module] = struct{}{} + entrypoints = append(entrypoints, module) + } + sort.Strings(entrypoints) + return entrypoints +} + +func pythonToolchainModules(env support.Context, target core.TargetConfig, cfg core.QualityDeadCodeConfig) map[string]pythonToolchainModule { + includeTests := cfg.IncludeTests != nil && *cfg.IncludeTests + nodes := map[string]pythonToolchainModule{} + env.VisitTargetFiles(target, func(rel string) bool { + if !strings.HasSuffix(rel, ".py") { + return false + } + if !includeTests && pythonToolchainTestPath(rel) { + return false + } + if pythonToolchainVendorPath(rel) { + return false + } + for _, pattern := range cfg.Python.IgnorePaths { + if support.PathMatchesPattern(pattern, rel) { + return false + } + } + return true + }, func(rel string, data []byte) { + source := string(data) + if pythonToolchainGenerated(source) { + return + } + module := pythonToolchainModuleName(rel) + nodes[module] = pythonToolchainModule{ + module: module, + rel: filepath.ToSlash(rel), + live: pythonToolchainConservativeLive(rel, source), + dynamicImport: pythonToolchainHasUnknownDynamicImport(source), + } + }) + env.VisitTargetFiles(target, func(rel string) bool { + _, ok := nodes[pythonToolchainModuleName(rel)] + return ok + }, func(rel string, data []byte) { + module := pythonToolchainModuleName(rel) + node := nodes[module] + source := string(data) + node.edges = pythonToolchainImportEdges(module, source, nodes) + nodes[module] = node + for _, exported := range pythonToolchainPackageExportEdges(module, node.rel, source, nodes) { + exportedNode := nodes[exported] + exportedNode.live = true + nodes[exported] = exportedNode + } + }) + pythonToolchainMarkUnknownDynamicImportsLive(nodes) + return nodes +} + +func pythonToolchainImportEdges(module string, source string, nodes map[string]pythonToolchainModule) []string { + seen := map[string]struct{}{} + add := func(candidate string) { + candidate = strings.Trim(candidate, ".") + for candidate != "" { + if _, ok := nodes[candidate]; ok { + seen[candidate] = struct{}{} + return + } + if idx := strings.LastIndex(candidate, "."); idx >= 0 { + candidate = candidate[:idx] + continue + } + return + } + } + for _, match := range pythonToolchainImportPattern.FindAllStringSubmatch(source, -1) { + add(match[1]) + } + for _, match := range pythonToolchainFromPattern.FindAllStringSubmatch(source, -1) { + name := match[1] + if strings.HasPrefix(name, ".") { + name = pythonToolchainResolveRelativeImport(module, name) + } + add(name) + } + for _, match := range pythonToolchainFromDotImportPattern.FindAllStringSubmatch(source, -1) { + base := pythonToolchainResolveRelativeImport(module, match[1]) + for _, imported := range strings.Split(match[2], ",") { + name := pythonToolchainImportedName(imported) + if name != "" { + add(strings.Trim(base+"."+name, ".")) + } + } + } + for _, match := range pythonToolchainDynamicImportPattern.FindAllStringSubmatch(source, -1) { + add(match[1]) + } + edges := make([]string, 0, len(seen)) + for edge := range seen { + edges = append(edges, edge) + } + sort.Strings(edges) + return edges +} + +func pythonToolchainReachable(nodes map[string]pythonToolchainModule, entrypoints []string) map[string]bool { + reachable := map[string]bool{} + queue := append([]string(nil), entrypoints...) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if reachable[current] { + continue + } + node, ok := nodes[current] + if !ok { + continue + } + reachable[current] = true + for _, edge := range node.edges { + if !reachable[edge] { + queue = append(queue, edge) + } + } + } + return reachable +} + +func pythonToolchainModuleName(rel string) string { + normalized := strings.TrimSuffix(filepath.ToSlash(rel), ".py") + normalized = strings.TrimSuffix(normalized, "/__init__") + return strings.Trim(strings.ReplaceAll(normalized, "/", "."), ".") +} + +func pythonToolchainResolveRelativeImport(module string, name string) string { + level := 0 + for level < len(name) && name[level] == '.' { + level++ + } + baseParts := strings.Split(module, ".") + if len(baseParts) > 0 && baseParts[len(baseParts)-1] != "__init__" { + baseParts = baseParts[:len(baseParts)-1] + } + if level > 1 { + drop := level - 1 + if drop >= len(baseParts) { + baseParts = nil + } else { + baseParts = baseParts[:len(baseParts)-drop] + } + } + suffix := strings.TrimPrefix(name, strings.Repeat(".", level)) + if suffix != "" { + baseParts = append(baseParts, suffix) + } + return strings.Join(baseParts, ".") +} + +func pythonToolchainSortedModules(nodes map[string]pythonToolchainModule) []string { + modules := make([]string, 0, len(nodes)) + for module := range nodes { + modules = append(modules, module) + } + sort.Strings(modules) + return modules +} + +func pythonToolchainAlwaysLive(rel string) bool { + base := path.Base(filepath.ToSlash(rel)) + return base == "__init__.py" +} + +func pythonToolchainConservativeLive(rel string, source string) bool { + if pythonToolchainAlwaysLive(rel) { + return true + } + normalized := filepath.ToSlash(rel) + base := path.Base(normalized) + switch base { + case "manage.py", "wsgi.py", "asgi.py", "urls.py", "views.py", "routes.py", "tasks.py", "signals.py", "admin.py", "apps.py": + return true + } + return strings.Contains(source, "importlib.import_module") || + strings.Contains(source, "__import__") || + strings.Contains(source, "@app.route") || + strings.Contains(source, "@router.") || + strings.Contains(source, "@shared_task") || + strings.Contains(source, "@celery.task") || + strings.Contains(source, "@receiver(") || + strings.Contains(source, "urlpatterns") || + strings.Contains(source, "FastAPI(") || + strings.Contains(source, "Flask(") || + strings.Contains(source, "__all__") +} + +func pythonToolchainPackageExportEdges(module string, rel string, source string, nodes map[string]pythonToolchainModule) []string { + if path.Base(filepath.ToSlash(rel)) != "__init__.py" { + return nil + } + seen := map[string]struct{}{} + add := func(candidate string) { + if _, ok := nodes[candidate]; ok { + seen[candidate] = struct{}{} + } + } + for _, edge := range pythonToolchainImportEdges(module, source, nodes) { + add(edge) + } + for _, name := range pythonToolchainQuotedAllNames(source) { + add(strings.Trim(module+"."+name, ".")) + } + edges := make([]string, 0, len(seen)) + for edge := range seen { + edges = append(edges, edge) + } + sort.Strings(edges) + return edges +} + +func pythonToolchainTestPath(rel string) bool { + normalized := strings.ToLower(filepath.ToSlash(rel)) + base := strings.TrimSuffix(path.Base(normalized), ".py") + return strings.Contains(normalized, "/test/") || + strings.Contains(normalized, "/tests/") || + strings.Contains(normalized, "/fixtures/") || + strings.Contains(normalized, "/testdata/") || + strings.HasPrefix(base, "test_") || + strings.HasSuffix(base, "_test") +} + +func pythonToolchainVendorPath(rel string) bool { + normalized := strings.ToLower(filepath.ToSlash(rel)) + return strings.HasPrefix(normalized, "vendor/") || + strings.Contains(normalized, "/vendor/") || + strings.HasPrefix(normalized, "third_party/") || + strings.Contains(normalized, "/third_party/") || + strings.HasPrefix(normalized, "third-party/") || + strings.Contains(normalized, "/third-party/") +} + +func pythonToolchainGenerated(source string) bool { + for _, line := range strings.Split(source, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.Contains(trimmed, "Code generated") && strings.Contains(trimmed, "DO NOT EDIT") { + return true + } + if !strings.HasPrefix(trimmed, "#") { + break + } + } + return false +} + +func pythonToolchainImportedName(value string) string { + value = strings.TrimSpace(value) + if idx := strings.Index(value, " as "); idx >= 0 { + value = value[:idx] + } + return strings.TrimSpace(value) +} + +func pythonToolchainQuotedAllNames(source string) []string { + if !strings.Contains(source, "__all__") { + return nil + } + names := make([]string, 0) + for _, assignment := range pythonToolchainAllAssignmentPattern.FindAllString(source, -1) { + for _, match := range pythonToolchainEntryPointValuePattern.FindAllStringSubmatch(assignment, -1) { + if strings.Contains(match[1], ".") { + continue + } + names = append(names, match[1]) + } + } + return names +} + +func pythonToolchainHasUnknownDynamicImport(source string) bool { + return pythonToolchainUnknownDynamicPattern.MatchString(source) +} + +func pythonToolchainMarkUnknownDynamicImportsLive(nodes map[string]pythonToolchainModule) { + packages := map[string]struct{}{} + markAll := false + for module, node := range nodes { + if !node.dynamicImport { + continue + } + pkg := pythonToolchainContainingPackage(module) + if pkg == "" { + markAll = true + continue + } + packages[pkg] = struct{}{} + } + if len(packages) == 0 && !markAll { + return + } + for module, node := range nodes { + if markAll || pythonToolchainPackageCovered(module, packages) { + node.live = true + nodes[module] = node + } + } +} + +func pythonToolchainContainingPackage(module string) string { + if idx := strings.LastIndex(module, "."); idx >= 0 { + return module[:idx] + } + return "" +} + +func pythonToolchainPackageCovered(module string, packages map[string]struct{}) bool { + for pkg := range packages { + if module == pkg || strings.HasPrefix(module, pkg+".") { + return true + } + } + return false +} + +func pythonToolchainDiscoveredEntrypoints(env support.Context, target core.TargetConfig) []string { + seen := map[string]struct{}{} + add := func(module string) { + module = strings.Trim(strings.Split(module, ":")[0], ". ") + if module == "" { + return + } + seen[module] = struct{}{} + } + env.VisitTargetFiles(target, func(rel string) bool { + normalized := filepath.ToSlash(rel) + switch path.Base(normalized) { + case "pyproject.toml", "setup.cfg": + return true + case "manage.py", "wsgi.py", "asgi.py", "app.py", "main.py": + return strings.HasSuffix(normalized, ".py") + default: + return false + } + }, func(rel string, data []byte) { + switch path.Base(filepath.ToSlash(rel)) { + case "pyproject.toml": + for _, module := range pythonToolchainPyprojectEntrypoints(string(data)) { + add(module) + } + case "setup.cfg": + for _, module := range pythonToolchainSetupCfgEntrypoints(string(data)) { + add(module) + } + default: + add(pythonToolchainModuleName(rel)) + } + }) + entrypoints := make([]string, 0, len(seen)) + for module := range seen { + entrypoints = append(entrypoints, module) + } + sort.Strings(entrypoints) + return entrypoints +} + +func pythonToolchainPyprojectEntrypoints(source string) []string { + entrypoints := make([]string, 0) + section := "" + for _, line := range strings.Split(source, "\n") { + trimmed := strings.TrimSpace(strings.Split(line, "#")[0]) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + section = strings.Trim(trimmed, "[] ") + continue + } + switch section { + case "project.scripts", "project.gui-scripts", "tool.poetry.scripts": + for _, match := range pythonToolchainEntryPointValuePattern.FindAllStringSubmatch(trimmed, -1) { + entrypoints = append(entrypoints, strings.Split(match[1], ":")[0]) + } + } + } + return entrypoints +} + +func pythonToolchainSetupCfgEntrypoints(source string) []string { + entrypoints := make([]string, 0) + section := "" + group := "" + for _, line := range strings.Split(source, "\n") { + trimmed := strings.TrimSpace(strings.Split(line, "#")[0]) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + section = strings.Trim(trimmed, "[] ") + group = "" + continue + } + if section != "options.entry_points" { + continue + } + if strings.HasSuffix(trimmed, "=") { + group = strings.TrimSpace(strings.TrimSuffix(trimmed, "=")) + continue + } + if group != "console_scripts" && group != "gui_scripts" { + continue + } + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + continue + } + module := strings.TrimSpace(strings.Split(parts[1], ":")[0]) + if module != "" { + entrypoints = append(entrypoints, module) + } + } + return entrypoints +} diff --git a/internal/codeguard/checks/quality/quality_rust_toolchain_dead_code.go b/internal/codeguard/checks/quality/quality_rust_toolchain_dead_code.go new file mode 100644 index 0000000..3ee3d54 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_rust_toolchain_dead_code.go @@ -0,0 +1,933 @@ +package quality + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +const ( + rustToolchainDeadCodeRuleID = "quality.dead-code.toolchain" + rustToolchainCommandOutputLimit = 1 << 20 + rustToolchainArtifactCode = "artifact_unreachable" + rustToolchainArtifactReportLimit = 8 << 20 +) + +type rustToolchainIssue struct { + path string + line int + column int + code string + message string +} + +type rustToolchainArtifactCandidate struct { + path string + line int + column int + name string + moduleSymbol string + symbol string + legacyMangled string + legacyModuleMangled string +} + +type rustToolchainArtifactEvidence struct { + reports []string + lines []string +} + +type rustToolchainCrateRoot struct { + rel string + crateName string +} + +type cargoCompilerMessage struct { + Reason string `json:"reason"` + Message struct { + Level string `json:"level"` + Message string `json:"message"` + Code *struct { + Code string `json:"code"` + } `json:"code"` + Spans []struct { + FileName string `json:"file_name"` + LineStart int `json:"line_start"` + ColumnStart int `json:"column_start"` + IsPrimary bool `json:"is_primary"` + } `json:"spans"` + } `json:"message"` +} + +func rustToolchainDeadCodeFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding { + cfg := env.Config.Checks.QualityRules.DeadCode + if !toolchainDeadCodeEnabled(cfg) || !isRustQualityTarget(target) { + return nil + } + level := toolchainDeadCodeLevel(cfg) + issues, err := rustToolchainDeadCodeIssues(ctx, env, target, cfg) + findings := make([]core.Finding, 0, len(issues)+1) + for _, issue := range issues { + if rustToolchainIssueIgnored(env, target, cfg, issue) { + continue + } + message := fmt.Sprintf("Rust compiler reported %s: %s", issue.code, issue.message) + metadata := map[string]string{ + "language": "rust", + "lint": issue.code, + } + if issue.code == rustToolchainArtifactCode { + message = issue.message + metadata["evidence"] = "artifact-symbol-report" + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: rustToolchainDeadCodeRuleID, + Level: level, + Path: issue.path, + Line: issue.line, + Column: issue.column, + Confidence: core.ConfidenceHigh, + Message: message, + Metadata: metadata, + })) + } + if err != nil { + findings = append(findings, rustToolchainDeadCodeDiagnostic(env, level, err.Error())) + } + return findings +} + +func toolchainDeadCodeEnabled(cfg core.QualityDeadCodeConfig) bool { + if cfg.Enabled == nil || !*cfg.Enabled { + return false + } + switch strings.ToLower(strings.TrimSpace(cfg.Mode)) { + case "", "toolchain": + return true + default: + return false + } +} + +func toolchainDeadCodeLevel(cfg core.QualityDeadCodeConfig) string { + switch strings.ToLower(strings.TrimSpace(cfg.Level)) { + case "fail": + return "fail" + default: + return "warn" + } +} + +func rustToolchainDeadCodeDiagnostic(env support.Context, level string, message string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: rustToolchainDeadCodeRuleID, + Level: level, + Message: "Rust toolchain dead-code analysis failed: " + message, + }) +} + +func isRustQualityTarget(target core.TargetConfig) bool { + switch support.NormalizedLanguage(target.Language) { + case "rust": + return true + default: + return false + } +} + +func rustToolchainDeadCodeIssues(ctx context.Context, env support.Context, target core.TargetConfig, cfg core.QualityDeadCodeConfig) ([]rustToolchainIssue, error) { + targetDir := target.Path + tmpDir, err := os.MkdirTemp("", "codeguard-rust-dead-code-*") + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + crates := rustToolchainCrates(cfg.Rust.Crates) + allIssues := make([]rustToolchainIssue, 0) + for _, crate := range crates { + issues, crateErr := rustToolchainDeadCodeIssuesForCrate(ctx, targetDir, tmpDir, crate, cfg) + allIssues = append(allIssues, issues...) + if crateErr != nil { + return allIssues, crateErr + } + } + artifactIssues, err := rustToolchainArtifactIssues(env, target, cfg) + if err != nil { + return allIssues, err + } + allIssues = appendRustToolchainUniqueIssues(allIssues, artifactIssues...) + sort.Slice(allIssues, func(i, j int) bool { + if allIssues[i].path != allIssues[j].path { + return allIssues[i].path < allIssues[j].path + } + if allIssues[i].line != allIssues[j].line { + return allIssues[i].line < allIssues[j].line + } + return allIssues[i].column < allIssues[j].column + }) + return allIssues, nil +} + +func appendRustToolchainUniqueIssues(base []rustToolchainIssue, more ...rustToolchainIssue) []rustToolchainIssue { + seen := map[string]struct{}{} + for _, issue := range base { + seen[fmt.Sprintf("%s:%d:%d", issue.path, issue.line, issue.column)] = struct{}{} + } + for _, issue := range more { + key := fmt.Sprintf("%s:%d:%d", issue.path, issue.line, issue.column) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + base = append(base, issue) + } + return base +} + +func rustToolchainCrates(configured []string) []string { + if len(configured) == 0 { + return []string{""} + } + crates := make([]string, 0, len(configured)) + for _, crate := range configured { + trimmed := filepath.ToSlash(strings.TrimSpace(crate)) + if trimmed != "" { + crates = append(crates, trimmed) + } + } + return crates +} + +func rustToolchainDeadCodeIssuesForCrate(ctx context.Context, targetDir string, tmpDir string, crate string, cfg core.QualityDeadCodeConfig) ([]rustToolchainIssue, error) { + args := []string{"check", "--message-format=json"} + if manifest := rustToolchainManifestPath(crate); manifest != "" { + args = append(args, "--manifest-path", manifest) + } + if cfg.IncludeTests != nil && *cfg.IncludeTests { + args = append(args, "--all-targets") + } + for _, pkg := range cfg.Rust.Packages { + args = append(args, "--package", strings.TrimSpace(pkg)) + } + + output, err := runRustToolchainCommand(ctx, targetDir, rustToolchainCommandEnv(tmpDir), "cargo", args...) + issues := parseCargoDeadCodeDiagnostics(output, targetDir) + if err != nil && len(issues) == 0 { + if strings.TrimSpace(output) != "" { + return nil, fmt.Errorf("%w: %s", err, firstNonEmptyLine(output)) + } + return nil, err + } + return issues, nil +} + +func rustToolchainManifestPath(crate string) string { + crate = filepath.ToSlash(strings.TrimSpace(crate)) + if crate == "" || crate == "." { + return "" + } + if strings.HasSuffix(crate, ".toml") { + return crate + } + return filepath.ToSlash(filepath.Join(crate, "Cargo.toml")) +} + +func rustToolchainArtifactIssues(env support.Context, target core.TargetConfig, cfg core.QualityDeadCodeConfig) ([]rustToolchainIssue, error) { + if len(cfg.Rust.Reports) == 0 { + return nil, nil + } + evidence, err := rustToolchainArtifactEvidenceFromReports(target.Path, cfg.Rust.Reports) + if err != nil { + return nil, err + } + candidates := rustToolchainArtifactCandidates(env, target, cfg) + issues := make([]rustToolchainIssue, 0) + for _, candidate := range candidates { + if !evidence.HasModuleEvidence(candidate) || evidence.HasSymbol(candidate) { + continue + } + issues = append(issues, rustToolchainIssue{ + path: candidate.path, + line: candidate.line, + column: candidate.column, + code: rustToolchainArtifactCode, + message: fmt.Sprintf( + "private Rust function %q is absent from configured artifact symbol reports with same-module evidence (%s)", + candidate.name, + strings.Join(evidence.reports, ", "), + ), + }) + } + return issues, nil +} + +func rustToolchainArtifactEvidenceFromReports(targetDir string, reports []string) (rustToolchainArtifactEvidence, error) { + evidence := rustToolchainArtifactEvidence{ + reports: make([]string, 0, len(reports)), + lines: make([]string, 0), + } + for _, report := range reports { + abs, rel, err := rustToolchainReportPath(targetDir, report) + if err != nil { + return evidence, err + } + info, err := os.Stat(abs) + if err != nil { + return evidence, fmt.Errorf("read Rust artifact report %q: %w", rel, err) + } + if info.Size() > rustToolchainArtifactReportLimit { + return evidence, fmt.Errorf("rust artifact report %q exceeds %d bytes", rel, rustToolchainArtifactReportLimit) + } + data, err := os.ReadFile(abs) //nolint:gosec // report path is validated target-relative before reading. + if err != nil { + return evidence, fmt.Errorf("read Rust artifact report %q: %w", rel, err) + } + evidence.reports = append(evidence.reports, rel) + for _, line := range strings.Split(string(data), "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + evidence.lines = append(evidence.lines, trimmed) + } + } + } + return evidence, nil +} + +func rustToolchainReportPath(targetDir string, report string) (string, string, error) { + trimmed := filepath.ToSlash(strings.TrimSpace(report)) + if trimmed == "" { + return "", "", fmt.Errorf("quality_rules.dead_code.rust.reports contains a blank path") + } + clean := filepath.Clean(filepath.FromSlash(trimmed)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("quality_rules.dead_code.rust.reports path %q must be relative to the target", report) + } + rel := filepath.ToSlash(clean) + return filepath.Join(targetDir, clean), rel, nil +} + +func (e rustToolchainArtifactEvidence) HasModuleEvidence(candidate rustToolchainArtifactCandidate) bool { + if candidate.moduleSymbol == "" { + return false + } + modulePrefix := candidate.moduleSymbol + "::" + for _, line := range e.lines { + if strings.Contains(line, modulePrefix) || (candidate.legacyModuleMangled != "" && strings.Contains(line, candidate.legacyModuleMangled)) { + return true + } + } + return false +} + +func (e rustToolchainArtifactEvidence) HasSymbol(candidate rustToolchainArtifactCandidate) bool { + if candidate.symbol == "" { + return false + } + for _, line := range e.lines { + if rustToolchainLineContainsSymbol(line, candidate.symbol) || (candidate.legacyMangled != "" && strings.Contains(line, candidate.legacyMangled)) { + return true + } + } + return false +} + +func rustToolchainArtifactCandidates(env support.Context, target core.TargetConfig, cfg core.QualityDeadCodeConfig) []rustToolchainArtifactCandidate { + if env.VisitTargetFiles == nil { + return nil + } + roots := rustToolchainCrateRoots(env, target, cfg) + candidates := make([]rustToolchainArtifactCandidate, 0) + env.VisitTargetFiles(target, func(rel string) bool { + rel = filepath.ToSlash(rel) + if !strings.HasSuffix(rel, ".rs") || rustToolchainArtifactPathIgnored(rel, cfg) { + return false + } + return true + }, func(rel string, data []byte) { + if rustToolchainGeneratedFile(data) || rustToolchainProcMacroFile(data) || rustToolchainPluginRegistryFile(data) { + return + } + candidates = append(candidates, rustToolchainArtifactCandidatesForFile(filepath.ToSlash(rel), data, roots)...) + }) + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].path != candidates[j].path { + return candidates[i].path < candidates[j].path + } + if candidates[i].line != candidates[j].line { + return candidates[i].line < candidates[j].line + } + return candidates[i].name < candidates[j].name + }) + return candidates +} + +func rustToolchainArtifactPathIgnored(rel string, cfg core.QualityDeadCodeConfig) bool { + for _, pattern := range cfg.Rust.IgnorePaths { + if support.PathMatchesPattern(pattern, rel) { + return true + } + } + if cfg.IncludeTests == nil || !*cfg.IncludeTests { + switch { + case strings.HasPrefix(rel, "tests/"), strings.HasPrefix(rel, "benches/"), strings.HasPrefix(rel, "examples/"): + return true + case strings.Contains(rel, "/tests/"), strings.Contains(rel, "/benches/"), strings.Contains(rel, "/examples/"): + return true + case strings.HasSuffix(rel, "_test.rs"): + return true + } + } + return strings.Contains(rel, "/generated/") || + strings.HasPrefix(rel, "generated/") || + strings.Contains(rel, "/vendor/") || + strings.HasPrefix(rel, "vendor/") || + strings.Contains(rel, "/target/") || + strings.HasPrefix(rel, "target/") +} + +func rustToolchainCrateRoots(env support.Context, target core.TargetConfig, cfg core.QualityDeadCodeConfig) []rustToolchainCrateRoot { + crates := rustToolchainCrates(cfg.Rust.Crates) + roots := make([]rustToolchainCrateRoot, 0, len(crates)) + for _, crate := range crates { + rel := rustToolchainCrateRootRel(crate) + crateName := rustToolchainCrateName(env, target, rel) + roots = append(roots, rustToolchainCrateRoot{rel: rel, crateName: crateName}) + } + sort.Slice(roots, func(i, j int) bool { return len(roots[i].rel) > len(roots[j].rel) }) + return roots +} + +func rustToolchainCrateRootRel(crate string) string { + crate = filepath.ToSlash(strings.TrimSpace(crate)) + switch { + case crate == "", crate == ".": + return "" + case strings.HasSuffix(crate, ".toml"): + return filepath.ToSlash(filepath.Dir(crate)) + default: + return strings.Trim(filepath.ToSlash(crate), "/") + } +} + +func rustToolchainCrateName(env support.Context, target core.TargetConfig, rootRel string) string { + manifest := filepath.ToSlash(filepath.Join(rootRel, "Cargo.toml")) + if rootRel == "" { + manifest = "Cargo.toml" + } + if data, ok := rustToolchainReadTargetFile(env, target, manifest); ok { + if name := rustToolchainPackageNameFromManifest(data); name != "" { + return name + } + } + if rootRel != "" { + return rustToolchainNormalizeCrateName(filepath.Base(rootRel)) + } + return rustToolchainNormalizeCrateName(filepath.Base(target.Path)) +} + +func rustToolchainPackageNameFromManifest(data []byte) string { + inPackage := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + switch { + case trimmed == "[package]": + inPackage = true + continue + case strings.HasPrefix(trimmed, "[") && trimmed != "[package]": + inPackage = false + } + if !inPackage || !strings.HasPrefix(trimmed, "name") { + continue + } + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + continue + } + return rustToolchainNormalizeCrateName(strings.Trim(strings.TrimSpace(parts[1]), `"`)) + } + return "" +} + +func rustToolchainNormalizeCrateName(name string) string { + return strings.ReplaceAll(strings.TrimSpace(name), "-", "_") +} + +func rustToolchainArtifactCandidatesForFile(rel string, data []byte, roots []rustToolchainCrateRoot) []rustToolchainArtifactCandidate { + _, module, ok := rustToolchainModuleSymbolForFile(rel, roots) + if !ok { + return nil + } + lines := strings.Split(string(data), "\n") + candidates := make([]rustToolchainArtifactCandidate, 0) + depth := 0 + attrs := make([]string, 0) + for idx, line := range lines { + trimmed := strings.TrimSpace(line) + if depth == 0 && strings.HasPrefix(trimmed, "#[") { + attrs = append(attrs, trimmed) + continue + } + if depth == 0 { + if name, column, ok := rustToolchainPrivateFunctionOnLine(line); ok && rustToolchainArtifactFunctionEligible(data, idx+1, strings.Join(attrs, "\n")) { + symbol := module + "::" + name + candidates = append(candidates, rustToolchainArtifactCandidate{ + path: rel, + line: idx + 1, + column: column, + name: name, + moduleSymbol: module, + symbol: symbol, + legacyMangled: rustToolchainLegacyMangledPrefix(append(strings.Split(module, "::"), name)), + legacyModuleMangled: rustToolchainLegacyMangledPrefix(strings.Split(module, "::")), + }) + } + if trimmed != "" && !strings.HasPrefix(trimmed, "//") { + attrs = attrs[:0] + } + } + depth += strings.Count(line, "{") + depth -= strings.Count(line, "}") + if depth < 0 { + depth = 0 + } + } + return candidates +} + +func rustToolchainModuleSymbolForFile(rel string, roots []rustToolchainCrateRoot) (rustToolchainCrateRoot, string, bool) { + for _, root := range roots { + fileRel := rel + if root.rel != "" { + if rel != root.rel && !strings.HasPrefix(rel, root.rel+"/") { + continue + } + fileRel = strings.TrimPrefix(rel, root.rel+"/") + } + if !strings.HasPrefix(fileRel, "src/") { + continue + } + srcRel := strings.TrimPrefix(fileRel, "src/") + if srcRel == "lib.rs" || srcRel == "main.rs" || strings.HasPrefix(srcRel, "bin/") { + if strings.HasPrefix(srcRel, "bin/") { + return root, "", false + } + return root, root.crateName, true + } + var moduleRel string + switch { + case strings.HasSuffix(srcRel, "/mod.rs"): + moduleRel = strings.TrimSuffix(srcRel, "/mod.rs") + case strings.HasSuffix(srcRel, ".rs"): + moduleRel = strings.TrimSuffix(srcRel, ".rs") + default: + return root, "", false + } + if moduleRel == "" { + return root, root.crateName, true + } + parts := []string{root.crateName} + for _, part := range strings.Split(moduleRel, "/") { + if part != "" { + parts = append(parts, part) + } + } + return root, strings.Join(parts, "::"), true + } + return rustToolchainCrateRoot{}, "", false +} + +func rustToolchainPrivateFunctionOnLine(line string) (string, int, bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "pub ") || strings.HasPrefix(trimmed, "pub(") { + return "", 0, false + } + rest := trimmed + for { + changed := false + for _, prefix := range []string{"async ", "unsafe ", "const "} { + if strings.HasPrefix(rest, prefix) { + rest = strings.TrimSpace(strings.TrimPrefix(rest, prefix)) + changed = true + } + } + if !changed { + break + } + } + if strings.HasPrefix(rest, "extern ") || !strings.HasPrefix(rest, "fn ") { + return "", 0, false + } + rest = strings.TrimSpace(strings.TrimPrefix(rest, "fn ")) + if rest == "" || !rustToolchainIsIdentifierStart(rest[0]) { + return "", 0, false + } + end := 1 + for end < len(rest) && rustToolchainIsIdentifierChar(rest[end]) { + end++ + } + name := rest[:end] + afterName := strings.TrimSpace(rest[end:]) + if name == "main" || strings.HasPrefix(afterName, "<") { + return "", 0, false + } + return name, strings.Index(line, "fn "+name) + 4, true +} + +func rustToolchainArtifactFunctionEligible(data []byte, line int, attrs string) bool { + attrText := strings.ToLower(attrs) + if strings.Contains(attrText, "#[test") || + strings.Contains(attrText, "#[bench") || + strings.Contains(attrText, "#[cfg") || + strings.Contains(attrText, "#[inline") || + strings.Contains(attrText, "#[no_mangle]") || + strings.Contains(attrText, "#[export_name") || + strings.Contains(attrText, "#[used]") || + strings.Contains(attrText, "#[proc_macro") { + return false + } + return !rustToolchainLineLooksPublic(data, line) && + !strings.Contains(rustToolchainSourceLine(data, line), `extern "C"`) && + !rustToolchainLineIsCfgTestOnly(data, line) && + !rustToolchainLineInTraitImpl(data, line) +} + +func rustToolchainLegacyMangledPrefix(parts []string) string { + var b strings.Builder + b.WriteString("_ZN") + for _, part := range parts { + if part == "" { + continue + } + _, _ = fmt.Fprintf(&b, "%d%s", len(part), part) + } + return b.String() +} + +func rustToolchainLineContainsSymbol(line string, symbol string) bool { + offset := 0 + for { + idx := strings.Index(line[offset:], symbol) + if idx < 0 { + return false + } + start := offset + idx + if start > 0 && rustToolchainIsIdentifierChar(line[start-1]) { + offset = start + len(symbol) + continue + } + end := start + len(symbol) + if end < len(line) && rustToolchainIsIdentifierChar(line[end]) { + offset = end + continue + } + return true + } +} + +func rustToolchainIsIdentifierStart(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '_' +} + +func rustToolchainIsIdentifierChar(b byte) bool { + return rustToolchainIsIdentifierStart(b) || (b >= '0' && b <= '9') +} + +func rustToolchainCommandEnv(tmpDir string) []string { + env := make([]string, 0, len(os.Environ())+2) + var rustFlags string + for _, item := range os.Environ() { + switch { + case strings.HasPrefix(item, "CARGO_TARGET_DIR="): + continue + case strings.HasPrefix(item, "RUSTFLAGS="): + rustFlags = strings.TrimPrefix(item, "RUSTFLAGS=") + continue + default: + env = append(env, item) + } + } + flags := strings.TrimSpace(rustFlags + " -Ddead_code -Dunreachable_code") + env = append(env, + "CARGO_TARGET_DIR="+filepath.Join(tmpDir, "target"), + "RUSTFLAGS="+flags, + ) + return env +} + +func runRustToolchainCommand(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // fixed Cargo invocation; config values are validated args, not commands + cmd.Dir = dir + cmd.Env = env + var buf bytes.Buffer + limited := newRustToolchainLimitedWriter(&buf, rustToolchainCommandOutputLimit) + cmd.Stdout = limited + cmd.Stderr = limited + err := cmd.Run() + if limited.Truncated() { + return "", fmt.Errorf("%s output exceeded %d bytes", name, rustToolchainCommandOutputLimit) + } + return buf.String(), err +} + +func parseCargoDeadCodeDiagnostics(output string, targetDir string) []rustToolchainIssue { + seen := map[string]struct{}{} + issues := make([]rustToolchainIssue, 0) + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + var msg cargoCompilerMessage + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil || msg.Reason != "compiler-message" || msg.Message.Code == nil { + continue + } + code := msg.Message.Code.Code + if code != "dead_code" && code != "unreachable_code" { + continue + } + span, ok := primaryCargoSpan(msg) + if !ok { + continue + } + rel, ok := rustToolchainRelativePath(targetDir, span.FileName) + if !ok { + continue + } + key := fmt.Sprintf("%s:%d:%d:%s", rel, span.LineStart, span.ColumnStart, code) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + issues = append(issues, rustToolchainIssue{ + path: rel, + line: span.LineStart, + column: span.ColumnStart, + code: code, + message: msg.Message.Message, + }) + } + return issues +} + +func primaryCargoSpan(msg cargoCompilerMessage) (struct { + FileName string `json:"file_name"` + LineStart int `json:"line_start"` + ColumnStart int `json:"column_start"` + IsPrimary bool `json:"is_primary"` +}, bool) { + for _, span := range msg.Message.Spans { + if span.IsPrimary && span.FileName != "" && span.LineStart > 0 { + return span, true + } + } + return struct { + FileName string `json:"file_name"` + LineStart int `json:"line_start"` + ColumnStart int `json:"column_start"` + IsPrimary bool `json:"is_primary"` + }{}, false +} + +func rustToolchainRelativePath(targetDir string, fileName string) (string, bool) { + if strings.HasPrefix(fileName, "<") { + return "", false + } + clean := filepath.Clean(filepath.FromSlash(fileName)) + if !filepath.IsAbs(clean) { + return filepath.ToSlash(clean), true + } + rel, err := filepath.Rel(targetDir, clean) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + return filepath.ToSlash(rel), true +} + +func rustToolchainIssueIgnored(env support.Context, target core.TargetConfig, cfg core.QualityDeadCodeConfig, issue rustToolchainIssue) bool { + if !strings.HasSuffix(issue.path, ".rs") { + return true + } + if rustToolchainArtifactPathIgnored(issue.path, cfg) { + return true + } + data, ok := rustToolchainReadTargetFile(env, target, issue.path) + if !ok { + return false + } + if rustToolchainGeneratedFile(data) || rustToolchainProcMacroFile(data) || rustToolchainPluginRegistryFile(data) { + return true + } + return rustToolchainLineLooksPublic(data, issue.line) || + rustToolchainLineHasRuntimeExport(data, issue.line) || + rustToolchainLineIsCfgTestOnly(data, issue.line) || + rustToolchainLineInTraitImpl(data, issue.line) +} + +func rustToolchainReadTargetFile(env support.Context, target core.TargetConfig, rel string) ([]byte, bool) { + if env.ReadTargetFile != nil { + data, err := env.ReadTargetFile(target, rel) + if err == nil { + return data, true + } + } + var found []byte + env.VisitTargetFiles(target, func(path string) bool { return filepath.ToSlash(path) == filepath.ToSlash(rel) }, func(_ string, data []byte) { + found = data + }) + return found, found != nil +} + +func rustToolchainGeneratedFile(data []byte) bool { + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.Contains(trimmed, "generated") && strings.Contains(strings.ToLower(trimmed), "do not edit") { + return true + } + if !strings.HasPrefix(trimmed, "//") && !strings.HasPrefix(trimmed, "/*") && !strings.HasPrefix(trimmed, "*") { + return false + } + } + return false +} + +func rustToolchainProcMacroFile(data []byte) bool { + source := string(data) + return strings.Contains(source, "extern crate proc_macro") || + strings.Contains(source, "use proc_macro::") || + strings.Contains(source, "#[proc_macro") || + strings.Contains(source, "#[proc_macro_attribute") || + strings.Contains(source, "#[proc_macro_derive") +} + +func rustToolchainPluginRegistryFile(data []byte) bool { + source := string(data) + return strings.Contains(source, "inventory::submit!") || + strings.Contains(source, "linkme::distributed_slice") || + strings.Contains(source, "#[distributed_slice]") || + strings.Contains(source, "ctor::ctor") +} + +func rustToolchainLineLooksPublic(data []byte, line int) bool { + current := rustToolchainSourceLine(data, line) + return strings.Contains(current, "pub fn ") || + strings.Contains(current, "pub(crate) fn ") || + strings.Contains(current, "pub(super) fn ") || + strings.Contains(current, "pub struct ") || + strings.Contains(current, "pub enum ") || + strings.Contains(current, "pub trait ") || + strings.Contains(current, "pub type ") || + strings.Contains(current, "pub const ") || + strings.Contains(current, "pub static ") +} + +func rustToolchainLineHasRuntimeExport(data []byte, line int) bool { + window := rustToolchainSourceWindow(data, line, 4) + return strings.Contains(window, "#[no_mangle]") || + strings.Contains(window, "#[export_name") || + strings.Contains(window, "#[used]") || + strings.Contains(window, "extern \"C\"") +} + +func rustToolchainLineIsCfgTestOnly(data []byte, line int) bool { + window := rustToolchainSourceWindow(data, line, 6) + return strings.Contains(window, "#[cfg(test)]") || strings.Contains(window, "cfg_attr(test") +} + +func rustToolchainLineInTraitImpl(data []byte, line int) bool { + lines := strings.Split(string(data), "\n") + if line <= 0 || line > len(lines) { + return false + } + depth := 0 + implDepths := make([]int, 0) + for idx, rawLine := range lines { + if idx+1 == line { + current := strings.TrimSpace(rawLine) + return strings.Contains(current, "fn ") && len(implDepths) > 0 + } + trimmed := strings.TrimSpace(rawLine) + if strings.HasPrefix(trimmed, "impl ") && strings.Contains(trimmed, " for ") && strings.Contains(trimmed, "{") { + implDepths = append(implDepths, depth+strings.Count(rawLine, "{")) + } + depth += strings.Count(rawLine, "{") + depth -= strings.Count(rawLine, "}") + if depth < 0 { + depth = 0 + } + for len(implDepths) > 0 && depth < implDepths[len(implDepths)-1] { + implDepths = implDepths[:len(implDepths)-1] + } + } + return false +} + +func rustToolchainSourceLine(data []byte, line int) string { + lines := strings.Split(string(data), "\n") + if line <= 0 || line > len(lines) { + return "" + } + return strings.TrimSpace(lines[line-1]) +} + +func rustToolchainSourceWindow(data []byte, line int, before int) string { + lines := strings.Split(string(data), "\n") + if line <= 0 || line > len(lines) { + return "" + } + start := line - before - 1 + if start < 0 { + start = 0 + } + return strings.Join(lines[start:line], "\n") +} + +func firstNonEmptyLine(output string) string { + for _, line := range strings.Split(output, "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + return trimmed + } + } + return strings.TrimSpace(output) +} + +type rustToolchainLimitedWriter struct { + buf *bytes.Buffer + limit int + truncated bool +} + +func newRustToolchainLimitedWriter(buf *bytes.Buffer, limit int) *rustToolchainLimitedWriter { + return &rustToolchainLimitedWriter{buf: buf, limit: limit} +} + +func (w *rustToolchainLimitedWriter) Write(p []byte) (int, error) { + if w.truncated { + return len(p), nil + } + remaining := w.limit - w.buf.Len() + if remaining <= 0 { + w.truncated = true + return len(p), nil + } + if len(p) > remaining { + _, _ = w.buf.Write(p[:remaining]) + w.truncated = true + return len(p), nil + } + _, _ = w.buf.Write(p) + return len(p), nil +} + +func (w *rustToolchainLimitedWriter) Truncated() bool { + return w.truncated +} diff --git a/internal/codeguard/checks/quality/quality_rust_toolchain_dead_code_test.go b/internal/codeguard/checks/quality/quality_rust_toolchain_dead_code_test.go new file mode 100644 index 0000000..539121e --- /dev/null +++ b/internal/codeguard/checks/quality/quality_rust_toolchain_dead_code_test.go @@ -0,0 +1,314 @@ +package quality + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func TestParseCargoDeadCodeDiagnostics(t *testing.T) { + output := `{"reason":"compiler-message","message":{"level":"error","message":"function ` + "`unused_helper`" + ` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/lib.rs","line_start":3,"column_start":4,"is_primary":true}]}}` + "\n" + + `{"reason":"compiler-message","message":{"level":"warning","message":"unreachable statement","code":{"code":"unreachable_code"},"spans":[{"file_name":"src/lib.rs","line_start":8,"column_start":5,"is_primary":true}]}}` + "\n" + + `{"reason":"compiler-message","message":{"level":"warning","message":"unused variable","code":{"code":"unused_variables"},"spans":[{"file_name":"src/lib.rs","line_start":9,"column_start":5,"is_primary":true}]}}` + + issues := parseCargoDeadCodeDiagnostics(output, t.TempDir()) + + if len(issues) != 2 { + t.Fatalf("issues len = %d, want 2: %+v", len(issues), issues) + } + if issues[0].code != "dead_code" || issues[0].path != "src/lib.rs" || issues[0].line != 3 || issues[0].column != 4 { + t.Fatalf("first issue = %+v", issues[0]) + } + if issues[1].code != "unreachable_code" || issues[1].line != 8 { + t.Fatalf("second issue = %+v", issues[1]) + } +} + +func TestParseCargoDeadCodeDiagnosticsSkipsExternalSpans(t *testing.T) { + dir := t.TempDir() + external := filepath.Join(filepath.Dir(dir), "external", "lib.rs") + output := `{"reason":"compiler-message","message":{"level":"error","message":"function is never used","code":{"code":"dead_code"},"spans":[{"file_name":` + quoteJSON(external) + `,"line_start":3,"column_start":4,"is_primary":true}]}}` + + issues := parseCargoDeadCodeDiagnostics(output, dir) + + if len(issues) != 0 { + t.Fatalf("issues = %+v, want none", issues) + } +} + +func TestRustToolchainIssueIgnoredAvoidsFalsePositiveSurfaces(t *testing.T) { + target := core.TargetConfig{Name: "crate", Path: ".", Language: "rust"} + tests := []struct { + name string + source string + line int + }{ + { + name: "public api", + source: `pub fn exported_api() {} +`, + line: 1, + }, + { + name: "trait implementation", + source: `trait Handler { fn handle(&self); } +struct Job; +impl Handler for Job { + fn handle(&self) {} +} +`, + line: 4, + }, + { + name: "cfg test", + source: `#[cfg(test)] +fn helper_for_tests() {} +`, + line: 2, + }, + { + name: "runtime export", + source: `#[no_mangle] +pub extern "C" fn plugin_entrypoint() {} +`, + line: 2, + }, + { + name: "generated file", + source: `// Code generated by bindgen. DO NOT EDIT. +fn generated_helper() {} +`, + line: 2, + }, + { + name: "proc macro", + source: `extern crate proc_macro; +use proc_macro::TokenStream; + +#[proc_macro] +pub fn make(_input: TokenStream) -> TokenStream { TokenStream::new() } +`, + line: 5, + }, + { + name: "plugin registry", + source: `inventory::submit! { + Plugin::new() +} + +fn registered_plugin() {} +`, + line: 5, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := rustToolchainTestContext("src/lib.rs", []byte(tt.source)) + ignored := rustToolchainIssueIgnored(env, target, core.QualityDeadCodeConfig{}, rustToolchainIssue{ + path: "src/lib.rs", + line: tt.line, + code: "dead_code", + }) + if !ignored { + t.Fatal("issue was not ignored") + } + }) + } +} + +func TestRustToolchainIssueIgnoredKeepsPrivateDeadCode(t *testing.T) { + target := core.TargetConfig{Name: "crate", Path: ".", Language: "rust"} + env := rustToolchainTestContext("src/lib.rs", []byte(`fn unused_helper() {} +`)) + + ignored := rustToolchainIssueIgnored(env, target, core.QualityDeadCodeConfig{}, rustToolchainIssue{ + path: "src/lib.rs", + line: 1, + code: "dead_code", + }) + + if ignored { + t.Fatal("private dead-code issue was ignored") + } +} + +func TestRustToolchainArtifactIssuesReportsAbsentPrivateFunction(t *testing.T) { + dir := t.TempDir() + writeRustToolchainTestFile(t, filepath.Join(dir, ".codeguard", "rust-symbols.txt"), `00000000 T rust_dead_code_test::used_private +`) + env := rustToolchainMapTestContext(map[string][]byte{ + "Cargo.toml": []byte(`[package] +name = "rust-dead-code-test" +version = "0.1.0" +`), + "src/lib.rs": []byte(`pub fn api() { used_private(); } + +fn used_private() {} + +fn unused_private() {} +`), + }) + target := core.TargetConfig{Name: "crate", Path: dir, Language: "rust"} + + issues, err := rustToolchainArtifactIssues(env, target, core.QualityDeadCodeConfig{ + Rust: core.RustDeadCodeConfig{Reports: []string{".codeguard/rust-symbols.txt"}}, + }) + + if err != nil { + t.Fatalf("artifact issues: %v", err) + } + if len(issues) != 1 { + t.Fatalf("issues len = %d, want 1: %+v", len(issues), issues) + } + if issues[0].code != rustToolchainArtifactCode || issues[0].path != "src/lib.rs" || issues[0].line != 5 { + t.Fatalf("issue = %+v, want artifact issue for src/lib.rs:5", issues[0]) + } +} + +func TestRustToolchainArtifactIssuesRecognizesLegacyMangledUsedSymbols(t *testing.T) { + dir := t.TempDir() + writeRustToolchainTestFile(t, filepath.Join(dir, ".codeguard", "rust-symbols.txt"), `_ZN19rust_dead_code_test12used_private17h1234567890abcdefE +`) + env := rustToolchainMapTestContext(map[string][]byte{ + "Cargo.toml": []byte(`[package] +name = "rust-dead-code-test" +version = "0.1.0" +`), + "src/lib.rs": []byte(`fn used_private() {} + +fn unused_private() {} +`), + }) + target := core.TargetConfig{Name: "crate", Path: dir, Language: "rust"} + + issues, err := rustToolchainArtifactIssues(env, target, core.QualityDeadCodeConfig{ + Rust: core.RustDeadCodeConfig{Reports: []string{".codeguard/rust-symbols.txt"}}, + }) + + if err != nil { + t.Fatalf("artifact issues: %v", err) + } + if len(issues) != 1 || issues[0].line != 3 { + t.Fatalf("issues = %+v, want only unused_private", issues) + } +} + +func TestRustToolchainArtifactIssuesRequiresSameModuleEvidence(t *testing.T) { + dir := t.TempDir() + writeRustToolchainTestFile(t, filepath.Join(dir, ".codeguard", "rust-symbols.txt"), `00000000 T other_crate::used_private +`) + env := rustToolchainMapTestContext(map[string][]byte{ + "Cargo.toml": []byte(`[package] +name = "rust-dead-code-test" +version = "0.1.0" +`), + "src/lib.rs": []byte(`fn unused_private() {} +`), + }) + target := core.TargetConfig{Name: "crate", Path: dir, Language: "rust"} + + issues, err := rustToolchainArtifactIssues(env, target, core.QualityDeadCodeConfig{ + Rust: core.RustDeadCodeConfig{Reports: []string{".codeguard/rust-symbols.txt"}}, + }) + + if err != nil { + t.Fatalf("artifact issues: %v", err) + } + if len(issues) != 0 { + t.Fatalf("issues = %+v, want none without same-module evidence", issues) + } +} + +func TestRustToolchainArtifactCandidatesAvoidFalsePositiveSurfaces(t *testing.T) { + source := []byte(`pub fn exported_api() {} + +#[inline] +fn inline_helper() {} + +fn generic_helper(value: T) -> T { value } + +#[no_mangle] +extern "C" fn plugin_entrypoint() {} + +fn used_private() {} +`) + env := rustToolchainMapTestContext(map[string][]byte{ + "Cargo.toml": []byte(`[package] +name = "rust-dead-code-test" +version = "0.1.0" +`), + "src/lib.rs": source, + }) + target := core.TargetConfig{Name: "crate", Path: ".", Language: "rust"} + + candidates := rustToolchainArtifactCandidates(env, target, core.QualityDeadCodeConfig{}) + + if len(candidates) != 1 { + t.Fatalf("candidates = %+v, want only used_private", candidates) + } + if candidates[0].name != "used_private" { + t.Fatalf("candidate = %+v, want used_private", candidates[0]) + } +} + +func rustToolchainTestContext(path string, data []byte) support.Context { + return support.Context{ + ReadTargetFile: func(_ core.TargetConfig, rel string) ([]byte, error) { + if filepath.ToSlash(rel) != filepath.ToSlash(path) { + return nil, os.ErrNotExist + } + return data, nil + }, + VisitTargetFiles: func(core.TargetConfig, func(string) bool, func(string, []byte)) {}, + } +} + +func rustToolchainMapTestContext(files map[string][]byte) support.Context { + return support.Context{ + ReadTargetFile: func(_ core.TargetConfig, rel string) ([]byte, error) { + data, ok := files[filepath.ToSlash(rel)] + if !ok { + return nil, os.ErrNotExist + } + return data, nil + }, + VisitTargetFiles: func(_ core.TargetConfig, include func(string) bool, visit func(string, []byte)) { + for rel, data := range files { + if include == nil || include(rel) { + visit(rel, data) + } + } + }, + } +} + +func writeRustToolchainTestFile(t *testing.T, path string, data string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func quoteJSON(value string) string { + var out strings.Builder + out.WriteByte('"') + for _, r := range value { + switch r { + case '\\': + out.WriteString(`\\`) + case '"': + out.WriteString(`\"`) + default: + out.WriteRune(r) + } + } + out.WriteByte('"') + return out.String() +} diff --git a/internal/codeguard/checks/reliability/reliability_typescript.go b/internal/codeguard/checks/reliability/reliability_typescript.go index 8d0025d..efd5c9c 100644 --- a/internal/codeguard/checks/reliability/reliability_typescript.go +++ b/internal/codeguard/checks/reliability/reliability_typescript.go @@ -42,12 +42,13 @@ func typeScriptFindingsForFile(env support.Context, file string, data []byte) [] source := strings.ReplaceAll(string(data), "\r\n", "\n") code := support.StripTypeScriptCommentsAndStrings(source) scan := &tsReliabilityScan{ - env: env, - file: file, - rules: env.Config.Checks.ReliabilityRules, - limited: tsLimitHint.MatchString(source), - cancellable: tsCancellationHint.MatchString(source), - hasShutdown: tsShutdownHint.MatchString(source), + env: env, + file: file, + rules: env.Config.Checks.ReliabilityRules, + limited: tsLimitHint.MatchString(source), + cancellable: tsCancellationHint.MatchString(source), + hasShutdown: tsShutdownHint.MatchString(source), + hasErrorHandler: hasTypeScriptErrorHandler(code), } for idx, line := range strings.Split(code, "\n") { scan.consumeLine(idx+1, line) @@ -68,18 +69,19 @@ func typeScriptFindingsForFile(env support.Context, file string, data []byte) [] } type tsReliabilityScan struct { - env support.Context - file string - rules core.ReliabilityRulesConfig - limited bool - cancellable bool - hasShutdown bool - depth int - loops []int - unboundedLoops []int - detachedLines []int - resourceLine int - findings []core.Finding + env support.Context + file string + rules core.ReliabilityRulesConfig + limited bool + cancellable bool + hasShutdown bool + hasErrorHandler bool + depth int + loops []int + unboundedLoops []int + detachedLines []int + resourceLine int + findings []core.Finding } func (s *tsReliabilityScan) consumeLine(lineNo int, line string) { @@ -131,11 +133,25 @@ func (s *tsReliabilityScan) checkLine(lineNo int, line string, inLoop bool, inUn if enabled(s.rules.DetectNonIdempotentRetry) && inLoop && tsNonIdempotentCall.MatchString(line) && !tsIdempotencyHint.MatchString(line) { s.add("reliability.non-idempotent-retry", "fail", lineNo, "retry-like JavaScript loop wraps a non-idempotent side effect without idempotency evidence", "medium", "retry", "side-effect") } - if enabled(s.rules.DetectRecoverablePanic) && tsGenericThrow.MatchString(line) { + if enabled(s.rules.DetectRecoverablePanic) && tsGenericThrow.MatchString(line) && !s.hasErrorHandler && !tsThrowLooksDefensive(line) { s.add("reliability.recoverable-panic", "fail", lineNo, "production code throws a generic exception for a recoverable failure path", "medium", "exception", "generic-throw") } } +func hasTypeScriptErrorHandler(code string) bool { + return strings.Contains(code, ".catch(") || + regexp.MustCompile(`\btry\s*\{`).MatchString(code) && regexp.MustCompile(`\bcatch\s*\(`).MatchString(code) +} + +func tsThrowLooksDefensive(line string) bool { + lowered := strings.ToLower(line) + return strings.Contains(lowered, "invariant") || + strings.Contains(lowered, "unreachable") || + strings.Contains(lowered, "impossible") || + strings.Contains(lowered, "must ") || + strings.Contains(lowered, "expected ") +} + func (s *tsReliabilityScan) trackResourceLeak(lineNo int, line string) { if tsResourceOpen.MatchString(line) { s.resourceLine = lineNo diff --git a/internal/codeguard/checks/support/scan_helpers.go b/internal/codeguard/checks/support/scan_helpers.go index 4046e21..4263807 100644 --- a/internal/codeguard/checks/support/scan_helpers.go +++ b/internal/codeguard/checks/support/scan_helpers.go @@ -2,17 +2,52 @@ package support import ( "context" + "path/filepath" "regexp" + "strconv" + "strings" "github.com/devr-tools/codeguard/internal/codeguard/core" ) func CollectTargetFindings(ctx context.Context, env Context, collect func(context.Context, Context, core.TargetConfig) []core.Finding) []core.Finding { findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each target appends a variable number + physicalPaths := map[string]string{} for _, target := range env.Config.Targets { - findings = append(findings, collect(ctx, env, target)...) + targetFindings := collect(ctx, env, target) + for _, finding := range targetFindings { + if finding.Path != "" { + physicalPaths[findingKey(finding)] = targetPhysicalFindingPath(target, finding.Path) + } + } + findings = append(findings, targetFindings...) } - return findings + return DedupeFindings(findings, func(finding core.Finding) string { + path := finding.Path + if physical := physicalPaths[findingKey(finding)]; physical != "" { + path = physical + } + return finding.RuleID + "|" + path + "|" + strconv.Itoa(finding.Line) + "|" + finding.Message + }) +} + +func findingKey(finding core.Finding) string { + return finding.RuleID + "|" + finding.Path + "|" + strconv.Itoa(finding.Line) + "|" + finding.Message +} + +func targetPhysicalFindingPath(target core.TargetConfig, rel string) string { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if rel == "" { + return "" + } + if filepath.IsAbs(rel) { + return filepath.Clean(rel) + } + joined := filepath.Join(target.Path, filepath.FromSlash(rel)) + if abs, err := filepath.Abs(joined); err == nil { + return abs + } + return filepath.Clean(joined) } // RunTargetSection collects findings for every configured target through diff --git a/internal/codeguard/checks/support/typescript_semantic.go b/internal/codeguard/checks/support/typescript_semantic.go index 2b2500f..0662851 100644 --- a/internal/codeguard/checks/support/typescript_semantic.go +++ b/internal/codeguard/checks/support/typescript_semantic.go @@ -16,6 +16,9 @@ import ( //go:embed typescript_semantic_runner_core.js var typeScriptSemanticRunnerCore string +//go:embed typescript_semantic_runner_dead_code_reports.js +var typeScriptSemanticRunnerDeadCodeReports string + //go:embed typescript_semantic_runner_security.js var typeScriptSemanticRunnerSecurity string @@ -27,6 +30,7 @@ var typeScriptSemanticRunnerBootstrap string var typeScriptSemanticRunner = strings.Join([]string{ typeScriptSemanticRunnerCore, + typeScriptSemanticRunnerDeadCodeReports, typeScriptSemanticRunnerSecurity, typeScriptSemanticRunnerTaint, typeScriptSemanticRunnerBootstrap, diff --git a/internal/codeguard/checks/support/typescript_semantic_runner_core.js b/internal/codeguard/checks/support/typescript_semantic_runner_core.js index 866c8aa..883ff09 100644 --- a/internal/codeguard/checks/support/typescript_semantic_runner_core.js +++ b/internal/codeguard/checks/support/typescript_semantic_runner_core.js @@ -16,66 +16,171 @@ const directivePatterns = [ ]; function main() { - const program = loadProgram(); - const checker = program.getTypeChecker(); + const programs = loadPrograms(); + for (const program of programs) { + const checker = program.getTypeChecker(); - for (const sourceFile of program.getSourceFiles()) { - if (!isAnalyzableSourceFile(sourceFile)) { - continue; - } - const relPath = normalizePath(path.relative(targetPath, sourceFile.fileName)); - const flavor = scriptFlavor(relPath); - if (!flavor) { - continue; - } + analyzeDeadCodeDiagnostics(program); + + for (const sourceFile of program.getSourceFiles()) { + if (!isAnalyzableSourceFile(sourceFile)) { + continue; + } + const relPath = normalizePath(path.relative(targetPath, sourceFile.fileName)); + const flavor = scriptFlavor(relPath); + if (!flavor) { + continue; + } - analyzeModuleName(sourceFile, relPath); - analyzeDirectives(sourceFile, relPath, flavor); - analyzeDesign(sourceFile, relPath); + analyzeModuleName(sourceFile, relPath); + analyzeDirectives(sourceFile, relPath, flavor); + analyzeDesign(sourceFile, relPath); - const bindings = collectBindings(sourceFile); - sourceFile.bindings = bindings; - analyzeTaintFlows(sourceFile, relPath, flavor, bindings, checker); - visit(sourceFile, sourceFile, relPath, flavor, bindings, checker); + const bindings = collectBindings(sourceFile); + sourceFile.bindings = bindings; + analyzeTaintFlows(sourceFile, relPath, flavor, bindings, checker); + visit(sourceFile, sourceFile, relPath, flavor, bindings, checker); + } + + analyzeTaint(program, checker); } - analyzeTaint(program, checker); + analyzeDeadCodeReports(programs); process.stdout.write(JSON.stringify(results)); } -function loadProgram() { - const configPath = findConfigPath(); - const rootNames = input.source_files; - if (configPath) { - const config = ts.readConfigFile(configPath, ts.sys.readFile); - if (config.error) { - throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); - } - const parsed = ts.parseJsonConfigFileContent( - config.config, - ts.sys, - path.dirname(configPath), - defaultCompilerOptions(), - configPath, - ); - return ts.createProgram({ - rootNames: rootNames || parsed.fileNames.filter((name) => isWithinTarget(path.resolve(name))), - options: parsed.options, - }); +function loadPrograms() { + const configPaths = selectedConfigPaths(); + if (configPaths.length > 0) { + return configPaths.map(loadProgramFromConfig); } + return [loadProgramWithoutConfig()]; +} + +function loadProgramFromConfig(configPath) { + const config = ts.readConfigFile(configPath, ts.sys.readFile); + if (config.error) { + throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); + } + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + path.dirname(configPath), + defaultCompilerOptions(), + configPath, + ); + const options = parsed.options; + applyDeadCodeCompilerOptions(options); + return ts.createProgram({ + rootNames: sourceRootsForConfig(parsed), + options, + }); +} +function loadProgramWithoutConfig() { + const options = defaultCompilerOptions(); + applyDeadCodeCompilerOptions(options); return ts.createProgram({ - rootNames: rootNames || ts.sys.readDirectory(targetPath, scriptExtensions(), undefined, undefined), - options: defaultCompilerOptions(), + rootNames: input.source_files || ts.sys.readDirectory(targetPath, scriptExtensions(), undefined, undefined), + options, }); } +function sourceRootsForConfig(parsed) { + const configured = parsed.fileNames.filter((name) => isWithinTarget(path.resolve(name))); + const allowed = sourceFileSet(); + if (!allowed) { + return configured; + } + const roots = new Map(); + for (const name of configured) { + if (allowed.has(path.resolve(name))) { + roots.set(path.resolve(name), name); + } + } + for (const name of input.source_files) { + const absolute = path.resolve(name); + if (isWithinTarget(absolute)) { + roots.set(absolute, name); + } + } + return Array.from(roots.values()).sort(); +} + +function sourceFileSet() { + if (!Array.isArray(input.source_files)) { + return null; + } + return new Set(input.source_files.map((name) => path.resolve(name))); +} + +function selectedConfigPaths() { + const configured = configuredProjectPaths(); + if (configured.length > 0) { + return configured; + } + const configPath = findConfigPath(); + return configPath ? [configPath] : []; +} + +function configuredProjectPaths() { + if (!input.dead_code || !input.dead_code.enabled) { + return []; + } + const patterns = activeDeadCodeList("projects"); + return resolveConfiguredFiles(patterns, [".json"], isProjectConfigFile); +} + function findConfigPath() { return ts.findConfigFile(targetPath, ts.sys.fileExists, "tsconfig.json") || ts.findConfigFile(targetPath, ts.sys.fileExists, "jsconfig.json"); } +function activeDeadCodeList(kind) { + if (!input.dead_code) { + return []; + } + const language = String(input.target_language || "").toLowerCase(); + if (language === "javascript" || language === "js" || language === "jsx") { + return input.dead_code[`javascript_${kind}`] || []; + } + return input.dead_code[`typescript_${kind}`] || []; +} + +function resolveConfiguredFiles(patterns, extensions, predicate) { + if (!Array.isArray(patterns) || patterns.length === 0) { + return []; + } + const resolved = new Set(); + for (const pattern of patterns) { + const normalized = normalizePath(String(pattern || "").trim()).replace(/^\.\//, ""); + if (!normalized) { + continue; + } + if (!normalized.includes("*")) { + const candidate = path.resolve(targetPath, normalized); + if (isWithinTarget(candidate) && ts.sys.fileExists(candidate) && predicate(candidate)) { + resolved.add(candidate); + } + continue; + } + const candidates = ts.sys.readDirectory(targetPath, extensions, undefined, undefined); + for (const candidate of candidates) { + const relPath = normalizePath(path.relative(targetPath, candidate)); + if (matchesPathPattern(relPath, normalized) && predicate(candidate)) { + resolved.add(path.resolve(candidate)); + } + } + } + return Array.from(resolved).sort(); +} + +function isProjectConfigFile(filePath) { + const base = path.basename(filePath).toLowerCase(); + return base === "tsconfig.json" || base === "jsconfig.json" || /^tsconfig\..+\.json$/.test(base) || /^jsconfig\..+\.json$/.test(base); +} + function defaultCompilerOptions() { return { allowJs: true, @@ -88,6 +193,15 @@ function defaultCompilerOptions() { }; } +function applyDeadCodeCompilerOptions(options) { + if (!input.dead_code || !input.dead_code.enabled) { + return; + } + options.noUnusedLocals = true; + options.noUnusedParameters = true; + options.allowUnreachableCode = false; +} + function scriptExtensions() { return [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]; } @@ -163,6 +277,152 @@ function pushFinding(section, sourceFile, relPath, flavor, ruleId, level, messag }); } +function analyzeDeadCodeDiagnostics(program) { + if (!input.dead_code || !input.dead_code.enabled) { + return; + } + const diagnostics = program.getSemanticDiagnostics(); + for (const diagnostic of diagnostics) { + if (!isDeadCodeDiagnostic(diagnostic) || !diagnostic.file) { + continue; + } + const sourceFile = diagnostic.file; + if (!isAnalyzableSourceFile(sourceFile)) { + continue; + } + const relPath = normalizePath(path.relative(targetPath, sourceFile.fileName)); + const flavor = scriptFlavor(relPath); + if (!flavor || !deadCodeEnabledForFlavor(flavor) || shouldSkipDeadCodePath(relPath, sourceFile.text, flavor)) { + continue; + } + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + pushFinding( + "quality", + sourceFile, + relPath, + flavor, + "quality.dead-code.toolchain", + input.dead_code.level || "warn", + `${scriptLabel(flavor)} compiler reports dead or unused code: ${message}`, + typeof diagnostic.start === "number" ? diagnostic.start : 0, + ); + } +} + +function isDeadCodeDiagnostic(diagnostic) { + switch (diagnostic.code) { + case 6133: // declared but never read + case 6192: // all imports in import declaration are unused + case 6196: // declared but never used + case 7027: // unreachable code detected + return true; + default: + return false; + } +} + +function deadCodeEnabledForFlavor(flavor) { + const language = String(input.target_language || "").toLowerCase(); + if (flavor === "javascript") { + return language === "javascript" || language === "js" || language === "jsx"; + } + return language === "typescript" || language === "ts" || language === "tsx"; +} + +function shouldSkipDeadCodePath(relPath, sourceText, flavor) { + if (matchesAnyPathPattern(relPath, flavor === "javascript" ? input.dead_code.javascript_ignore_paths : input.dead_code.typescript_ignore_paths)) { + return true; + } + if (!input.dead_code.include_tests && isTestOrStoryPath(relPath)) { + return true; + } + if (isGeneratedSource(relPath, sourceText)) { + return true; + } + return false; +} + +function isTestOrStoryPath(relPath) { + const lower = relPath.toLowerCase(); + const base = path.basename(lower); + return lower.startsWith("__tests__/") || + lower.startsWith("test/") || + lower.startsWith("tests/") || + lower.startsWith("fixtures/") || + lower.startsWith("fixture/") || + lower.includes("/__tests__/") || + lower.includes("/test/") || + lower.includes("/tests/") || + lower.includes("/fixtures/") || + lower.includes("/fixture/") || + base.includes(".test.") || + base.includes(".spec.") || + base.includes(".stories.") || + base.includes(".story."); +} + +function isGeneratedSource(relPath, sourceText) { + const lower = relPath.toLowerCase(); + if (lower.includes("/generated/") || lower.includes("/__generated__/") || lower.endsWith(".generated.ts") || lower.endsWith(".generated.tsx") || lower.endsWith(".generated.js") || lower.endsWith(".generated.jsx")) { + return true; + } + const header = sourceText.slice(0, 4096); + return /code generated/i.test(header) && /do not edit/i.test(header); +} + +function matchesAnyPathPattern(relPath, patterns) { + if (!Array.isArray(patterns)) { + return false; + } + for (const pattern of patterns) { + if (matchesPathPattern(relPath, String(pattern || ""))) { + return true; + } + } + return false; +} + +function matchesPathPattern(relPath, pattern) { + const normalizedPattern = normalizePath(pattern.trim()).replace(/\\/g, "/"); + if (!normalizedPattern) { + return false; + } + if (normalizedPattern === relPath) { + return true; + } + if (normalizedPattern.endsWith("/**")) { + const prefix = normalizedPattern.slice(0, -3); + return relPath === prefix || relPath.startsWith(prefix + "/"); + } + if (normalizedPattern.includes("*")) { + const regex = new RegExp("^" + globPatternToRegExp(normalizedPattern) + "$"); + return regex.test(relPath); + } + return relPath.startsWith(normalizedPattern.endsWith("/") ? normalizedPattern : normalizedPattern + "/"); +} + +function globPatternToRegExp(pattern) { + let out = ""; + for (let index = 0; index < pattern.length; index++) { + const char = pattern[index]; + if (char !== "*") { + out += escapeRegExp(char); + continue; + } + if (pattern[index + 1] === "*") { + out += ".*"; + index++; + continue; + } + out += "[^/]*"; + } + return out; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function analyzeModuleName(sourceFile, relPath) { const moduleName = normalizedModuleName(relPath); for (const forbidden of input.forbidden_package_names || []) { diff --git a/internal/codeguard/checks/support/typescript_semantic_runner_dead_code_reports.js b/internal/codeguard/checks/support/typescript_semantic_runner_dead_code_reports.js new file mode 100644 index 0000000..e28c13a --- /dev/null +++ b/internal/codeguard/checks/support/typescript_semantic_runner_dead_code_reports.js @@ -0,0 +1,423 @@ +function analyzeDeadCodeReports(programs) { + if (!input.dead_code || !input.dead_code.enabled) { + return; + } + const reports = activeReportPaths(); + if (reports.length === 0) { + return; + } + const sourceFiles = sourceFilesByRelPath(programs); + for (const reportPath of reports) { + const data = readJSONFile(reportPath); + if (!data) { + continue; + } + if (isEsbuildMetafile(data)) { + analyzeEsbuildMetafile(data, sourceFiles); + continue; + } + if (isWebpackStats(data)) { + analyzeWebpackStats(data, sourceFiles); + continue; + } + if (isRollupBundleReport(data)) { + analyzeRollupBundleReport(data, sourceFiles); + } + } +} + +function sourceFilesByRelPath(programs) { + const sourceFiles = new Map(); + for (const program of programs) { + for (const sourceFile of program.getSourceFiles()) { + if (!isAnalyzableSourceFile(sourceFile)) { + continue; + } + const relPath = normalizePath(path.relative(targetPath, sourceFile.fileName)); + const flavor = scriptFlavor(relPath); + if (!flavor || !deadCodeEnabledForFlavor(flavor) || shouldSkipDeadCodePath(relPath, sourceFile.text, flavor)) { + continue; + } + if (!sourceFiles.has(relPath)) { + sourceFiles.set(relPath, sourceFile); + } + } + } + return sourceFiles; +} + +function activeReportPaths() { + return resolveConfiguredFiles(activeDeadCodeList("reports"), [".json"], () => true); +} + +function normalizeReportSourcePath(value) { + if (typeof value !== "string") { + return ""; + } + let normalized = value.trim(); + if (!normalized) { + return ""; + } + normalized = normalized.split("!").pop(); + normalized = normalized.split("?")[0].split("#")[0]; + normalized = normalized.replace(/^webpack:\/\/[^/]+\//, ""); + normalized = normalized.replace(/^\.\//, ""); + const dotSlash = normalized.indexOf("/./"); + if (dotSlash >= 0) { + normalized = normalized.slice(dotSlash + 3); + } + normalized = normalizePath(normalized); + if (path.isAbsolute(normalized)) { + if (!isWithinTarget(normalized)) { + return ""; + } + normalized = normalizePath(path.relative(targetPath, normalized)); + } + normalized = normalized.replace(/^\.\//, ""); + if (!scriptFlavor(normalized) || normalized.startsWith("../") || isVendorPath(normalized)) { + return ""; + } + return normalized; +} + +function readJSONFile(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + results.debug.push(`dead_code_report_parse_failed:${normalizePath(path.relative(targetPath, filePath))}`); + return null; + } +} + +function readOptionalJSONFile(filePath) { + try { + if (!ts.sys.fileExists(filePath)) { + return null; + } + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + return null; + } +} + +function isEsbuildMetafile(data) { + return !!data && + typeof data === "object" && + data.inputs && + typeof data.inputs === "object" && + data.outputs && + typeof data.outputs === "object"; +} + +function analyzeEsbuildMetafile(data, sourceFiles) { + const inputBytesByRelPath = new Map(); + for (const output of Object.values(data.outputs || {})) { + if (!output || typeof output !== "object" || !output.inputs || typeof output.inputs !== "object") { + continue; + } + for (const [rawInputPath, inputMeta] of Object.entries(output.inputs)) { + const relPath = normalizeReportSourcePath(rawInputPath); + if (!relPath || !sourceFiles.has(relPath)) { + continue; + } + const bytes = typeof inputMeta.bytesInOutput === "number" ? inputMeta.bytesInOutput : null; + if (bytes === null) { + continue; + } + const current = inputBytesByRelPath.get(relPath); + inputBytesByRelPath.set(relPath, current === undefined ? bytes : Math.max(current, bytes)); + } + } + for (const [relPath, maxBytesInOutput] of inputBytesByRelPath.entries()) { + if (maxBytesInOutput !== 0) { + continue; + } + const sourceFile = sourceFiles.get(relPath); + const flavor = scriptFlavor(relPath); + if (!isBundlerDeadCodeCandidate(sourceFile, relPath, { allowExports: true })) { + continue; + } + pushFinding( + "quality", + sourceFile, + relPath, + flavor, + "quality.dead-code.toolchain", + input.dead_code.level || "warn", + `${scriptLabel(flavor)} bundler metafile reports this module was tree-shaken out of every configured output`, + 0, + ); + } +} + +function isWebpackStats(data) { + return !!data && + typeof data === "object" && + (Array.isArray(data.modules) || Array.isArray(data.children)); +} + +function analyzeWebpackStats(data, sourceFiles) { + for (const moduleInfo of collectWebpackModules(data)) { + if (!moduleInfo || moduleInfo.orphan !== true) { + continue; + } + const relPath = normalizeWebpackModulePath(moduleInfo); + if (!relPath || !sourceFiles.has(relPath)) { + continue; + } + const sourceFile = sourceFiles.get(relPath); + const flavor = scriptFlavor(relPath); + if (!isBundlerDeadCodeCandidate(sourceFile, relPath, { allowExports: true })) { + continue; + } + pushFinding( + "quality", + sourceFile, + relPath, + flavor, + "quality.dead-code.toolchain", + input.dead_code.level || "warn", + `${scriptLabel(flavor)} webpack stats mark this module as orphaned from emitted chunks`, + 0, + ); + } +} + +function isRollupBundleReport(data) { + if (!data || typeof data !== "object") { + return false; + } + return collectRollupChunks(data).length > 0; +} + +function analyzeRollupBundleReport(data, sourceFiles) { + const renderedLengthByRelPath = new Map(); + for (const chunk of collectRollupChunks(data)) { + if (!chunk.modules || typeof chunk.modules !== "object") { + continue; + } + for (const [rawModulePath, moduleInfo] of Object.entries(chunk.modules)) { + const relPath = normalizeReportSourcePath(rawModulePath); + if (!relPath || !sourceFiles.has(relPath) || !moduleInfo || typeof moduleInfo !== "object") { + continue; + } + const renderedLength = typeof moduleInfo.renderedLength === "number" ? moduleInfo.renderedLength : null; + if (renderedLength === null) { + continue; + } + const current = renderedLengthByRelPath.get(relPath); + renderedLengthByRelPath.set(relPath, current === undefined ? renderedLength : Math.max(current, renderedLength)); + } + } + for (const [relPath, maxRenderedLength] of renderedLengthByRelPath.entries()) { + if (maxRenderedLength !== 0) { + continue; + } + const sourceFile = sourceFiles.get(relPath); + const flavor = scriptFlavor(relPath); + if (!isBundlerDeadCodeCandidate(sourceFile, relPath, { allowExports: true })) { + continue; + } + pushFinding( + "quality", + sourceFile, + relPath, + flavor, + "quality.dead-code.toolchain", + input.dead_code.level || "warn", + `${scriptLabel(flavor)} Rollup/Vite artifact reports this module rendered zero bytes in every configured output`, + 0, + ); + } +} + +function collectRollupChunks(value) { + const chunks = []; + const visit = (node) => { + if (!node || typeof node !== "object") { + return; + } + if (Array.isArray(node)) { + for (const item of node) { + visit(item); + } + return; + } + if (node.type === "chunk" && node.modules && typeof node.modules === "object") { + chunks.push(node); + } + for (const key of ["output", "outputs", "chunks", "bundle"]) { + if (Array.isArray(node[key]) || node[key] && typeof node[key] === "object") { + visit(node[key]); + } + } + }; + visit(value); + return chunks; +} + +function collectWebpackModules(value) { + const modules = []; + function collect(node) { + if (!node || typeof node !== "object") { + return; + } + if (Array.isArray(node.modules)) { + modules.push(...node.modules); + for (const child of node.modules) { + collect(child); + } + } + if (Array.isArray(node.children)) { + for (const child of node.children) { + collect(child); + } + } + } + collect(value); + return modules; +} + +function normalizeWebpackModulePath(moduleInfo) { + for (const field of ["identifier", "name", "nameForCondition"]) { + const relPath = normalizeReportSourcePath(moduleInfo[field]); + if (relPath) { + return relPath; + } + } + return ""; +} + +function isBundlerDeadCodeCandidate(sourceFile, relPath, options) { + if (isPackageEntrypoint(relPath) || isFrameworkConventionPath(relPath) || isVendorPath(relPath)) { + return false; + } + if (!options.allowExports && hasTopLevelExport(sourceFile)) { + return false; + } + if (hasTopLevelSideEffectStatement(sourceFile)) { + return false; + } + return hasRuntimeDeclaration(sourceFile); +} + +function hasTopLevelExport(sourceFile) { + return sourceFile.statements.some((statement) => { + const flags = ts.getCombinedModifierFlags(statement); + return (flags & ts.ModifierFlags.Export) !== 0 || ts.isExportDeclaration(statement) || ts.isExportAssignment(statement); + }); +} + +function hasRuntimeDeclaration(sourceFile) { + return sourceFile.statements.some((statement) => + ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isVariableStatement(statement)); +} + +function hasTopLevelSideEffectStatement(sourceFile) { + return sourceFile.statements.some((statement) => { + if (ts.isImportDeclaration(statement) || ts.isImportEqualsDeclaration(statement)) { + return !statement.importClause; + } + if (ts.isExpressionStatement(statement) || ts.isAwaitExpression(statement)) { + return true; + } + if (ts.isForStatement(statement) || ts.isForInStatement(statement) || ts.isForOfStatement(statement) || + ts.isWhileStatement(statement) || ts.isDoStatement(statement) || ts.isIfStatement(statement) || + ts.isSwitchStatement(statement) || ts.isTryStatement(statement) || ts.isThrowStatement(statement)) { + return true; + } + return false; + }); +} + +function isPackageEntrypoint(relPath) { + const entrypoints = packageEntrypoints(); + return entrypoints.has(relPath) || entrypoints.has(stripKnownScriptExtension(relPath)); +} + +let cachedPackageEntrypoints = null; + +function packageEntrypoints() { + if (cachedPackageEntrypoints !== null) { + return cachedPackageEntrypoints; + } + const entrypoints = new Set(); + addConfiguredEntrypoints(entrypoints); + const pkg = readOptionalJSONFile(path.join(targetPath, "package.json")); + if (pkg && typeof pkg === "object") { + addPackageEntrypointValue(entrypoints, pkg.main); + addPackageEntrypointValue(entrypoints, pkg.module); + addPackageEntrypointValue(entrypoints, pkg.browser); + addPackageEntrypointValue(entrypoints, pkg.exports); + } + cachedPackageEntrypoints = entrypoints; + return entrypoints; +} + +function addConfiguredEntrypoints(entrypoints) { + for (const entrypoint of activeDeadCodeList("entrypoints")) { + const relPath = normalizePath(String(entrypoint || "").trim()).replace(/^\.\//, ""); + if (relPath) { + entrypoints.add(relPath); + entrypoints.add(stripKnownScriptExtension(relPath)); + } + } +} + +function addPackageEntrypointValue(entrypoints, value) { + if (!value) { + return; + } + if (typeof value === "string") { + const relPath = normalizeReportSourcePath(value); + if (relPath) { + entrypoints.add(relPath); + entrypoints.add(stripKnownScriptExtension(relPath)); + } + return; + } + if (Array.isArray(value)) { + for (const item of value) { + addPackageEntrypointValue(entrypoints, item); + } + return; + } + if (typeof value === "object") { + for (const nested of Object.values(value)) { + addPackageEntrypointValue(entrypoints, nested); + } + } +} + +function stripKnownScriptExtension(relPath) { + for (const suffix of [".d.ts", ".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".mts", ".cts"]) { + if (relPath.endsWith(suffix)) { + return relPath.slice(0, -suffix.length); + } + } + return relPath; +} + +function isFrameworkConventionPath(relPath) { + const normalized = relPath.toLowerCase(); + const base = path.basename(normalized); + if (/^(page|layout|route|loading|error|not-found)\.[cm]?[jt]sx?$/.test(base) && + /(^|\/)(app|pages)(\/|$)/.test(normalized)) { + return true; + } + if (/^pages\/api\//.test(normalized) || /^src\/pages\/api\//.test(normalized)) { + return true; + } + return false; +} + +function isVendorPath(relPath) { + const normalized = relPath.toLowerCase(); + return normalized.includes("/node_modules/") || + normalized.startsWith("node_modules/") || + normalized.includes("/vendor/") || + normalized.startsWith("vendor/"); +} diff --git a/internal/codeguard/checks/support/typescript_semantic_types.go b/internal/codeguard/checks/support/typescript_semantic_types.go index f7729e5..8381738 100644 --- a/internal/codeguard/checks/support/typescript_semantic_types.go +++ b/internal/codeguard/checks/support/typescript_semantic_types.go @@ -16,6 +16,7 @@ type TypeScriptSemanticResults struct { type typeScriptSemanticInput struct { TypeScriptLibPath string `json:"typescript_lib_path"` TargetPath string `json:"target_path"` + TargetLanguage string `json:"target_language"` SourceFiles []string `json:"source_files"` ForbiddenPackageNames []string `json:"forbidden_package_names"` MaxMethodsPerType int `json:"max_methods_per_type"` @@ -23,14 +24,30 @@ type typeScriptSemanticInput struct { MaxFunctionLines int `json:"max_function_lines"` MaxParameters int `json:"max_parameters"` MaxCyclomaticComplexity int `json:"max_cyclomatic_complexity"` + DeadCode typeScriptDeadCode `json:"dead_code"` TaintModel TypeScriptTaintModel `json:"taint_model"` TaintMaxDepth int `json:"taint_max_depth"` } +type typeScriptDeadCode struct { + Enabled bool `json:"enabled"` + Level string `json:"level"` + IncludeTests bool `json:"include_tests"` + TypeScriptProjects []string `json:"typescript_projects"` + TypeScriptEntrypoints []string `json:"typescript_entrypoints"` + TypeScriptReports []string `json:"typescript_reports"` + TypeScriptIgnorePaths []string `json:"typescript_ignore_paths"` + JavaScriptProjects []string `json:"javascript_projects"` + JavaScriptEntrypoints []string `json:"javascript_entrypoints"` + JavaScriptReports []string `json:"javascript_reports"` + JavaScriptIgnorePaths []string `json:"javascript_ignore_paths"` +} + func newTypeScriptSemanticInput(target core.TargetConfig, cfg core.Config, libPath string, sourceFiles []string) typeScriptSemanticInput { return typeScriptSemanticInput{ TypeScriptLibPath: libPath, TargetPath: target.Path, + TargetLanguage: NormalizedLanguage(target.Language), SourceFiles: semanticSourcePaths(target.Path, sourceFiles), ForbiddenPackageNames: append([]string(nil), cfg.Checks.DesignRules.ForbiddenPackageNames...), MaxMethodsPerType: cfg.Checks.DesignRules.MaxMethodsPerType, @@ -38,11 +55,43 @@ func newTypeScriptSemanticInput(target core.TargetConfig, cfg core.Config, libPa MaxFunctionLines: cfg.Checks.QualityRules.MaxFunctionLines, MaxParameters: cfg.Checks.QualityRules.MaxParameters, MaxCyclomaticComplexity: cfg.Checks.QualityRules.MaxCyclomaticComplexity, + DeadCode: newTypeScriptDeadCodeInput(cfg.Checks.QualityRules.DeadCode), TaintModel: defaultTypeScriptTaintModel(), TaintMaxDepth: cfg.Checks.SecurityRules.TypeScriptTaintMaxDepth, } } +func newTypeScriptDeadCodeInput(cfg core.QualityDeadCodeConfig) typeScriptDeadCode { + enabled := false + if cfg.Enabled != nil && *cfg.Enabled { + switch NormalizedLanguage(cfg.Mode) { + case "", "toolchain": + enabled = true + } + } + level := "warn" + if NormalizedLanguage(cfg.Level) == "fail" { + level = "fail" + } + includeTests := false + if cfg.IncludeTests != nil { + includeTests = *cfg.IncludeTests + } + return typeScriptDeadCode{ + Enabled: enabled, + Level: level, + IncludeTests: includeTests, + TypeScriptProjects: append([]string(nil), cfg.TypeScript.Projects...), + TypeScriptEntrypoints: append([]string(nil), cfg.TypeScript.Entrypoints...), + TypeScriptReports: append([]string(nil), cfg.TypeScript.Reports...), + TypeScriptIgnorePaths: append([]string(nil), cfg.TypeScript.IgnorePaths...), + JavaScriptProjects: append([]string(nil), cfg.JavaScript.Projects...), + JavaScriptEntrypoints: append([]string(nil), cfg.JavaScript.Entrypoints...), + JavaScriptReports: append([]string(nil), cfg.JavaScript.Reports...), + JavaScriptIgnorePaths: append([]string(nil), cfg.JavaScript.IgnorePaths...), + } +} + func semanticSourcePaths(root string, sourceFiles []string) []string { if sourceFiles == nil { return nil diff --git a/internal/codeguard/config/validate.go b/internal/codeguard/config/validate.go index 09b8be7..1a3c7f6 100644 --- a/internal/codeguard/config/validate.go +++ b/internal/codeguard/config/validate.go @@ -23,6 +23,7 @@ func Validate(cfg core.Config) error { validateAIChangeRisk(cfg.Checks.QualityRules.AIChangeRisk), validateRiskScoring(cfg.Checks.QualityRules.RiskScoring), validateAIChecks(cfg.Checks.QualityRules.AIChecks), + validateQualityDeadCode(cfg.Checks.QualityRules.DeadCode), validateQualityNaming(cfg.Checks.QualityRules.Naming), validateSupplyChainRules(cfg.Checks.SupplyChainRules), validateDeliveryRules(cfg.Checks.DeliveryRules), diff --git a/internal/codeguard/config/validate_helpers.go b/internal/codeguard/config/validate_helpers.go index d2a7247..402e636 100644 --- a/internal/codeguard/config/validate_helpers.go +++ b/internal/codeguard/config/validate_helpers.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "path/filepath" "regexp" "strings" @@ -27,6 +28,120 @@ func validateRuleSeverity(rule core.CustomRuleConfig) error { } } +func validateQualityDeadCode(cfg core.QualityDeadCodeConfig) error { + switch strings.TrimSpace(strings.ToLower(cfg.Mode)) { + case "", "off", "toolchain": + default: + return fmt.Errorf("quality_rules.dead_code.mode must be off or toolchain, got %q", cfg.Mode) + } + switch strings.TrimSpace(strings.ToLower(cfg.Level)) { + case "", "warn", "fail": + default: + return fmt.Errorf("quality_rules.dead_code.level must be warn or fail, got %q", cfg.Level) + } + return firstError( + validateGoDeadCode("quality_rules.dead_code.go", cfg.Go), + validateRustDeadCode("quality_rules.dead_code.rust", cfg.Rust), + validateCPPDeadCode("quality_rules.dead_code.cpp", cfg.CPP), + validatePythonDeadCode("quality_rules.dead_code.python", cfg.Python), + validateScriptDeadCode("quality_rules.dead_code.typescript", cfg.TypeScript), + validateScriptDeadCode("quality_rules.dead_code.javascript", cfg.JavaScript), + ) +} + +func validateGoDeadCode(field string, cfg core.GoDeadCodeToolchainConfig) error { + return firstError( + validateRelativePatternList(field+".packages", cfg.Packages), + validateRelativePatternList(field+".entrypoints", cfg.Entrypoints), + validateRelativePatternList(field+".ignore_paths", cfg.IgnorePaths), + ) +} + +func validateRustDeadCode(field string, cfg core.RustDeadCodeConfig) error { + return firstError( + validateRelativePatternList(field+".crates", cfg.Crates), + validateNameList(field+".packages", cfg.Packages), + validateRelativePatternList(field+".entrypoints", cfg.Entrypoints), + validateRelativePatternList(field+".reports", cfg.Reports), + validateRelativePatternList(field+".ignore_paths", cfg.IgnorePaths), + ) +} + +func validateCPPDeadCode(field string, cfg core.CPPDeadCodeConfig) error { + return firstError( + validateOptionalRelativePattern(field+".compile_commands", cfg.CompileCommands), + validateRelativePatternList(field+".entrypoints", cfg.Entrypoints), + validateRelativePatternList(field+".reports", cfg.Reports), + validateRelativePatternList(field+".ignore_paths", cfg.IgnorePaths), + ) +} + +func validatePythonDeadCode(field string, cfg core.PythonDeadCodeConfig) error { + return firstError( + validateRelativePatternList(field+".modules", cfg.Modules), + validateRelativePatternList(field+".entrypoints", cfg.Entrypoints), + validateRelativePatternList(field+".reports", cfg.Reports), + validateRelativePatternList(field+".ignore_paths", cfg.IgnorePaths), + ) +} + +func validateRelativePatternList(field string, patterns []string) error { + for idx, pattern := range patterns { + if err := validateRelativePattern(fmt.Sprintf("%s[%d]", field, idx), pattern); err != nil { + return err + } + } + return nil +} + +func validateNameList(field string, names []string) error { + for idx, name := range names { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return fmt.Errorf("%s[%d] must not be blank", field, idx) + } + if strings.HasPrefix(trimmed, "-") { + return fmt.Errorf("%s[%d] must be a name, not a flag", field, idx) + } + } + return nil +} + +func validateScriptDeadCode(field string, cfg core.ScriptDeadCodeConfig) error { + if err := validateRelativePatternList(field+".projects", cfg.Projects); err != nil { + return err + } + if err := validateRelativePatternList(field+".entrypoints", cfg.Entrypoints); err != nil { + return err + } + if err := validateRelativePatternList(field+".reports", cfg.Reports); err != nil { + return err + } + return validateRelativePatternList(field+".ignore_paths", cfg.IgnorePaths) +} + +func validateRelativePattern(field string, pattern string) error { + trimmed := strings.TrimSpace(pattern) + if trimmed == "" { + return fmt.Errorf("%s must not be blank", field) + } + clean := filepath.Clean(filepath.FromSlash(trimmed)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("%s must be relative to the target", field) + } + if strings.HasPrefix(trimmed, "-") { + return fmt.Errorf("%s must be a path or glob, not a flag", field) + } + return nil +} + +func validateOptionalRelativePattern(field string, pattern string) error { + if strings.TrimSpace(pattern) == "" { + return nil + } + return validateRelativePattern(field, pattern) +} + func validateRuleMatchers(rule core.CustomRuleConfig) error { if len(rule.Paths) > 0 || strings.TrimSpace(rule.PathRegex) != "" || strings.TrimSpace(rule.ContentRegex) != "" || strings.TrimSpace(rule.NaturalLanguage) != "" || len(rule.FileExtensions) > 0 { return nil diff --git a/internal/codeguard/core/config_rule_types.go b/internal/codeguard/core/config_rule_types.go index 3550e7b..cb45b19 100644 --- a/internal/codeguard/core/config_rule_types.go +++ b/internal/codeguard/core/config_rule_types.go @@ -7,6 +7,7 @@ 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"` + DeadCode QualityDeadCodeConfig `json:"dead_code,omitempty" yaml:"dead_code,omitempty"` AIProvenance AIProvenanceConfig `json:"ai_provenance,omitempty" yaml:"ai_provenance,omitempty"` AIChangeRisk AIChangeRiskConfig `json:"ai_change_risk,omitempty" yaml:"ai_change_risk,omitempty"` RiskScoring RiskScoringConfig `json:"risk_scoring,omitempty" yaml:"risk_scoring,omitempty"` @@ -17,6 +18,66 @@ type QualityRulesConfig struct { Naming QualityNamingConfig `json:"naming,omitempty" yaml:"naming,omitempty"` } +// QualityDeadCodeConfig enables toolchain-backed dead-code evidence. It is +// intentionally separate from AIChecks.DeadCode, which stays a fast heuristic +// pass for obvious unreachable code and AI leftovers. +type QualityDeadCodeConfig struct { + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + Mode string `json:"mode,omitempty" yaml:"mode,omitempty"` + Level string `json:"level,omitempty" yaml:"level,omitempty"` + IncludeTests *bool `json:"include_tests,omitempty" yaml:"include_tests,omitempty"` + Go GoDeadCodeToolchainConfig `json:"go,omitempty" yaml:"go,omitempty"` + Rust RustDeadCodeConfig `json:"rust,omitempty" yaml:"rust,omitempty"` + CPP CPPDeadCodeConfig `json:"cpp,omitempty" yaml:"cpp,omitempty"` + Python PythonDeadCodeConfig `json:"python,omitempty" yaml:"python,omitempty"` + TypeScript ScriptDeadCodeConfig `json:"typescript,omitempty" yaml:"typescript,omitempty"` + JavaScript ScriptDeadCodeConfig `json:"javascript,omitempty" yaml:"javascript,omitempty"` +} + +// GoDeadCodeToolchainConfig asks CodeGuard to build configured Go entrypoints +// with the Go linker dependency graph enabled and compare linked functions +// against private package-level declarations in the target. +type GoDeadCodeToolchainConfig struct { + Entrypoints []string `json:"entrypoints,omitempty" yaml:"entrypoints,omitempty"` + Packages []string `json:"packages,omitempty" yaml:"packages,omitempty"` + IgnorePaths []string `json:"ignore_paths,omitempty" yaml:"ignore_paths,omitempty"` + Linker *bool `json:"linker,omitempty" yaml:"linker,omitempty"` +} + +type RustDeadCodeConfig struct { + // Crates names target-relative crate roots or Cargo.toml files to check. + // Empty runs cargo check from the target root. + Crates []string `json:"crates,omitempty" yaml:"crates,omitempty"` + // Packages is passed as fixed cargo --package values after validation. + // Values are package names, not commands or flags. + Packages []string `json:"packages,omitempty" yaml:"packages,omitempty"` + Entrypoints []string `json:"entrypoints,omitempty" yaml:"entrypoints,omitempty"` + Reports []string `json:"reports,omitempty" yaml:"reports,omitempty"` + IgnorePaths []string `json:"ignore_paths,omitempty" yaml:"ignore_paths,omitempty"` +} + +type CPPDeadCodeConfig struct { + CompileCommands string `json:"compile_commands,omitempty" yaml:"compile_commands,omitempty"` + Entrypoints []string `json:"entrypoints,omitempty" yaml:"entrypoints,omitempty"` + Reports []string `json:"reports,omitempty" yaml:"reports,omitempty"` + IgnorePaths []string `json:"ignore_paths,omitempty" yaml:"ignore_paths,omitempty"` + Graph *bool `json:"graph,omitempty" yaml:"graph,omitempty"` +} + +type PythonDeadCodeConfig struct { + Modules []string `json:"modules,omitempty" yaml:"modules,omitempty"` + Entrypoints []string `json:"entrypoints,omitempty" yaml:"entrypoints,omitempty"` + Reports []string `json:"reports,omitempty" yaml:"reports,omitempty"` + IgnorePaths []string `json:"ignore_paths,omitempty" yaml:"ignore_paths,omitempty"` +} + +type ScriptDeadCodeConfig struct { + Projects []string `json:"projects,omitempty" yaml:"projects,omitempty"` + Entrypoints []string `json:"entrypoints,omitempty" yaml:"entrypoints,omitempty"` + Reports []string `json:"reports,omitempty" yaml:"reports,omitempty"` + IgnorePaths []string `json:"ignore_paths,omitempty" yaml:"ignore_paths,omitempty"` +} + type QualityNamingConfig struct { Glossary map[string]QualityNamingGlossaryEntry `json:"glossary,omitempty" yaml:"glossary,omitempty"` AllowedAbbreviations []string `json:"allowed_abbreviations,omitempty" yaml:"allowed_abbreviations,omitempty"` diff --git a/internal/codeguard/rules/catalog_fix_templates_quality.go b/internal/codeguard/rules/catalog_fix_templates_quality.go index 8cfeead..214e189 100644 --- a/internal/codeguard/rules/catalog_fix_templates_quality.go +++ b/internal/codeguard/rules/catalog_fix_templates_quality.go @@ -10,6 +10,7 @@ var qualityFixTemplates = map[string]core.FixTemplate{ "quality.cpp.compiler-parse": {Kind: guided, Text: "Generate compile_commands.json (for CMake, configure with CMAKE_EXPORT_COMPILE_COMMANDS=ON), then fix the first syntax diagnostic reported by clang++. Codeguard deliberately ignores compiler executables, plugins, response files, and output flags stored in the database."}, "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.dead-code.toolchain": {Kind: guided, Text: "Confirm the toolchain evidence against the configured entrypoints before deleting code. Go private declarations absent from linked binaries and Rust dead_code/unreachable_code compiler diagnostics are usually safe cleanup candidates; Rust artifact symbol report findings are stronger when the report comes from an unstripped debug or release artifact with useful symbols. C++ linker map, nm, or dumpbin-style discard findings are stronger than C++ graph findings; graph-only findings mean the file is outside the configured include/module closure. TypeScript/JavaScript bundler findings require explicit zero-byte, zero-rendered-length, or orphan evidence. Python trusted-report findings are stronger than static import-graph findings. Exported APIs, trait/virtual dispatch, templates, macros, reflection hooks, init-time registration, plugins, generated wiring, cfg/test-only code, proc macros, C ABI callbacks, and cgo callbacks need caller or runtime evidence.\n\nBefore:\nfunc unusedHelper() string { return \"legacy\" }\n\nAfter:\n// delete unusedHelper, or add a real call path/test if it is an intended extension point"}, "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"}, diff --git a/internal/codeguard/rules/catalog_quality.go b/internal/codeguard/rules/catalog_quality.go index b63e7c7..f0fefa5 100644 --- a/internal/codeguard/rules/catalog_quality.go +++ b/internal/codeguard/rules/catalog_quality.go @@ -41,6 +41,16 @@ var qualityCatalog = map[string]core.RuleMetadata{ Description: "Fails when a Go source file cannot be parsed.", HowToFix: "Fix the syntax error so the file parses cleanly.", }, + "quality.dead-code.toolchain": { + ID: "quality.dead-code.toolchain", + Section: "Code Quality", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelCommandDriven, + LanguageCoverage: core.FixedRuleLanguageCoverage(core.RuleLanguageGo, core.RuleLanguageRust, core.RuleLanguageCPP, core.RuleLanguagePython, core.RuleLanguageTypeScript, core.RuleLanguageJavaScript), + Title: "Toolchain dead code", + Description: "Warns when opt-in toolchain or language-graph evidence indicates code is unreachable from configured entrypoints. Go uses linker dependency evidence; Rust uses cargo/rustc dead_code and unreachable_code diagnostics plus configured artifact symbol reports; C++ ingests trusted linker map, nm, and dumpbin-style artifacts when configured and otherwise falls back to target-local include and C++20 named-module graph evidence; Python uses static import reachability plus trusted reports; TypeScript and JavaScript use compiler semantic diagnostics plus trusted bundler reports.", + HowToFix: "Remove the unreachable code, wire it into a real entrypoint, or document and suppress dynamic, public API, registration, plugin, or callback usage that the configured toolchain evidence cannot prove.", + }, "quality.max-file-lines": { ID: "quality.max-file-lines", Section: "Code Quality", diff --git a/internal/codeguard/runner/support/exec_limited.go b/internal/codeguard/runner/support/exec_limited.go index a612d96..d818d3b 100644 --- a/internal/codeguard/runner/support/exec_limited.go +++ b/internal/codeguard/runner/support/exec_limited.go @@ -8,8 +8,15 @@ import ( ) func RunLimitedCommand(ctx context.Context, dir string, maxOutputBytes int, name string, args ...string) (string, error) { + return RunLimitedCommandWithEnv(ctx, dir, nil, maxOutputBytes, name, args...) +} + +func RunLimitedCommandWithEnv(ctx context.Context, dir string, env []string, maxOutputBytes int, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // caller validates untrusted args before invoking this bounded subprocess helper cmd.Dir = dir + if len(env) > 0 { + cmd.Env = env + } var buf bytes.Buffer limited := NewLimitedBufferWriter(&buf, maxOutputBytes) cmd.Stdout = limited diff --git a/pkg/codeguard/sdk_types_config_checks.go b/pkg/codeguard/sdk_types_config_checks.go index 216cb2c..382d2fe 100644 --- a/pkg/codeguard/sdk_types_config_checks.go +++ b/pkg/codeguard/sdk_types_config_checks.go @@ -3,6 +3,12 @@ package codeguard import "github.com/devr-tools/codeguard/internal/codeguard/core" type QualityRulesConfig = core.QualityRulesConfig +type QualityDeadCodeConfig = core.QualityDeadCodeConfig +type GoDeadCodeToolchainConfig = core.GoDeadCodeToolchainConfig +type RustDeadCodeConfig = core.RustDeadCodeConfig +type CPPDeadCodeConfig = core.CPPDeadCodeConfig +type PythonDeadCodeConfig = core.PythonDeadCodeConfig +type ScriptDeadCodeConfig = core.ScriptDeadCodeConfig type QualityNamingConfig = core.QualityNamingConfig type QualityNamingGlossaryEntry = core.QualityNamingGlossaryEntry type CPPToolingConfig = core.CPPToolingConfig diff --git a/tests/checks/change_test.go b/tests/checks/change_test.go index 2ef4815..621adb3 100644 --- a/tests/checks/change_test.go +++ b/tests/checks/change_test.go @@ -129,6 +129,38 @@ func TestChangeDetectsMixedAndTooManyConcerns(t *testing.T) { assertFindingRulePresent(t, report, "Change Safety", "change.too-many-concerns") } +func TestChangeAllowsCohesiveDaintreeLMPFeatureAcrossLayers(t *testing.T) { + dir := initChangeRepo(t) + fixtures := map[string]string{ + "src/app/(app)/apps/lmp/_components/account-detail/feature-access-tab.tsx": "export function FeatureAccessTab() { return null }\n", + "src/app/api/apps/lmp/new-editor/route.ts": "export async function POST() { return Response.json({ ok: true }) }\n", + "src/lib/statsig/override-service.ts": "export async function applyStatsigOverride() { return true }\n", + "src/lib/statsig/override-store.ts": "export async function markOverrideCleared() { return true }\n", + "supabase/migrations/20260824000000_lmp_new_editor_access.sql": "select 1;\n", + } + for rel, content := range fixtures { + writeFile(t, filepath.Join(dir, rel), content) + } + commitAll(t, dir, "base") + for rel, content := range fixtures { + writeFile(t, filepath.Join(dir, rel), content+"\n") + } + + cfg := changeSafetyTestConfig("change-lmp-cohesive", dir) + cfg.Targets[0].Language = "typescript" + cfg.Checks.ChangeRules.MaxChangedFiles = 20 + cfg.Checks.ChangeRules.MaxChangedDirectories = 20 + cfg.Checks.ChangeRules.MaxChangedLines = 100 + cfg.Checks.ChangeRules.MaxPublicInterfacesChanged = 20 + cfg.Checks.ChangeRules.MaxConcernFamilies = 2 + cfg.Checks.ChangeRules.MinTestToProductionRatioPercent = 0 + + report := runChangeDiff(t, cfg) + + assertFindingRuleAbsent(t, report, "Change Safety", "change.mixed-concerns") + assertFindingRuleAbsent(t, report, "Change Safety", "change.too-many-concerns") +} + func TestChangeDetectsMoveMixedWithBehaviorAndNoVerification(t *testing.T) { dir := initChangeRepo(t) writeFile(t, filepath.Join(dir, "service", "handler.go"), "package service\n\nfunc Handle() int {\n\treturn 1\n}\n") diff --git a/tests/checks/data_multilang_test.go b/tests/checks/data_multilang_test.go index c5bd59e..2da5427 100644 --- a/tests/checks/data_multilang_test.go +++ b/tests/checks/data_multilang_test.go @@ -180,6 +180,23 @@ async function handleMessage(email, event) { assertFindingRuleAbsent(t, report, "Data Correctness", "data.exactly-once-assumption") } +func TestDataTypeScriptAcceptsSupabaseMaybeSingleBounds(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "override-store.ts"), ` +async function markOverrideCleared(update) { + const { data, error } = await update.select("id").maybeSingle(); + return { data, error }; +} +`) + + report, err := codeguard.Run(context.Background(), dataLangConfig("data-ts-supabase-single", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Data Correctness", "data.unbounded-read") +} + func TestDataJavaScriptUsesSameDetector(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "service.js"), ` diff --git a/tests/checks/observability_test.go b/tests/checks/observability_test.go index 2b0758b..5068dd3 100644 --- a/tests/checks/observability_test.go +++ b/tests/checks/observability_test.go @@ -128,6 +128,30 @@ func CheckoutHandler(ctx context.Context, orderID string, err error) { assertFindingRuleAbsent(t, report, "Observability", "observability.critical-path-uninstrumented") } +func TestObservabilityAllowsWithTrackedRouteInstrumentation(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "app", "api", "apps", "lmp", "new-editor", "route.ts"), `function actor() { + return { id: "operator" }; +} + +async function postHandler(request: Request) { + actor(); + await request.json(); + return Response.json({ ok: true }); +} + +export const POST = withTrackedRoute({ surface: "lmp.new-editor-access" }, postHandler); +declare function withTrackedRoute(options: unknown, handler: unknown): unknown; +`) + + report, err := codeguard.Run(context.Background(), observabilityConfig("observability-tracked-route", dir, "typescript")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Observability", "observability.critical-path-uninstrumented") +} + func TestObservabilityDetectorMatrixAcrossLanguages(t *testing.T) { cases := []observabilityMatrixCase{ observabilityCase("unstructured", "observability.unstructured-log", map[string]string{ diff --git a/tests/checks/quality_cpp_toolchain_dead_code_test.go b/tests/checks/quality_cpp_toolchain_dead_code_test.go new file mode 100644 index 0000000..63bd738 --- /dev/null +++ b/tests/checks/quality_cpp_toolchain_dead_code_test.go @@ -0,0 +1,255 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestCPPToolchainDeadCodeIsOptIn(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "#include \"live.cpp\"\nint main() { return live(); }\n") + writeFile(t, filepath.Join(dir, "src", "live.cpp"), "int live() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "orphan.cpp"), "int orphan() { return 2; }\n") + + report := runCPPToolchainDeadCodeScan(t, dir, false) + + assertFindingRuleAbsent(t, report, "Code Quality", goToolchainDeadCodeRuleID) +} + +func TestCPPToolchainDeadCodeReportsSourcesOutsideEntrypointGraph(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "#include \"live.cpp\"\nint main() { return live(); }\n") + writeFile(t, filepath.Join(dir, "src", "live.cpp"), "int live() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "orphan.cpp"), "int orphan() { return 2; }\n") + + report := runCPPToolchainDeadCodeScan(t, dir, true) + + assertToolchainDeadCodeFindingPath(t, report, "src/orphan.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/live.cpp") +} + +func TestCPPToolchainDeadCodeReportsUnreachableNamedModules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "import live;\nint main() { return live_value(); }\n") + writeFile(t, filepath.Join(dir, "src", "live.cppm"), "export module live;\nexport int live_value() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "orphan.cppm"), "export module orphan;\nexport int orphan_value() { return 2; }\n") + + report := runCPPToolchainDeadCodeScan(t, dir, true) + + assertToolchainDeadCodeFindingPath(t, report, "src/orphan.cppm") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/live.cppm") +} + +func TestCPPToolchainDeadCodeReportsDiscardedLinkerSymbols(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "int main() { return 0; }\n") + writeFile(t, filepath.Join(dir, "src", "orphan.cpp"), "static int orphan_fn() { return 2; }\n") + writeFile(t, filepath.Join(dir, "build", "app.map"), "ld.lld: removing unused section '.text._Z9orphan_fnv'\n") + + report := runCPPToolchainDeadCodeScanWithConfig(t, dir, func(cfg *codeguard.Config) { + graph := false + cfg.Checks.QualityRules.DeadCode.CPP.Graph = &graph + cfg.Checks.QualityRules.DeadCode.CPP.Reports = []string{"build/app.map"} + }) + + assertToolchainDeadCodeFindingPath(t, report, "src/orphan.cpp") +} + +func TestCPPToolchainDeadCodeReportsDiscardedLinkerObjects(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "int main() { return 0; }\n") + writeFile(t, filepath.Join(dir, "src", "orphan.cpp"), "int orphan_api() { return 2; }\n") + writeFile(t, filepath.Join(dir, "build", "app.map"), "linker: discarded object src/orphan.o\n") + + report := runCPPToolchainDeadCodeScanWithConfig(t, dir, func(cfg *codeguard.Config) { + graph := false + cfg.Checks.QualityRules.DeadCode.CPP.Graph = &graph + cfg.Checks.QualityRules.DeadCode.CPP.Reports = []string{"build/*.map"} + }) + + assertToolchainDeadCodeFindingPath(t, report, "src/orphan.cpp") +} + +func TestCPPToolchainDeadCodeLiveSymbolReportSuppressesGraphOnlyFinding(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "int main() { return 0; }\n") + writeFile(t, filepath.Join(dir, "src", "library_api.cpp"), "int public_api() { return 1; }\n") + writeFile(t, filepath.Join(dir, "build", "nm.txt"), "0000000000001000 T _Z10public_apiv\n") + + report := runCPPToolchainDeadCodeScanWithConfig(t, dir, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.CPP.Reports = []string{"build/nm.txt"} + }) + + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/library_api.cpp") +} + +func TestCPPToolchainDeadCodeMachODeadStripMapReportsDiscardedSymbol(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "int main() { return 0; }\n") + writeFile(t, filepath.Join(dir, "src", "dead.cpp"), "static int apple_dead() { return 1; }\n") + writeFile(t, filepath.Join(dir, "build", "app.map"), "<> [ 1] _Z10apple_deadv\n") + + report := runCPPToolchainDeadCodeScanWithConfig(t, dir, func(cfg *codeguard.Config) { + graph := false + cfg.Checks.QualityRules.DeadCode.CPP.Graph = &graph + cfg.Checks.QualityRules.DeadCode.CPP.Reports = []string{"build/app.map"} + }) + + assertToolchainDeadCodeFindingPath(t, report, "src/dead.cpp") +} + +func TestCPPToolchainDeadCodeDumpbinLiveSymbolSuppressesGraphOnlyFinding(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "int main() { return 0; }\n") + writeFile(t, filepath.Join(dir, "src", "library_api.cpp"), "int public_api() { return 1; }\n") + writeFile(t, filepath.Join(dir, "build", "dumpbin.txt"), "004 00000000 SECT3 notype () External | ?public_api@@YAHXZ (int __cdecl public_api(void))\n") + + report := runCPPToolchainDeadCodeScanWithConfig(t, dir, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.CPP.Reports = []string{"build/dumpbin.txt"} + }) + + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/library_api.cpp") +} + +func TestCPPToolchainDeadCodeIgnoresWeakDiscardNoise(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "int main() { return 0; }\n") + writeFile(t, filepath.Join(dir, "src", "weak.cpp"), "static int weak_hook() { return 1; }\n") + writeFile(t, filepath.Join(dir, "build", "app.map"), "ld.lld: removing unused weak section '.text._Z9weak_hookv'\n") + + report := runCPPToolchainDeadCodeScanWithConfig(t, dir, func(cfg *codeguard.Config) { + graph := false + cfg.Checks.QualityRules.DeadCode.CPP.Graph = &graph + cfg.Checks.QualityRules.DeadCode.CPP.Reports = []string{"build/app.map"} + }) + + assertFindingRuleAbsent(t, report, "Code Quality", goToolchainDeadCodeRuleID) +} + +func TestCPPToolchainDeadCodeAvoidsKnownCPPFalsePositives(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "int main() { return 0; }\n") + writeFile(t, filepath.Join(dir, "tests", "helper_test.cpp"), "int helper_only_for_tests() { return 1; }\n") + writeFile(t, filepath.Join(dir, "third_party", "vendor.cpp"), "int vendor_api() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "generated.cpp"), "// Code generated by fixture. DO NOT EDIT.\nint generated_hook() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "abi.cpp"), "extern \"C\" int callback_entrypoint() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "plugin.cpp"), "REGISTER_PLUGIN(\"demo\", make_plugin);\nint make_plugin() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "templated.cpp"), "template \nT identity(T value) { return value; }\n") + writeFile(t, filepath.Join(dir, "src", "virtuals.cpp"), "struct Base { virtual int run(); };\nint Base::run() { return 1; }\n") + + report := runCPPToolchainDeadCodeScan(t, dir, true) + + assertFindingRuleAbsent(t, report, "Code Quality", goToolchainDeadCodeRuleID) +} + +func TestCPPToolchainDeadCodeCoversFalsePositiveCorpusAndReportsRealOrphan(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "#include \"live.cpp\"\nint main() { return live(); }\n") + writeFile(t, filepath.Join(dir, "src", "live.cpp"), "int live() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "public_api.cpp"), "int exported_library_api() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "callback.cpp"), "extern \"C\" int callback_entrypoint() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "plugin.cpp"), "REGISTER_PLUGIN(\"demo\", make_plugin);\nint make_plugin() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "template.cpp"), "template \nT identity(T value) { return value; }\n") + writeFile(t, filepath.Join(dir, "src", "virtuals.cpp"), "struct Base { virtual int run(); };\nint Base::run() { return 1; }\n") + writeFile(t, filepath.Join(dir, "generated", "schema.cpp"), "// Code generated by fixture. DO NOT EDIT.\nint generated_hook() { return 1; }\n") + writeFile(t, filepath.Join(dir, "third_party", "vendor.cpp"), "int vendor_api() { return 1; }\n") + writeFile(t, filepath.Join(dir, "tests", "helper_test.cpp"), "int helper_only_for_tests() { return 1; }\n") + writeFile(t, filepath.Join(dir, "src", "orphan.cpp"), "int orphan() { return 2; }\n") + writeFile(t, filepath.Join(dir, "build", "nm.txt"), "0000000000001000 T _Z20exported_library_apiv\n") + + report := runCPPToolchainDeadCodeScanWithConfig(t, dir, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.CPP.Reports = []string{"build/nm.txt"} + }) + + assertToolchainDeadCodeFindingPath(t, report, "src/orphan.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/live.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/public_api.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/callback.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/plugin.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/template.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/virtuals.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "generated/schema.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "third_party/vendor.cpp") + assertToolchainDeadCodeFindingPathAbsent(t, report, "tests/helper_test.cpp") +} + +func TestCPPToolchainDeadCodeDoesNotTreatStrippedArtifactNoiseAsDiscardProof(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "main.cpp"), "int main() { return 0; }\n") + writeFile(t, filepath.Join(dir, "src", "ambiguous.cpp"), "static int maybe_inlined_or_stripped() { return 1; }\n") + writeFile(t, filepath.Join(dir, "build", "link.txt"), "strip: removed local symbols from src/ambiguous.o\n") + + report := runCPPToolchainDeadCodeScanWithConfig(t, dir, func(cfg *codeguard.Config) { + graph := false + cfg.Checks.QualityRules.DeadCode.CPP.Graph = &graph + cfg.Checks.QualityRules.DeadCode.CPP.Reports = []string{"build/link.txt"} + }) + + assertFindingRuleAbsent(t, report, "Code Quality", goToolchainDeadCodeRuleID) +} + +func runCPPToolchainDeadCodeScan(t *testing.T, dir string, enabled bool) codeguard.Report { + t.Helper() + cfg := cppToolchainDeadCodeConfig(dir, enabled) + return runCPPToolchainDeadCodeScanConfig(t, cfg) +} + +func runCPPToolchainDeadCodeScanWithConfig(t *testing.T, dir string, mutate func(*codeguard.Config)) codeguard.Report { + t.Helper() + cfg := cppToolchainDeadCodeConfig(dir, true) + mutate(&cfg) + return runCPPToolchainDeadCodeScanConfig(t, cfg) +} + +func runCPPToolchainDeadCodeScanConfig(t *testing.T, cfg codeguard.Config) codeguard.Report { + t.Helper() + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + return report +} + +func cppToolchainDeadCodeConfig(dir string, enabled bool) codeguard.Config { + disabled := false + cfg := codeguard.ExampleConfig() + cfg.Name = "cpp-toolchain-dead-code-test" + cfg.Targets = []codeguard.TargetConfig{{ + Name: "cpp", + Path: dir, + Language: "cpp", + Entrypoints: []string{"src/main.cpp"}, + }} + cfg.Checks.Quality = true + cfg.Checks.Design = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + cfg.Checks.Performance = &disabled + cfg.Checks.SupplyChain = false + cfg.Checks.Delivery = &disabled + cfg.Checks.Reliability = &disabled + cfg.Checks.Data = &disabled + cfg.Checks.Observability = &disabled + cfg.Checks.Operations = &disabled + cfg.Checks.Change = &disabled + cfg.Checks.Contracts = &disabled + cfg.Checks.Context = &disabled + cfg.Checks.QualityRules.AIChecks.DeadCode = &disabled + if enabled { + graph := true + cfg.Checks.QualityRules.DeadCode = codeguard.QualityDeadCodeConfig{ + Enabled: &enabled, + Mode: "toolchain", + Level: "warn", + CPP: codeguard.CPPDeadCodeConfig{ + Entrypoints: []string{"src/main.cpp"}, + Graph: &graph, + }, + } + } + return cfg +} diff --git a/tests/checks/quality_go_toolchain_dead_code_test.go b/tests/checks/quality_go_toolchain_dead_code_test.go new file mode 100644 index 0000000..c6b9ae3 --- /dev/null +++ b/tests/checks/quality_go_toolchain_dead_code_test.go @@ -0,0 +1,369 @@ +package checks_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +const goToolchainDeadCodeRuleID = "quality.dead-code.toolchain" + +func TestGoToolchainDeadCodeIsOptIn(t *testing.T) { + dir := t.TempDir() + writeGoToolchainModule(t, dir) + writeFile(t, filepath.Join(dir, "cmd", "app", "main.go"), `package main + +import "example.com/toolchaindeadcode/internal/service" + +func main() { + service.Run() +} +`) + writeFile(t, filepath.Join(dir, "internal", "service", "service.go"), `package service + +func Run() {} + +func unusedPrivateHelper() {} +`) + + report := runGoToolchainDeadCodeScan(t, dir, false) + + assertFindingRuleAbsent(t, report, "Code Quality", goToolchainDeadCodeRuleID) +} + +func TestGoToolchainDeadCodeReportsUnusedPrivateGoFunctions(t *testing.T) { + dir := t.TempDir() + writeGoToolchainModule(t, dir) + writeFile(t, filepath.Join(dir, "cmd", "app", "main.go"), `package main + +import "example.com/toolchaindeadcode/internal/service" + +func main() { + service.Run() +} +`) + writeFile(t, filepath.Join(dir, "internal", "service", "service.go"), `package service + +func Run() int { + return usedHelper() +} + +func usedHelper() int { return 1 } + +func unusedPrivateHelper() int { return 2 } +`) + + report := runGoToolchainDeadCodeScan(t, dir, true) + + assertToolchainDeadCodeFindingPath(t, report, "internal/service/service.go") +} + +func TestGoToolchainDeadCodeReportsPackagesUnreachableFromGoEntrypoints(t *testing.T) { + dir := t.TempDir() + writeGoToolchainModule(t, dir) + writeFile(t, filepath.Join(dir, "cmd", "app", "main.go"), `package main + +import "example.com/toolchaindeadcode/internal/live" + +func main() { + live.Run() +} +`) + writeFile(t, filepath.Join(dir, "internal", "live", "live.go"), `package live + +func Run() {} +`) + writeFile(t, filepath.Join(dir, "internal", "orphan", "orphan.go"), `package orphan + +func Run() {} +`) + + report := runGoToolchainDeadCodeScan(t, dir, true) + + assertToolchainDeadCodeFindingPath(t, report, "internal/orphan/orphan.go") + assertToolchainDeadCodeFindingPathAbsent(t, report, "internal/live/live.go") +} + +func TestGoToolchainDeadCodeAvoidsKnownEntrypointAndAPIFalsePositives(t *testing.T) { + dir := t.TempDir() + writeGoToolchainModule(t, dir) + writeFile(t, filepath.Join(dir, "cmd", "app", "main.go"), `package main + +import "example.com/toolchaindeadcode/internal/service" + +func main() { + service.Run() +} +`) + writeFile(t, filepath.Join(dir, "internal", "service", "service.go"), `package service + +import "reflect" + +type Runner interface { + run() string +} + +type serviceRunner struct{} + +func init() { + register("startup", exportedByDirective) +} + +func Run() string { + var runner Runner = serviceRunner{} + reflect.ValueOf(reflectedHook).Pointer() + hookRegistry["stringRegisteredHook"]() + return runner.run() +} + +func ExportedAPI() string { return "public" } + +func (serviceRunner) run() string { return "interface implementation" } + +var hookRegistry = map[string]func(){ + "stringRegisteredHook": stringRegisteredHook, +} + +func reflectedHook() {} + +func stringRegisteredHook() {} + +func exportedByDirective() {} + +func register(name string, hook func()) {} +`) + writeFile(t, filepath.Join(dir, "internal", "service", "service_test.go"), `package service + +import "testing" + +func TestRun(t *testing.T) { + if Run() == "" { + t.Fatal("empty") + } +} + +func helperOnlyForTests() {} +`) + + report := runGoToolchainDeadCodeScan(t, dir, true) + + assertFindingRuleAbsent(t, report, "Code Quality", goToolchainDeadCodeRuleID) +} + +func TestGoToolchainDeadCodeAvoidsGoLinknameDirectiveFalsePositive(t *testing.T) { + dir := t.TempDir() + writeGoToolchainModule(t, dir) + writeFile(t, filepath.Join(dir, "cmd", "app", "main.go"), `package main + +import _ "example.com/toolchaindeadcode/internal/hooks" + +func main() {} +`) + writeFile(t, filepath.Join(dir, "internal", "hooks", "hooks.go"), `package hooks + +import _ "unsafe" + +//go:linkname linkedRuntimeHook example.com/toolchaindeadcode/internal/runtime.linkedRuntimeHook +func linkedRuntimeHook() {} +`) + + report := runGoToolchainDeadCodeScan(t, dir, true) + + assertFindingRuleAbsent(t, report, "Code Quality", goToolchainDeadCodeRuleID) +} + +func TestGoToolchainDeadCodeCoversRealWorldFalsePositiveSurfaces(t *testing.T) { + dir := t.TempDir() + writeGoToolchainModule(t, dir) + writeFile(t, filepath.Join(dir, "cmd", "app", "main.go"), `package main + +import ( + _ "example.com/toolchaindeadcode/internal/plugins" + "example.com/toolchaindeadcode/internal/service" +) + +func main() { + service.Run() +} +`) + writeFile(t, filepath.Join(dir, "internal", "service", "service.go"), `package service + +var callbacks = map[string]func(){ + "startup": startupHook, +} + +func Run() { + registerCallback(callbackHook) + callbacks["startup"]() +} + +func PublicAPI() string { return "public" } + +func callbackHook() {} + +func startupHook() {} + +func registerCallback(fn func()) { fn() } +`) + writeFile(t, filepath.Join(dir, "internal", "plugins", "plugins.go"), `package plugins + +func init() { + register("demo", pluginHook) +} + +func pluginHook() {} + +func register(name string, hook func()) { hook() } +`) + writeFile(t, filepath.Join(dir, "internal", "generated", "generated.go"), `// Code generated by fixture. DO NOT EDIT. +package generated + +func GeneratedHook() {} +`) + writeFile(t, filepath.Join(dir, "vendor", "example.com", "legacy", "legacy.go"), `package legacy + +func VendorOnly() {} +`) + writeFile(t, filepath.Join(dir, "internal", "service", "service_test.go"), `package service + +func helperOnlyForTests() {} +`) + writeFile(t, filepath.Join(dir, "internal", "orphan", "orphan.go"), `package orphan + +func cleanup() {} +`) + + report := runGoToolchainDeadCodeScan(t, dir, true) + + assertToolchainDeadCodeFindingPath(t, report, "internal/orphan/orphan.go") + assertToolchainDeadCodeFindingPathAbsent(t, report, "internal/service/service.go") + assertToolchainDeadCodeFindingPathAbsent(t, report, "internal/plugins/plugins.go") + assertToolchainDeadCodeFindingPathAbsent(t, report, "internal/generated/generated.go") + assertToolchainDeadCodeFindingPathAbsent(t, report, "vendor/example.com/legacy/legacy.go") + assertToolchainDeadCodeFindingPathAbsent(t, report, "internal/service/service_test.go") +} + +func writeGoToolchainModule(t *testing.T, dir string) { + t.Helper() + writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/toolchaindeadcode\n\ngo 1.23.0\n") +} + +func runGoToolchainDeadCodeScan(t *testing.T, dir string, enabled bool) codeguard.Report { + t.Helper() + writeGoToolchainDeadCodeConfig(t, dir, enabled) + cfg, err := codeguard.LoadConfigFile(filepath.Join(dir, "codeguard.yaml")) + if err != nil { + t.Fatalf("load config: %v", err) + } + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + return report +} + +func writeGoToolchainDeadCodeConfig(t *testing.T, dir string, enabled bool) { + t.Helper() + deadCodeBlock := "" + if enabled { + deadCodeBlock = ` + dead_code: + enabled: true + mode: toolchain + level: warn + include_tests: false + go: + packages: + - ./... + entrypoints: + - ./cmd/app + linker: true +` + } + writeFile(t, filepath.Join(dir, "codeguard.yaml"), `name: go-toolchain-dead-code-test +targets: + - name: repo + path: . + language: go + entrypoints: + - ./cmd/app +checks: + quality: true + design: false + security: false + prompts: false + ci: false + performance: false + supply_chain: false + delivery: false + reliability: false + data: false + observability: false + operations: false + change: false + contracts: false + context: false + quality_rules: + ai_checks: + dead_code: false +`+deadCodeBlock+`output: + format: text +`) +} + +func assertToolchainDeadCodeFindingPath(t *testing.T, report codeguard.Report, wantPath string) { + t.Helper() + for _, finding := range toolchainDeadCodeFindings(report) { + if filepath.ToSlash(finding.Path) == wantPath { + return + } + } + t.Fatalf("missing %s finding at %q; got %s", goToolchainDeadCodeRuleID, wantPath, formatFindings(report)) +} + +func assertToolchainDeadCodeFindingPathAbsent(t *testing.T, report codeguard.Report, path string) { + t.Helper() + for _, finding := range toolchainDeadCodeFindings(report) { + if filepath.ToSlash(finding.Path) == path { + t.Fatalf("unexpected %s finding at %q: %+v", goToolchainDeadCodeRuleID, path, finding) + } + } +} + +func toolchainDeadCodeFindings(report codeguard.Report) []codeguard.Finding { + var findings []codeguard.Finding + for _, section := range report.Sections { + if section.Name != "Code Quality" { + continue + } + for _, finding := range section.Findings { + if finding.RuleID == goToolchainDeadCodeRuleID { + findings = append(findings, finding) + } + } + } + return findings +} + +func formatFindings(report codeguard.Report) string { + var out strings.Builder + for _, section := range report.Sections { + for _, finding := range section.Findings { + out.WriteString(section.Name) + out.WriteString(" ") + out.WriteString(finding.RuleID) + out.WriteString(" ") + out.WriteString(finding.Path) + out.WriteString(":") + out.WriteString(finding.Message) + out.WriteString("\n") + } + } + if out.Len() == 0 { + return "" + } + return out.String() +} diff --git a/tests/checks/quality_python_toolchain_dead_code_test.go b/tests/checks/quality_python_toolchain_dead_code_test.go new file mode 100644 index 0000000..66502a6 --- /dev/null +++ b/tests/checks/quality_python_toolchain_dead_code_test.go @@ -0,0 +1,372 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestPythonToolchainDeadCodeReportsUnreachableModules(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "__init__.py"), "") + writeFile(t, filepath.Join(dir, "app", "__main__.py"), `from app.live import run + +run() +`) + writeFile(t, filepath.Join(dir, "app", "live.py"), `def run(): + return "ok" +`) + writeFile(t, filepath.Join(dir, "app", "orphan.py"), `def cleanup(): + return "legacy" +`) + + report := runPythonToolchainDeadCodeScan(t, dir, true) + + assertToolchainDeadCodePath(t, report, "app/orphan.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/live.py") +} + +func TestPythonToolchainDeadCodeAvoidsKnownFalsePositives(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "__init__.py"), "") + writeFile(t, filepath.Join(dir, "app", "__main__.py"), `from app.live import run + +run() +`) + writeFile(t, filepath.Join(dir, "app", "live.py"), `def run(): + return "ok" +`) + writeFile(t, filepath.Join(dir, "app", "dynamic.py"), `import importlib + +handler = importlib.import_module("app.handlers") +`) + writeFile(t, filepath.Join(dir, "app", "routes.py"), `@app.route("/health") +def health(): + return "ok" +`) + writeFile(t, filepath.Join(dir, "tests", "test_helpers.py"), `def test_only_helper(): + return "test" +`) + writeFile(t, filepath.Join(dir, "app", "generated.py"), `# Code generated by test fixture. DO NOT EDIT. +def generated_helper(): + return "generated" +`) + writeFile(t, filepath.Join(dir, "app", "ignored", "legacy.py"), `def ignored(): + return "ignored" +`) + + report := runPythonToolchainDeadCodeScanWithConfig(t, dir, true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.Python.IgnorePaths = []string{"app/ignored/**"} + }) + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.dead-code.toolchain") +} + +func TestPythonToolchainDeadCodeDiscoversPyprojectScripts(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pyproject.toml"), `[project.scripts] +serve = "app.cli:main" +`) + writeFile(t, filepath.Join(dir, "app", "__init__.py"), "") + writeFile(t, filepath.Join(dir, "app", "cli.py"), `from app.live import run + +def main(): + return run() +`) + writeFile(t, filepath.Join(dir, "app", "live.py"), `from app.loader import load + +def run(): + return load("plugins").activate() +`) + writeFile(t, filepath.Join(dir, "app", "orphan.py"), `def cleanup(): + return "legacy" +`) + + report := runPythonToolchainDeadCodeScanWithConfig(t, dir, true, func(cfg *codeguard.Config) { + cfg.Targets[0].Entrypoints = nil + }) + + assertToolchainDeadCodePath(t, report, "app/orphan.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/cli.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/live.py") +} + +func TestPythonToolchainDeadCodeTreatsDynamicImportsExportsAndFrameworkFilesAsLive(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "setup.cfg"), `[options.entry_points] +console_scripts = + worker = app.loader:main +`) + writeFile(t, filepath.Join(dir, "app", "__init__.py"), `from . import public + +__all__ = ["public"] +`) + writeFile(t, filepath.Join(dir, "app", "loader.py"), `import importlib + +def main(): + return importlib.import_module("app.plugin") +`) + writeFile(t, filepath.Join(dir, "app", "plugin.py"), `def activate(): + return "plugin" +`) + writeFile(t, filepath.Join(dir, "app", "public.py"), `def facade(): + return "public" +`) + writeFile(t, filepath.Join(dir, "app", "views.py"), `from django.urls import path + +urlpatterns = [path("health", lambda request: "ok")] +`) + writeFile(t, filepath.Join(dir, "app", "orphan.py"), `def cleanup(): + return "legacy" +`) + + report := runPythonToolchainDeadCodeScanWithConfig(t, dir, true, func(cfg *codeguard.Config) { + cfg.Targets[0].Entrypoints = nil + }) + + assertToolchainDeadCodePath(t, report, "app/orphan.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/loader.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/plugin.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/public.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/views.py") +} + +func TestPythonToolchainDeadCodeTreatsUnknownDynamicImportsAsPackageLive(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "__init__.py"), "") + writeFile(t, filepath.Join(dir, "app", "__main__.py"), `from app.loader import run + +run("plugin") +`) + writeFile(t, filepath.Join(dir, "app", "loader.py"), `import importlib + +def run(name): + return importlib.import_module (name) +`) + writeFile(t, filepath.Join(dir, "app", "plugin.py"), `def activate(): + return "loaded dynamically" +`) + writeFile(t, filepath.Join(dir, "app", "legacy.py"), `def cleanup(): + return "maybe plugin" +`) + + report := runPythonToolchainDeadCodeScan(t, dir, true) + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.dead-code.toolchain") +} + +func TestPythonToolchainDeadCodeDiscoversSetupCfgGuiScriptsAndMultilinePackageExports(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "setup.cfg"), `[options.entry_points] +gui_scripts = + viewer = app.gui:main +`) + writeFile(t, filepath.Join(dir, "app", "__init__.py"), `__all__: list[str] = [ + "public", +] +`) + writeFile(t, filepath.Join(dir, "app", "gui.py"), `from app.live import run + +def main(): + return run() +`) + writeFile(t, filepath.Join(dir, "app", "live.py"), `from app.loader import load + +def run(): + return load("plugins").activate() +`) + writeFile(t, filepath.Join(dir, "app", "public.py"), `def facade(): + return "exported" +`) + writeFile(t, filepath.Join(dir, "app", "orphan.py"), `def cleanup(): + return "legacy" +`) + + report := runPythonToolchainDeadCodeScanWithConfig(t, dir, true, func(cfg *codeguard.Config) { + cfg.Targets[0].Entrypoints = nil + }) + + assertToolchainDeadCodePath(t, report, "app/orphan.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/gui.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/live.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/public.py") +} + +func TestPythonToolchainDeadCodeCoversFrameworkRuntimeAndVendorFalsePositives(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pyproject.toml"), `[project.gui-scripts] +viewer = "app.gui:main" +`) + writeFile(t, filepath.Join(dir, "app", "__init__.py"), `from . import exported + +__all__ = [ + "exported", +] +`) + writeFile(t, filepath.Join(dir, "app", "gui.py"), `from app.live import run + +def main(): + return run() +`) + writeFile(t, filepath.Join(dir, "app", "live.py"), `from app.loader import load + +def run(): + return load("plugins").activate() +`) + writeFile(t, filepath.Join(dir, "app", "loader.py"), `import importlib + +def load(name): + return importlib.import_module("app.plugins") +`) + writeFile(t, filepath.Join(dir, "app", "runtime.py"), `def load(name): + return __import__("app.plugins") +`) + writeFile(t, filepath.Join(dir, "app", "plugins.py"), `def activate(): + return "plugin" +`) + writeFile(t, filepath.Join(dir, "app", "routes.py"), `from fastapi import FastAPI + +app = FastAPI() + +@app.get("/health") +def health(): + return "ok" +`) + writeFile(t, filepath.Join(dir, "app", "tasks.py"), `from celery import shared_task + +@shared_task +def rebuild(): + return "queued" +`) + writeFile(t, filepath.Join(dir, "app", "signals.py"), `from django.dispatch import receiver + +@receiver(object()) +def on_event(sender, **kwargs): + return None +`) + writeFile(t, filepath.Join(dir, "app", "exported.py"), `def facade(): + return "exported" +`) + writeFile(t, filepath.Join(dir, "vendor", "legacy.py"), `def vendor_helper(): + return "vendor" +`) + writeFile(t, filepath.Join(dir, "app", "orphan.py"), `def cleanup(): + return "legacy" +`) + + report := runPythonToolchainDeadCodeScanWithConfig(t, dir, true, func(cfg *codeguard.Config) { + cfg.Targets[0].Entrypoints = nil + }) + + assertToolchainDeadCodePath(t, report, "app/orphan.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/gui.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/live.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/loader.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/runtime.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/plugins.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/routes.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/tasks.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/signals.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/exported.py") + assertToolchainDeadCodeFindingPathAbsent(t, report, "vendor/legacy.py") +} + +func TestPythonToolchainDeadCodeReportsTrustedVultureText(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "__init__.py"), "") + writeFile(t, filepath.Join(dir, "app", "__main__.py"), `def main(): + return "ok" +`) + writeFile(t, filepath.Join(dir, "app", "legacy.py"), `def cleanup(): + return "legacy" +`) + writeFile(t, filepath.Join(dir, ".codeguard", "vulture.txt"), `app/legacy.py:1: unused function 'cleanup' (90% confidence) +`) + + report := runPythonToolchainDeadCodeScanWithConfig(t, dir, true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.Python.Reports = []string{".codeguard/vulture.txt"} + }) + + assertToolchainDeadCodePathMessageContains(t, report, "app/legacy.py", "trusted dead-code report") +} + +func TestPythonToolchainDeadCodeReportsTrustedJSONAndSkipsLowConfidence(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "__init__.py"), "") + writeFile(t, filepath.Join(dir, "app", "__main__.py"), `def main(): + return "ok" +`) + writeFile(t, filepath.Join(dir, "app", "dead.py"), `def unused(): + return "dead" +`) + writeFile(t, filepath.Join(dir, "app", "maybe.py"), `def maybe(): + return "dynamic" + +__all__ = ["maybe"] +`) + writeFile(t, filepath.Join(dir, ".codeguard", "dead-code.json"), `{ + "findings": [ + {"path": "app/dead.py", "line": 1, "kind": "unused function", "name": "unused", "confidence": 95}, + {"path": "app/maybe.py", "line": 1, "kind": "unused function", "name": "maybe", "confidence": 60} + ] +}`) + + report := runPythonToolchainDeadCodeScanWithConfig(t, dir, true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.Python.Reports = []string{".codeguard/dead-code.json"} + }) + + assertToolchainDeadCodePathMessageContains(t, report, "app/dead.py", "trusted dead-code report") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/maybe.py") +} + +func runPythonToolchainDeadCodeScan(t *testing.T, dir string, enabled bool) codeguard.Report { + t.Helper() + return runPythonToolchainDeadCodeScanWithConfig(t, dir, enabled, nil) +} + +func runPythonToolchainDeadCodeScanWithConfig(t *testing.T, dir string, enabled bool, mutate func(*codeguard.Config)) codeguard.Report { + t.Helper() + cfg := codeguard.ExampleConfig() + cfg.Name = "python-toolchain-dead-code-test" + cfg.Targets = []codeguard.TargetConfig{{ + Name: "py", + Path: dir, + Language: "python", + Entrypoints: []string{"app/__main__.py"}, + }} + cfg.Checks.Quality = true + cfg.Checks.Design = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + disabled := false + cfg.Checks.Performance = &disabled + cfg.Checks.SupplyChain = false + cfg.Checks.Delivery = &disabled + cfg.Checks.Reliability = &disabled + cfg.Checks.Data = &disabled + cfg.Checks.Observability = &disabled + cfg.Checks.Operations = &disabled + cfg.Checks.Change = &disabled + cfg.Checks.Contracts = &disabled + cfg.Checks.Context = &disabled + cfg.Checks.QualityRules.AIChecks.DeadCode = &disabled + if enabled { + enabled := true + cfg.Checks.QualityRules.DeadCode = codeguard.QualityDeadCodeConfig{ + Enabled: &enabled, + Mode: "toolchain", + Level: "warn", + } + } + if mutate != nil { + mutate(&cfg) + } + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + return report +} diff --git a/tests/checks/quality_rust_toolchain_dead_code_test.go b/tests/checks/quality_rust_toolchain_dead_code_test.go new file mode 100644 index 0000000..3aa88ec --- /dev/null +++ b/tests/checks/quality_rust_toolchain_dead_code_test.go @@ -0,0 +1,194 @@ +package checks_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestRustToolchainDeadCodeIsOptIn(t *testing.T) { + dir := t.TempDir() + writeRustToolchainCrate(t, dir) + installFakeCargo(t, `{"reason":"compiler-message","message":{"level":"error","message":"function `+"`unused_private`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/lib.rs","line_start":3,"column_start":4,"is_primary":true}]}}`) + + report := runScan(t, rustToolchainDeadCodeConfig(dir, false)) + + assertFindingRuleAbsent(t, report, "Code Quality", goToolchainDeadCodeRuleID) +} + +func TestRustToolchainDeadCodeReportsCargoDeadCodeDiagnostics(t *testing.T) { + dir := t.TempDir() + writeRustToolchainCrate(t, dir) + installFakeCargo(t, `{"reason":"compiler-message","message":{"level":"error","message":"function `+"`unused_private`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/lib.rs","line_start":3,"column_start":4,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`exported_api`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/lib.rs","line_start":5,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"warning","message":"unused variable","code":{"code":"unused_variables"},"spans":[{"file_name":"src/lib.rs","line_start":7,"column_start":9,"is_primary":true}]}}`) + + report := runScan(t, rustToolchainDeadCodeConfig(dir, true)) + findings := findingsForRule(report, goToolchainDeadCodeRuleID) + + if len(findings) != 1 { + t.Fatalf("toolchain dead-code findings = %d, want 1: %+v", len(findings), findings) + } + if findings[0].Path != "src/lib.rs" || findings[0].Line != 3 { + t.Fatalf("finding = %+v, want private src/lib.rs:3 diagnostic", findings[0]) + } +} + +func TestRustToolchainDeadCodeReportsArtifactReachability(t *testing.T) { + dir := t.TempDir() + writeRustToolchainCrate(t, dir) + writeFile(t, filepath.Join(dir, ".codeguard", "rust-symbols.txt"), `00000000 T rust_dead_code_test::used +`) + installFakeCargoSuccess(t) + + cfg := rustToolchainDeadCodeConfig(dir, true) + cfg.Checks.QualityRules.DeadCode.Rust.Reports = []string{".codeguard/rust-symbols.txt"} + report := runScan(t, cfg) + findings := findingsForRule(report, goToolchainDeadCodeRuleID) + + if len(findings) != 1 { + t.Fatalf("toolchain dead-code findings = %d, want 1: %+v", len(findings), findings) + } + if findings[0].Path != "src/lib.rs" || findings[0].Line != 3 { + t.Fatalf("finding = %+v, want artifact finding for src/lib.rs:3", findings[0]) + } + if findings[0].Metadata["evidence"] != "artifact-symbol-report" { + t.Fatalf("metadata = %+v, want artifact evidence", findings[0].Metadata) + } +} + +func TestRustToolchainDeadCodeAvoidsRealWorldFalsePositiveSurfaces(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "Cargo.toml"), `[package] +name = "rust-dead-code-test" +version = "0.1.0" +edition = "2021" +`) + writeFile(t, filepath.Join(dir, "src", "lib.rs"), `pub fn exported_api() {} + +#[no_mangle] +pub extern "C" fn ffi_entrypoint() {} + +#[cfg(test)] +fn test_helper() {} + +trait Handler { fn handle(&self); } +struct Job; +impl Handler for Job { + fn handle(&self) {} +} + + + + + + + + + + + + + + +fn truly_unused() {} +`) + writeFile(t, filepath.Join(dir, "src", "registry.rs"), `inventory::submit! { + Plugin::new() +} + +fn registered_plugin() {} +`) + writeFile(t, filepath.Join(dir, "generated", "bindings.rs"), `// Code generated by bindgen. DO NOT EDIT. +fn generated_helper() {} +`) + writeFile(t, filepath.Join(dir, "vendor", "ffi.rs"), `fn vendor_helper() {} +`) + writeFile(t, filepath.Join(dir, "tests", "integration.rs"), `fn integration_helper() {} +`) + writeFile(t, filepath.Join(dir, "examples", "demo.rs"), `fn example_helper() {} +`) + writeFile(t, filepath.Join(dir, "benches", "bench.rs"), `fn bench_helper() {} +`) + installFakeCargo(t, `{"reason":"compiler-message","message":{"level":"error","message":"function `+"`exported_api`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/lib.rs","line_start":1,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`ffi_entrypoint`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/lib.rs","line_start":4,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`test_helper`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/lib.rs","line_start":7,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"method `+"`handle`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/lib.rs","line_start":12,"column_start":5,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`truly_unused`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/lib.rs","line_start":28,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`registered_plugin`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"src/registry.rs","line_start":5,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`generated_helper`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"generated/bindings.rs","line_start":2,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`vendor_helper`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"vendor/ffi.rs","line_start":1,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`integration_helper`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"tests/integration.rs","line_start":1,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`example_helper`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"examples/demo.rs","line_start":1,"column_start":1,"is_primary":true}]}} +{"reason":"compiler-message","message":{"level":"error","message":"function `+"`bench_helper`"+` is never used","code":{"code":"dead_code"},"spans":[{"file_name":"benches/bench.rs","line_start":1,"column_start":1,"is_primary":true}]}}`) + + report := runScan(t, rustToolchainDeadCodeConfig(dir, true)) + findings := findingsForRule(report, goToolchainDeadCodeRuleID) + + if len(findings) != 1 { + t.Fatalf("toolchain dead-code findings = %d, want 1: %+v", len(findings), findings) + } + if findings[0].Path != "src/lib.rs" || findings[0].Line != 28 { + t.Fatalf("finding = %+v, want only private src/lib.rs:28 diagnostic", findings[0]) + } +} + +func writeRustToolchainCrate(t *testing.T, dir string) { + t.Helper() + writeFile(t, filepath.Join(dir, "Cargo.toml"), `[package] +name = "rust-dead-code-test" +version = "0.1.0" +edition = "2021" +`) + writeFile(t, filepath.Join(dir, "src", "lib.rs"), `pub fn used() {} + +fn unused_private() {} + +pub fn exported_api() {} +`) +} + +func rustToolchainDeadCodeConfig(dir string, enabled bool) codeguard.Config { + cfg := codeguard.ExampleConfig() + cfg.Name = "rust-toolchain-dead-code-test" + cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: "rust"}} + cfg.Checks.Quality = true + cfg.Checks.Design = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + cfg.Checks.Performance = boolValue(false) + cfg.Checks.SupplyChain = false + cfg.Checks.Delivery = boolValue(false) + cfg.Checks.Reliability = boolValue(false) + cfg.Checks.Data = boolValue(false) + cfg.Checks.Observability = boolValue(false) + cfg.Checks.Operations = boolValue(false) + cfg.Checks.Change = boolValue(false) + cfg.Checks.Contracts = boolValue(false) + cfg.Checks.Context = boolValue(false) + cfg.Checks.QualityRules.AIChecks.DeadCode = boolValue(false) + cfg.Cache.Enabled = boolValue(false) + if enabled { + cfg.Checks.QualityRules.DeadCode.Enabled = boolValue(true) + cfg.Checks.QualityRules.DeadCode.Mode = "toolchain" + cfg.Checks.QualityRules.DeadCode.Level = "warn" + } + return cfg +} + +func installFakeCargo(t *testing.T, output string) { + t.Helper() + binDir := t.TempDir() + writeExecutableFile(t, filepath.Join(binDir, "cargo"), "#!/bin/sh\ncat <<'JSON'\n"+output+"\nJSON\nexit 1\n") + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func installFakeCargoSuccess(t *testing.T) { + t.Helper() + binDir := t.TempDir() + writeExecutableFile(t, filepath.Join(binDir, "cargo"), "#!/bin/sh\nexit 0\n") + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/tests/checks/quality_script_toolchain_dead_code_test.go b/tests/checks/quality_script_toolchain_dead_code_test.go new file mode 100644 index 0000000..0900e7f --- /dev/null +++ b/tests/checks/quality_script_toolchain_dead_code_test.go @@ -0,0 +1,453 @@ +package checks_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +const scriptToolchainDeadCodeRuleID = "quality.dead-code.toolchain" + +func TestScriptToolchainDeadCodeIsOptIn(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "index.ts"), `export function run(): number { + return 1; +} + +function orphanHelper(): number { + return 2; +} +`) + + report := runScriptToolchainDeadCodeScan(t, dir, "typescript", false) + + assertFindingRuleAbsent(t, report, "Code Quality", scriptToolchainDeadCodeRuleID) +} + +func TestScriptToolchainDeadCodeReportsUnusedTypeScriptDeclarations(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "index.ts"), `export function run(): number { + return usedHelper(); +} + +function usedHelper(): number { + return 1; +} + +function orphanHelper(): number { + return 2; +} +`) + + report := runScriptToolchainDeadCodeScan(t, dir, "typescript", true) + + assertToolchainDeadCodePath(t, report, "src/index.ts") +} + +func TestScriptToolchainDeadCodeReportsUnreachableJavaScriptStatements(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "index.js"), `export function run() { + return 1; + console.log("never reached"); +} +`) + + report := runScriptToolchainDeadCodeScan(t, dir, "javascript", true) + + assertToolchainDeadCodePath(t, report, "src/index.js") +} + +func TestScriptToolchainDeadCodeUsesConfiguredTypeScriptProject(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions":{"allowJs":true,"checkJs":true,"noEmit":true},"include":["src/**/*.ts"]}`) + writeFile(t, filepath.Join(dir, "src", "index.ts"), `export function root(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "packages", "app", "tsconfig.json"), `{"compilerOptions":{"allowJs":true,"checkJs":true,"noEmit":true},"include":["src/**/*.ts"]}`) + writeFile(t, filepath.Join(dir, "packages", "app", "src", "app.ts"), `export function run(): number { + return 1; +} + +function orphanInConfiguredProject(): number { + return 2; +} +`) + + report := runScriptToolchainDeadCodeScanWithConfig(t, dir, "typescript", true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.TypeScript.Projects = []string{"packages/app/tsconfig.json"} + }) + + assertToolchainDeadCodePath(t, report, "packages/app/src/app.ts") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/index.ts") +} + +func TestScriptToolchainDeadCodeReportsEsbuildMetafileTreeShakenModule(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions":{"allowJs":true,"checkJs":true,"noEmit":true},"include":["src/**/*.ts"]}`) + writeFile(t, filepath.Join(dir, "src", "index.ts"), `export function run(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "shaken.ts"), `export function treeShaken(): number { + return 2; +} +`) + writeFile(t, filepath.Join(dir, "meta.json"), `{ + "inputs": { + "src/index.ts": {"bytes": 42}, + "src/shaken.ts": {"bytes": 50} + }, + "outputs": { + "dist/app.js": { + "entryPoint": "src/index.ts", + "inputs": { + "src/index.ts": {"bytesInOutput": 20}, + "src/shaken.ts": {"bytesInOutput": 0} + } + } + } +}`) + + report := runScriptToolchainDeadCodeScanWithConfig(t, dir, "typescript", true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.TypeScript.Reports = []string{"meta.json"} + }) + + assertToolchainDeadCodePathMessageContains(t, report, "src/shaken.ts", "bundler metafile") +} + +func TestScriptToolchainDeadCodeReportsWebpackOrphanJavaScriptModule(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "jsconfig.json"), `{"compilerOptions":{"allowJs":true,"checkJs":true,"noEmit":true},"include":["src/**/*.js"]}`) + writeFile(t, filepath.Join(dir, "src", "index.js"), `export function run() { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "orphan.js"), `export function orphaned() { + return 2; +} +`) + writeFile(t, filepath.Join(dir, "webpack-stats.json"), `{ + "modules": [ + {"name": "./src/index.js", "orphan": false}, + {"name": "./src/orphan.js", "orphan": true} + ] +}`) + + report := runScriptToolchainDeadCodeScanWithConfig(t, dir, "javascript", true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.JavaScript.Reports = []string{"webpack-stats.json"} + }) + + assertToolchainDeadCodePathMessageContains(t, report, "src/orphan.js", "webpack stats") +} + +func TestScriptToolchainDeadCodeReportsRollupViteZeroRenderedModule(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions":{"allowJs":true,"checkJs":true,"noEmit":true},"include":["src/**/*.ts"]}`) + writeFile(t, filepath.Join(dir, "src", "index.ts"), `export function run(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "rolled.ts"), `export function rolledAway(): number { + return 2; +} +`) + writeFile(t, filepath.Join(dir, "rollup-output.json"), `{ + "output": [ + { + "type": "chunk", + "fileName": "assets/index.js", + "isEntry": true, + "modules": { + "src/index.ts": {"renderedLength": 24}, + "src/rolled.ts": {"renderedLength": 0, "removedExports": ["rolledAway"]} + } + } + ] +}`) + + report := runScriptToolchainDeadCodeScanWithConfig(t, dir, "typescript", true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.TypeScript.Reports = []string{"rollup-output.json"} + }) + + assertToolchainDeadCodePathMessageContains(t, report, "src/rolled.ts", "Rollup/Vite artifact") +} + +func TestScriptToolchainDeadCodeDoesNotInferDeadCodeFromViteManifest(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions":{"allowJs":true,"checkJs":true,"noEmit":true},"include":["src/**/*.ts"]}`) + writeFile(t, filepath.Join(dir, "src", "entry.ts"), `export function run(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "manifest_only.ts"), `export function maybeLoadedElsewhere(): number { + return 2; +} +`) + writeFile(t, filepath.Join(dir, "manifest.json"), `{ + "src/entry.ts": { + "file": "assets/entry.js", + "src": "src/entry.ts", + "isEntry": true + } +}`) + + report := runScriptToolchainDeadCodeScanWithConfig(t, dir, "typescript", true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.TypeScript.Reports = []string{"manifest.json"} + }) + + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/manifest_only.ts") +} + +func TestScriptToolchainDeadCodeAvoidsKnownScriptFalsePositives(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "public.ts"), `export function publicApi(): number { + return 1; +} + +export const publicValue = 2; +`) + writeFile(t, filepath.Join(dir, "src", "used.ts"), `export function run(): number { + return helper(); +} + +function helper(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "widget.test.ts"), `function testOnlyHelper(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "tests", "helper.ts"), `function topLevelTestHelper(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "__tests__", "helper.ts"), `function topLevelJestHelper(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "widget.stories.tsx"), `function StoryOnlyHelper(): null { + return null; +} +`) + writeFile(t, filepath.Join(dir, "src", "__generated__", "schema.ts"), `// Code generated by test fixture. DO NOT EDIT. +function generatedHelper(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "ignored", "legacy.ts"), `function ignoredHelper(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "package.json"), `{"main":"./src/public.ts"}`) + writeFile(t, filepath.Join(dir, "src", "register.ts"), `setup(); + +function setup(): void { +} +`) + writeFile(t, filepath.Join(dir, "meta.json"), `{ + "inputs": { + "src/public.ts": {"bytes": 42}, + "src/register.ts": {"bytes": 42} + }, + "outputs": { + "dist/app.js": { + "inputs": { + "src/public.ts": {"bytesInOutput": 0}, + "src/register.ts": {"bytesInOutput": 0} + } + } + } +}`) + + report := runScriptToolchainDeadCodeScanWithConfig(t, dir, "typescript", true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.TypeScript.IgnorePaths = []string{"src/ignored/**"} + cfg.Checks.QualityRules.DeadCode.TypeScript.Reports = []string{"meta.json"} + }) + + assertFindingRuleAbsent(t, report, "Code Quality", scriptToolchainDeadCodeRuleID) +} + +func TestScriptToolchainDeadCodeCoversBundlerFalsePositiveCorpusAndReportsRealDeadModule(t *testing.T) { + requireTypeScriptSemanticRuntime(t) + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions":{"allowJs":true,"checkJs":true,"noEmit":true,"jsx":"preserve"},"include":["src/**/*.ts","src/**/*.tsx","app/**/*.tsx"]}`) + writeFile(t, filepath.Join(dir, "package.json"), `{ + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./feature": "./src/feature.ts" + }, + "sideEffects": ["./src/polyfill.ts"] +}`) + writeFile(t, filepath.Join(dir, "src", "index.ts"), `export function run(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "feature.ts"), `export function publicFeature(): number { + return 2; +} +`) + writeFile(t, filepath.Join(dir, "app", "dashboard", "page.tsx"), `export default function Page() { + return null; +} +`) + writeFile(t, filepath.Join(dir, "src", "polyfill.ts"), `import "./install"; + +export function configure(): void {} +`) + writeFile(t, filepath.Join(dir, "src", "install.ts"), `globalThis.__fixture = true; +`) + writeFile(t, filepath.Join(dir, "src", "widget.spec.ts"), `function helperOnlyForSpecs(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "widget.stories.tsx"), `export function Primary() { + return null; +} +`) + writeFile(t, filepath.Join(dir, "src", "__generated__", "schema.ts"), `// Code generated by test fixture. DO NOT EDIT. +export function generatedHelper(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "vendor", "legacy.ts"), `export function vendorHelper(): number { + return 1; +} +`) + writeFile(t, filepath.Join(dir, "src", "dead.ts"), `export function removedByBundler(): number { + return 3; +} +`) + writeFile(t, filepath.Join(dir, "meta.json"), `{ + "inputs": { + "src/index.ts": {"bytes": 42}, + "src/feature.ts": {"bytes": 42}, + "app/dashboard/page.tsx": {"bytes": 42}, + "src/polyfill.ts": {"bytes": 42}, + "src/widget.spec.ts": {"bytes": 42}, + "src/widget.stories.tsx": {"bytes": 42}, + "src/__generated__/schema.ts": {"bytes": 42}, + "vendor/legacy.ts": {"bytes": 42}, + "src/dead.ts": {"bytes": 42} + }, + "outputs": { + "dist/app.js": { + "inputs": { + "src/index.ts": {"bytesInOutput": 0}, + "src/feature.ts": {"bytesInOutput": 0}, + "app/dashboard/page.tsx": {"bytesInOutput": 0}, + "src/polyfill.ts": {"bytesInOutput": 0}, + "src/widget.spec.ts": {"bytesInOutput": 0}, + "src/widget.stories.tsx": {"bytesInOutput": 0}, + "src/__generated__/schema.ts": {"bytesInOutput": 0}, + "vendor/legacy.ts": {"bytesInOutput": 0}, + "src/dead.ts": {"bytesInOutput": 0} + } + } + } +}`) + + report := runScriptToolchainDeadCodeScanWithConfig(t, dir, "typescript", true, func(cfg *codeguard.Config) { + cfg.Checks.QualityRules.DeadCode.TypeScript.Reports = []string{"meta.json"} + cfg.Checks.QualityRules.DeadCode.TypeScript.Entrypoints = []string{"src/index.ts"} + }) + + assertToolchainDeadCodePathMessageContains(t, report, "src/dead.ts", "bundler metafile") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/index.ts") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/feature.ts") + assertToolchainDeadCodeFindingPathAbsent(t, report, "app/dashboard/page.tsx") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/polyfill.ts") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/widget.spec.ts") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/widget.stories.tsx") + assertToolchainDeadCodeFindingPathAbsent(t, report, "src/__generated__/schema.ts") + assertToolchainDeadCodeFindingPathAbsent(t, report, "vendor/legacy.ts") +} + +func runScriptToolchainDeadCodeScan(t *testing.T, dir string, language string, enabled bool) codeguard.Report { + t.Helper() + return runScriptToolchainDeadCodeScanWithConfig(t, dir, language, enabled, nil) +} + +func runScriptToolchainDeadCodeScanWithConfig(t *testing.T, dir string, language string, enabled bool, mutate func(*codeguard.Config)) codeguard.Report { + t.Helper() + cfg := codeguard.ExampleConfig() + cfg.Name = "script-toolchain-dead-code-test" + cfg.Targets = []codeguard.TargetConfig{{Name: "web", Path: dir, Language: language}} + cfg.Checks.Quality = true + cfg.Checks.Design = false + cfg.Checks.Security = false + cfg.Checks.Prompts = false + cfg.Checks.CI = false + disabled := false + cfg.Checks.Performance = &disabled + cfg.Checks.SupplyChain = false + cfg.Checks.Delivery = &disabled + cfg.Checks.Reliability = &disabled + cfg.Checks.Data = &disabled + cfg.Checks.Observability = &disabled + cfg.Checks.Operations = &disabled + cfg.Checks.Change = &disabled + cfg.Checks.Contracts = &disabled + cfg.Checks.Context = &disabled + cfg.Checks.QualityRules.AIChecks.DeadCode = &disabled + if enabled { + enabled := true + cfg.Checks.QualityRules.DeadCode = codeguard.QualityDeadCodeConfig{ + Enabled: &enabled, + Mode: "toolchain", + Level: "warn", + } + } + if mutate != nil { + mutate(&cfg) + } + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + return report +} + +func assertToolchainDeadCodePath(t *testing.T, report codeguard.Report, wantPath string) { + t.Helper() + for _, finding := range toolchainDeadCodeFindings(report) { + if filepath.ToSlash(finding.Path) == wantPath { + return + } + } + t.Fatalf("missing %s finding at %q; got %s", scriptToolchainDeadCodeRuleID, wantPath, formatFindings(report)) +} + +func assertToolchainDeadCodePathMessageContains(t *testing.T, report codeguard.Report, wantPath string, wantMessage string) { + t.Helper() + for _, finding := range toolchainDeadCodeFindings(report) { + if filepath.ToSlash(finding.Path) == wantPath && strings.Contains(finding.Message, wantMessage) { + return + } + } + t.Fatalf("missing %s finding at %q containing %q; got %s", scriptToolchainDeadCodeRuleID, wantPath, wantMessage, formatFindings(report)) +} diff --git a/tests/checks/quality_ui_false_positive_hardening_test.go b/tests/checks/quality_ui_false_positive_hardening_test.go index 104b928..494da06 100644 --- a/tests/checks/quality_ui_false_positive_hardening_test.go +++ b/tests/checks/quality_ui_false_positive_hardening_test.go @@ -170,6 +170,33 @@ func TestFunctionCommandQueryMixAllowsReactAndNextBoundaries(t *testing.T) { } } +func TestFunctionMutationRulesAllowWrappedNextRouteHandlersAndRollbackNames(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "app", "api", "apps", "lmp", "new-editor", "route.ts"), strings.Join([]string{ + "import { NextResponse } from 'next/server';", + "export async function postHandler(request: Request) {", + " await overrides.update(await request.json());", + " return NextResponse.json({ ok: true });", + "}", + "export const POST = withTrackedRoute({ surface: 'lmp.new-editor-access' }, postHandler);", + "declare const overrides: { update(input: unknown): Promise };", + "declare function withTrackedRoute(options: unknown, handler: unknown): unknown;", + }, "\n")) + writeFile(t, filepath.Join(dir, "src", "lib", "statsig", "override-service.ts"), strings.Join([]string{ + "export async function rollbackStatsigOverride(store: Store, id: string) {", + " await store.update(id);", + " return { rolledBack: true };", + "}", + "interface Store { update(id: string): Promise }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertCodeQualityRuleAbsentForPath(t, report, "function.command-query-mix", "route.ts") + assertCodeQualityRuleAbsentForPath(t, report, "function.hidden-mutation", "route.ts") + assertCodeQualityRuleAbsentForPath(t, report, "function.hidden-mutation", "override-service.ts") +} + func TestQualityAmbiguousNameAllowsNestJSPayloadNames(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "apps/api/src/users/users.controller.ts"), strings.Join([]string{ @@ -195,6 +222,47 @@ func TestQualityAmbiguousNameAllowsNestJSPayloadNames(t *testing.T) { assertFindingRuleAbsent(t, report, "Code Quality", "quality.hidden-side-effect") } +func TestQualityDuplicatedKnowledgeSkipsZodEnumAndUnionLiterals(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "app", "(app)", "apps", "lmp", "_components", "account-detail", "feature-access-tab.tsx"), strings.Join([]string{ + "import { z } from 'zod';", + "const ActionSchema = z.enum([", + " 'force_new',", + " 'force_old',", + " 'reset',", + "]);", + "export type NewEditorEffectiveState =", + " | 'new_editor'", + " | 'old_editor'", + " | 'unassigned';", + "export function FeatureAccessTab() {", + " const forcedState: NewEditorEffectiveState = 'new_editor';", + " return ;", + "}", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.duplicated-knowledge") +} + +func TestErrorLoggedAndReturnedSkipsAuditWrites(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "app", "api", "apps", "lmp", "new-editor", "route.ts"), strings.Join([]string{ + "import { NextResponse } from 'next/server';", + "export async function postHandler() {", + " const message = 'statsig override failed';", + " await audit('error', message);", + " return NextResponse.json({ error: message }, { status: 502 });", + "}", + "declare function audit(kind: string, message: string): Promise;", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "error.logged-and-returned") +} + func TestQualityDuplicatedKnowledgeSkipsDisplayStringsAndIncludesLiteral(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "apps/web/app/claims/labels.tsx"), strings.Join([]string{ diff --git a/tests/checks/refactor_test.go b/tests/checks/refactor_test.go index 3c37d09..aa3efff 100644 --- a/tests/checks/refactor_test.go +++ b/tests/checks/refactor_test.go @@ -109,6 +109,29 @@ func Normalize(value int) int { return value } assertFindingRulePresent(t, report, "Change Safety", "refactor.visibility-expanded") } +func TestRefactorAllowsAdditiveTypeScriptExports(t *testing.T) { + dir := initChangeRepo(t) + writeFile(t, filepath.Join(dir, "src", "lib", "apps", "index.ts"), `export function existingApp() { + return "ok"; +} +`) + commitAll(t, dir, "base") + writeFile(t, filepath.Join(dir, "src", "lib", "apps", "index.ts"), `export function existingApp() { + return "ok"; +} + +export const LMP_HELP = { + title: "New editor access", +}; + +export class OverrideOwnershipConflict extends Error {} +`) + + report := runChangeDiff(t, refactorTestConfig(t, dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Change Safety", "refactor.public-contract-changed") +} + func TestRefactorDetectsTestCoverageReduced(t *testing.T) { dir := initChangeRepo(t) writeFile(t, filepath.Join(dir, "pricing", "price_test.go"), `package pricing @@ -138,6 +161,31 @@ func TestPriceBase(t *testing.T) { assertFindingRulePresent(t, report, "Change Safety", "refactor.test-coverage-reduced") } +func TestRefactorAllowsReactImportsInClientComponents(t *testing.T) { + dir := initChangeRepo(t) + writeFile(t, filepath.Join(dir, "src", "app", "(app)", "apps", "lmp", "_components", "account-detail", "feature-access-tab.tsx"), `"use client"; + +export function FeatureAccessTab() { + return null; +} +`) + commitAll(t, dir, "base") + writeFile(t, filepath.Join(dir, "src", "app", "(app)", "apps", "lmp", "_components", "account-detail", "feature-access-tab.tsx"), `"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export function FeatureAccessTab() { + const [ready, setReady] = useState(false); + useEffect(() => setReady(true), []); + return ready; +} +`) + + report := runChangeDiff(t, refactorTestConfig(t, dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Change Safety", "refactor.dependency-direction-worsened") +} + func TestRefactorDetectsDependencyDirectionWorsenedAcrossLanguages(t *testing.T) { cases := []struct { name string diff --git a/tests/checks/reliability_test.go b/tests/checks/reliability_test.go index 1adcaa2..27c367e 100644 --- a/tests/checks/reliability_test.go +++ b/tests/checks/reliability_test.go @@ -129,6 +129,59 @@ type Card struct{} assertFindingRulePresent(t, report, "Reliability", "reliability.non-idempotent-retry") } +func TestReliabilityTypeScriptAllowsCaughtPromiseRejections(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "feature-access-tab.tsx"), `async function fetchNewEditorAccess() { + const response = await fetch("/api/apps/lmp/new-editor"); + if (!response.ok) { + throw new Error("failed to load new editor access"); + } + return response.json(); +} + +export function FeatureAccessTab() { + fetchNewEditorAccess().catch((error) => { + setError(String(error)); + }); + return null; +} + +declare function setError(message: string): void; +`) + + cfg := reliabilityConfig("reliability-ts-caught-throw", dir) + cfg.Targets[0].Language = "typescript" + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Reliability", "reliability.recoverable-panic") +} + +func TestReliabilityDedupesOverlappingTargetFindings(t *testing.T) { + dir := t.TempDir() + rel := filepath.Join("src", "app", "(app)", "apps", "lmp", "_components", "account-detail", "feature-access-tab.tsx") + writeFile(t, filepath.Join(dir, rel), `export async function refresh() { + return fetch("/api/apps/lmp/new-editor"); +} +`) + + cfg := reliabilityConfig("reliability-overlap-dedupe", dir) + cfg.Targets = []codeguard.TargetConfig{ + {Name: "apps", Path: filepath.Join(dir, "src", "app", "(app)", "apps"), Language: "typescript"}, + {Name: "lmp-detail", Path: filepath.Join(dir, "src", "app", "(app)", "apps", "lmp", "_components", "account-detail"), Language: "typescript"}, + } + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + if got := countRuleFindings(report, "Reliability", "reliability.missing-timeout"); got != 1 { + t.Fatalf("expected one deduped missing-timeout finding, got %d", got) + } +} + func TestReliabilityGoDoesNotFlagBoundedHTTPWithContextAndCleanup(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "client.go"), `package sample diff --git a/tests/checks/test_helpers_test.go b/tests/checks/test_helpers_test.go index 825f236..2a2ec7a 100644 --- a/tests/checks/test_helpers_test.go +++ b/tests/checks/test_helpers_test.go @@ -68,6 +68,21 @@ func assertSectionFindingCountAtLeast(t *testing.T, report codeguard.Report, nam t.Fatalf("section %q not found", name) } +func countRuleFindings(report codeguard.Report, sectionName string, ruleID string) int { + count := 0 + for _, section := range report.Sections { + if section.Name != sectionName { + continue + } + for _, finding := range section.Findings { + if finding.RuleID == ruleID { + count++ + } + } + } + return count +} + func strippedANSI(value string) string { return strings.NewReplacer( "\x1b[38;2;10;18;60m", "", diff --git a/tests/checks/testing_testability_test.go b/tests/checks/testing_testability_test.go index fb66c41..1f1aa08 100644 --- a/tests/checks/testing_testability_test.go +++ b/tests/checks/testing_testability_test.go @@ -138,6 +138,29 @@ func TestTestingNondeterministicDomainLogicFindsDomainClock(t *testing.T) { assertFindingRulePresent(t, report, "Change Safety", "testing.nondeterministic-domain-logic") } +func TestTestingNondeterministicDomainLogicAllowsDurationMeasurement(t *testing.T) { + dir := testingGitRepo(t) + writeFile(t, filepath.Join(dir, "src", "domain", "audit.ts"), `export async function runAudit() { + return { durationMs: 0 }; +} +`) + commitAll(t, dir, "base") + writeFile(t, filepath.Join(dir, "src", "domain", "audit.ts"), `export async function runAudit() { + const startedAt = Date.now(); + await audit("start"); + return { durationMs: Date.now() - startedAt }; +} +`) + + cfg := testingChangeConfig(t, dir, "typescript") + cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest = boolValue(false) + cfg.Checks.ChangeRules.DetectFailurePathMissing = boolValue(false) + cfg.Checks.ChangeRules.DetectHardwiredDependency = boolValue(false) + report := runTestingChangeScan(t, cfg) + + assertFindingRuleAbsent(t, report, "Change Safety", "testing.nondeterministic-domain-logic") +} + func TestTestingFailurePathMissingAcrossLanguages(t *testing.T) { for _, tc := range testabilityFailurePathCases() { t.Run(tc.name, func(t *testing.T) { diff --git a/tests/cli/scan_test.go b/tests/cli/scan_test.go index 9362bbe..de066c3 100644 --- a/tests/cli/scan_test.go +++ b/tests/cli/scan_test.go @@ -159,6 +159,87 @@ func TestRunScanPathFlagScopesFolder(t *testing.T) { } } +func TestRunScanAppliesConfigOverrides(t *testing.T) { + dir := t.TempDir() + writeScanTestFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n") + configPath := filepath.Join(dir, "codeguard.json") + config := `{ + "name": "override-scan", + "targets": [{"name": "repo", "path": ".", "language": "go"}], + "checks": {"quality": false, "design": false, "security": false, "prompts": false, "ci": false, "performance": false, "context": false}, + "output": {"format": "text"} +}` + if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{ + "scan", + "-config", configPath, + "-set", "checks.quality=true", + "-set", "checks.quality_rules.max_file_lines=1", + "-set", "output.format=json", + }, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("scan exit code = %d, stderr = %s\nstdout = %s", code, stderr.String(), stdout.String()) + } + if !strings.Contains(stdout.String(), `"rule_id": "quality.max-file-lines"`) { + t.Fatalf("expected max-file-lines finding from overrides, got:\n%s", stdout.String()) + } +} + +func TestRunValidateRejectsUnknownConfigOverride(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "codeguard.json") + config := `{ + "name": "bad-override", + "targets": [{"name": "repo", "path": ".", "language": "go"}], + "checks": {"quality": false, "design": false, "security": false, "prompts": false, "ci": false, "performance": false}, + "output": {"format": "text"} +}` + if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"validate", "-config", configPath, "-set", "checks.not_a_real_field=true"}, strings.NewReader(""), &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit 1, got %d; stdout = %s", code, stdout.String()) + } + if !strings.Contains(stderr.String(), "unknown config field") { + t.Fatalf("expected unknown override error, got %s", stderr.String()) + } +} + +func TestRunValidateAppliesIndexedListOverride(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "codeguard.json") + config := `{ + "name": "indexed-override", + "targets": [{"name": "repo", "path": ".", "language": "go"}], + "checks": {"quality": false, "design": false, "security": false, "prompts": false, "ci": false, "performance": false}, + "output": {"format": "text"} +}` + if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{ + "validate", + "-config", configPath, + "-set", "targets[0].language=python", + "-set", "targets[0].entrypoints=app/__main__.py,app/worker.py", + }, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("validate exit code = %d, stderr = %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "config valid") { + t.Fatalf("expected valid config, got stdout=%s stderr=%s", stdout.String(), stderr.String()) + } +} + func TestRunScanFolderWithoutConfigUsesDefaultProfile(t *testing.T) { cwd, err := os.Getwd() if err != nil { diff --git a/tests/codeguard/quality_dead_code_config_test.go b/tests/codeguard/quality_dead_code_config_test.go new file mode 100644 index 0000000..455eae1 --- /dev/null +++ b/tests/codeguard/quality_dead_code_config_test.go @@ -0,0 +1,165 @@ +package codeguard_test + +import ( + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestValidateQualityDeadCodeConfig(t *testing.T) { + tests := []struct { + name string + deadCode codeguard.QualityDeadCodeConfig + want string + }{ + { + name: "invalid mode", + deadCode: codeguard.QualityDeadCodeConfig{Mode: "compiler"}, + want: "dead_code.mode", + }, + { + name: "invalid level", + deadCode: codeguard.QualityDeadCodeConfig{Level: "error"}, + want: "dead_code.level", + }, + { + name: "blank package pattern", + deadCode: codeguard.QualityDeadCodeConfig{Go: codeguard.GoDeadCodeToolchainConfig{ + Packages: []string{" "}, + }}, + want: "dead_code.go.packages[0]", + }, + { + name: "flag-shaped entrypoint", + deadCode: codeguard.QualityDeadCodeConfig{Go: codeguard.GoDeadCodeToolchainConfig{ + Entrypoints: []string{"-race"}, + }}, + want: "dead_code.go.entrypoints[0]", + }, + { + name: "escaping ignore path", + deadCode: codeguard.QualityDeadCodeConfig{Go: codeguard.GoDeadCodeToolchainConfig{ + IgnorePaths: []string{"../generated/**"}, + }}, + want: "dead_code.go.ignore_paths[0]", + }, + { + name: "absolute rust crate", + deadCode: codeguard.QualityDeadCodeConfig{Rust: codeguard.RustDeadCodeConfig{ + Crates: []string{"/tmp/crate"}, + }}, + want: "dead_code.rust.crates[0]", + }, + { + name: "flag-shaped rust package", + deadCode: codeguard.QualityDeadCodeConfig{Rust: codeguard.RustDeadCodeConfig{ + Packages: []string{"--workspace"}, + }}, + want: "dead_code.rust.packages[0]", + }, + { + name: "escaping rust report", + deadCode: codeguard.QualityDeadCodeConfig{Rust: codeguard.RustDeadCodeConfig{ + Reports: []string{"../target/rust-symbols.txt"}, + }}, + want: "dead_code.rust.reports[0]", + }, + { + name: "escaping c++ compile database", + deadCode: codeguard.QualityDeadCodeConfig{CPP: codeguard.CPPDeadCodeConfig{ + CompileCommands: "../compile_commands.json", + }}, + want: "dead_code.cpp.compile_commands", + }, + { + name: "blank c++ report", + deadCode: codeguard.QualityDeadCodeConfig{CPP: codeguard.CPPDeadCodeConfig{ + Reports: []string{""}, + }}, + want: "dead_code.cpp.reports[0]", + }, + { + name: "blank python report", + deadCode: codeguard.QualityDeadCodeConfig{Python: codeguard.PythonDeadCodeConfig{ + Reports: []string{""}, + }}, + want: "dead_code.python.reports[0]", + }, + { + name: "flag-shaped typescript project", + deadCode: codeguard.QualityDeadCodeConfig{TypeScript: codeguard.ScriptDeadCodeConfig{ + Projects: []string{"--project"}, + }}, + want: "dead_code.typescript.projects[0]", + }, + { + name: "escaping javascript report", + deadCode: codeguard.QualityDeadCodeConfig{JavaScript: codeguard.ScriptDeadCodeConfig{ + Reports: []string{"../meta.json"}, + }}, + want: "dead_code.javascript.reports[0]", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := codeguard.ExampleConfig() + cfg.Checks.QualityRules.DeadCode = tt.deadCode + err := codeguard.ValidateConfig(cfg) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("ValidateConfig error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestValidateQualityDeadCodeConfigAcceptsToolchainGoSettings(t *testing.T) { + enabled := true + linker := true + cfg := codeguard.ExampleConfig() + cfg.Checks.QualityRules.DeadCode = codeguard.QualityDeadCodeConfig{ + Enabled: &enabled, + Mode: "toolchain", + Level: "warn", + Go: codeguard.GoDeadCodeToolchainConfig{ + Packages: []string{"./..."}, + Entrypoints: []string{"./cmd/app"}, + IgnorePaths: []string{"internal/generated/**"}, + Linker: &linker, + }, + Rust: codeguard.RustDeadCodeConfig{ + Crates: []string{"crates/app"}, + Packages: []string{"app"}, + Entrypoints: []string{"src/main.rs"}, + Reports: []string{".codeguard/rust-symbols.txt"}, + IgnorePaths: []string{"target/**"}, + }, + CPP: codeguard.CPPDeadCodeConfig{ + CompileCommands: "compile_commands.json", + Entrypoints: []string{"src/main.cpp"}, + Reports: []string{"build/app.map", "build/nm.txt"}, + IgnorePaths: []string{"build/**"}, + }, + Python: codeguard.PythonDeadCodeConfig{ + Modules: []string{"src/app"}, + Entrypoints: []string{"src/app/__main__.py"}, + Reports: []string{".codeguard/vulture.json"}, + IgnorePaths: []string{"migrations/**"}, + }, + TypeScript: codeguard.ScriptDeadCodeConfig{ + Projects: []string{"tsconfig.json"}, + Entrypoints: []string{"src/main.ts"}, + Reports: []string{"dist/meta.json"}, + IgnorePaths: []string{"src/generated/**"}, + }, + JavaScript: codeguard.ScriptDeadCodeConfig{ + Projects: []string{"jsconfig.json"}, + Entrypoints: []string{"src/main.js"}, + Reports: []string{"dist/js-meta.json"}, + IgnorePaths: []string{"generated/**"}, + }, + } + if err := codeguard.ValidateConfig(cfg); err != nil { + t.Fatalf("ValidateConfig: %v", err) + } +}