Skip to content

Commit afb3499

Browse files
authored
Honor ignore files and exclude tests from scans (#5)
* feat: honor ignore files and test exclusions * fix: bound score penalties by issue density * feat: report aggregate comment line stats * feat: classify comment and code line stats * feat: report deterministic scan state
1 parent 0d17b82 commit afb3499

22 files changed

Lines changed: 2078 additions & 417 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
- Parse `.gitignore`, `.ignore`, and `.git/info/exclude` during working-tree scans.
6+
- Add `--exclude-tests` to skip well-known test-case paths from aggregate scan analysis.
7+
- Replace the linear score deduction with a bounded issue-density curve so noisy large repositories do not collapse to zero.
8+
- Add aggregate code/blank/comment-only/inline-comment line statistics to scan and diff reports, and use code lines for issue-density scoring when available.
9+
- Add explicit scan and diff status, regression, threshold, and incomplete-report metadata.
10+
- Count files skipped due to size or per-file analysis budget, and mark affected reports incomplete.
11+
- Stream blob analysis so scans retain aggregate facts instead of repository-wide raw blob contents.
12+
513
## 0.0.1 - 2026-06-30
614

715
- Initial public code-signal scanner.

README.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ scanner scan .
5252
# Emit aggregate JSON
5353
scanner scan . --json
5454

55+
# Exclude well-known test files and test directories
56+
scanner scan . --exclude-tests
57+
5558
# Detect languages and module roots
5659
scanner detect .
5760

@@ -92,17 +95,35 @@ code-signal detects and analyzes these languages with built-in lexical/parser ch
9295

9396
Detection uses extensions, shebangs, and common manifests such as `go.mod`, `package.json`, `pyproject.toml`, `Cargo.toml`, `pom.xml`, Gradle files, `.csproj`, `.sln`, `composer.json`, `Gemfile`, `Dockerfile`, `Containerfile`, and `*.dockerfile`.
9497

98+
## Aggregate line statistics
99+
100+
Scan JSON includes `lines_scanned`, `blank_lines`, `code_lines`, `comment_lines`, `comment_only_lines`, `inline_comment_lines`, and `comment_density_percent`. The same aggregate fields are reported for `totals`, `by_language`, and `by_module`; diff JSON includes before/after/delta values under `totals_delta`.
101+
102+
`lines_scanned` is physical scanned lines. `code_lines` excludes blank lines and comment-only lines, but keeps lines that contain code plus an inline comment. The score model uses `code_lines` when available and falls back to physical lines for older reports.
103+
104+
Comment detection is lexical and language-aware for supported languages: it recognizes `//`, `#`, `/* ... */`, Rust nested block comments, Ruby `=begin`/`=end`, and standalone Python triple-quoted doc/comment blocks where those forms are valid. It ignores common markers inside quoted strings/raw strings/template literals and JavaScript/TypeScript regex literals, and never emits per-file locations or source snippets.
105+
106+
## Ignore files, test exclusion, and skip policy
107+
108+
Working-tree scans parse `.gitignore`, `.ignore`, and `.git/info/exclude` using gitignore-style rules before reading candidate files.
109+
110+
Pass `--exclude-tests` to `scan` or `diff` to skip well-known test-case paths from aggregate analysis. The built-in defaults cover common Go (`*_test.go`, `testdata`), Java (`src/test/**`, `*Test.java`, `*Tests.java`, `*IT.java`), Python (`tests/**`, `test_*.py`, `*_test.py`, `conftest.py`), TypeScript/JavaScript (`__tests__/**`, `*.test.*`, `*.spec.*`, `cypress/**`, `playwright/**`, `e2e/**`), and Rust (`tests/**`, `benches/**`, `*_test.rs`) conventions.
111+
112+
Built-in skips still apply for common dependency, generated, build, coverage, and minified paths such as `node_modules`, `vendor`, `target`, `dist`, `build`, `.next`, `coverage`, `third_party`, `generated`, `gen`, `*.min.js`, `*.min.css`, `*.pb.go`, and `*_generated.go`.
113+
95114
## Configuration
96115

97116
Configuration is optional. If present, `scanner.json` is loaded from the scanned repository root or from `--config`.
98117

99118
```json
100119
{
101120
"scan": {
102-
"default_timeout_seconds": 30,
121+
"default_timeout_seconds": 0,
122+
"max_file_analysis_ms": 2000,
103123
"max_file_bytes": 1048576,
104124
"follow_symlinks": false,
105-
"workers": 0
125+
"workers": 0,
126+
"exclude_tests": false
106127
},
107128
"score": {
108129
"fail_under": 75,
@@ -120,6 +141,26 @@ Configuration is optional. If present, `scanner.json` is loaded from the scanned
120141

121142
Unknown config fields and trailing JSON values are rejected.
122143

144+
`scan.default_timeout_seconds` is disabled by default; use `--timeout` or set a
145+
positive value when a whole-command emergency brake is needed. Files larger than
146+
`scan.max_file_bytes` are skipped before content is read. Files whose built-in
147+
analysis exceeds `scan.max_file_analysis_ms` are skipped after the bounded
148+
attempt. These are included in `totals.files_skipped_due_to_size` or
149+
`totals.files_skipped_due_to_timeout`. When this happens, scan and diff reports
150+
set `incomplete: true`, so the score is clearly a quick optimistic signal over
151+
the scanned subset.
152+
153+
## Score model
154+
155+
The score is a bounded weighted issue-density signal, not a SonarQube debt rating:
156+
157+
```text
158+
weighted_density_per_kloc = round((errors*10 + warnings*4 + info*1) * 1000 / max(code_lines, 1000))
159+
score = round(100 * 100 / (100 + weighted_density_per_kloc))
160+
```
161+
162+
Error caps still apply after density scoring: any error caps the score at `89`, and five or more errors cap it at `69`. Bands are `excellent` for `90+`, `good` for `75-89`, `needs work` for `50-74`, and `poor` below `50`.
163+
123164
## Privacy and output contract
124165

125166
Reports are aggregate-only. Facts and report JSON do not include file paths, line/column coordinates, snippets, clone groups, per-file findings, or source text. The scanner reads local files and local git objects only; it performs no telemetry, update checks, remote rule downloads, package installs, or network calls.

internal/cli/cli.go

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const (
2323

2424
flagBase = "--base"
2525
flagConfig = "--config"
26+
flagExcludeTests = "--exclude-tests"
2627
flagFailOnRegression = "--fail-on-regression"
2728
flagFailUnder = "--fail-under"
2829
flagHead = "--head"
@@ -89,17 +90,19 @@ type diffArgs struct {
8990
configPath string
9091
json bool
9192
failOnRegression bool
93+
excludeTests bool
9294
timeout time.Duration
9395
timeoutSet bool
9496
}
9597

9698
type scanArgs struct {
97-
path string
98-
configPath string
99-
json bool
100-
failUnder *int
101-
timeout time.Duration
102-
timeoutSet bool
99+
path string
100+
configPath string
101+
json bool
102+
failUnder *int
103+
excludeTests bool
104+
timeout time.Duration
105+
timeoutSet bool
103106
}
104107

105108
type detectArgs struct {
@@ -119,7 +122,7 @@ func runDiff(ctx context.Context, args []string, stdout io.Writer) (int, error)
119122
}
120123
if parsed.timeoutSet {
121124
var cancel context.CancelFunc
122-
ctx, cancel = context.WithTimeout(ctx, parsed.timeout)
125+
ctx, cancel = commandContext(ctx, config.Config{}, parsed.timeout, true)
123126
defer cancel()
124127
}
125128
if err := resolveDiffRepoAndScope(ctx, &parsed); err != nil {
@@ -129,6 +132,17 @@ func runDiff(ctx context.Context, args []string, stdout io.Writer) (int, error)
129132
if err != nil {
130133
return 2, err
131134
}
135+
if parsed.excludeTests {
136+
cfg.Scan.ExcludeTests = true
137+
}
138+
if parsed.failOnRegression {
139+
cfg.Diff.FailOnScoreDrop = true
140+
}
141+
if !parsed.timeoutSet {
142+
var cancel context.CancelFunc
143+
ctx, cancel = commandContext(ctx, cfg, 0, false)
144+
defer cancel()
145+
}
132146
diff, err := scan.Diff(ctx, parsed.repo, parsed.base, parsed.head, parsed.scopePath, scan.DiffOptions{Config: cfg})
133147
if err != nil {
134148
return 1, err
@@ -151,15 +165,19 @@ func runScan(ctx context.Context, args []string, stdout io.Writer) (int, error)
151165
if err != nil {
152166
return 2, err
153167
}
154-
if parsed.timeoutSet {
155-
var cancel context.CancelFunc
156-
ctx, cancel = context.WithTimeout(ctx, parsed.timeout)
157-
defer cancel()
158-
}
159168
cfg, err := loadConfig(parsed.configPath, parsed.path)
160169
if err != nil {
161170
return 2, err
162171
}
172+
if parsed.excludeTests {
173+
cfg.Scan.ExcludeTests = true
174+
}
175+
if parsed.failUnder != nil {
176+
cfg.Score.FailUnder = *parsed.failUnder
177+
}
178+
var cancel context.CancelFunc
179+
ctx, cancel = commandContext(ctx, cfg, parsed.timeout, parsed.timeoutSet)
180+
defer cancel()
163181
scanReport, err := scan.ScanPath(ctx, parsed.path, scan.ScanOptions{Config: cfg})
164182
if err != nil {
165183
return 1, err
@@ -186,6 +204,8 @@ func runDetect(ctx context.Context, args []string, stdout io.Writer) (int, error
186204
if err != nil {
187205
return 2, err
188206
}
207+
ctx, cancel := commandContext(ctx, cfg, 0, false)
208+
defer cancel()
189209
detected, err := scan.DetectPath(ctx, parsed.path, scan.DetectOptions{Config: cfg})
190210
if err != nil {
191211
return 1, err
@@ -260,6 +280,9 @@ func parseDiffFlag(args []string, index int, parsed *diffArgs) (int, bool, error
260280
case flagConfig:
261281
next, err := assignFlagValue(args, index, flagConfig, &parsed.configPath)
262282
return next, true, err
283+
case flagExcludeTests:
284+
parsed.excludeTests = true
285+
return index, true, nil
263286
case flagTimeout:
264287
next, err := assignTimeoutValue(args, index, &parsed.timeout, &parsed.timeoutSet)
265288
return next, true, err
@@ -303,6 +326,9 @@ func parseScanFlag(args []string, index int, parsed *scanArgs) (int, bool, error
303326
case flagConfig:
304327
next, err := assignFlagValue(args, index, flagConfig, &parsed.configPath)
305328
return next, true, err
329+
case flagExcludeTests:
330+
parsed.excludeTests = true
331+
return index, true, nil
306332
case flagTimeout:
307333
next, err := assignTimeoutValue(args, index, &parsed.timeout, &parsed.timeoutSet)
308334
return next, true, err
@@ -417,6 +443,16 @@ func parseTimeout(value string) (time.Duration, error) {
417443
return time.Duration(seconds) * time.Second, nil
418444
}
419445

446+
func commandContext(ctx context.Context, cfg config.Config, timeout time.Duration, timeoutSet bool) (context.Context, context.CancelFunc) {
447+
if timeoutSet {
448+
return context.WithTimeout(ctx, timeout)
449+
}
450+
if cfg.Scan.DefaultTimeoutSeconds <= 0 {
451+
return context.WithCancel(ctx)
452+
}
453+
return context.WithTimeout(ctx, time.Duration(cfg.Scan.DefaultTimeoutSeconds)*time.Second)
454+
}
455+
420456
func appendPositional(command string, positionals []string, arg string) ([]string, error) {
421457
if strings.HasPrefix(arg, "-") {
422458
return positionals, fmt.Errorf("unknown %s flag %q", command, arg)
@@ -539,10 +575,17 @@ func writeString(stdout io.Writer, value string) error {
539575
func printScan(stdout io.Writer, scanReport model.ScanReport) error {
540576
var builder strings.Builder
541577
fmt.Fprintf(&builder, "Score: %d (%s)\n", scanReport.Score, scanReport.Band)
578+
fmt.Fprintf(&builder, "Status: %s (score %d, fail-under %d)\n", scanReport.Status, scanReport.Score, scanReport.Thresholds.FailUnder)
579+
if scanReport.Incomplete {
580+
fmt.Fprintf(&builder, "Incomplete: true (%s)\n", strings.Join(scanReport.IncompleteReasons, ", "))
581+
}
542582
fmt.Fprintf(&builder, "Issues: %d (errors: %d, warnings: %d, info: %d)\n",
543583
scanReport.Totals.Issues, scanReport.Totals.Errors, scanReport.Totals.Warnings, scanReport.Totals.Info)
544-
fmt.Fprintf(&builder, "Files scanned: %d | Lines scanned: %d | Files skipped: %d\n",
545-
scanReport.Totals.FilesScanned, scanReport.Totals.LinesScanned, scanReport.Totals.FilesSkipped)
584+
fmt.Fprintf(&builder, "Files scanned: %d | Lines scanned: %d | Code lines: %d | Files skipped: %d\n",
585+
scanReport.Totals.FilesScanned, scanReport.Totals.LinesScanned, scanReport.Totals.CodeLines, scanReport.Totals.FilesSkipped)
586+
fmt.Fprintf(&builder, "Comments: %d lines (%.1f%%) | Comment-only: %d | Inline: %d | Blank: %d\n",
587+
scanReport.Totals.CommentLines, scanReport.Totals.CommentDensityPercent,
588+
scanReport.Totals.CommentOnlyLines, scanReport.Totals.InlineCommentLines, scanReport.Totals.BlankLines)
546589
fmt.Fprintf(&builder, "Languages: %s | Modules: %d (%s)\n",
547590
joinOrNone(scanLanguages(scanReport)), scanModuleCount(scanReport), moduleProfileText(scanReport))
548591
printDuplicationSummary(&builder, scanReport.Duplication)

0 commit comments

Comments
 (0)