Skip to content

Commit 3897ecc

Browse files
committed
feat: report aggregate comment line stats
1 parent fae65d4 commit 3897ecc

10 files changed

Lines changed: 179 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Parse `.gitignore`, `.ignore`, and `.git/info/exclude` during working-tree scans.
66
- Add `--exclude-tests` to skip well-known test-case paths from aggregate scan analysis.
77
- Replace the linear score deduction with a bounded issue-density curve so noisy large repositories do not collapse to zero.
8+
- Add aggregate commented-line statistics to scan and diff reports.
89

910
## 0.0.1 - 2026-06-30
1011

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ code-signal detects and analyzes these languages with built-in lexical/parser ch
9595

9696
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`.
9797

98+
## Aggregate line statistics
99+
100+
Scan JSON includes `comment_lines` and `comment_density_percent` beside existing line totals. 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+
Comment detection is lexical and language-aware: it recognizes `//`, `#`, and `/* ... */` style comments where those markers are valid, ignores markers inside quoted strings/raw strings, and never emits per-file locations or source snippets.
103+
98104
## Ignore files, test exclusion, and skip policy
99105

100106
Working-tree scans parse `.gitignore`, `.ignore`, and `.git/info/exclude` using gitignore-style rules before reading candidate files.

internal/cli/cli.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,8 @@ func printScan(stdout io.Writer, scanReport model.ScanReport) error {
558558
scanReport.Totals.Issues, scanReport.Totals.Errors, scanReport.Totals.Warnings, scanReport.Totals.Info)
559559
fmt.Fprintf(&builder, "Files scanned: %d | Lines scanned: %d | Files skipped: %d\n",
560560
scanReport.Totals.FilesScanned, scanReport.Totals.LinesScanned, scanReport.Totals.FilesSkipped)
561+
fmt.Fprintf(&builder, "Comment lines: %d (%.1f%%)\n",
562+
scanReport.Totals.CommentLines, scanReport.Totals.CommentDensityPercent)
561563
fmt.Fprintf(&builder, "Languages: %s | Modules: %d (%s)\n",
562564
joinOrNone(scanLanguages(scanReport)), scanModuleCount(scanReport), moduleProfileText(scanReport))
563565
printDuplicationSummary(&builder, scanReport.Duplication)

internal/delta/delta.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ func Compare(base, head model.ScanReport) model.DiffReport {
2525

2626
func compareTotals(base, head model.ScanTotals) model.TotalsDelta {
2727
return model.TotalsDelta{
28-
FilesScanned: countDelta(base.FilesScanned, head.FilesScanned),
29-
FilesSkipped: countDelta(base.FilesSkipped, head.FilesSkipped),
30-
LinesScanned: countDelta(base.LinesScanned, head.LinesScanned),
31-
Issues: countDelta(base.Issues, head.Issues),
32-
Errors: countDelta(base.Errors, head.Errors),
33-
Warnings: countDelta(base.Warnings, head.Warnings),
34-
Info: countDelta(base.Info, head.Info),
28+
FilesScanned: countDelta(base.FilesScanned, head.FilesScanned),
29+
FilesSkipped: countDelta(base.FilesSkipped, head.FilesSkipped),
30+
LinesScanned: countDelta(base.LinesScanned, head.LinesScanned),
31+
CommentLines: countDelta(base.CommentLines, head.CommentLines),
32+
CommentDensityPercent: percentDelta(base.CommentDensityPercent, head.CommentDensityPercent),
33+
Issues: countDelta(base.Issues, head.Issues),
34+
Errors: countDelta(base.Errors, head.Errors),
35+
Warnings: countDelta(base.Warnings, head.Warnings),
36+
Info: countDelta(base.Info, head.Info),
3537
}
3638
}
3739

@@ -128,3 +130,9 @@ func countDelta(before, after int) model.CountDelta {
128130
Delta: after - before,
129131
}
130132
}
133+
134+
func percentDelta(before, after float64) model.PercentDelta {
135+
before = roundTenth(before)
136+
after = roundTenth(after)
137+
return model.PercentDelta{Before: before, After: after, Delta: roundTenth(after - before)}
138+
}

