Skip to content

Commit 5ac46c9

Browse files
authored
Merge pull request #377 from open-edge-platform/fix/deduplicate-dot-edges
fix: Improvements to DOT dependency graph generation
2 parents 211dfc4 + 877c5ed commit 5ac46c9

10 files changed

Lines changed: 122 additions & 231 deletions

File tree

docs/architecture/adr-dep-analyzer.md

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,6 @@ is already required for DOT rendering).
9494
- Multiple output formats supported: DOT, SVG, PNG, PDF
9595
- Auto-generated filenames reflect analysis parameters
9696

97-
4. **Color Preservation**
98-
- Maintain semantic colors from os-image-composer output
99-
- Sliced subgraphs retain visual context
100-
10197
---
10298

10399
## Command Line Interface
@@ -170,22 +166,6 @@ Examples:
170166

171167
---
172168

173-
## Semantic Color Preservation
174-
175-
The os-image-composer assigns colors to indicate package categories:
176-
177-
| Color | Hex Code | Package Category |
178-
|-------|----------|------------------|
179-
| Yellow | `#fff4d6` | Essential packages |
180-
| Green | `#d4efdf` | System packages (user-specified) |
181-
| Blue | `#d6eaf8` | Kernel packages |
182-
| Orange | `#fdebd0` | Bootloader packages |
183-
184-
The dep-analyzer preserves these colors in sliced subgraphs, maintaining
185-
visual context and category information.
186-
187-
---
188-
189169
## Analysis Modes
190170

191171
### Slicing Mode

docs/architecture/os-image-composer-cli-specification.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ os-image-composer build [flags] TEMPLATE_FILE
144144
| `--cache-dir, -d DIR` | Package cache directory (overrides config). Proper caching significantly improves build times. |
145145
| `--work-dir DIR` | Working directory for builds (overrides config). This directory is where images are constructed before being finalized. |
146146
| `--verbose, -v` | Enable verbose output (equivalent to --log-level debug). Displays detailed information about each step of the build process. |
147-
| `--dotfile, -f FILE` | Generate a dot file for the merged template dependency graph (user + defaults with resolved packages). Nodes are color-coded: essentials (pale yellow), template packages (green), kernel (blue), bootloader (orange). |
147+
| `--dotfile, -f FILE` | Generate a dot file for the merged template dependency graph (user + defaults with resolved packages). |
148148
| `--system-packages-only` | When paired with `--dotfile`, limit the dependency graph to roots defined in `SystemConfig.Packages`. Dependencies pulled in by those roots still appear, but essentials/kernel/bootloader packages aren't drawn unless required by a system package. |
149149

150150
**Example:**

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ Flags:
220220
- `--cache-dir, -d`: Package cache directory (overrides the configuration file)
221221
- `--work-dir`: Working directory for builds (overrides the configuration file)
222222
- `--verbose, -v`: Enable verbose output
223-
- `--dotfile, -f`: Generate a dependency graph for the merged template as a dot file (color legend: essential = pale yellow, user packages = green, kernel = blue, bootloader = orange)
223+
- `--dotfile, -f`: Generate a dependency graph for the merged template as a dot file
224224
- `--system-packages-only`: Use together with `--dotfile` to keep only `SystemConfig.Packages` roots in the graph (dependencies still appear if required)
225225
- `--config`: Path to the configuration file
226226
- `--log-level`: Log level (debug, info, warn, and error)

internal/config/config.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,10 @@ func (t *ImageTemplate) GetPackages() []string {
346346

347347
var packageSourcePriority = map[PackageSource]int{
348348
PackageSourceUnknown: 0,
349-
PackageSourceEssential: 10,
349+
PackageSourceSystem: 10,
350350
PackageSourceKernel: 20,
351351
PackageSourceBootloader: 20,
352-
PackageSourceSystem: 30,
352+
PackageSourceEssential: 30,
353353
}
354354

355355
// GetPackageSourceMap returns a map of package name to the template section that requested it.
@@ -367,10 +367,10 @@ func (t *ImageTemplate) GetPackageSourceMap() map[string]PackageSource {
367367
}
368368
}
369369

370-
setSources(t.EssentialPkgList, PackageSourceEssential)
370+
setSources(t.SystemConfig.Packages, PackageSourceSystem)
371371
setSources(t.KernelPkgList, PackageSourceKernel)
372372
setSources(t.BootloaderPkgList, PackageSourceBootloader)
373-
setSources(t.SystemConfig.Packages, PackageSourceSystem)
373+
setSources(t.EssentialPkgList, PackageSourceEssential)
374374

375375
return sources
376376
}

