Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions internal/codeguard/checks/quality/quality_precision.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"go/ast"
"go/printer"
"go/token"
"path/filepath"
"regexp"
"strings"

Expand Down Expand Up @@ -88,20 +89,24 @@ func excessiveParameterFinding(env support.Context, file string, fn functionMetr
func goPrecisionFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File, data []byte) []core.Finding {
findings := make([]core.Finding, 0)
ast.Inspect(parsed, func(n ast.Node) bool {
switch node := n.(type) {
case *ast.FuncDecl:
if node, ok := n.(*ast.FuncDecl); ok {
fn := goPrecisionFunction(fset, node, data)
findings = append(findings, precisionFunctionFindings(env, file, fn)...)
if node.Body != nil {
findings = append(findings, goDefensiveFindings(env, file, fset, node.Body)...)
}
case *ast.GenDecl:
findings = append(findings, goGenericDeclFindings(env, file, fset, node)...)
findings = append(findings, goMutableGlobalFindings(env, file, fset, node)...)
findings = append(findings, goDuplicatedKnowledgeFindings(env, file, fset, node)...)
}
return true
})
for _, decl := range parsed.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
findings = append(findings, goGenericDeclFindings(env, file, fset, gen)...)
findings = append(findings, goMutableGlobalFindings(env, file, fset, gen)...)
findings = append(findings, goDuplicatedKnowledgeFindings(env, file, fset, gen)...)
}
findings = append(findings, redundantCommentFindings(env, file, string(data))...)
findings = append(findings, sourceDuplicatedKnowledgeFindings(env, file, string(data))...)
findings = append(findings, sourceNamingFindings(env, file, string(data))...)
Expand Down Expand Up @@ -426,6 +431,7 @@ func precisionFunctionFindings(env support.Context, file string, fn precisionFun
}
if mixedAbstractionLevel(fn) &&
!isQualityFixturePath(file) &&
!isPostgresRepositoryPath(file) &&
!isAdapterOrOrchestrationFunction(file, fn) &&
!isFrameworkOrchestrationBoundary(file, fn) &&
!isScriptEntrypoint(file, fn.Name) &&
Expand Down Expand Up @@ -578,7 +584,7 @@ func commandQueryMix(file string, fn precisionFunction) bool {
if isQualityFixturePath(file) {
return false
}
if isFrameworkOrchestrationBoundary(file, fn) || isReactComponentOrNamedHookBoundary(file, fn) || isUIHelperOrMappingContext(file, fn) || isScriptEntrypoint(file, fn.Name) || isSeedOrScriptSourcePath(file) || isAdapterOrOrchestrationFunction(file, fn) || isSecurityOrConfigUtilityFunction(file, fn) || explicitMutationName(fn.Name) || isUICommandHelperName(file, fn.Name) || isDomainSideEffectBoundaryName(fn.Name) {
if isFrameworkOrchestrationBoundary(file, fn) || isReactComponentOrNamedHookBoundary(file, fn) || isUIHelperOrMappingContext(file, fn) || isScriptEntrypoint(file, fn.Name) || isSeedOrScriptSourcePath(file) || isAdapterOrOrchestrationFunction(file, fn) || isPostgresRepositoryPath(file) || isSecurityOrConfigUtilityFunction(file, fn) || explicitMutationName(fn.Name) || isUICommandHelperName(file, fn.Name) || isDomainSideEffectBoundaryName(fn.Name) {
return false
}
if !fn.Returns {
Expand Down Expand Up @@ -762,6 +768,9 @@ func sourceMutableGlobalFindings(env support.Context, file string, source string
if isQualityFixturePath(file) {
return nil
}
if strings.EqualFold(filepath.Ext(file), ".go") {
return nil
}
normalized := strings.ToLower(strings.ReplaceAll(file, "\\", "/"))
if strings.HasPrefix(normalized, "bin/") || strings.Contains(normalized, "/integrations/") || strings.Contains(normalized, "integrations/") {
return nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ func isIntegrationAdapterPath(file string) bool {
strings.Contains(normalized, "/integrations/")
}

func isPostgresRepositoryPath(file string) bool {
normalized := strings.ToLower(strings.ReplaceAll(file, "\\", "/"))
if strings.HasPrefix(normalized, "platform/storage/postgres/") ||
strings.Contains(normalized, "/platform/storage/postgres/") {
return true
}
base := normalized
if slash := strings.LastIndex(base, "/"); slash >= 0 {
base = base[slash+1:]
}
return (strings.Contains(normalized, "/postgres/") || strings.Contains(normalized, "/pgx/")) &&
(strings.Contains(base, "repository") || strings.Contains(base, "repo"))
}

func configuredPluralDomainAbbreviation(name string) bool {
switch name {
case "docs", "krs":
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package quality

Check warning on line 1 in internal/codeguard/checks/quality/quality_precision_workstreams_cd.go

View workflow job for this annotation

GitHub Actions / codeguard

[design.max-decls-per-file] file has 65 declarations; max is 60. Fix: Move related declarations into smaller files with clearer ownership.

Check warning on line 1 in internal/codeguard/checks/quality/quality_precision_workstreams_cd.go

View workflow job for this annotation

GitHub Actions / codeguard

[design.max-decls-per-file] file has 65 declarations; max is 60. Fix: Move related declarations into smaller files with clearer ownership.

import (
"fmt"
Expand Down Expand Up @@ -452,6 +452,9 @@
if explicitNullableReturnContract(fn) {
return false
}
if standardGoResultErrorContract(fn) && !returnsNonZeroValueWithError(fn) {
return false
}
returns := returnCategories(fn.Body)
if returns.total < 2 {
return false
Expand Down Expand Up @@ -547,9 +550,77 @@
if strings.Contains(loweredName, "partial") || strings.Contains(loweredName, "try") || explicitResultObjectContract(fn) {
return false
}
if standardGoResultErrorContract(fn) && !returnsNonZeroValueWithError(fn) {
return false
}
return partialReturnPattern.MatchString(fn.Body)
}

func standardGoResultErrorContract(fn precisionFunction) bool {
first := standardGoResultErrorFirstType(fn)
return first != ""
}

func standardGoResultErrorFirstType(fn precisionFunction) string {
signature := strings.ToLower(strings.ReplaceAll(fn.Signature, " ", ""))
if !strings.HasPrefix(signature, "(") || !strings.HasSuffix(signature, ",error)") {
return ""
}
first := strings.TrimPrefix(strings.TrimSuffix(signature, ",error)"), "(")
if first == "" || strings.Contains(first, ",") {
return ""
}
return first
}

func returnsNonZeroValueWithError(fn precisionFunction) bool {
firstType := standardGoResultErrorFirstType(fn)
for _, match := range returnLinePattern.FindAllStringSubmatch(fn.Body, -1) {
if len(match) < 2 {
continue
}
expr := strings.TrimSpace(match[1])
parts := strings.Split(expr, ",")
if len(parts) < 2 {
continue
}
first := strings.TrimSpace(parts[0])
second := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(parts[1], ";")))
if second != "err" && second != "error" {
continue
}
if !isEmptyReturnExpr(first) && !isGoZeroReturnExpr(firstType, first) {
return true
}
}
return false
}

func isGoZeroReturnExpr(resultType string, expr string) bool {
resultType = strings.TrimSpace(strings.ToLower(resultType))
expr = strings.TrimSpace(strings.TrimSuffix(expr, ";"))
loweredExpr := strings.ToLower(expr)
if resultType == "bool" {
return loweredExpr == "false"
}
if resultType == "string" {
return expr == `""` || expr == "``"
}
if scalarTypePattern.MatchString(resultType) || strings.HasPrefix(resultType, "uint") {
return loweredExpr == "0"
}
if strings.HasPrefix(resultType, "*") || strings.HasPrefix(resultType, "[]") ||
strings.HasPrefix(resultType, "map[") || strings.HasPrefix(resultType, "chan ") ||
strings.HasPrefix(resultType, "func(") || resultType == "any" || resultType == "interface{}" {
return loweredExpr == "nil"
}
if strings.HasSuffix(loweredExpr, "{}") {
zeroType := strings.TrimSpace(strings.TrimSuffix(loweredExpr, "{}"))
return zeroType == resultType
}
return false
}

func explicitResultObjectContract(fn precisionFunction) bool {
loweredSignature := strings.ToLower(fn.Signature)
loweredBody := strings.ToLower(fn.Body)
Expand Down Expand Up @@ -698,12 +769,22 @@

func isPredicateName(name string) bool {
lowered := strings.ToLower(strings.Trim(name, "_$"))
for _, prefix := range []string{"arrivals", "deep", "is", "are", "has", "have", "can", "could", "should", "must", "allow", "allows", "enable", "enabled", "disable", "disabled", "needs", "requires", "supports", "valid", "verify", "visible", "ready", "show", "looks", "matches", "same", "pass", "passes"} {
for _, prefix := range []string{
"allow", "allows", "are", "can", "changed", "compatible", "complete",
"contains", "could", "deep", "disable", "disabled", "enable", "enabled",
"exists", "forbidden", "has", "have", "included", "is", "looks",
"matches", "must", "needs", "pass", "passes", "readable", "ready",
"requires", "same", "should", "show", "supported", "supports", "valid",
"verify", "visible",
} {
if strings.HasPrefix(lowered, prefix) {
return true
}
}
for _, suffix := range []string{"allowed", "equal", "equals", "differs", "matches"} {
for _, suffix := range []string{
"allowed", "changed", "compatible", "complete", "differs", "equal",
"equals", "forbidden", "included", "matches", "readable", "supported",
} {
if strings.HasSuffix(lowered, suffix) {
return true
}
Expand Down Expand Up @@ -1013,4 +1094,4 @@
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}

Check warning on line 1097 in internal/codeguard/checks/quality/quality_precision_workstreams_cd.go

View workflow job for this annotation

GitHub Actions / codeguard

[quality.max-file-lines] file has 1097 lines; max is 1000. Fix: Split the file into smaller units, reduce branching in the file's functions, or raise the configured threshold intentionally.
7 changes: 7 additions & 0 deletions internal/codeguard/core/report_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ type BaselineEntry struct {
// (rule, path, and normalized surrounding source). Absent in baseline files
// written before it existed; those entries match on Fingerprint alone.
ContextFingerprint string `json:"context_fingerprint,omitempty"`
// ContentFingerprint is the path-insensitive version of ContextFingerprint.
// It lets a baseline keep suppressing unchanged logic after the code is
// split into new files or moved as part of a refactor.
ContentFingerprint string `json:"content_fingerprint,omitempty"`
RuleID string `json:"rule_id,omitempty"`
Path string `json:"path,omitempty"`
Message string `json:"message,omitempty"`
Expand Down Expand Up @@ -60,6 +64,9 @@ type Finding struct {
// survives unrelated edits that only shift the finding within the file.
// Falls back to Fingerprint when no source context is available.
ContextFingerprint string `json:"context_fingerprint,omitempty"`
// ContentFingerprint hashes the rule and normalized source context without
// the path, preserving baseline suppression across file moves.
ContentFingerprint string `json:"content_fingerprint,omitempty"`
Suppressed bool `json:"suppressed,omitempty"`
SuppressionReason string `json:"suppression_reason,omitempty"`
// Metadata carries machine-readable, non-sensitive finding attributes. It
Expand Down
10 changes: 5 additions & 5 deletions internal/codeguard/runner/support/cache_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ func ConfigFingerprint(cfg core.Config, extras ...string) string {
if err != nil {
return ""
}
// version 8: findings gained ContextFingerprint, so entries cached by
// version 9: findings gained ContentFingerprint, so entries cached by
// earlier scanners must be recomputed rather than replayed without it.
prefix := "scanner-version-8|" + strings.Join(extras, "|") + "|"
prefix := "scanner-version-9|" + strings.Join(extras, "|") + "|"
return hashBytes(append([]byte(prefix), data...))
}

Expand All @@ -40,9 +40,9 @@ func ConfigFingerprint(cfg core.Config, extras ...string) string {
// section id that sectionConfigFamily does not recognize, so a newly added
// section can never silently serve stale cache entries.
func SectionConfigHashes(cfg core.Config, catalog map[string]core.RuleMetadata, extras ...string) map[string]string {
// v3: quality/security findings can now come from the tree-sitter path, so
// the parser selection (cfg.Parsers) is part of their fingerprints.
prefix := "section-config-v3|" + strings.Join(extras, "|") + "|"
// v4: findings gained ContentFingerprint, so cached per-file findings need
// to be regenerated with the path-insensitive fingerprint.
prefix := "section-config-v4|" + strings.Join(extras, "|") + "|"
checks := cfg.Checks
return map[string]string{
// quality reads both QualityRules and DesignRules, and its AI-quality
Expand Down
6 changes: 3 additions & 3 deletions internal/codeguard/runner/support/cache_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ type cacheEntry struct {

// scanCacheVersion is bumped whenever the on-disk cache layout or the meaning of
// a stored fingerprint changes, so stale caches are discarded wholesale rather
// than reused with mismatched semantics. v7 introduced per-section config
// fingerprints (see SectionConfigHashes).
const scanCacheVersion = 7
// than reused with mismatched semantics. v8 introduced content fingerprints for
// path-insensitive baseline suppression after code moves.
const scanCacheVersion = 8
4 changes: 4 additions & 0 deletions internal/codeguard/runner/support/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ func BaselineEntriesFromReport(report core.Report) []core.BaselineEntry {
entries = append(entries, core.BaselineEntry{
Fingerprint: finding.Fingerprint,
ContextFingerprint: contextFP,
ContentFingerprint: finding.ContentFingerprint,
RuleID: finding.RuleID,
Path: finding.Path,
Message: finding.Message,
Expand Down Expand Up @@ -200,6 +201,9 @@ func loadBaselineFile(path string) (map[string]core.BaselineEntry, error) {
if entry.ContextFingerprint != "" {
out[entry.ContextFingerprint] = entry
}
if entry.ContentFingerprint != "" {
out[entry.ContentFingerprint] = entry
}
}
return out, nil
}
14 changes: 13 additions & 1 deletion internal/codeguard/runner/support/context_fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ const contextFingerprintRadius = 2
// is unreadable, or the line is past end of file), letting the caller fall
// back to the legacy fingerprint.
func contextFingerprint(sc Context, ruleID string, normalizedPath string, line int) string {
return findingContextFingerprint(sc, ruleID, normalizedPath, line, true)
}

func contentFingerprint(sc Context, ruleID string, normalizedPath string, line int) string {
return findingContextFingerprint(sc, ruleID, normalizedPath, line, false)
}

func findingContextFingerprint(sc Context, ruleID string, normalizedPath string, line int, includePath bool) string {
if line <= 0 || normalizedPath == "" {
return ""
}
Expand All @@ -31,7 +39,11 @@ func contextFingerprint(sc Context, ruleID string, normalizedPath string, line i
if !ok {
return ""
}
sum := sha256.Sum256([]byte(strings.Join([]string{ruleID, normalizedPath, context}, "|")))
parts := []string{ruleID, context}
if includePath {
parts = []string{ruleID, normalizedPath, context}
}
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
return hex.EncodeToString(sum[:])
}

Expand Down
2 changes: 2 additions & 0 deletions internal/codeguard/runner/support/findings_section.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func NewFinding(sc Context, input FindingInput) core.Finding {
if contextFP == "" {
contextFP = legacy
}
contentFP := contentFingerprint(sc, input.RuleID, normalizedPath, input.Line)
return core.Finding{
RuleID: input.RuleID,
Level: input.Level,
Expand All @@ -50,6 +51,7 @@ func NewFinding(sc Context, input FindingInput) core.Finding {
Column: input.Column,
Fingerprint: legacy,
ContextFingerprint: contextFP,
ContentFingerprint: contentFP,
Metadata: cloneMetadata(input.Metadata),
}
}
Expand Down
5 changes: 5 additions & 0 deletions internal/codeguard/runner/support/suppressions.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ func IsSuppressed(sc Context, finding core.Finding) (bool, string) {
return true, "baseline"
}
}
if finding.ContentFingerprint != "" {
if _, ok := sc.Baseline[finding.ContentFingerprint]; ok {
return true, SuppressionReasonBaseline
}
}
}
if len(MatchingWaivers(sc, finding)) > 0 {
return true, SuppressionReasonWaiver
Expand Down
35 changes: 35 additions & 0 deletions tests/checks/fingerprint_baseline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package checks_test

import (
"context"
"os"
"path/filepath"
"testing"

Expand Down Expand Up @@ -97,6 +98,40 @@ func TestBaselineSuppressesFindingAfterLineShift(t *testing.T) {
}
}

func TestBaselineSuppressesFindingAfterMoveToSplitFile(t *testing.T) {
dir := t.TempDir()
legacyPath := filepath.Join(dir, "prompts", "legacy", "system.prompt")
writeFile(t, legacyPath, shiftPromptBody)

cfg := promptOnlyConfig(dir, "fingerprint-move-test")

report, err := codeguard.Run(context.Background(), cfg)
if err != nil {
t.Fatalf("run: %v", err)
}
assertSectionStatus(t, report, "AI Prompts", "fail")

baselinePath := filepath.Join(dir, "codeguard-baseline.json")
if writeErr := codeguard.WriteBaselineFile(baselinePath, codeguard.BaselineEntriesFromReport(report)); writeErr != nil {
t.Fatalf("write baseline: %v", writeErr)
}

if removeErr := os.Remove(legacyPath); removeErr != nil {
t.Fatalf("remove legacy file: %v", removeErr)
}
writeFile(t, filepath.Join(dir, "prompts", "split", "system.prompt"), shiftPromptBody)

cfg.Baseline.Path = baselinePath
report, err = codeguard.Run(context.Background(), cfg)
if err != nil {
t.Fatalf("run with baseline after move: %v", err)
}
assertSectionStatus(t, report, "AI Prompts", "pass")
if report.Summary.SuppressedFindings == 0 {
t.Fatal("expected the pre-move baseline to suppress the moved finding")
}
}

// Baseline files written before context fingerprints existed carry legacy-only
// entries; they must keep suppressing unchanged findings.
func TestLegacyOnlyBaselineStillSuppresses(t *testing.T) {
Expand Down
Loading
Loading