diff --git a/internal/codeguard/checks/quality/quality_precision.go b/internal/codeguard/checks/quality/quality_precision.go index 0d7852f..ed7117c 100644 --- a/internal/codeguard/checks/quality/quality_precision.go +++ b/internal/codeguard/checks/quality/quality_precision.go @@ -6,6 +6,7 @@ import ( "go/ast" "go/printer" "go/token" + "path/filepath" "regexp" "strings" @@ -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))...) @@ -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) && @@ -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 { @@ -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 diff --git a/internal/codeguard/checks/quality/quality_precision_retune_helpers.go b/internal/codeguard/checks/quality/quality_precision_retune_helpers.go index d808239..ed33abd 100644 --- a/internal/codeguard/checks/quality/quality_precision_retune_helpers.go +++ b/internal/codeguard/checks/quality/quality_precision_retune_helpers.go @@ -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": diff --git a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go index 3e367fd..9ab69a0 100644 --- a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go +++ b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go @@ -452,6 +452,9 @@ func inconsistentReturnContract(fn precisionFunction) bool { if explicitNullableReturnContract(fn) { return false } + if standardGoResultErrorContract(fn) && !returnsNonZeroValueWithError(fn) { + return false + } returns := returnCategories(fn.Body) if returns.total < 2 { return false @@ -547,9 +550,77 @@ func partialResult(fn precisionFunction) bool { 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) @@ -698,12 +769,22 @@ func isBooleanType(typ string) bool { 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 } diff --git a/internal/codeguard/core/report_types.go b/internal/codeguard/core/report_types.go index af5b884..b7cdb0a 100644 --- a/internal/codeguard/core/report_types.go +++ b/internal/codeguard/core/report_types.go @@ -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"` @@ -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 diff --git a/internal/codeguard/runner/support/cache_helpers.go b/internal/codeguard/runner/support/cache_helpers.go index b78b09b..6cf0b4b 100644 --- a/internal/codeguard/runner/support/cache_helpers.go +++ b/internal/codeguard/runner/support/cache_helpers.go @@ -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...)) } @@ -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 diff --git a/internal/codeguard/runner/support/cache_types.go b/internal/codeguard/runner/support/cache_types.go index a2cefa6..05d6ac6 100644 --- a/internal/codeguard/runner/support/cache_types.go +++ b/internal/codeguard/runner/support/cache_types.go @@ -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 diff --git a/internal/codeguard/runner/support/context.go b/internal/codeguard/runner/support/context.go index 06203a4..00a8ada 100644 --- a/internal/codeguard/runner/support/context.go +++ b/internal/codeguard/runner/support/context.go @@ -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, @@ -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 } diff --git a/internal/codeguard/runner/support/context_fingerprint.go b/internal/codeguard/runner/support/context_fingerprint.go index f31f3bf..c778447 100644 --- a/internal/codeguard/runner/support/context_fingerprint.go +++ b/internal/codeguard/runner/support/context_fingerprint.go @@ -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 "" } @@ -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[:]) } diff --git a/internal/codeguard/runner/support/findings_section.go b/internal/codeguard/runner/support/findings_section.go index 6e90a71..a6b6b7e 100644 --- a/internal/codeguard/runner/support/findings_section.go +++ b/internal/codeguard/runner/support/findings_section.go @@ -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, @@ -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), } } diff --git a/internal/codeguard/runner/support/suppressions.go b/internal/codeguard/runner/support/suppressions.go index e803cb1..6dac063 100644 --- a/internal/codeguard/runner/support/suppressions.go +++ b/internal/codeguard/runner/support/suppressions.go @@ -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 diff --git a/tests/checks/fingerprint_baseline_test.go b/tests/checks/fingerprint_baseline_test.go index fb33c9f..43b8b5f 100644 --- a/tests/checks/fingerprint_baseline_test.go +++ b/tests/checks/fingerprint_baseline_test.go @@ -2,6 +2,7 @@ package checks_test import ( "context" + "os" "path/filepath" "testing" @@ -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) { diff --git a/tests/checks/function_precision_test.go b/tests/checks/function_precision_test.go index 404a078..7a61bc0 100644 --- a/tests/checks/function_precision_test.go +++ b/tests/checks/function_precision_test.go @@ -323,6 +323,226 @@ func TestFunctionReturnContractRules(t *testing.T) { assertFindingRulePresent(t, report, "Code Quality", "function.partial-result") } +func TestFunctionReturnContractAllowsStandardGoRepositoryResults(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "services", "postgres", "collections_repository.go"), strings.Join([]string{ + "package postgres", + "", + "import \"context\"", + "", + "type Collection struct{ ID string }", + "type CollectionItem struct{ ID string }", + "type Row interface{ Scan(...any) error }", + "type Rows interface{ Next() bool; Scan(...any) error; Err() error; Close() error }", + "type Querier interface{ QueryRow(context.Context, string, ...any) Row; Query(context.Context, string, ...any) (Rows, error); Exec(context.Context, string, ...any) (CommandTag, error) }", + "type CommandTag interface{ RowsAffected() int64 }", + "type CollectionsRepository struct{ db Querier }", + "", + "func (r *CollectionsRepository) FindCollection(ctx context.Context, id string) (*Collection, error) {", + "\trow := r.db.QueryRow(ctx, \"select id from collections where id=$1\", id)", + "\tcollection := &Collection{}", + "\tif err := row.Scan(&collection.ID); err != nil {", + "\t\treturn nil, err", + "\t}", + "\treturn collection, nil", + "}", + "", + "func (r *CollectionsRepository) ListCollectionItems(ctx context.Context, id string) ([]*CollectionItem, error) {", + "\trows, err := r.db.Query(ctx, \"select id from collection_items where collection_id=$1\", id)", + "\tif err != nil {", + "\t\treturn nil, err", + "\t}", + "\tdefer rows.Close()", + "\titems := []*CollectionItem{}", + "\tfor rows.Next() {", + "\t\titem := &CollectionItem{}", + "\t\tif err := rows.Scan(&item.ID); err != nil {", + "\t\t\treturn nil, err", + "\t\t}", + "\t\titems = append(items, item)", + "\t}", + "\treturn items, rows.Err()", + "}", + "", + "func (r *CollectionsRepository) CollectionExists(ctx context.Context, id string) (bool, error) {", + "\trow := r.db.QueryRow(ctx, \"select exists(select 1 from collections where id=$1)\", id)", + "\tvar exists bool", + "\tif err := row.Scan(&exists); err != nil {", + "\t\treturn false, err", + "\t}", + "\treturn exists, nil", + "}", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "function.inconsistent-return-contract") + assertFindingRuleAbsent(t, report, "Code Quality", "function.partial-result") +} + +func TestBooleanPredicateNamingOnlyRunsOnBooleanReturns(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "values.go"), strings.Join([]string{ + "package sample", + "", + "type Field struct{}", + "type Row struct{}", + "type Timing struct{}", + "type Context struct{}", + "type Collection struct{}", + "", + "func Get(limit int) *Collection {", + "\treturn &Collection{}", + "}", + "", + "func RawStringField(row Row, fallback string) string {", + "\treturn \"value\"", + "}", + "", + "func ValueOrNotFound(value string, fallback string) (string, error) {", + "\treturn value, nil", + "}", + "", + "func CollectOneRowOrNilAndClose(rows []Row, limit int) (*Row, error) {", + "\treturn nil, nil", + "}", + "", + "func TimingFromContext(ctx Context, fallback Timing) Timing {", + "\treturn Timing{}", + "}", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "naming.boolean-not-predicate") +} + +func TestBooleanPredicateNamingAcceptsConventionalPredicateVocabulary(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "predicates.go"), strings.Join([]string{ + "package sample", + "", + "func ContainsNeedle(haystack string, needle string) bool { return true }", + "func MatchesPattern(value string) bool { return true }", + "func AllowsAccess(userID string) bool { return true }", + "func ExistsInCache(key string) bool { return true }", + "func SupportedFormat(format string) bool { return true }", + "func ChangedSince(version int) bool { return true }", + "func ReadableBy(userID string) bool { return true }", + "func CompatibleWith(version string) bool { return true }", + "func CompleteEnough(score int) bool { return true }", + "func ForbiddenFor(role string) bool { return true }", + "func IncludedInPlan(plan string) bool { return true }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "naming.boolean-not-predicate") +} + +func TestGoLocalRowAssignmentsDoNotLookLikeMutableGlobals(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "collections.go"), strings.Join([]string{ + "package sample", + "", + "type CollectionItem struct{ ID string }", + "", + "func BuildItems(ids []string) []CollectionItem {", + "\titems := make([]CollectionItem, 0, len(ids))", + "\tvar row CollectionItem", + "\tfor _, id := range ids {", + "\t\trow = CollectionItem{ID: id}", + "\t\titems = append(items, row)", + "\t}", + "\treturn items", + "}", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.mutable-global-state") +} + +func TestPostgresRepositoryAllowsPgxQueriesAndRowsAffectedResults(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "platform", "storage", "postgres", "collections_repository.go"), strings.Join([]string{ + "package postgres", + "", + "import \"context\"", + "", + "type Collection struct{ ID string }", + "type Row interface{ Scan(...any) error }", + "type CommandTag interface{ RowsAffected() int64 }", + "type PgxPool interface{ QueryRow(context.Context, string, ...any) Row; Exec(context.Context, string, ...any) (CommandTag, error) }", + "type CollectionsRepository struct{ pool PgxPool }", + "", + "func (r *CollectionsRepository) FindCollection(ctx context.Context, id string) (*Collection, error) {", + "\tif err := validateCollectionID(id); err != nil {", + "\t\treturn nil, err", + "\t}", + "\trow := r.pool.QueryRow(ctx, \"select id from collections where id=$1\", id)", + "\treturn scanCollection(row)", + "}", + "", + "func (r *CollectionsRepository) LikeCollection(ctx context.Context, userID string, collectionID string) (bool, error) {", + "\tif err := validateCollectionID(collectionID); err != nil {", + "\t\treturn false, err", + "\t}", + "\ttag, err := r.pool.Exec(ctx, \"insert into collection_likes(user_id, collection_id) values($1, $2) on conflict do nothing\", userID, collectionID)", + "\tif err != nil {", + "\t\treturn false, err", + "\t}", + "\treturn tag.RowsAffected() > 0, nil", + "}", + "", + "func (r *CollectionsRepository) UnlikeCollection(ctx context.Context, userID string, collectionID string) (bool, error) {", + "\ttag, err := r.pool.Exec(ctx, \"delete from collection_likes where user_id=$1 and collection_id=$2\", userID, collectionID)", + "\tif err != nil {", + "\t\treturn false, err", + "\t}", + "\treturn tag.RowsAffected() > 0, nil", + "}", + "", + "func validateCollectionID(string) error { return nil }", + "func scanCollection(Row) (*Collection, error) { return &Collection{}, nil }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "function.mixed-abstraction-level") + assertFindingRuleAbsent(t, report, "Code Quality", "function.command-query-mix") + assertFindingRuleAbsent(t, report, "Code Quality", "function.partial-result") +} + +func TestPostgresStorageAdapterAllowsPgxQueriesOutsideRepositoryFilename(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "platform", "storage", "postgres", "collection_store.go"), strings.Join([]string{ + "package postgres", + "", + "import \"context\"", + "", + "type Collection struct{ ID string }", + "type Row interface{ Scan(...any) error }", + "type PgxPool interface{ QueryRow(context.Context, string, ...any) Row }", + "type Store struct{ pool PgxPool }", + "", + "func (s *Store) LoadCollection(ctx context.Context, id string) (*Collection, error) {", + "\tif err := validateCollectionID(id); err != nil {", + "\t\treturn nil, err", + "\t}", + "\trow := s.pool.QueryRow(ctx, \"select id from collections where id=$1\", id)", + "\treturn scanCollection(row)", + "}", + "", + "func validateCollectionID(string) error { return nil }", + "func scanCollection(Row) (*Collection, error) { return &Collection{}, nil }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir)) + + assertFindingRuleAbsent(t, report, "Code Quality", "function.mixed-abstraction-level") +} + func TestFunctionPrecisionSkipsExplicitSingleResponsibility(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "clean.ts"), strings.Join([]string{