diff --git a/internal/codeguard/checks/quality/quality_ai.go b/internal/codeguard/checks/quality/quality_ai.go index 57fbace..11a1940 100644 --- a/internal/codeguard/checks/quality/quality_ai.go +++ b/internal/codeguard/checks/quality/quality_ai.go @@ -68,7 +68,19 @@ func typeScriptAIQualityFindings(ctx typeScriptScanContext) []core.Finding { findings = append(findings, warnFinding(ctx.env, "quality.ai.swallowed-error", ctx.file, line, 1, support.ScriptLabelForPath(ctx.file)+" catch block swallows the error without handling it")) } + inJSDoc := false for idx, line := range strings.Split(ctx.source, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "/**") { + inJSDoc = !strings.Contains(trimmed, "*/") + continue + } + if inJSDoc { + if strings.Contains(trimmed, "*/") { + inJSDoc = false + } + continue + } text, ok := extractScriptCommentText(line) if !ok || !isNarrativeComment(text) { continue diff --git a/internal/codeguard/checks/quality/quality_ai_dead_code_script.go b/internal/codeguard/checks/quality/quality_ai_dead_code_script.go index e8504cb..dc43402 100644 --- a/internal/codeguard/checks/quality/quality_ai_dead_code_script.go +++ b/internal/codeguard/checks/quality/quality_ai_dead_code_script.go @@ -15,6 +15,8 @@ var ( scriptTerminatorPattern = regexp.MustCompile(`^(?:return\b[^;{}]*;|throw\b[^;{}]*;|break\s*;|continue\s*;|return;?$|break$|continue$)`) scriptBlockResumePattern = regexp.MustCompile(`^(?:\}|case\b|default\s*:|else\b|catch\b|finally\b)`) scriptLocalFunctionPattern = regexp.MustCompile(`(?m)^[ \t]*(?:async[ \t]+)?function[ \t]+([A-Za-z_$][\w$]*)[ \t]*\(`) + scriptLocalConstPattern = regexp.MustCompile(`(?m)^[ \t]*(?:const|let)[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=\n]+)?=[ \t]*(?:async[ \t]+)?(?:\([^\n]*\)|[A-Za-z_$][\w$]*)[ \t]*=>`) + scriptControlHeaderPattern = regexp.MustCompile(`^(?:if|while|for|with)\b.*\)[ \t]*$`) ) // unreachableStatementFinding builds the shared dead-code finding emitted when @@ -29,6 +31,7 @@ func scriptUnreachableFindings(env support.Context, file string, source string) sanitized := sanitizeScriptSource(source) depth := 0 pendingDepth := -1 + previousWasUnbracedControlHeader := false for idx, line := range strings.Split(sanitized, "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" { @@ -42,9 +45,10 @@ func scriptUnreachableFindings(env support.Context, file string, source string) } pendingDepth = -1 } - if scriptTerminatorPattern.MatchString(trimmed) && balancedParens(trimmed) { + if scriptTerminatorPattern.MatchString(trimmed) && balancedParens(trimmed) && !previousWasUnbracedControlHeader { pendingDepth = depth } + previousWasUnbracedControlHeader = scriptControlHeaderPattern.MatchString(trimmed) && !strings.Contains(trimmed, "{") } return findings } @@ -58,14 +62,14 @@ func balancedParens(line string) bool { func scriptUnusedFunctionFindings(env support.Context, file string, source string) []core.Finding { sanitized := sanitizeScriptSource(source) findings := make([]core.Finding, 0) - for _, match := range scriptLocalFunctionPattern.FindAllStringSubmatchIndex(sanitized, -1) { + for _, match := range scriptLocalDeclarationMatches(sanitized) { name := sanitized[match[2]:match[3]] lineStart := strings.LastIndexByte(sanitized[:match[0]], '\n') + 1 declLine := sanitized[lineStart:lineEnd(sanitized, match[0])] if strings.Contains(declLine, "export") { continue } - if countWordOccurrences(sanitized, name) > 1 { + if scriptLocalDeclarationIsReferenced(sanitized, name) { continue } line := 1 + strings.Count(sanitized[:match[2]], "\n") @@ -75,6 +79,18 @@ func scriptUnusedFunctionFindings(env support.Context, file string, source strin return findings } +func scriptLocalDeclarationIsReferenced(source string, name string) bool { + // JSX identifiers are references, not opaque markup. Check them directly + // before falling back to ordinary expression references. + jsxReference := regexp.MustCompile(`<\s*` + regexp.QuoteMeta(name) + `(?:\s|/|>)`) + return jsxReference.MatchString(source) || countWordOccurrences(source, name) > 1 +} + +func scriptLocalDeclarationMatches(source string) [][]int { + matches := append(scriptLocalFunctionPattern.FindAllStringSubmatchIndex(source, -1), scriptLocalConstPattern.FindAllStringSubmatchIndex(source, -1)...) + return matches +} + func lineEnd(source string, from int) int { if idx := strings.IndexByte(source[from:], '\n'); idx >= 0 { return from + idx diff --git a/internal/codeguard/checks/quality/quality_ai_helpers.go b/internal/codeguard/checks/quality/quality_ai_helpers.go index 65074f6..832a3c9 100644 --- a/internal/codeguard/checks/quality/quality_ai_helpers.go +++ b/internal/codeguard/checks/quality/quality_ai_helpers.go @@ -8,6 +8,7 @@ import ( var ( aiNarrativeCommentPattern = regexp.MustCompile(`(?i)^(initialize|create|set|get|call|return|check|convert|update|build|iterate|loop|run|assign|store)\b`) aiRationalePattern = regexp.MustCompile(`(?i)\b(because|so that|why|ensure|ensures|avoid|must|needed|required|reason|safely|in order to)\b`) + aiRouteCommentPattern = regexp.MustCompile(`^(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+/`) aiEmptyCatchPattern = regexp.MustCompile(`(?s)\bcatch\s*(?:\([^)]*\))?\s*\{\s*(?:(?://[^\n]*\n)|(?:/\*.*?\*/\s*))*\}`) aiPythonPassExceptPattern = regexp.MustCompile(`(?m)^\s*except(?:\s+[^\n:]+)?\s*:\s*(?:#.*)?\n\s*(pass|\.\.\.)\b`) ) @@ -20,13 +21,21 @@ func aiCheckEnabled(flag *bool) bool { func isNarrativeComment(text string) bool { trimmed := strings.TrimSpace(text) - if trimmed == "" || aiRationalePattern.MatchString(trimmed) || !aiNarrativeCommentPattern.MatchString(trimmed) { + if trimmed == "" || aiRationalePattern.MatchString(trimmed) || isCommentInstructionOrHeader(trimmed) || !aiNarrativeCommentPattern.MatchString(trimmed) { return false } words := strings.Fields(trimmed) return len(words) >= 2 && len(words) <= 10 } +func isCommentInstructionOrHeader(text string) bool { + lower := strings.ToLower(text) + return strings.HasPrefix(lower, "run:") || + strings.HasPrefix(lower, "usage:") || + strings.HasPrefix(lower, "example:") || + aiRouteCommentPattern.MatchString(text) +} + func regexLineMatches(pattern *regexp.Regexp, source string) []int { indices := pattern.FindAllStringIndex(source, -1) lines := make([]int, 0, len(indices)) diff --git a/tests/checks/quality_ai_dead_code_test.go b/tests/checks/quality_ai_dead_code_test.go index eb279a3..8f35530 100644 --- a/tests/checks/quality_ai_dead_code_test.go +++ b/tests/checks/quality_ai_dead_code_test.go @@ -197,6 +197,69 @@ function helper(): number { assertFindingRuleAbsent(t, report, "Code Quality", "quality.ai.dead-code") } +func TestQualityCheckAllowsJSXAndExpressionReferencesToLocalHelpers(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "page.tsx"), `function StatusBadge({ status }: { status: string }) { + return {status}; +} + +const Row = ({ children }: { children: unknown }) =>
{children}
; + +function fmtDate(value: string): string { + return value; +} + +export function Page({ status, nextReviewDate }: { status: string; nextReviewDate: string }) { + return {fmtDate(nextReviewDate)}; +} +`) + + cfg := qualityAITestConfig(dir, "quality-ai-tsx-local-references") + cfg.Targets[0].Language = "typescript" + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.ai.dead-code") +} + +func TestQualityCheckAllowsStatementAfterUnbracedConditionalReturn(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "handler.ts"), `export function run(done: boolean): string { + if (done) + return "complete"; + return "pending"; +} +`) + + cfg := qualityAITestConfig(dir, "quality-ai-ts-unbraced-return") + cfg.Targets[0].Language = "typescript" + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.ai.dead-code") +} + +func TestQualityCheckWarnsForUnusedLocalTypeScriptArrowFunction(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "handler.ts"), `export const run = () => "ok"; + +const orphanHelper = () => "unused"; +`) + + cfg := qualityAITestConfig(dir, "quality-ai-ts-unused-arrow") + cfg.Targets[0].Language = "typescript" + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.dead-code") +} + func TestQualityCheckHonorsDeadCodeToggle(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "unreachable.go"), `package sample diff --git a/tests/checks/quality_ai_test.go b/tests/checks/quality_ai_test.go index 145dece..f874b8e 100644 --- a/tests/checks/quality_ai_test.go +++ b/tests/checks/quality_ai_test.go @@ -48,6 +48,26 @@ func buildClient() {} assertFindingConfidence(t, report, "Code Quality", "quality.ai.narrative-comment", "low") } +func TestQualityCheckAllowsUsefulTypeScriptComments(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "route.ts"), `/** GET /api/files/[versionId]/download — stream a single FileVersion. */ +// Run: pnpm --filter @legal-nest/db exec tsx prisma/seed.ts +// Autoscroll on new turn in a useEffect +// Dispatch on entity type. Prisma accepts string indexing, but TS does not model it. +// Higher-is-worse fields default to desc; priorities and labels default to asc. +export function route() {} +`) + + cfg := qualityAITestConfig(dir, "quality-ai-useful-comments") + cfg.Targets[0].Language = "typescript" + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.ai.narrative-comment") +} + func TestQualityCheckWarnsForEmptyCatchInTypeScript(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "handler.ts"), `export function run() {