internal/generic/generic.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,13 @@ func AggregateSnapshot(entries []model.SnapshotEntry, facts map[model.BlobKey]mo
8787

8888
totals.FilesScanned++
8989
totals.LinesScanned += blobFacts.Lines
90+
totals.CommentLines += blobFacts.CommentLines
9091
totals.Errors += blobFacts.Issues.Errors
9192
totals.Warnings += blobFacts.Issues.Warnings
9293
totals.Info += blobFacts.Issues.Info
9394
totals.Issues += blobFacts.Issues.Errors + blobFacts.Issues.Warnings + blobFacts.Issues.Info
9495
}
96+
totals.CommentDensityPercent = model.LineDensityPercent(totals.CommentLines, totals.LinesScanned)
9597
return totals
9698
}
9799

@@ -181,8 +183,12 @@ func analyzeBlobLines(language string, data []byte, cfg Config, catalog rules.Ca
181183
counts := countLineIssues(line, cfg, nesting, lane)
182184
counts.mergeConflicts = mergeConflict.count(line)
183185
mergeConflicts += counts.mergeConflicts
186+
commentText, hasComment := comments.scan(line)
187+
if hasComment {
188+
facts.CommentLines++
189+
}
184190
if lane.todo {
185-
counts.todos = comments.countTodoMarkers(line)
191+
counts.todos = countTodoMarkers(commentText)
186192
}
187193
if lane.riskyShell {
188194
counts.riskyShell = riskyShell.count(line)
@@ -1009,10 +1015,6 @@ func newCommentScanner(lane languageRuleLane) commentScanner {
10091015
}
10101016
}
10111017

1012-
func (scanner *commentScanner) countTodoMarkers(line string) int {
1013-
return countTodoMarkers(scanner.text(line))
1014-
}
1015-
10161018
func countTodoMarkers(line string) int {
10171019
count := 0
10181020
for _, marker := range []string{"TODO", "FIXME", "HACK"} {
@@ -1029,12 +1031,13 @@ type commentQuoteState struct {
10291031
escaped bool
10301032
}
10311033

1032-
func (scanner *commentScanner) text(line string) string {
1034+
func (scanner *commentScanner) scan(line string) (text string, hasComment bool) {
10331035
var comments []string
10341036
for i := 0; i < len(line); {
10351037
if scanner.inBlock {
10361038
text, next, closed := blockCommentSegment(line, i)
10371039
comments = append(comments, text)
1040+
hasComment = true
10381041
scanner.inBlock = !closed
10391042
i = next
10401043
continue
@@ -1045,19 +1048,21 @@ func (scanner *commentScanner) text(line string) string {
10451048
}
10461049
if marker, ok := matchingCommentMarker(line[i:], scanner.lineMarkers); ok {
10471050
comments = append(comments, line[i+len(marker):])
1051+
hasComment = true
10481052
break
10491053
}
10501054
if scanner.blockComments && strings.HasPrefix(line[i:], "/*") {
10511055
text, next, closed := blockCommentSegment(line, i+len("/*"))
10521056
comments = append(comments, text)
1057+
hasComment = true
10531058
scanner.inBlock = !closed
10541059
i = next
10551060
continue
10561061
}
10571062
i++
10581063
}
10591064
scanner.quoteState.finishLine()
1060-
return strings.Join(comments, "\n")
1065+
return strings.Join(comments, "\n"), hasComment
10611066
}
10621067

10631068
func blockCommentSegment(line string, start int) (text string, next int, closed bool) {

internal/generic/generic_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,32 @@ func TestAnalyzeBlobCountsGenericRulesAggregately(t *testing.T) {
108108
}
109109
}
110110

111+
func TestAnalyzeBlobCountsCommentLinesAggregately(t *testing.T) {
112+
data := strings.Join([]string{
113+
"package main",
114+
"const url = \"https://example.invalid/not-comment\"",
115+
"// package comment",
116+
"func main() { println(url) } // inline comment",
117+
"/* block start",
118+
"block middle",
119+
"*/",
120+
"const raw = `// not a comment`",
121+
}, "\n") + "\n"
122+
123+
facts := AnalyzeBlob(testKeyForLanguage("comments", "go"), []byte(data), Config{}, rules.DefaultCatalog())
124+
payload, err := json.Marshal(facts)
125+
if err != nil {
126+
t.Fatal(err)
127+
}
128+
var decoded map[string]any
129+
if err := json.Unmarshal(payload, &decoded); err != nil {
130+
t.Fatalf("unmarshal facts JSON: %v", err)
131+
}
132+
if got := decoded["comment_lines"]; got != float64(5) {
133+
t.Fatalf("comment_lines = %#v, want 5 in %s", got, payload)
134+
}
135+
}
136+
111137
func TestAnalyzeBlobSkipsGeneratedAndMinifiedContent(t *testing.T) {
112138
catalog := rules.DefaultCatalog()
113139
tests := []struct {

internal/model/model.go

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -85,33 +85,38 @@ type BlobKey struct {
8585
// BlobFacts stores aggregate facts for a single analyzed blob. It intentionally has
8686
// no file findings, snippets, or line/column locations.
8787
type BlobFacts struct {
88-
Lines int `json:"lines"`
89-
Issues IssueCounts `json:"issues"`
90-
ByCategory map[Category]IssueCounts `json:"by_category,omitempty"`
91-
ByRule map[string]RuleCount `json:"by_rule,omitempty"`
92-
Skipped bool `json:"skipped,omitempty"`
93-
SkipReason string `json:"skip_reason,omitempty"`
88+
Lines int `json:"lines"`
89+
CommentLines int `json:"comment_lines,omitempty"`
90+
Issues IssueCounts `json:"issues"`
91+
ByCategory map[Category]IssueCounts `json:"by_category,omitempty"`
92+
ByRule map[string]RuleCount `json:"by_rule,omitempty"`
93+
Skipped bool `json:"skipped,omitempty"`
94+
SkipReason string `json:"skip_reason,omitempty"`
9495
}
9596

9697
// ScanTotals stores top-level aggregate counters for one complete snapshot scan.
9798
type ScanTotals struct {
98-
FilesScanned int `json:"files_scanned"`
99-
FilesSkipped int `json:"files_skipped"`
100-
LinesScanned int `json:"lines_scanned"`
101-
Issues int `json:"issues"`
102-
Errors int `json:"errors"`
103-
Warnings int `json:"warnings"`
104-
Info int `json:"info"`
99+
FilesScanned int `json:"files_scanned"`
100+
FilesSkipped int `json:"files_skipped"`
101+
LinesScanned int `json:"lines_scanned"`
102+
CommentLines int `json:"comment_lines"`
103+
CommentDensityPercent float64 `json:"comment_density_percent"`
104+
Issues int `json:"issues"`
105+
Errors int `json:"errors"`
106+
Warnings int `json:"warnings"`
107+
Info int `json:"info"`
105108
}
106109

107110
// GroupCounts stores aggregate counters for a language or module grouping.
108111
type GroupCounts struct {
109-
Files int `json:"files"`
110-
Lines int `json:"lines"`
111-
Issues int `json:"issues"`
112-
Errors int `json:"errors"`
113-
Warnings int `json:"warnings"`
114-
Info int `json:"info"`
112+
Files int `json:"files"`
113+
Lines int `json:"lines"`
114+
CommentLines int `json:"comment_lines"`
115+
CommentDensityPercent float64 `json:"comment_density_percent"`
116+
Issues int `json:"issues"`
117+
Errors int `json:"errors"`
118+
Warnings int `json:"warnings"`
119+
Info int `json:"info"`
115120
}
116121

117122
// ScanReport is the aggregate report for one complete snapshot scan.
@@ -135,15 +140,24 @@ type CountDelta struct {
135140
Delta int `json:"delta"`
136141
}
137142

143+
// PercentDelta stores an aggregate before/after/delta percentage value.
144+
type PercentDelta struct {
145+
Before float64 `json:"before"`
146+
After float64 `json:"after"`
147+
Delta float64 `json:"delta"`
148+
}
149+
138150
// TotalsDelta stores top-level aggregate deltas between complete scans.
139151
type TotalsDelta struct {
140-
FilesScanned CountDelta `json:"files_scanned"`
141-
FilesSkipped CountDelta `json:"files_skipped"`
142-
LinesScanned CountDelta `json:"lines_scanned"`
143-
Issues CountDelta `json:"issues"`
144-
Errors CountDelta `json:"errors"`
145-
Warnings CountDelta `json:"warnings"`
146-
Info CountDelta `json:"info"`
152+
FilesScanned CountDelta `json:"files_scanned"`
153+
FilesSkipped CountDelta `json:"files_skipped"`
154+
LinesScanned CountDelta `json:"lines_scanned"`
155+
CommentLines CountDelta `json:"comment_lines"`
156+
CommentDensityPercent PercentDelta `json:"comment_density_percent"`
157+
Issues CountDelta `json:"issues"`
158+
Errors CountDelta `json:"errors"`
159+
Warnings CountDelta `json:"warnings"`
160+
Info CountDelta `json:"info"`
147161
}
148162

149163
// DiffReport is the aggregate comparison between two complete snapshot scans.
@@ -163,6 +177,14 @@ type DiffReport struct {
163177
DuplicationDelta DuplicationDelta `json:"duplication_delta,omitempty"`
164178
}
165179

180+
// LineDensityPercent returns part/total as a percentage rounded to one decimal.
181+
func LineDensityPercent(part, total int) float64 {
182+
if part <= 0 || total <= 0 {
183+
return 0
184+
}
185+
return float64((int64(part)*1000+int64(total)/2)/int64(total)) / 10
186+
}
187+
166188
// DetectedProject describes aggregate project detection results.
167189
type DetectedProject struct {
168190
Root string `json:"root,omitempty"`

internal/report/report.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ func HumanDiff(diff model.DiffReport) string {
1818
fmt.Fprintf(&b, "Files scanned: %d -> %d (%+d) | Lines scanned: %d -> %d (%+d)\n",
1919
diff.TotalsDelta.FilesScanned.Before, diff.TotalsDelta.FilesScanned.After, diff.TotalsDelta.FilesScanned.Delta,
2020
diff.TotalsDelta.LinesScanned.Before, diff.TotalsDelta.LinesScanned.After, diff.TotalsDelta.LinesScanned.Delta)
21+
fmt.Fprintf(&b, "Comment lines: %d -> %d (%+d) | comment density %.1f%% -> %.1f%% (%+.1f%%)\n",
22+
diff.TotalsDelta.CommentLines.Before, diff.TotalsDelta.CommentLines.After, diff.TotalsDelta.CommentLines.Delta,
23+
diff.TotalsDelta.CommentDensityPercent.Before, diff.TotalsDelta.CommentDensityPercent.After, diff.TotalsDelta.CommentDensityPercent.Delta)
2124
fmt.Fprintf(&b, "Languages: %s | Modules: %s\n", displayList(languageKeys(diff.LanguageDelta), displayLanguage), displayList(stringKeys(diff.ModuleDelta), identity))
2225
fmt.Fprintf(&b, "Issues: %d -> %d (%+d) | Errors: %d -> %d (%+d) | Warnings: %d -> %d (%+d) | Info: %d -> %d (%+d)\n",
2326
diff.TotalsDelta.Issues.Before, diff.TotalsDelta.Issues.After, diff.TotalsDelta.Issues.Delta,

internal/scan/scan.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ func buildReport(entries []model.SnapshotEntry, detection model.DetectedProject,
512512
duplicateConfig(cfg),
513513
)
514514
addDuplicateSummary(&report, duplicateSummary)
515+
finalizeCommentStats(&report)
515516
scoreReport(&report, cfg)
516517
return report
517518
}
@@ -666,6 +667,7 @@ func addFactToReport(report *model.ScanReport, entry model.SnapshotEntry, fact m
666667
issues := issueTotal(fact.Issues)
667668
report.Totals.FilesScanned++
668669
report.Totals.LinesScanned += fact.Lines
670+
report.Totals.CommentLines += fact.CommentLines
669671
report.Totals.Errors += fact.Issues.Errors
670672
report.Totals.Warnings += fact.Issues.Warnings
671673
report.Totals.Info += fact.Issues.Info
@@ -707,12 +709,25 @@ func addFactToReport(report *model.ScanReport, entry model.SnapshotEntry, fact m
707709
func addGroupFact(group *model.GroupCounts, fact model.BlobFacts, issues int) {
708710
group.Files++
709711
group.Lines += fact.Lines
712+
group.CommentLines += fact.CommentLines
710713
group.Issues += issues
711714
group.Errors += fact.Issues.Errors
712715
group.Warnings += fact.Issues.Warnings
713716
group.Info += fact.Issues.Info
714717
}
715718

719+
func finalizeCommentStats(report *model.ScanReport) {
720+
report.Totals.CommentDensityPercent = model.LineDensityPercent(report.Totals.CommentLines, report.Totals.LinesScanned)
721+
for language, group := range report.ByLanguage {
722+
group.CommentDensityPercent = model.LineDensityPercent(group.CommentLines, group.Lines)
723+
report.ByLanguage[language] = group
724+
}
725+
for module, group := range report.ByModule {
726+
group.CommentDensityPercent = model.LineDensityPercent(group.CommentLines, group.Lines)
727+
report.ByModule[module] = group
728+
}
729+
}
730+
716731
func uniqueBlobKeys(groups [][]model.SnapshotEntry, cfg config.Config) []model.BlobKey {
717732
seen := make(map[model.BlobKey]bool)
718733
for _, entries := range groups {

internal/scan/scan_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,37 @@ func TestScanPathReportsAggregateJSONParseErrors(t *testing.T) {
9696
}
9797
}
9898

99+
func TestScanPathReportsCommentLineStats(t *testing.T) {
100+
root := t.TempDir()
101+
writeRepoFile(t, root, "main.go", strings.Join([]string{
102+
"package main",
103+
"// file comment",
104+
"func main() {",
105+
"println(\"ok\") // inline comment",
106+
"/* block comment */",
107+
"}",
108+
}, "\n")+"\n")
109+
110+
report, err := ScanPath(context.Background(), root, ScanOptions{})
111+
if err != nil {
112+
t.Fatalf("ScanPath() error = %v", err)
113+
}
114+
payload, err := json.Marshal(report)
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
var decoded map[string]any
119+
if err := json.Unmarshal(payload, &decoded); err != nil {
120+
t.Fatalf("unmarshal report JSON: %v", err)
121+
}
122+
assertJSONNumber(t, decoded, "totals.comment_lines", 3)
123+
assertJSONNumber(t, decoded, "totals.comment_density_percent", 50)
124+
assertJSONNumber(t, decoded, "by_language.go.comment_lines", 3)
125+
assertJSONNumber(t, decoded, "by_language.go.comment_density_percent", 50)
126+
assertJSONNumberPath(t, decoded, []string{"by_module", ".", "comment_lines"}, 3)
127+
assertJSONNumberPath(t, decoded, []string{"by_module", ".", "comment_density_percent"}, 50)
128+
}
129+
99130
func TestScanPathAggregatesGoParseAndComplexityAggregateOnly(t *testing.T) {
100131
root := t.TempDir()
101132
writeRepoFile(t, root, "services/api/go.mod", "module example.test/api\n\ngo 1.26\n")
@@ -544,6 +575,26 @@ func writeRepoFile(t *testing.T, repo, name, content string) {
544575
}
545576
}
546577

578+
func assertJSONNumber(t *testing.T, root map[string]any, dottedPath string, want float64) {
579+
t.Helper()
580+
assertJSONNumberPath(t, root, strings.Split(dottedPath, "."), want)
581+
}
582+
583+
func assertJSONNumberPath(t *testing.T, root map[string]any, path []string, want float64) {
584+
t.Helper()
585+
var current any = root
586+
for _, key := range path {
587+
object, ok := current.(map[string]any)
588+
if !ok {
589+
t.Fatalf("%s parent = %#v, want object", strings.Join(path, "."), current)
590+
}
591+
current = object[key]
592+
}
593+
if current != want {
594+
t.Fatalf("%s = %#v, want %.1f", strings.Join(path, "."), current, want)
595+
}
596+
}
597+
547598
func listenUnixSocket(t *testing.T, path string) net.Listener {
548599
t.Helper()
549600
listener, err := net.Listen("unix", path)

0 commit comments

Comments
 (0)