Skip to content

feat: deduplicate ranking table entries sharing the same image digest - #36

Merged
Mani Bindra (maniSbindra) merged 3 commits into
mainfrom
feat/digest-dedup-reports
May 26, 2026
Merged

feat: deduplicate ranking table entries sharing the same image digest#36
Mani Bindra (maniSbindra) merged 3 commits into
mainfrom
feat/digest-dedup-reports

Conversation

@maniSbindra

Copy link
Copy Markdown
Member

Summary

Images with different tags but identical digests (e.g., nodejs:24 and nodejs:24.14) previously occupied multiple rows in the ranking tables. This PR deduplicates them at report generation time, showing the most specific tag as primary with alternates in an Also Tagged As column.

Changes

  • pkg/domain/models.go — Added AlternateTags to RecommendedImage
  • pkg/infrastructure/report/dedup.go (new) — DeduplicateByDigest groups images by digest
  • pkg/infrastructure/report/dedup_test.go (new) — 14 test cases
  • pkg/infrastructure/report/markdown.go — Query all, dedup, topN; added Also Tagged As column
  • pkg/infrastructure/report/json.go — Added alternateTags field; same dedup flow
  • Updated test files for new column

Design Decisions

  • Report-level only — no database schema changes
  • Tag specificity scoring — numeric tags beat latest, more segments beat fewer
  • Empty digests never grouped
  • TopN applied after dedup

Closes #35

Images with different tags but identical digests (e.g., nodejs:24 and
nodejs:24.14) now appear as a single row. The most specific tag is
shown as primary, with alternates in an "Also Tagged As" column.

Deduplication is applied at report generation time for both markdown
and JSON reports. No database schema changes.

Closes #35

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the readability of the generated recommendation reports by deduplicating ranking rows that refer to the same underlying image digest, surfacing the most specific tag as the primary entry and listing other tags as alternates.

Changes:

  • Added AlternateTags to domain.RecommendedImage and populated it via new DeduplicateByDigest logic.
  • Updated markdown and JSON report generation to query all rows, deduplicate by digest, then apply topN post-dedup; markdown now includes an “Also Tagged As” column.
  • Added unit tests for deduplication behavior and updated existing report tests for the new column.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
pkg/domain/models.go Extends RecommendedImage with AlternateTags.
pkg/infrastructure/report/dedup.go New digest-based deduplication + tag specificity scoring.
pkg/infrastructure/report/dedup_test.go Unit tests covering dedup and tag parsing/scoring.
pkg/infrastructure/report/markdown.go Applies dedup + post-dedup topN; adds “Also Tagged As” column.
pkg/infrastructure/report/markdown_test.go Updates expectations for the new markdown table column.
pkg/infrastructure/report/json.go Applies same dedup flow; adds alternateTags to JSON output.
pkg/infrastructure/report/json_test.go Updates markdown-table expectations in an integration-style test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +80 to +89
images, err := repo.QueryTopImagesByOS(lang, osName, 0)
if err != nil {
log.Warnf("Failed to query images for %s: %v", lang, err)
continue
}

images = DeduplicateByDigest(images)
if topN > 0 && len(images) > topN {
images = images[:topN]
}
Comment on lines +96 to +105
osImages, err := repo.QueryTopImagesByOS(lang, osName, 0)
if err != nil {
log.Warnf("Failed to query images for %s/%s: %v", lang, osName, err)
continue
}

osImages = DeduplicateByDigest(osImages)
if topN > 0 && len(osImages) > topN {
osImages = osImages[:topN]
}
Comment thread pkg/infrastructure/report/dedup.go Outdated
Comment on lines +56 to +60
if gi, ok := byDigest[d]; ok {
groups[gi].indices = append(groups[gi].indices, i)
if tagSpecificity(images[i].Name) > tagSpecificity(images[groups[gi].primary].Name) {
groups[gi].primary = i
}
Comment on lines +116 to +127
// extractTag returns the tag portion of a full image name.
// E.g., "mcr.microsoft.com/repo:3.12-nonroot" → "3.12-nonroot"
func extractTag(name string) string {
lastSlash := strings.LastIndex(name, "/")
lastColon := strings.LastIndex(name, ":")

if lastColon > lastSlash {
return name[lastColon+1:]
}

return ""
}
Comment on lines +107 to +116
images, err := repo.QueryTopImagesByOS(lang, osName, 0)
if err != nil {
log.Warnf("Failed to query images for %s/%s: %v", lang, osName, err)
continue
}

images = DeduplicateByDigest(images)
if topN > 0 && len(images) > topN {
images = images[:topN]
}
Comment on lines 118 to 136
@@ -126,6 +132,7 @@ func GenerateJSONReport(repo *database.Repository, outputPath string, topN int,
PinnedReference: FormatPinnedReference(img.Name, img.Digest),
StableTag: FormatStableTag(img.Name, img.Version),
DockerfileFrom: FormatDockerfileFrom(img.Name, img.Digest),
AlternateTags: img.AlternateTags,
})
Comment on lines +222 to +224
assert.Contains(t, content, "| Rank | Image | Version | Also Tagged As | Crit | High | Total | Size | Created | Digest | Pinned Reference |")
assert.Contains(t, content, "| 1 | `mcr.microsoft.com/azurelinux/base/python:3.12` | 3.12.1 | - | 0 | 1 | 5 | 81.1 MB | 2025-04-15 | `sha256:abcdef123456` |")
assert.Contains(t, content, "| 2 | `empty-created:latest` | - | - | 0 | 0 | 0 | - | - | `` | `-` |")
…ntegration tests

