From 2fde7bb41171bf79f7835b462f4ae61cbec00a04 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Mon, 27 Jul 2026 21:02:44 -0400 Subject: [PATCH 1/2] fix: harden react false positive handling --- .../checks/design/local_abstraction.go | 68 +++++- .../checks/quality/quality_defensive.go | 23 +- .../checks/quality/quality_precision.go | 41 ++-- .../quality_precision_mutation_targets.go | 52 ++++- .../quality/quality_precision_react_hooks.go | 7 + .../quality/quality_precision_support.go | 36 +++ .../quality_precision_ui_conventions.go | 103 ++++++++- .../quality_precision_workstreams_cd.go | 10 +- .../checks/quality/quality_smells.go | 6 + .../checks/quality/quality_smells_ui.go | 51 +++++ tests/checks/design_local_abstraction_test.go | 127 ++++++++++ .../function_hidden_mutation_noise_test.go | 60 +++++ ...uality_ui_false_positive_hardening_test.go | 216 ++++++++++++++++++ 13 files changed, 762 insertions(+), 38 deletions(-) create mode 100644 internal/codeguard/checks/quality/quality_precision_support.go create mode 100644 internal/codeguard/checks/quality/quality_smells_ui.go diff --git a/internal/codeguard/checks/design/local_abstraction.go b/internal/codeguard/checks/design/local_abstraction.go index 01889d5..f130573 100644 --- a/internal/codeguard/checks/design/local_abstraction.go +++ b/internal/codeguard/checks/design/local_abstraction.go @@ -111,6 +111,7 @@ func leakFindings(env support.Context, file string, source string) []core.Findin domainPath := isDomainPath(file) apiPath := isAPIPath(file) handlerPath := isHandlerPath(file) + persistenceBoundaryPath := domainPath || apiPath || handlerPath || isContractBoundaryPath(file) for idx, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "#") { @@ -121,10 +122,10 @@ func leakFindings(env support.Context, file string, source string) []core.Findin findings = append(findings, designFinding(env, ruleInfrastructureLeak, file, lineNo, "infrastructure/framework type leaks into a domain or public boundary", core.ConfidenceHigh)) } - if (apiPath || handlerPath || isPublicDeclaration(trimmed)) && persistenceLeakPattern.MatchString(trimmed) && - !allowedGeneratedPersistenceEnumLine(trimmed) && !allowedTypeScriptRecordUtilityLine(trimmed) { + if persistenceBoundaryPath && (apiPath || handlerPath || isPublicDeclaration(trimmed)) && persistenceLeakPattern.MatchString(trimmed) && + !allowedGeneratedPersistenceEnumLine(trimmed) && !allowedTypeScriptRecordUtilityLine(trimmed) && !allowedUIPropsDerivedTypeLine(file, trimmed) { findings = append(findings, designFinding(env, rulePersistenceLeak, file, lineNo, - "persistence model or ORM concept leaks through a public/API boundary", core.ConfidenceHigh)) + fmt.Sprintf("persistence model or ORM concept leaks through boundary at %s:%d: %s", file, lineNo, findingLineExcerpt(trimmed)), core.ConfidenceHigh)) } if domainPath && configLeakPattern.MatchString(trimmed) { findings = append(findings, designFinding(env, ruleConfigurationLeak, file, lineNo, @@ -145,6 +146,18 @@ func allowedTypeScriptRecordUtilityLine(line string) bool { !strings.Contains(line, "Row") } +func allowedUIPropsDerivedTypeLine(file string, line string) bool { + if !isFrontendUIPath(file) { + return false + } + trimmed := strings.TrimSpace(line) + return strings.Contains(trimmed, "Props") || + strings.Contains(trimmed, "ComponentProps") || + strings.Contains(trimmed, "Pick<") || + strings.Contains(trimmed, "Omit<") || + strings.Contains(trimmed, "typeof ") +} + func allowedGeneratedPersistenceEnumLine(line string) bool { lowered := strings.ToLower(line) if !strings.Contains(lowered, "from") || !strings.Contains(lowered, "@prisma/client") { @@ -441,8 +454,44 @@ func isDomainPath(file string) bool { func isAPIPath(file string) bool { normalized := strings.ToLower(filepathSlash(file)) - return strings.Contains(normalized, "/api/") || strings.Contains(normalized, "/contract/") || - strings.Contains(normalized, "/contracts/") + if strings.Contains(normalized, "/api/") { + return true + } + if isFrontendUIPath(file) { + return false + } + return strings.Contains(normalized, "/contract/") +} + +func isContractBoundaryPath(file string) bool { + normalized := strings.ToLower(filepathSlash(file)) + if isFrontendUIPath(file) { + return false + } + return strings.Contains(normalized, "/contracts/") || + strings.Contains(normalized, "/dto/") || + strings.Contains(normalized, "/schema/") +} + +func isFrontendUIPath(file string) bool { + normalized := strings.ToLower(filepathSlash(file)) + if strings.Contains(normalized, "/api/") || + strings.Contains(normalized, "/server/") || + strings.Contains(normalized, "/backend/") || + strings.Contains(normalized, "/route.") { + return false + } + return strings.Contains(normalized, "/_components/") || + strings.Contains(normalized, "/components/") || + strings.Contains(normalized, "/screens/") || + strings.Contains(normalized, "/navigation/") || + strings.Contains(normalized, "/hooks/") || + strings.Contains(normalized, "/packages/ui/") || + strings.Contains(normalized, "/packages/design-system/") || + strings.Contains(normalized, "/apps/mobile/") || + strings.Contains(normalized, "/apps/native/") || + strings.Contains(normalized, "/react-native/") || + strings.Contains(normalized, "/app/") && strings.Contains(normalized, "web/") } func isHandlerPath(file string) bool { @@ -485,3 +534,12 @@ func designFinding(env support.Context, ruleID string, file string, line int, me Confidence: confidence, }) } + +func findingLineExcerpt(line string) string { + const maxExcerptLen = 120 + line = strings.Join(strings.Fields(line), " ") + if len(line) <= maxExcerptLen { + return line + } + return line[:maxExcerptLen-1] + "…" +} diff --git a/internal/codeguard/checks/quality/quality_defensive.go b/internal/codeguard/checks/quality/quality_defensive.go index 3898419..81e4a61 100644 --- a/internal/codeguard/checks/quality/quality_defensive.go +++ b/internal/codeguard/checks/quality/quality_defensive.go @@ -53,7 +53,7 @@ func defensiveBoundaryFindings(env support.Context, file string, fn precisionFun findings = append(findings, precisionWarnFinding(env, defensiveNullAssumptionRuleID, file, line, "nullable boundary value is dereferenced without a nil/null guard", core.ConfidenceMedium)) } - if line, ok := integerOverflowLine(fn, loweredBody); ok { + if line, ok := integerOverflowLine(file, fn, loweredBody); ok { findings = append(findings, precisionWarnFinding(env, defensiveIntegerOverflowRuleID, file, line, "arithmetic on count, size, or length input lacks an overflow bound check", core.ConfidenceMedium)) } @@ -182,7 +182,10 @@ func firstUseLine(fn precisionFunction, name string) int { return fn.StartLine } -func integerOverflowLine(fn precisionFunction, loweredBody string) (int, bool) { +func integerOverflowLine(file string, fn precisionFunction, loweredBody string) (int, bool) { + if isUIRenderArithmeticContext(file, fn, loweredBody) { + return 0, false + } if containsAny(loweredBody, []string{"maxint", "math.max", "checked", "saturating", "overflow", "limits<", "safeint"}) { return 0, false } @@ -195,6 +198,22 @@ func integerOverflowLine(fn precisionFunction, loweredBody string) (int, bool) { return 0, false } +func isUIRenderArithmeticContext(file string, fn precisionFunction, loweredBody string) bool { + if isUIHelperOrMappingContext(file, fn) { + return true + } + if !isScriptLikeSourcePath(file) || !isLikelyUIFile(file) { + return false + } + if isUIRenderHelperName(fn.Name) || isUIRenderMappingBody(fn.Body) { + return true + } + return containsAny(loweredBody, []string{ + "stylesheet.", "dimensions.", "pixelratio.", "fontscale", "spacing", + "padding", "margin", "width", "height", "opacity", "zindex", + }) +} + func boundsAssumptionLine(fn precisionFunction, loweredBody string) (int, bool) { if containsAny(loweredBody, []string{"len(", ".length", ".size()", "empty()", "bounds", "range", "count >"}) { return 0, false diff --git a/internal/codeguard/checks/quality/quality_precision.go b/internal/codeguard/checks/quality/quality_precision.go index 2d7e2a6..a2f13bd 100644 --- a/internal/codeguard/checks/quality/quality_precision.go +++ b/internal/codeguard/checks/quality/quality_precision.go @@ -68,22 +68,11 @@ type precisionFunction struct { Assignments []support.ParsedAssignment Calls []support.ParsedCall Statements []support.ParsedStatement + Nested []precisionLineRange Body string Returns bool } -func precisionWarnFinding(env support.Context, ruleID string, file string, line int, message string, confidence string) core.Finding { - return env.NewFinding(support.FindingInput{ - RuleID: ruleID, - Level: "warn", - Path: file, - Line: line, - Column: 1, - Message: message, - Confidence: confidence, - }) -} - func localPrecisionEnabled(env support.Context) bool { return env.Config.Checks.QualityRules.LocalPrecision == nil || *env.Config.Checks.QualityRules.LocalPrecision } @@ -271,7 +260,7 @@ func goDuplicatedKnowledgeFindings(env support.Context, file string, fset *token } if first, exists := seen[lit.Value]; exists { return []core.Finding{precisionWarnFinding(env, qualityDuplicatedKnowledgeRuleID, file, fset.Position(expr.Pos()).Line, - fmt.Sprintf("business literal is duplicated near line %d; centralize shared domain knowledge", fset.Position(first).Line), core.ConfidenceLow)} + fmt.Sprintf("business literal %s is duplicated near line %d; centralize shared domain knowledge", lit.Value, fset.Position(first).Line), core.ConfidenceLow)} } seen[lit.Value] = expr.Pos() } @@ -369,6 +358,7 @@ func parsedPrecisionFunction(fn *support.ParsedFunction) precisionFunction { Assignments: fn.Assignments, Calls: fn.Calls, Statements: fn.Statements, + Nested: nestedPrecisionLineRanges(fn), Body: body, Returns: strings.Contains(body, "return "), } @@ -482,8 +472,9 @@ func hiddenSideEffect(fn precisionFunction) bool { if !queryFunctionPrefixPattern.MatchString(strings.ToLower(fn.Name)) { return false } - for _, call := range fn.Calls { - if mutatingCallPattern.MatchString(call.Callee) { + localTargets := localMutationTargets(fn) + for _, call := range directCalls(fn) { + if mutatingCallPattern.MatchString(call.Callee) && !isLocalMutationCall(call.Callee, localTargets) { return true } } @@ -527,8 +518,9 @@ func commandQueryMix(file string, fn precisionFunction) bool { if !queryFunctionPrefixPattern.MatchString(name) && !strings.Contains(fn.Body, "return ") { return false } - for _, call := range fn.Calls { - if mutatingCallPattern.MatchString(call.Callee) { + localTargets := localMutationTargets(fn) + for _, call := range directCalls(fn) { + if mutatingCallPattern.MatchString(call.Callee) && !isLocalMutationCall(call.Callee, localTargets) { return true } } @@ -759,8 +751,8 @@ func domainKnowledgeLiteral(value string) bool { if len(trimmed) < 4 && !strings.ContainsAny(trimmed, "0123456789") { return false } - if _, err := strconv.Atoi(trimmed); err == nil { - return true + if numeric, ok := duplicatedKnowledgeNumber(trimmed); ok { + return numeric >= 10 } if likelyDisplayLabel(trimmed) { return false @@ -768,6 +760,17 @@ func domainKnowledgeLiteral(value string) bool { return domainPrimitiveNamePattern.MatchString(trimmed) || strings.Contains(trimmed, "_") } +func duplicatedKnowledgeNumber(value string) (int, bool) { + number, err := strconv.Atoi(value) + if err != nil { + return 0, false + } + if number < 0 { + number = -number + } + return number, true +} + func likelyDisplayLabel(value string) bool { if strings.Contains(value, "_") { return false diff --git a/internal/codeguard/checks/quality/quality_precision_mutation_targets.go b/internal/codeguard/checks/quality/quality_precision_mutation_targets.go index f880b99..31f367e 100644 --- a/internal/codeguard/checks/quality/quality_precision_mutation_targets.go +++ b/internal/codeguard/checks/quality/quality_precision_mutation_targets.go @@ -15,7 +15,7 @@ var localAccumulatorExprPattern = regexp.MustCompile(`(?i)^(?:new\s+)?(?:array|f func localMutationTargets(fn precisionFunction) map[string]struct{} { params := paramNames(fn) targets := make(map[string]struct{}) - for _, assignment := range fn.Assignments { + for _, assignment := range directAssignments(fn) { name := strings.TrimSpace(assignment.Name) if name == "" || assignment.Augmented { continue @@ -30,6 +30,54 @@ func localMutationTargets(fn precisionFunction) map[string]struct{} { return targets } +func directAssignments(fn precisionFunction) []support.ParsedAssignment { + if len(fn.Nested) == 0 { + return fn.Assignments + } + assignments := make([]support.ParsedAssignment, 0, len(fn.Assignments)) + for _, assignment := range fn.Assignments { + if !callInNestedFunction(fn, assignment.Line) { + assignments = append(assignments, assignment) + } + } + return assignments +} + +func directCalls(fn precisionFunction) []support.ParsedCall { + if len(fn.Nested) == 0 { + return fn.Calls + } + calls := make([]support.ParsedCall, 0, len(fn.Calls)) + for _, call := range fn.Calls { + if !callInNestedFunction(fn, call.Line) { + calls = append(calls, call) + } + } + return calls +} + +func directStatements(fn precisionFunction) []support.ParsedStatement { + if len(fn.Nested) == 0 { + return fn.Statements + } + statements := make([]support.ParsedStatement, 0, len(fn.Statements)) + for _, statement := range fn.Statements { + if !callInNestedFunction(fn, statement.Line) { + statements = append(statements, statement) + } + } + return statements +} + +func callInNestedFunction(fn precisionFunction, line int) bool { + for _, nested := range fn.Nested { + if line >= nested.Start && line <= nested.End { + return true + } + } + return false +} + func assignmentLooksLocalAccumulator(fn precisionFunction, assignment support.ParsedAssignment) bool { expr := strings.TrimSpace(assignment.Expr) if localAccumulatorExprPattern.MatchString(expr) { @@ -81,7 +129,7 @@ func isLocalMutationCall(callee string, localTargets map[string]struct{}) bool { func isBareLocalMutationCall(callee string) bool { switch strings.TrimSpace(callee) { - case "append", "Set": + case "append", "Set", "Array", "Object", "Map", "WeakMap", "WeakSet": return true default: return false diff --git a/internal/codeguard/checks/quality/quality_precision_react_hooks.go b/internal/codeguard/checks/quality/quality_precision_react_hooks.go index fa8c764..f81b719 100644 --- a/internal/codeguard/checks/quality/quality_precision_react_hooks.go +++ b/internal/codeguard/checks/quality/quality_precision_react_hooks.go @@ -40,6 +40,13 @@ func isReactHookStateBoundary(file string, fn precisionFunction) bool { return false } +func isReactLocalStateBoundary(file string, fn precisionFunction) bool { + if isReactHookStateBoundary(file, fn) { + return true + } + return isReactComponentOrHookBoundary(file, fn) && callsReactLocalStateSetter(fn) +} + func isScriptLikeSourcePath(file string) bool { lowered := strings.ToLower(file) return strings.HasSuffix(lowered, ".ts") || strings.HasSuffix(lowered, ".tsx") || diff --git a/internal/codeguard/checks/quality/quality_precision_support.go b/internal/codeguard/checks/quality/quality_precision_support.go new file mode 100644 index 0000000..2618694 --- /dev/null +++ b/internal/codeguard/checks/quality/quality_precision_support.go @@ -0,0 +1,36 @@ +package quality + +import ( + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type precisionLineRange struct { + Start int + End int +} + +func precisionWarnFinding(env support.Context, ruleID string, file string, line int, message string, confidence string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: ruleID, + Level: "warn", + Path: file, + Line: line, + Column: 1, + Message: message, + Confidence: confidence, + }) +} + +func nestedPrecisionLineRanges(fn *support.ParsedFunction) []precisionLineRange { + out := make([]precisionLineRange, 0, len(fn.Nested)) + var collect func(items []*support.ParsedFunction) + collect = func(items []*support.ParsedFunction) { + for _, nested := range items { + out = append(out, precisionLineRange{Start: nested.StartLine, End: nested.EndLine}) + collect(nested.Nested) + } + } + collect(fn.Nested) + return out +} diff --git a/internal/codeguard/checks/quality/quality_precision_ui_conventions.go b/internal/codeguard/checks/quality/quality_precision_ui_conventions.go index b1533ef..d91ca37 100644 --- a/internal/codeguard/checks/quality/quality_precision_ui_conventions.go +++ b/internal/codeguard/checks/quality/quality_precision_ui_conventions.go @@ -13,6 +13,9 @@ func isReactComponentOrHookBoundary(file string, fn precisionFunction) bool { if isReactHookName(fn.Name) || isReactHookFile(file) { return true } + if isReactNativeComponentOrScreenBoundary(file, fn) { + return true + } if isTSXLikeSourcePath(file) && isReactComponentName(fn.Name) { return true } @@ -34,6 +37,44 @@ func isReactComponentName(name string) bool { return first >= 'A' && first <= 'Z' } +func isEventHandlerName(name string) bool { + name = strings.TrimSpace(name) + if len(name) > len("on") && strings.HasPrefix(name, "on") { + next := rune(name[len("on")]) + return next >= 'A' && next <= 'Z' + } + if len(name) <= len("handle") || !strings.HasPrefix(name, "handle") { + return false + } + next := rune(name[len("handle")]) + return next >= 'A' && next <= 'Z' +} + +func isReactNativeComponentOrScreenBoundary(file string, fn precisionFunction) bool { + if !isScriptLikeSourcePath(file) { + return false + } + if !isReactNativeContext(file, fn) { + return false + } + return isReactComponentName(fn.Name) || strings.Contains(strings.ToLower(fn.Name), "screen") +} + +func isReactNativeContext(file string, fn precisionFunction) bool { + normalized := strings.ToLower(strings.ReplaceAll(file, "\\", "/")) + if strings.Contains(normalized, ".native.") || + strings.Contains(normalized, "/screens/") || + strings.Contains(normalized, "/screen/") { + return true + } + body := strings.ToLower(fn.Body) + return strings.Contains(body, "react-native") || + strings.Contains(body, "stylesheet.create") || + strings.Contains(body, "{props.contract.id};", + "}", + }, "\n")) + writeFile(t, filepath.Join(dir, "packages", "api", "contracts", "contract.ts"), strings.Join([]string{ + "export type ContractModel = { id: string };", + }, "\n")) + + report := runDesignLocalScan(t, designLocalConfig(dir, "typescript")) + + assertFindingRulePresent(t, report, "Design Patterns", "design.persistence-model-leak") + for _, section := range report.Sections { + if section.Name != "Design Patterns" { + continue + } + for _, finding := range section.Findings { + if finding.RuleID == "design.persistence-model-leak" && strings.Contains(finding.Path, "contract-card.tsx") { + t.Fatalf("frontend UI props should not leak persistence model: %+v", finding) + } + } + } +} + +func TestDesignPersistenceModelLeakSkipsReactAndReactNativePresentationPaths(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps", "web", "app", "(legal)", "contracts", "[id]", "_components", "contract-detail.tsx"), strings.Join([]string{ + "import type { ContractStatus } from '@prisma/client';", + "type ContractModel = { id: string; status: ContractStatus };", + "export type ContractDetailProps = {", + " contract: ContractModel;", + "};", + "export const contractStatusLabels: Record = {", + " DRAFT: 'Draft',", + "};", + "export function ContractDetail(props: ContractDetailProps) {", + " return {props.contract.id};", + "}", + }, "\n")) + writeFile(t, filepath.Join(dir, "apps", "mobile", "src", "screens", "contracts", "ContractDetailsScreen.tsx"), strings.Join([]string{ + "import type { ContractStatus } from '@prisma/client';", + "type ContractModel = { id: string; status: ContractStatus };", + "export type ContractDetailsScreenProps = {", + " contract: ContractModel;", + "};", + "export const contractStatusOptions: Array<{ value: ContractStatus; label: string }> = [];", + "export function ContractDetailsScreen(props: ContractDetailsScreenProps) {", + " return {props.contract.id};", + "}", + }, "\n")) + writeFile(t, filepath.Join(dir, "packages", "ui", "src", "components", "ContractBadge.tsx"), strings.Join([]string{ + "type ContractModel = { id: string };", + "export type ContractBadgeProps = { contract: ContractModel };", + "export function ContractBadge(props: ContractBadgeProps) {", + " return {props.contract.id};", + "}", + }, "\n")) + writeFile(t, filepath.Join(dir, "packages", "api", "contracts", "public.ts"), strings.Join([]string{ + "export type ContractModel = { id: string };", + }, "\n")) + + report := runDesignLocalScan(t, designLocalConfig(dir, "typescript")) + + assertFindingRulePresent(t, report, "Design Patterns", "design.persistence-model-leak") + for _, section := range report.Sections { + if section.Name != "Design Patterns" { + continue + } + for _, finding := range section.Findings { + if finding.RuleID != "design.persistence-model-leak" { + continue + } + if strings.Contains(finding.Path, "contract-detail.tsx") || + strings.Contains(finding.Path, "ContractDetailsScreen.tsx") || + strings.Contains(finding.Path, "ContractBadge.tsx") { + t.Fatalf("React/React Native presentation path should not leak persistence model: %+v", finding) + } + } + } +} + +func TestDesignPersistenceModelLeakKeepsAPIAndDomainBoundaries(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps", "web", "app", "api", "contracts", "route.ts"), strings.Join([]string{ + "export type ContractModel = { id: string };", + "export async function GET() {", + " return Response.json({ ok: true });", + "}", + }, "\n")) + writeFile(t, filepath.Join(dir, "packages", "domain", "contracts", "model.ts"), strings.Join([]string{ + "export type ContractRecord = { id: string };", + }, "\n")) + + report := runDesignLocalScan(t, designLocalConfig(dir, "typescript")) + + assertFindingRulePresent(t, report, "Design Patterns", "design.persistence-model-leak") + foundAPI := false + foundDomain := false + for _, section := range report.Sections { + if section.Name != "Design Patterns" { + continue + } + for _, finding := range section.Findings { + if finding.RuleID != "design.persistence-model-leak" { + continue + } + if !strings.Contains(finding.Message, "Contract") { + t.Fatalf("persistence leak finding should include source context: %+v", finding) + } + foundAPI = foundAPI || strings.Contains(finding.Path, "route.ts") + foundDomain = foundDomain || strings.Contains(finding.Path, "model.ts") + } + } + if !foundAPI { + t.Fatal("expected API route persistence model leak") + } + if !foundDomain { + t.Fatal("expected domain persistence record leak") + } +} diff --git a/tests/checks/function_hidden_mutation_noise_test.go b/tests/checks/function_hidden_mutation_noise_test.go index 0bca437..d2c08b4 100644 --- a/tests/checks/function_hidden_mutation_noise_test.go +++ b/tests/checks/function_hidden_mutation_noise_test.go @@ -172,6 +172,66 @@ func TestFunctionHiddenMutationAllowsNextRouteHandlerNames(t *testing.T) { } } +func TestFunctionHiddenMutationDoesNotBubbleNestedUICallbackMutationToComponent(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/web/app/claims/_components/claim-card.tsx"), strings.Join([]string{ + "export function ClaimCard(repo: Repository, claim: Claim) {", + " async function onSave() {", + " await repo.save(claim);", + " }", + " return ;", + "}", + "interface Repository { save(input: unknown): Promise }", + "interface Claim { id: string }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "function.hidden-mutation") +} + +func TestFunctionHiddenMutationAllowsReactNativeLocalStateAndHandlers(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/mobile/src/screens/ProfileScreen.tsx"), strings.Join([]string{ + "import { FlatList, Pressable, Text, View } from 'react-native';", + "import { useState } from 'react';", + "export function ProfileScreen({ users }: Props) {", + " const [selected, setSelected] = useState(null);", + " const visibleIds = new Set();", + " users.forEach((item) => visibleIds.add(item.id));", + " function handlePress(value: string) {", + " setSelected(value);", + " }", + " return handlePress(item.id)}>{item.name}} />;", + "}", + "interface Props { users: Array<{ id: string; name: string }> }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "function.hidden-mutation") +} + +func TestFunctionHiddenMutationStillWarnsForReactNativeCollaboratorMutation(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/mobile/src/screens/ProfileScreen.tsx"), strings.Join([]string{ + "import { Pressable, Text } from 'react-native';", + "export function ProfileScreen(repo: Repository, user: User) {", + " function loadUser() {", + " repo.save(user);", + " return user;", + " }", + " return {user.name};", + "}", + "interface Repository { save(input: User): void }", + "interface User { name: string }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRulePresent(t, report, "Code Quality", "function.hidden-mutation") +} + func TestFunctionHiddenMutationAllowsConventionalCommandNames(t *testing.T) { cases := []struct { name string diff --git a/tests/checks/quality_ui_false_positive_hardening_test.go b/tests/checks/quality_ui_false_positive_hardening_test.go index 3478f8c..ff9fb38 100644 --- a/tests/checks/quality_ui_false_positive_hardening_test.go +++ b/tests/checks/quality_ui_false_positive_hardening_test.go @@ -29,6 +29,28 @@ func TestQualityAmbiguousNameAllowsConventionalUIParams(t *testing.T) { assertFindingRuleAbsent(t, report, "Code Quality", "quality.ambiguous-name") } +func TestQualityAmbiguousNameAllowsReactNativeRenderParams(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/mobile/src/screens/ProfileScreen.tsx"), strings.Join([]string{ + "import { FlatList, Pressable, Text } from 'react-native';", + "export function ProfileScreen({ users }: Props) {", + " function renderItem({ item }: { item: User }) {", + " return item.onPress(item.id)}>{item.name};", + " }", + " function keyExtractor(value: User) {", + " return value.id;", + " }", + " return ;", + "}", + "interface User { id: string; name: string; onPress(id: string): void }", + "interface Props { users: User[] }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.ambiguous-name") +} + func TestFunctionCommandQueryMixAllowsReactAndNextBoundaries(t *testing.T) { cases := []struct { name string @@ -101,6 +123,28 @@ func TestQualityDuplicatedKnowledgeSkipsDisplayStringsAndIncludesLiteral(t *test } } +func TestQualityDuplicatedKnowledgeSkipsTrivialRepeatedNumbers(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/web/app/claims/_components/rows.tsx"), strings.Join([]string{ + "export const page = 0;", + "export const start = 0;", + "export const second = 2;", + "export const columns = 2;", + "export const statusA = 'claim_status_code';", + "export const statusB = 'claim_status_code';", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + finding := firstFindingForRule(t, report, "Code Quality", "quality.duplicated-knowledge") + if strings.Contains(finding.Message, " 0 ") || strings.Contains(finding.Message, " 2 ") { + t.Fatalf("expected duplicated domain literal instead of trivial number, got %q", finding.Message) + } + if !strings.Contains(finding.Message, "'claim_status_code'") { + t.Fatalf("expected duplicated domain literal in message, got %q", finding.Message) + } +} + func TestNamingCardinalityMismatchAllowsFrameworkConventions(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "apps/web/app/okrs/use-kr-drag.ts"), strings.Join([]string{ @@ -119,6 +163,22 @@ func TestNamingCardinalityMismatchAllowsFrameworkConventions(t *testing.T) { assertFindingRuleAbsent(t, report, "Code Quality", "naming.cardinality-mismatch") } +func TestNamingCardinalityMismatchAllowsCollectionSuffixesAndMapPairs(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/web/app/okrs/use-kr-drag.ts"), strings.Join([]string{ + "export function buildKrLookup(krIds: string[], entries: Map) {", + " entries.forEach((v: string, k: string) => {", + " console.log(k, v);", + " });", + " return krIds;", + "}", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "naming.cardinality-mismatch") +} + func TestQualityMutableGlobalStateIgnoresReactLocalBindings(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "apps/web/app/claims/claim-classification-fields.tsx"), strings.Join([]string{ @@ -151,6 +211,162 @@ func TestNamingBooleanNotPredicateAllowsUIPropsAndHandlers(t *testing.T) { assertFindingRuleAbsent(t, report, "Code Quality", "naming.boolean-not-predicate") } +func TestNamingBooleanNotPredicateAllowsHandlersAndResourceIdentifiers(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/web/app/contracts/actions.ts"), strings.Join([]string{ + "export function configure(onClick: () => void, onSave: () => Promise, databaseSecretRoleArn: string) {", + " const selectedArn = databaseSecretRoleArn;", + " return { onClick, onSave, selectedArn };", + "}", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "naming.boolean-not-predicate") +} + +func TestFunctionCommandQueryMixAllowsLocalBuilderMutation(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/web/lib/filters.ts"), strings.Join([]string{ + "export function buildAvailableFilters(rows: Row[]) {", + " const data = new Map();", + " for (const row of rows) {", + " data.set(row.id, row.label);", + " }", + " return data;", + "}", + "interface Row { id: string; label: string }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "function.command-query-mix") + assertFindingRuleAbsent(t, report, "Code Quality", "quality.hidden-side-effect") +} + +func TestReactNativeScreenAllowsUIBooleanAndLocalCollections(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/mobile/src/screens/ClaimsScreen.tsx"), strings.Join([]string{ + "import { FlatList, Pressable, Text } from 'react-native';", + "export function ClaimsScreen(open: boolean, loading: boolean, onPress: () => void, krIds: string[], rows: Row[]) {", + " const ids = new Set();", + " rows.forEach((item) => ids.add(item.id));", + " const data = Array.from(ids);", + " if (open && !loading) {", + " onPress();", + " }", + " return {item}} />;", + "}", + "interface Row { id: string }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "naming.boolean-not-predicate") + assertFindingRuleAbsent(t, report, "Code Quality", "naming.cardinality-mismatch") + assertFindingRuleAbsent(t, report, "Code Quality", "quality.mutable-global-state") + assertFindingRuleAbsent(t, report, "Code Quality", "function.command-query-mix") +} + +func TestUISmellAndOverflowRulesSkipMappingHelpers(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/web/app/claims/_components/claim-map.tsx"), strings.Join([]string{ + "export function renderClaimRows(props: Props) {", + " const width = props.table.columns.length * props.theme.spacing.size;", + " const label = props.claim.owner.profile.department.name.toUpperCase();", + " return {label}{width};", + "}", + "interface Props { table: any; theme: any; claim: any }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "smell.message-chain") + assertFindingRuleAbsent(t, report, "Code Quality", "smell.feature-envy") + assertFindingRuleAbsent(t, report, "Code Quality", "defensive.integer-overflow") +} + +func TestUISmellAndOverflowRulesSkipReactNativeRenderingHelpers(t *testing.T) { + cases := []struct { + name string + file string + body []string + }{ + { + name: "screen mapping helper", + file: "apps/mobile/src/screens/claims/claimRows.ts", + body: []string{ + "export function collectClaimRows(props: Props) {", + " const width = props.route.params.claim.items.length * props.theme.spacing.medium;", + " const label = props.route.params.claim.owner.profile.department.name.toUpperCase();", + " return props.route.params.claim.items.map((item) => ({", + " id: item.id,", + " title: label,", + " width,", + " }));", + "}", + "interface Props { route: any; theme: any }", + }, + }, + { + name: "native style helper", + file: "apps/mobile/src/components/ClaimCard.native.ts", + body: []string{ + "export function buildClaimCardStyles(theme: Theme, props: Props) {", + " const width = props.layout.window.size.width * theme.spacing.medium;", + " const color = props.route.params.claim.owner.profile.department.color;", + " return { width, color, padding: theme.spacing.small };", + "}", + "interface Theme { spacing: any }", + "interface Props { layout: any; route: any }", + }, + }, + { + name: "tsx react native component", + file: "apps/mobile/src/screens/claims/ClaimScreen.tsx", + body: []string{ + "import { View, Text } from 'react-native';", + "export function ClaimScreen(props: Props) {", + " const width = props.route.params.claim.items.length * props.theme.spacing.medium;", + " const label = props.route.params.claim.owner.profile.department.name.toUpperCase();", + " return {label}{width};", + "}", + "interface Props { route: any; theme: any }", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, tc.file), strings.Join(tc.body, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "smell.message-chain") + assertFindingRuleAbsent(t, report, "Code Quality", "smell.feature-envy") + assertFindingRuleAbsent(t, report, "Code Quality", "defensive.integer-overflow") + }) + } +} + +func TestSmellAndOverflowRulesStillFlagNonUIProductionCode(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "packages/domain/src/account-risk.ts"), strings.Join([]string{ + "export function scoreCustomer(customer: Customer, count: number) {", + " const score = count * 4096;", + " const code = customer.profile.address.country.region.zone.owner.name.toUpperCase();", + " return customer.profile.name + customer.profile.email + customer.account.region + customer.account.plan + customer.account.status + code + score;", + "}", + "interface Customer { profile: any; account: any }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertStructuralSmellPresent(t, report, "smell.message-chain") + assertStructuralSmellPresent(t, report, "smell.feature-envy") + assertStructuralSmellPresent(t, report, "defensive.integer-overflow") +} + func firstFindingForRule(t *testing.T, report codeguard.Report, sectionName string, ruleID string) codeguard.Finding { t.Helper() for _, section := range report.Sections { From 220826eb2d9fea116013fb531bdeb88596a93ce5 Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Mon, 27 Jul 2026 21:17:41 -0400 Subject: [PATCH 2/2] fix: add next and nest framework precision coverage --- .../checks/design/local_abstraction.go | 20 +++++- .../checks/quality/quality_precision.go | 9 ++- .../quality/quality_precision_frameworks.go | 27 ++++++++ .../quality_precision_ui_conventions.go | 3 + .../quality_precision_workstreams_cd.go | 11 ++-- .../checks/support/framework_paths.go | 37 +++++++++++ tests/checks/design_local_abstraction_test.go | 36 +++++++++++ .../function_hidden_mutation_noise_test.go | 62 +++++++++++++++++++ ...uality_ui_false_positive_hardening_test.go | 59 ++++++++++++++++++ 9 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 internal/codeguard/checks/quality/quality_precision_frameworks.go create mode 100644 internal/codeguard/checks/support/framework_paths.go diff --git a/internal/codeguard/checks/design/local_abstraction.go b/internal/codeguard/checks/design/local_abstraction.go index f130573..a468f79 100644 --- a/internal/codeguard/checks/design/local_abstraction.go +++ b/internal/codeguard/checks/design/local_abstraction.go @@ -123,7 +123,8 @@ func leakFindings(env support.Context, file string, source string) []core.Findin "infrastructure/framework type leaks into a domain or public boundary", core.ConfidenceHigh)) } if persistenceBoundaryPath && (apiPath || handlerPath || isPublicDeclaration(trimmed)) && persistenceLeakPattern.MatchString(trimmed) && - !allowedGeneratedPersistenceEnumLine(trimmed) && !allowedTypeScriptRecordUtilityLine(trimmed) && !allowedUIPropsDerivedTypeLine(file, trimmed) { + !allowedGeneratedPersistenceEnumLine(trimmed) && !allowedTypeScriptRecordUtilityLine(trimmed) && + !allowedUIPropsDerivedTypeLine(file, trimmed) && !allowedFrameworkDTOBoundaryLine(file, trimmed) { findings = append(findings, designFinding(env, rulePersistenceLeak, file, lineNo, fmt.Sprintf("persistence model or ORM concept leaks through boundary at %s:%d: %s", file, lineNo, findingLineExcerpt(trimmed)), core.ConfidenceHigh)) } @@ -158,6 +159,23 @@ func allowedUIPropsDerivedTypeLine(file string, line string) bool { strings.Contains(trimmed, "typeof ") } +func allowedFrameworkDTOBoundaryLine(file string, line string) bool { + if !support.IsNestJSBoundaryPath(file) { + return false + } + trimmed := strings.TrimSpace(line) + if !strings.Contains(trimmed, "Dto") && !strings.Contains(trimmed, "DTO") { + return false + } + lowered := strings.ToLower(trimmed) + for _, blocked := range []string{"prismaclient", "prisma.", "typeorm", "sequelize", "sqlalchemy", "@entity", "entity", "model", "record", "row", "orm"} { + if strings.Contains(lowered, blocked) { + return false + } + } + return true +} + func allowedGeneratedPersistenceEnumLine(line string) bool { lowered := strings.ToLower(line) if !strings.Contains(lowered, "from") || !strings.Contains(lowered, "@prisma/client") { diff --git a/internal/codeguard/checks/quality/quality_precision.go b/internal/codeguard/checks/quality/quality_precision.go index a2f13bd..7354765 100644 --- a/internal/codeguard/checks/quality/quality_precision.go +++ b/internal/codeguard/checks/quality/quality_precision.go @@ -416,7 +416,7 @@ func precisionFunctionFindings(env support.Context, file string, fn precisionFun findings = append(findings, precisionWarnFinding(env, qualityPrimitiveObsessionRuleID, file, fn.StartLine, fmt.Sprintf("function %s passes several domain concepts as raw primitives", fn.Name), core.ConfidenceMedium)) } - if hiddenSideEffect(fn) { + if hiddenSideEffect(file, fn) { findings = append(findings, precisionWarnFinding(env, qualityHiddenSideEffectRuleID, file, fn.StartLine, fmt.Sprintf("function %s name implies a query/build operation but it performs side effects", fn.Name), core.ConfidenceMedium)) } @@ -468,7 +468,10 @@ func primitiveObsession(fn precisionFunction) bool { return count >= 3 } -func hiddenSideEffect(fn precisionFunction) bool { +func hiddenSideEffect(file string, fn precisionFunction) bool { + if isFrameworkOrchestrationBoundary(file, fn) { + return false + } if !queryFunctionPrefixPattern.MatchString(strings.ToLower(fn.Name)) { return false } @@ -508,7 +511,7 @@ func isDomainLevelCall(callee string) bool { } func commandQueryMix(file string, fn precisionFunction) bool { - if isFrameworkCommandBoundary(file, fn.Name) || isReactComponentOrHookBoundary(file, fn) { + if isFrameworkOrchestrationBoundary(file, fn) || isReactComponentOrHookBoundary(file, fn) { return false } if !fn.Returns { diff --git a/internal/codeguard/checks/quality/quality_precision_frameworks.go b/internal/codeguard/checks/quality/quality_precision_frameworks.go new file mode 100644 index 0000000..c185d0d --- /dev/null +++ b/internal/codeguard/checks/quality/quality_precision_frameworks.go @@ -0,0 +1,27 @@ +package quality + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" +) + +func isFrameworkOrchestrationBoundary(file string, fn precisionFunction) bool { + return isFrameworkCommandBoundary(file, fn.Name) || isNestJSRequestBoundary(file) +} + +func isFrameworkConventionalAmbiguousName(file string, fn precisionFunction, name string) bool { + if !isFrameworkOrchestrationBoundary(file, fn) { + return false + } + switch strings.ToLower(strings.Trim(name, "_$")) { + case "data", "value", "values", "item", "items": + return true + default: + return false + } +} + +func isNestJSRequestBoundary(file string) bool { + return isScriptLikeSourcePath(file) && support.IsNestJSBoundaryPath(file) +} diff --git a/internal/codeguard/checks/quality/quality_precision_ui_conventions.go b/internal/codeguard/checks/quality/quality_precision_ui_conventions.go index d91ca37..23c5fd4 100644 --- a/internal/codeguard/checks/quality/quality_precision_ui_conventions.go +++ b/internal/codeguard/checks/quality/quality_precision_ui_conventions.go @@ -83,6 +83,9 @@ func isUIConventionalAmbiguousName(file string, fn precisionFunction, name strin if !isUIConventionalAmbiguousToken(normalized) { return false } + if isFrameworkConventionalAmbiguousName(file, fn, name) { + return true + } if isReactComponentOrHookBoundary(file, fn) { return true } diff --git a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go index 7d19742..eb1f3ef 100644 --- a/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go +++ b/internal/codeguard/checks/quality/quality_precision_workstreams_cd.go @@ -50,7 +50,7 @@ var ( func additionalPrecisionFunctionFindings(env support.Context, file string, fn precisionFunction) []core.Finding { findings := make([]core.Finding, 0, 8) - if behaviorMismatch(fn) { + if behaviorMismatch(file, fn) { findings = append(findings, precisionWarnFinding(env, namingBehaviorMismatchRuleID, file, fn.StartLine, fmt.Sprintf("function %s name conflicts with observed query/command behavior", fn.Name), core.ConfidenceMedium)) } @@ -153,9 +153,12 @@ func sourceNamingFindings(env support.Context, file string, source string) []cor return findings } -func behaviorMismatch(fn precisionFunction) bool { +func behaviorMismatch(file string, fn precisionFunction) bool { + if isFrameworkOrchestrationBoundary(file, fn) { + return false + } name := strings.ToLower(fn.Name) - if hiddenSideEffect(fn) { + if hiddenSideEffect(file, fn) { return true } if !commandFunctionPrefixPattern.MatchString(name) || mutatingFunctionEvidence(fn) { @@ -170,7 +173,7 @@ func behaviorMismatch(fn precisionFunction) bool { } func hiddenMutation(file string, fn precisionFunction) bool { - if explicitMutationName(fn.Name) || isFrameworkCommandBoundary(file, fn.Name) || isScriptEntrypoint(file, fn.Name) { + if explicitMutationName(fn.Name) || isFrameworkOrchestrationBoundary(file, fn) || isScriptEntrypoint(file, fn.Name) { return false } mutatesParam := mutatesParameter(fn) diff --git a/internal/codeguard/checks/support/framework_paths.go b/internal/codeguard/checks/support/framework_paths.go new file mode 100644 index 0000000..a02cf98 --- /dev/null +++ b/internal/codeguard/checks/support/framework_paths.go @@ -0,0 +1,37 @@ +package support + +import ( + "path/filepath" + "strings" +) + +// IsNestJSBoundaryPath recognizes Nest request-boundary modules where +// decorators and framework conventions define command/orchestration behavior. +func IsNestJSBoundaryPath(file string) bool { + normalized := strings.ToLower(strings.ReplaceAll(file, "\\", "/")) + base := filepath.Base(normalized) + if strings.Contains(normalized, "/controllers/") || + strings.Contains(normalized, "/resolvers/") || + strings.Contains(normalized, "/gateways/") { + return true + } + for _, suffix := range []string{ + ".controller.ts", + ".controller.tsx", + ".controller.js", + ".controller.jsx", + ".resolver.ts", + ".resolver.tsx", + ".resolver.js", + ".resolver.jsx", + ".gateway.ts", + ".gateway.tsx", + ".gateway.js", + ".gateway.jsx", + } { + if strings.HasSuffix(base, suffix) { + return true + } + } + return false +} diff --git a/tests/checks/design_local_abstraction_test.go b/tests/checks/design_local_abstraction_test.go index b1de004..65e2cbd 100644 --- a/tests/checks/design_local_abstraction_test.go +++ b/tests/checks/design_local_abstraction_test.go @@ -251,6 +251,42 @@ func TestDesignPersistenceModelLeakSkipsReactAndReactNativePresentationPaths(t * } } +func TestDesignPersistenceModelLeakSkipsNestJSDTOControllerBoundaries(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps", "api", "src", "users", "users.controller.ts"), strings.Join([]string{ + "import { Body, Controller, Post } from '@nestjs/common';", + "@Controller('users')", + "export class UsersController {", + " @Post()", + " async create(@Body() data: CreateUserDto): Promise {", + " return this.usersService.create(data);", + " }", + "}", + "export interface CreateUserDto { id: string }", + "export interface UserResponseDto { id: string }", + }, "\n")) + writeFile(t, filepath.Join(dir, "apps", "api", "src", "users", "users.route.ts"), strings.Join([]string{ + "export type UserEntity = { id: string };", + "export async function GET(): Promise {", + " return db.user.findFirst();", + "}", + }, "\n")) + + report := runDesignLocalScan(t, designLocalConfig(dir, "typescript")) + + assertFindingRulePresent(t, report, "Design Patterns", "design.persistence-model-leak") + for _, section := range report.Sections { + if section.Name != "Design Patterns" { + continue + } + for _, finding := range section.Findings { + if finding.RuleID == "design.persistence-model-leak" && strings.Contains(finding.Path, "users.controller.ts") { + t.Fatalf("NestJS DTO controller boundary should not leak persistence model: %+v", finding) + } + } + } +} + func TestDesignPersistenceModelLeakKeepsAPIAndDomainBoundaries(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "apps", "web", "app", "api", "contracts", "route.ts"), strings.Join([]string{ diff --git a/tests/checks/function_hidden_mutation_noise_test.go b/tests/checks/function_hidden_mutation_noise_test.go index d2c08b4..751d11a 100644 --- a/tests/checks/function_hidden_mutation_noise_test.go +++ b/tests/checks/function_hidden_mutation_noise_test.go @@ -159,6 +159,26 @@ func TestFunctionHiddenMutationAllowsNextRouteHandlerNames(t *testing.T) { "}", }, }, + { + name: "patch", + file: "apps/web/app/api/users/[id]/route.ts", + source: []string{ + "export async function PATCH(request: Request) {", + " await users.update(await request.json());", + " return Response.json({ ok: true });", + "}", + }, + }, + { + name: "delete", + file: "apps/web/app/api/users/[id]/route.ts", + source: []string{ + "export async function DELETE(request: Request) {", + " await users.remove(request);", + " return Response.json({ ok: true });", + "}", + }, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -172,6 +192,48 @@ func TestFunctionHiddenMutationAllowsNextRouteHandlerNames(t *testing.T) { } } +func TestFunctionHiddenMutationAllowsNestJSControllerBoundaries(t *testing.T) { + cases := []struct { + name string + file string + }{ + {name: "controller", file: "apps/api/src/users/users.controller.ts"}, + {name: "resolver", file: "apps/api/src/users/users.resolver.ts"}, + {name: "gateway", file: "apps/api/src/events/events.gateway.ts"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, tc.file), strings.Join([]string{ + "import { Body, Controller, Get, Post } from '@nestjs/common';", + "@Controller('users')", + "export class UsersController {", + " constructor(private readonly usersService: UsersService) {}", + " @Get(':id')", + " async getUser(data: RequestDto) {", + " await this.usersService.recordAccess(data);", + " return this.usersService.findOne(data.id);", + " }", + " @Post()", + " async submit(@Body() value: CreateUserDto) {", + " await this.usersService.create(value);", + " return { ok: true };", + " }", + "}", + "interface RequestDto { id: string }", + "interface CreateUserDto { id: string }", + "interface UsersService { recordAccess(input: RequestDto): Promise; findOne(id: string): Promise; create(input: CreateUserDto): Promise }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "function.hidden-mutation") + assertFindingRuleAbsent(t, report, "Code Quality", "quality.hidden-side-effect") + assertFindingRuleAbsent(t, report, "Code Quality", "function.command-query-mix") + }) + } +} + func TestFunctionHiddenMutationDoesNotBubbleNestedUICallbackMutationToComponent(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "apps/web/app/claims/_components/claim-card.tsx"), strings.Join([]string{ diff --git a/tests/checks/quality_ui_false_positive_hardening_test.go b/tests/checks/quality_ui_false_positive_hardening_test.go index ff9fb38..91c5f00 100644 --- a/tests/checks/quality_ui_false_positive_hardening_test.go +++ b/tests/checks/quality_ui_false_positive_hardening_test.go @@ -92,6 +92,40 @@ func TestFunctionCommandQueryMixAllowsReactAndNextBoundaries(t *testing.T) { "}", }, }, + { + name: "next patch route", + file: "apps/web/app/api/users/[id]/route.ts", + source: []string{ + "export async function PATCH(request: Request) {", + " await users.update(await request.json());", + " return Response.json({ ok: true });", + "}", + }, + }, + { + name: "nest controller", + file: "apps/api/src/users/users.controller.ts", + source: []string{ + "import { Body, Controller, Get, Post } from '@nestjs/common';", + "@Controller('users')", + "export class UsersController {", + " constructor(private readonly usersService: UsersService) {}", + " @Get(':id')", + " async getUser(data: RequestDto) {", + " await this.usersService.recordAccess(data);", + " return this.usersService.findOne(data.id);", + " }", + " @Post()", + " async submit(@Body() value: CreateUserDto) {", + " await this.usersService.create(value);", + " return { ok: true };", + " }", + "}", + "interface RequestDto { id: string }", + "interface CreateUserDto { id: string }", + "interface UsersService { recordAccess(input: RequestDto): Promise; findOne(id: string): Promise; create(input: CreateUserDto): Promise }", + }, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -105,6 +139,31 @@ func TestFunctionCommandQueryMixAllowsReactAndNextBoundaries(t *testing.T) { } } +func TestQualityAmbiguousNameAllowsNestJSPayloadNames(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "apps/api/src/users/users.controller.ts"), strings.Join([]string{ + "import { Body, Controller, Patch, Query } from '@nestjs/common';", + "@Controller('users')", + "export class UsersController {", + " constructor(private readonly usersService: UsersService) {}", + " @Patch(':id')", + " async update(@Body() data: UpdateUserDto, @Query() value: QueryDto) {", + " await this.usersService.update(data);", + " return this.usersService.find(value);", + " }", + "}", + "interface UpdateUserDto { id: string }", + "interface QueryDto { id: string }", + "interface UsersService { update(input: UpdateUserDto): Promise; find(input: QueryDto): Promise }", + }, "\n")) + + report := runQualityPrecisionScan(t, qualityPrecisionConfigForLanguage(dir, "typescript")) + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.ambiguous-name") + assertFindingRuleAbsent(t, report, "Code Quality", "function.command-query-mix") + assertFindingRuleAbsent(t, report, "Code Quality", "quality.hidden-side-effect") +} + func TestQualityDuplicatedKnowledgeSkipsDisplayStringsAndIncludesLiteral(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "apps/web/app/claims/labels.tsx"), strings.Join([]string{