internal/config/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3307,8 +3307,8 @@ func TestGetPackageSourceMap(t *testing.T) {
33073307
if got := sources["vim"]; got != PackageSourceSystem {
33083308
t.Fatalf("vim source = %s, want system", got)
33093309
}
3310-
if got := sources["bash"]; got != PackageSourceSystem {
3311-
t.Fatalf("bash source = %s, want system override", got)
3310+
if got := sources["bash"]; got != PackageSourceEssential {
3311+
t.Fatalf("bash source = %s, want essential (essential has higher priority than system)", got)
33123312
}
33133313
if _, exists := sources[""]; exists {
33143314
t.Fatalf("unexpected empty key in package source map")

internal/ospackage/debutils/resolver.go

Lines changed: 8 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -24,26 +24,6 @@ type VersionConstraint struct {
2424
Alternative string // Alternative package name for constraints like "logsave | e2fsprogs (<< 1.45.3-1~)"
2525
}
2626

27-
type dotStyle struct {
28-
fillColor string
29-
borderColor string
30-
legendLabel string
31-
}
32-
33-
var packageSourceStyles = map[config.PackageSource]dotStyle{
34-
config.PackageSourceEssential: {fillColor: "#fff4d6", borderColor: "#f5c518", legendLabel: "EssentialPkgList"},
35-
config.PackageSourceSystem: {fillColor: "#d4efdf", borderColor: "#27ae60", legendLabel: "SystemConfig.Packages"},
36-
config.PackageSourceKernel: {fillColor: "#d6eaf8", borderColor: "#1f618d", legendLabel: "Kernel"},
37-
config.PackageSourceBootloader: {fillColor: "#fdebd0", borderColor: "#d35400", legendLabel: "Bootloader"},
38-
}
39-
40-
var legendOrder = []config.PackageSource{
41-
config.PackageSourceEssential,
42-
config.PackageSourceSystem,
43-
config.PackageSourceKernel,
44-
config.PackageSourceBootloader,
45-
}
46-
4727
func GenerateDot(pkgs []ospackage.PackageInfo, file string, pkgSources map[string]config.PackageSource) error {
4828
log := logger.Logger()
4929
log.Infof("Generating DOT file %s", file)
@@ -63,44 +43,32 @@ func GenerateDot(pkgs []ospackage.PackageInfo, file string, pkgSources map[strin
6343
if _, err := fmt.Fprintln(writer, " rankdir=LR;"); err != nil {
6444
return fmt.Errorf("writing DOT attributes: %w", err)
6545
}
66-
if _, err := fmt.Fprintln(writer, " node [shape=box, style=filled, fillcolor=\"#ffffff\", color=\"#666666\"];"); err != nil {
46+
if _, err := fmt.Fprintln(writer, " node [shape=box];"); err != nil {
6747
return fmt.Errorf("writing DOT node defaults: %w", err)
6848
}
6949

70-
legendUsed := make(map[config.PackageSource]bool)
50+
edgesWritten := make(map[string]bool)
7151

7252
for _, pkg := range pkgs {
7353
if pkg.Name == "" {
7454
continue
7555
}
76-
source := config.PackageSourceUnknown
77-
if pkgSources != nil {
78-
if val, ok := pkgSources[pkg.Name]; ok {
79-
source = val
80-
}
81-
}
82-
attr := fmt.Sprintf("label=\"%s\"", pkg.Name)
83-
if style, ok := packageSourceStyles[source]; ok {
84-
legendUsed[source] = true
85-
attr += fmt.Sprintf(", fillcolor=\"%s\", color=\"%s\"", style.fillColor, style.borderColor)
86-
}
87-
if _, err := fmt.Fprintf(writer, " \"%s\" [%s];\n", pkg.Name, attr); err != nil {
56+
if _, err := fmt.Fprintf(writer, " \"%s\";\n", pkg.Name); err != nil {
8857
return fmt.Errorf("writing DOT node for %s: %w", pkg.Name, err)
8958
}
9059
for _, dep := range pkg.Requires {
9160
depName := CleanDependencyName(dep)
9261
if depName == "" {
9362
continue
9463
}
64+
edgeKey := pkg.Name + "|" + depName
65+
if edgesWritten[edgeKey] {
66+
continue
67+
}
9568
if _, err := fmt.Fprintf(writer, " \"%s\" -> \"%s\";\n", pkg.Name, depName); err != nil {
9669
return fmt.Errorf("writing DOT edge %s->%s: %w", pkg.Name, depName, err)
9770
}
98-
}
99-
}
100-
101-
if len(legendUsed) > 0 {
102-
if err := writeLegend(writer, legendUsed); err != nil {
103-
return err
71+
edgesWritten[edgeKey] = true
10472
}
10573
}
10674

@@ -111,47 +79,6 @@ func GenerateDot(pkgs []ospackage.PackageInfo, file string, pkgSources map[strin
11179
return nil
11280
}
11381

114-
func writeLegend(writer *bufio.Writer, legendUsed map[config.PackageSource]bool) error {
115-
if _, err := fmt.Fprintln(writer, " subgraph cluster_legend {"); err != nil {
116-
return fmt.Errorf("writing legend header: %w", err)
117-
}
118-
if _, err := fmt.Fprintln(writer, " label=\"Legend\";"); err != nil {
119-
return fmt.Errorf("writing legend label: %w", err)
120-
}
121-
if _, err := fmt.Fprintln(writer, " style=\"dashed\";"); err != nil {
122-
return fmt.Errorf("writing legend style: %w", err)
123-
}
124-
if _, err := fmt.Fprintln(writer, " color=\"#bbbbbb\";"); err != nil {
125-
return fmt.Errorf("writing legend color: %w", err)
126-
}
127-
128-
var previous string
129-
for _, source := range legendOrder {
130-
if !legendUsed[source] {
131-
continue
132-
}
133-
style, ok := packageSourceStyles[source]
134-
if !ok {
135-
continue
136-
}
137-
nodeName := fmt.Sprintf("legend_%s", source)
138-
if _, err := fmt.Fprintf(writer, " %s [label=\"%s\", style=\"filled\", fillcolor=\"%s\", color=\"%s\"];\n", nodeName, style.legendLabel, style.fillColor, style.borderColor); err != nil {
139-
return fmt.Errorf("writing legend node for %s: %w", source, err)
140-
}
141-
if previous != "" {
142-
if _, err := fmt.Fprintf(writer, " %s -> %s [style=invis];\n", previous, nodeName); err != nil {
143-
return fmt.Errorf("writing legend spacing edge: %w", err)
144-
}
145-
}
146-
previous = nodeName
147-
}
148-
149-
if _, err := fmt.Fprintln(writer, " }"); err != nil {
150-
return fmt.Errorf("writing legend footer: %w", err)
151-
}
152-
return nil
153-
}
154-
15582
// ParseRepositoryMetadata parses the Packages.gz file from gzHref.
15683
func ParseRepositoryMetadata(baseURL string, pkggz string, releaseFile string, releaseSign string, pbGPGKey string, buildPath string, arch string) ([]ospackage.PackageInfo, error) {
15784
log := logger.Logger()

internal/ospackage/debutils/resolver_test.go

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,16 @@ func TestGenerateDot(t *testing.T) {
139139
filename: filepath.Join(tmpDir, "complex-deps.dot"),
140140
expectError: false,
141141
},
142+
{
143+
name: "duplicate dependencies should be deduplicated",
144+
pkgs: []ospackage.PackageInfo{
145+
{Name: "libstdc++", Version: "1.0", Requires: []string{"libc6", "libc6", "libc6", "libgcc", "libgcc"}},
146+
{Name: "libc6", Version: "1.0"},
147+
{Name: "libgcc", Version: "1.0", Requires: []string{"libc6"}},
148+
},
149+
filename: filepath.Join(tmpDir, "dedup-deps.dot"),
150+
expectError: false,
151+
},
142152
{
143153
name: "invalid path",
144154
pkgs: []ospackage.PackageInfo{
@@ -169,18 +179,6 @@ func TestGenerateDot(t *testing.T) {
169179
}
170180
contentStr := string(content)
171181

172-
if tc.pkgSources != nil {
173-
if !strings.Contains(contentStr, "legend_system") {
174-
t.Errorf("legend for system packages not found in DOT output")
175-
}
176-
if !strings.Contains(contentStr, "\"sys\" [label=\"sys\", fillcolor=\"#d4efdf\", color=\"#27ae60\"];") {
177-
t.Errorf("expected system package styling for sys node")
178-
}
179-
if !strings.Contains(contentStr, "\"ess\" [label=\"ess\", fillcolor=\"#fff4d6\", color=\"#f5c518\"];") {
180-
t.Errorf("expected essential package styling for ess node")
181-
}
182-
}
183-
184182
if !strings.Contains(contentStr, "digraph G {") {
185183
t.Error("DOT file should start with 'digraph G {'")
186184
}
@@ -195,10 +193,13 @@ func TestGenerateDot(t *testing.T) {
195193
if pkg.Name == "" {
196194
continue
197195
}
198-
nodePrefix := fmt.Sprintf("\"%s\" [label=\"%s\"", pkg.Name, pkg.Name)
199-
if !strings.Contains(contentStr, nodePrefix) {
196+
nodeDef := fmt.Sprintf("\"%s\";", pkg.Name)
197+
if !strings.Contains(contentStr, nodeDef) {
200198
t.Errorf("DOT file should contain node for %s", pkg.Name)
201199
}
200+
201+
// Check dependencies - each unique edge should appear exactly once
202+
seenEdges := make(map[string]bool)
202203
for _, dep := range pkg.Requires {
203204
depName := debutils.CleanDependencyName(dep)
204205
if depName == "" {
@@ -208,6 +209,17 @@ func TestGenerateDot(t *testing.T) {
208209
if !strings.Contains(contentStr, edge) {
209210
t.Errorf("DOT file should contain edge: %s", edge)
210211
}
212+
seenEdges[edge] = true
213+
}
214+
215+
// For duplicate dependency test, verify each unique edge appears only once
216+
if tc.name == "duplicate dependencies should be deduplicated" {
217+
for edge := range seenEdges {
218+
count := strings.Count(contentStr, edge)
219+
if count != 1 {
220+
t.Errorf("Edge %s should appear exactly once, but appears %d times", edge, count)
221+
}
222+
}
211223
}
212224
}
213225
})

0 commit comments

Comments
 (0)