Skip to content

Commit 456bd01

Browse files
grokifyclaude
andcommitted
feat(rubric): add CoverageReport for tracking spec coverage
Add generic coverage types for domain-specific metrics: - CoverageSection: tracks total, complete, percentage, and missing items - CoverageReport: aggregates coverage across multiple named sections Key features: - SetSection() for fluent API - ComputeOverall() for simple average - ComputeOverallWeighted() for weighted averages - MeetsThreshold() and AllComplete() for validation - SectionsAboveThreshold/BelowThreshold for filtering Integration with Rubric via Extensions: - SetCoverage()/GetCoverage() convenience methods - JSON round-trip support via coverageReportFromMap() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 29606df commit 456bd01

2 files changed

Lines changed: 498 additions & 0 deletions

File tree

rubric/coverage.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package rubric
2+
3+
// CoverageSection represents coverage metrics for a single section/area.
4+
// This is a generic type that can be used for:
5+
// - Spec coverage (components, foundations, patterns)
6+
// - Code coverage (functions, lines, branches)
7+
// - Test coverage (scenarios, edge cases)
8+
// - Documentation coverage (API docs, guides)
9+
type CoverageSection struct {
10+
// Total is the total number of items in this section.
11+
Total int `json:"total"`
12+
13+
// Complete is the number of items that are complete/covered.
14+
Complete int `json:"complete"`
15+
16+
// Percentage is the coverage percentage (0-100).
17+
Percentage int `json:"percentage"`
18+
19+
// Missing lists the IDs or names of missing/incomplete items.
20+
Missing []string `json:"missing,omitempty"`
21+
}
22+
23+
// CoverageReport aggregates coverage across multiple sections.
24+
type CoverageReport struct {
25+
// Sections contains coverage for each named section.
26+
// Example keys: "components", "foundations", "functions", "lines"
27+
Sections map[string]CoverageSection `json:"sections"`
28+
29+
// Overall is the aggregate coverage percentage (0-100).
30+
Overall int `json:"overall"`
31+
}
32+
33+
// NewCoverageReport creates an empty coverage report.
34+
func NewCoverageReport() *CoverageReport {
35+
return &CoverageReport{
36+
Sections: make(map[string]CoverageSection),
37+
}
38+
}
39+
40+
// AddSection adds a coverage section to the report.
41+
func (cr *CoverageReport) AddSection(name string, section CoverageSection) {
42+
cr.Sections[name] = section
43+
}
44+
45+
// SetSection is an alias for AddSection for fluent API.
46+
func (cr *CoverageReport) SetSection(name string, total, complete int, missing []string) *CoverageReport {
47+
percentage := 0
48+
if total > 0 {
49+
percentage = (complete * 100) / total
50+
}
51+
cr.Sections[name] = CoverageSection{
52+
Total: total,
53+
Complete: complete,
54+
Percentage: percentage,
55+
Missing: missing,
56+
}
57+
return cr
58+
}
59+
60+
// ComputeOverall calculates the overall coverage percentage.
61+
// Uses a simple average of all section percentages.
62+
func (cr *CoverageReport) ComputeOverall() int {
63+
if len(cr.Sections) == 0 {
64+
return 0
65+
}
66+
67+
total := 0
68+
for _, section := range cr.Sections {
69+
total += section.Percentage
70+
}
71+
cr.Overall = total / len(cr.Sections)
72+
return cr.Overall
73+
}
74+
75+
// ComputeOverallWeighted calculates overall coverage with weights.
76+
// The weights map should have keys matching section names.
77+
// Sections not in the weights map get weight 1.0.
78+
func (cr *CoverageReport) ComputeOverallWeighted(weights map[string]float64) int {
79+
if len(cr.Sections) == 0 {
80+
return 0
81+
}
82+
83+
totalWeight := 0.0
84+
weightedSum := 0.0
85+
86+
for name, section := range cr.Sections {
87+
weight := 1.0
88+
if w, ok := weights[name]; ok {
89+
weight = w
90+
}
91+
totalWeight += weight
92+
weightedSum += float64(section.Percentage) * weight
93+
}
94+
95+
if totalWeight == 0 {
96+
return 0
97+
}
98+
cr.Overall = int(weightedSum / totalWeight)
99+
return cr.Overall
100+
}
101+
102+
// GetSection retrieves a section by name.
103+
// Returns an empty section if not found.
104+
func (cr *CoverageReport) GetSection(name string) CoverageSection {
105+
if section, ok := cr.Sections[name]; ok {
106+
return section
107+
}
108+
return CoverageSection{}
109+
}
110+
111+
// HasSection checks if a section exists.
112+
func (cr *CoverageReport) HasSection(name string) bool {
113+
_, ok := cr.Sections[name]
114+
return ok
115+
}
116+
117+
// AllComplete returns true if all sections have 100% coverage.
118+
func (cr *CoverageReport) AllComplete() bool {
119+
for _, section := range cr.Sections {
120+
if section.Percentage < 100 {
121+
return false
122+
}
123+
}
124+
return true
125+
}
126+
127+
// MeetsThreshold returns true if overall coverage meets the threshold.
128+
func (cr *CoverageReport) MeetsThreshold(threshold int) bool {
129+
return cr.Overall >= threshold
130+
}
131+
132+
// SectionsAboveThreshold returns section names with coverage >= threshold.
133+
func (cr *CoverageReport) SectionsAboveThreshold(threshold int) []string {
134+
var result []string
135+
for name, section := range cr.Sections {
136+
if section.Percentage >= threshold {
137+
result = append(result, name)
138+
}
139+
}
140+
return result
141+
}
142+
143+
// SectionsBelowThreshold returns section names with coverage < threshold.
144+
func (cr *CoverageReport) SectionsBelowThreshold(threshold int) []string {
145+
var result []string
146+
for name, section := range cr.Sections {
147+
if section.Percentage < threshold {
148+
result = append(result, name)
149+
}
150+
}
151+
return result
152+
}
153+
154+
// ExtensionKeyCoverage is the standard extension key for coverage data.
155+
const ExtensionKeyCoverage = "coverage"
156+
157+
// SetCoverage is a convenience method to set coverage on a Rubric.
158+
func (r *Rubric) SetCoverage(coverage *CoverageReport) {
159+
r.SetExtension(ExtensionKeyCoverage, coverage)
160+
}
161+
162+
// GetCoverage retrieves coverage data from a Rubric's extensions.
163+
// Returns nil if not set or if type assertion fails.
164+
func (r *Rubric) GetCoverage() *CoverageReport {
165+
ext := r.GetExtension(ExtensionKeyCoverage)
166+
if ext == nil {
167+
return nil
168+
}
169+
170+
// Handle both direct type and map[string]any (from JSON unmarshal)
171+
if cr, ok := ext.(*CoverageReport); ok {
172+
return cr
173+
}
174+
175+
// Try to convert from map[string]any (common after JSON round-trip)
176+
if m, ok := ext.(map[string]any); ok {
177+
return coverageReportFromMap(m)
178+
}
179+
180+
return nil
181+
}
182+
183+
// coverageReportFromMap converts a map to CoverageReport.
184+
// Used when coverage data has been JSON marshaled/unmarshaled.
185+
func coverageReportFromMap(m map[string]any) *CoverageReport {
186+
cr := NewCoverageReport()
187+
188+
if overall, ok := m["overall"].(float64); ok {
189+
cr.Overall = int(overall)
190+
}
191+
192+
if sections, ok := m["sections"].(map[string]any); ok {
193+
for name, sectionData := range sections {
194+
if sectionMap, ok := sectionData.(map[string]any); ok {
195+
section := CoverageSection{}
196+
if total, ok := sectionMap["total"].(float64); ok {
197+
section.Total = int(total)
198+
}
199+
if complete, ok := sectionMap["complete"].(float64); ok {
200+
section.Complete = int(complete)
201+
}
202+
if percentage, ok := sectionMap["percentage"].(float64); ok {
203+
section.Percentage = int(percentage)
204+
}
205+
if missing, ok := sectionMap["missing"].([]any); ok {
206+
for _, item := range missing {
207+
if s, ok := item.(string); ok {
208+
section.Missing = append(section.Missing, s)
209+
}
210+
}
211+
}
212+
cr.Sections[name] = section
213+
}
214+
}
215+
}
216+
217+
return cr
218+
}

0 commit comments

Comments
 (0)