- Add lexicographic tie-break when tagSpecificity scores are equal
- Sort AlternateTags for deterministic output across runs
- Strip @sha256:... suffix in extractTag before parsing
- Add JSON integration test verifying alternateTags field after dedup
- Add markdown integration test verifying dedup row and Also Tagged As column

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Comment thread pkg/infrastructure/report/dedup.go Outdated
Comment on lines +32 to +67
// DeduplicateByDigest groups images sharing the same digest, keeping the most
// specific tag as the primary entry and collecting alternate tag names.
// Images with an empty digest are never grouped.
func DeduplicateByDigest(images []domain.RecommendedImage) []domain.RecommendedImage {
if len(images) <= 1 {
return images
}

type group struct {
primary int
indices []int
}

// Ordered list of groups preserving first-seen order.
var groups []group
// Maps non-empty digest to index in groups slice.
byDigest := make(map[string]int)

for i, img := range images {
d := img.Digest
if d == "" {
groups = append(groups, group{primary: i, indices: []int{i}})
continue
}

if gi, ok := byDigest[d]; ok {
groups[gi].indices = append(groups[gi].indices, i)
newScore := tagSpecificity(images[i].Name)
oldScore := tagSpecificity(images[groups[gi].primary].Name)
if newScore > oldScore || (newScore == oldScore && images[i].Name < images[groups[gi].primary].Name) {
groups[gi].primary = i
}
} else {
byDigest[d] = len(groups)
groups = append(groups, group{primary: i, indices: []int{i}})
}
Comment on lines +80 to +90
images, err := repo.QueryTopImagesByOS(lang, osName, 0)
if err != nil {
log.Warnf("Failed to query images for %s: %v", lang, err)
continue
}

images = DeduplicateByDigest(images)
if topN > 0 && len(images) > topN {
images = images[:topN]
}

Comment on lines +107 to +115
images, err := repo.QueryTopImagesByOS(lang, osName, 0)
if err != nil {
log.Warnf("Failed to query images for %s/%s: %v", lang, osName, err)
continue
}

images = DeduplicateByDigest(images)
if topN > 0 && len(images) > topN {
images = images[:topN]
Group by (repository, digest) instead of digest alone, so images from
different repositories that happen to share a digest are not merged.
Adds imageRepo helper and cross-repo test coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment on lines 137 to 141
// writeImageTable writes a ranked markdown table of images.
func writeImageTable(sb *strings.Builder, images []domain.RecommendedImage) {
sb.WriteString("| Rank | Image | Version | Crit | High | Total | Size | Created | Digest | Pinned Reference |\n")
sb.WriteString("|------|-------|---------|------|------|-------|------|---------|--------|------------------|\n")
sb.WriteString("| Rank | Image | Version | Also Tagged As | Crit | High | Total | Size | Created | Digest | Pinned Reference |\n")
sb.WriteString("|------|-------|---------|----------------|------|------|-------|------|---------|--------|------------------|\n")

Comment on lines +438 to +442
func TestGenerateMarkdownReport_DeduplicatesWithAlternateColumn(t *testing.T) {
db, repo := setupTestDB(t)
defer func() { _ = db.Close() }()

images := []domain.ImageRecord{
@maniSbindra
Mani Bindra (maniSbindra) merged commit 38abcdf into main May 26, 2026
14 checks passed
@maniSbindra
Mani Bindra (maniSbindra) deleted the feat/digest-dedup-reports branch May 26, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Report: Deduplicate ranking table entries that share the same image digest

2 participants