From 3499280f71a3c98e06338fd1d53aad0b612a2cf1 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Wed, 4 Mar 2026 11:34:37 +0100 Subject: [PATCH 1/6] feat(report): add hybrid build report model and metrics engine --- internal/analyze/stage_graph.go | 116 +++++++++++++ internal/analyze/stage_graph_test.go | 45 +++++ internal/backend/backend.go | 35 ++++ internal/backend/buildkit/direct.go | 127 +++++++++++++- internal/backend/buildkit/dockerdriver.go | 37 +++- internal/config/config.go | 17 ++ internal/config/loader.go | 26 +++ internal/report/baseline.go | 59 +++++++ internal/report/compare.go | 164 ++++++++++++++++++ internal/report/compare_test.go | 28 ++++ internal/report/export.go | 91 ++++++++++ internal/report/export_test.go | 38 +++++ internal/report/io.go | 46 +++++ internal/report/metrics.go | 196 ++++++++++++++++++++++ internal/report/metrics_test.go | 37 ++++ internal/report/model.go | 115 +++++++++++++ internal/report/trend.go | 72 ++++++++ internal/state/sqlite.go | 95 +++++++++++ 18 files changed, 1334 insertions(+), 10 deletions(-) create mode 100644 internal/analyze/stage_graph.go create mode 100644 internal/analyze/stage_graph_test.go create mode 100644 internal/report/baseline.go create mode 100644 internal/report/compare.go create mode 100644 internal/report/compare_test.go create mode 100644 internal/report/export.go create mode 100644 internal/report/export_test.go create mode 100644 internal/report/io.go create mode 100644 internal/report/metrics.go create mode 100644 internal/report/metrics_test.go create mode 100644 internal/report/model.go create mode 100644 internal/report/trend.go diff --git a/internal/analyze/stage_graph.go b/internal/analyze/stage_graph.go new file mode 100644 index 0000000..1dfd073 --- /dev/null +++ b/internal/analyze/stage_graph.go @@ -0,0 +1,116 @@ +package analyze + +import ( + "strconv" + "strings" +) + +type StageNode struct { + Name string `json:"name"` + Base string `json:"base,omitempty"` + Line int `json:"line"` +} + +type StageEdge struct { + From string `json:"from"` + To string `json:"to"` + Reason string `json:"reason"` +} + +type StageGraph struct { + Stages []StageNode `json:"stages"` + Edges []StageEdge `json:"edges"` +} + +func ParseStageGraph(contextDir, dockerfilePath string) (StageGraph, error) { + parsed, err := ParseDockerfile(contextDir, dockerfilePath) + if err != nil { + return StageGraph{}, err + } + + graph := StageGraph{ + Stages: make([]StageNode, 0, 8), + Edges: make([]StageEdge, 0, 16), + } + stageExists := map[string]bool{} + edgeSet := map[string]bool{} + currentStage := "" + + for index, inst := range parsed.Instructions { + switch inst.Command { + case "FROM": + base, alias := parseFromInstruction(instructionBody(inst.Raw, "FROM", inst.Value)) + stageName := alias + if stageName == "" { + stageName = "stage-" + strconv.Itoa(index) + } + if !stageExists[stageName] { + stageExists[stageName] = true + graph.Stages = append(graph.Stages, StageNode{ + Name: stageName, + Base: base, + Line: inst.Line, + }) + } + if base != "" && stageExists[base] { + graph.Edges = addStageEdge(graph.Edges, edgeSet, StageEdge{From: base, To: stageName, Reason: "from"}) + } + currentStage = stageName + case "COPY", "ADD": + source := parseCopyFrom(instructionBody(inst.Raw, inst.Command, inst.Value)) + if source != "" && currentStage != "" && stageExists[source] { + graph.Edges = addStageEdge(graph.Edges, edgeSet, StageEdge{From: source, To: currentStage, Reason: "copy-from"}) + } + } + } + + return graph, nil +} + +func instructionBody(raw, command, fallback string) string { + trimmedRaw := strings.TrimSpace(raw) + if trimmedRaw == "" { + return strings.TrimSpace(fallback) + } + command = strings.ToUpper(strings.TrimSpace(command)) + prefix := command + " " + upperRaw := strings.ToUpper(trimmedRaw) + if strings.HasPrefix(upperRaw, prefix) { + return strings.TrimSpace(trimmedRaw[len(prefix):]) + } + return strings.TrimSpace(fallback) +} + +func parseFromInstruction(value string) (base string, alias string) { + parts := strings.Fields(strings.TrimSpace(value)) + if len(parts) == 0 { + return "", "" + } + base = parts[0] + if len(parts) >= 3 && strings.EqualFold(parts[1], "as") { + alias = parts[2] + } + return base, alias +} + +func parseCopyFrom(value string) string { + for _, part := range strings.Fields(value) { + if !strings.HasPrefix(strings.ToLower(part), "--from=") { + continue + } + return strings.TrimSpace(strings.TrimPrefix(part, "--from=")) + } + return "" +} + +func addStageEdge(edges []StageEdge, edgeSet map[string]bool, edge StageEdge) []StageEdge { + if edge.From == "" || edge.To == "" || edge.From == edge.To { + return edges + } + key := edge.From + "->" + edge.To + ":" + edge.Reason + if edgeSet[key] { + return edges + } + edgeSet[key] = true + return append(edges, edge) +} diff --git a/internal/analyze/stage_graph_test.go b/internal/analyze/stage_graph_test.go new file mode 100644 index 0000000..50e8be9 --- /dev/null +++ b/internal/analyze/stage_graph_test.go @@ -0,0 +1,45 @@ +package analyze + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseStageGraphMultiStage(t *testing.T) { + t.Parallel() + dir := t.TempDir() + dockerfile := filepath.Join(dir, "Dockerfile") + content := `FROM golang:1.22 AS builder +WORKDIR /src +RUN go build ./... + +FROM alpine:3.20 AS runtime +COPY --from=builder /src/app /app +ENTRYPOINT ["/app"] +` + if err := os.WriteFile(dockerfile, []byte(content), 0o644); err != nil { + t.Fatalf("write dockerfile: %v", err) + } + + graph, err := ParseStageGraph(dir, "Dockerfile") + if err != nil { + t.Fatalf("parse stage graph: %v", err) + } + if len(graph.Stages) != 2 { + t.Fatalf("expected 2 stages, got %d", len(graph.Stages)) + } + if graph.Stages[0].Name != "builder" { + t.Fatalf("expected first stage builder, got %q", graph.Stages[0].Name) + } + if graph.Stages[1].Name != "runtime" { + t.Fatalf("expected second stage runtime, got %q", graph.Stages[1].Name) + } + if len(graph.Edges) != 1 { + t.Fatalf("expected 1 edge, got %d", len(graph.Edges)) + } + edge := graph.Edges[0] + if edge.From != "builder" || edge.To != "runtime" || edge.Reason != "copy-from" { + t.Fatalf("unexpected edge: %+v", edge) + } +} diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 5dbd7b2..014343c 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -95,6 +95,38 @@ type CacheStats struct { Misses int `json:"misses"` } +type BuildVertex struct { + ID string `json:"id"` + Name string `json:"name"` + Stage string `json:"stage,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + DurationMS int64 `json:"durationMs"` + Cached bool `json:"cached"` +} + +type BuildEdge struct { + From string `json:"from"` + To string `json:"to"` +} + +type SlowVertex struct { + ID string `json:"id"` + Name string `json:"name"` + DurationMS int64 `json:"durationMs"` +} + +type BuildMetrics struct { + CriticalPathMS int64 `json:"criticalPathMs"` + CriticalPathVertices []string `json:"criticalPathVertices"` + CacheHitRatio float64 `json:"cacheHitRatio"` + StageDistribution map[string]int64 `json:"stageDistribution"` + TimeDistribution map[string]int64 `json:"timeDistribution"` + LongestChain int `json:"longestChain"` + TopSlowVertices []SlowVertex `json:"topSlowVertices,omitempty"` + RepeatedMissPatterns []string `json:"repeatedMissPatterns,omitempty"` +} + type BuildResult struct { Backend string `json:"backend"` Endpoint string `json:"endpoint"` @@ -102,6 +134,9 @@ type BuildResult struct { Digest string `json:"digest"` ProvenanceAvailable bool `json:"provenanceAvailable"` CacheStats CacheStats `json:"cacheStats"` + Vertices []BuildVertex `json:"vertices,omitempty"` + Edges []BuildEdge `json:"edges,omitempty"` + GraphComplete bool `json:"graphComplete"` Warnings []string `json:"warnings"` ExporterResponse map[string]string `json:"-"` } diff --git a/internal/backend/buildkit/direct.go b/internal/backend/buildkit/direct.go index 1aef0a5..74557ac 100644 --- a/internal/backend/buildkit/direct.go +++ b/internal/backend/buildkit/direct.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "time" @@ -99,8 +100,8 @@ func (d *DirectDriver) Build(ctx context.Context, endpoint string, req backend.B } statusCh := make(chan *bkclient.SolveStatus) - var cacheHits int - var cacheMisses int + vertexByID := map[string]backend.BuildVertex{} + edgeSet := map[string]backend.BuildEdge{} progressCtx, cancelProgress := context.WithCancel(ctx) defer cancelProgress() @@ -118,14 +119,71 @@ func (d *DirectDriver) Build(ctx context.Context, endpoint string, req backend.B if vertex == nil { continue } - if vertex.Cached { - cacheHits++ - } else { - cacheMisses++ + vertexID := vertex.Digest.String() + if vertexID == "" { + continue + } + snapshot := vertexByID[vertexID] + snapshot.ID = vertexID + if vertex.Name != "" { + snapshot.Name = vertex.Name + snapshot.Stage = extractStageFromVertexName(vertex.Name) + } + snapshot.Cached = snapshot.Cached || vertex.Cached + if vertex.Started != nil { + started := vertex.Started.UTC() + snapshot.StartedAt = &started + } + if vertex.Completed != nil { + completed := vertex.Completed.UTC() + snapshot.CompletedAt = &completed + } + vertexByID[vertexID] = snapshot + + for _, input := range vertex.Inputs { + from := input.String() + if from == "" || from == vertexID { + continue + } + key := from + "->" + vertexID + edgeSet[key] = backend.BuildEdge{From: from, To: vertexID} + } + + state := "running" + if vertex.Completed != nil { + state = "completed" } if progressFn != nil { - progressFn(toBuildProgressEvent(vertex)) + event := toBuildProgressEvent(vertex) + event.Status = state + progressFn(event) + } + } + for _, statusEntry := range status.Statuses { + if statusEntry == nil { + continue + } + vertexID := statusEntry.Vertex.String() + if vertexID == "" { + continue + } + snapshot := vertexByID[vertexID] + snapshot.ID = vertexID + if statusEntry.Name != "" && snapshot.Name == "" { + snapshot.Name = statusEntry.Name + } + if statusEntry.Started != nil { + started := statusEntry.Started.UTC() + snapshot.StartedAt = &started + } + if statusEntry.Completed != nil { + completed := statusEntry.Completed.UTC() + snapshot.CompletedAt = &completed + } + if snapshot.Stage == "" { + snapshot.Stage = extractStageFromVertexName(snapshot.Name) } + vertexByID[vertexID] = snapshot } } } @@ -148,6 +206,38 @@ func (d *DirectDriver) Build(ctx context.Context, endpoint string, req backend.B digest = response.ExporterResponse["containerimage.config.digest"] } + vertices := make([]backend.BuildVertex, 0, len(vertexByID)) + cacheHits := 0 + cacheMisses := 0 + for _, vertex := range vertexByID { + if vertex.StartedAt != nil && vertex.CompletedAt != nil { + vertex.DurationMS = vertex.CompletedAt.Sub(*vertex.StartedAt).Milliseconds() + if vertex.DurationMS < 0 { + vertex.DurationMS = 0 + } + } + if vertex.Cached { + cacheHits++ + } else { + cacheMisses++ + } + vertices = append(vertices, vertex) + } + sort.Slice(vertices, func(i, j int) bool { + return vertices[i].ID < vertices[j].ID + }) + + edges := make([]backend.BuildEdge, 0, len(edgeSet)) + for _, edge := range edgeSet { + edges = append(edges, edge) + } + sort.Slice(edges, func(i, j int) bool { + if edges[i].From == edges[j].From { + return edges[i].To < edges[j].To + } + return edges[i].From < edges[j].From + }) + return backend.BuildResult{ Outputs: outputs, Digest: digest, @@ -156,6 +246,9 @@ func (d *DirectDriver) Build(ctx context.Context, endpoint string, req backend.B Hits: cacheHits, Misses: cacheMisses, }, + Vertices: vertices, + Edges: edges, + GraphComplete: len(vertices) > 0, Warnings: warnings, ExporterResponse: response.ExporterResponse, }, nil @@ -198,6 +291,26 @@ func toBuildProgressEvent(vertex *bkclient.Vertex) backend.BuildProgressEvent { return event } +func extractStageFromVertexName(name string) string { + name = strings.TrimSpace(name) + if !strings.HasPrefix(name, "[") { + return "" + } + end := strings.Index(name, "]") + if end <= 1 { + return "" + } + inside := strings.TrimSpace(name[1:end]) + if inside == "" { + return "" + } + parts := strings.Fields(inside) + if len(parts) == 0 { + return "" + } + return parts[0] +} + func configureExports(solveOpt *bkclient.SolveOpt, req backend.BuildRequest) ([]string, []string, error) { switch req.OutputMode { case "", backend.OutputImage: diff --git a/internal/backend/buildkit/dockerdriver.go b/internal/backend/buildkit/dockerdriver.go index 5900ff4..981c9da 100644 --- a/internal/backend/buildkit/dockerdriver.go +++ b/internal/backend/buildkit/dockerdriver.go @@ -127,6 +127,11 @@ func (d *DockerDriver) Build(ctx context.Context, req backend.BuildRequest, prog decoder := json.NewDecoder(response.Body) warnings := make([]string, 0) + vertices := make([]backend.BuildVertex, 0, 64) + edges := make([]backend.BuildEdge, 0, 63) + var previousVertexID string + cacheHits := 0 + cacheMisses := 0 for decoder.More() { var msg dockerBuildMessage if err := decoder.Decode(&msg); err != nil { @@ -153,6 +158,29 @@ func (d *DockerDriver) Build(ctx context.Context, req backend.BuildRequest, prog if strings.Contains(strings.ToLower(text), "warning") { warnings = append(warnings, text) } + if text != "" { + now := time.Now().UTC() + vertexID := fmt.Sprintf("docker-%06d", len(vertices)+1) + cached := strings.Contains(strings.ToLower(text), "cached") + if cached { + cacheHits++ + } else { + cacheMisses++ + } + vertices = append(vertices, backend.BuildVertex{ + ID: vertexID, + Name: text, + Stage: extractStageFromVertexName(text), + StartedAt: &now, + CompletedAt: &now, + DurationMS: 0, + Cached: cached, + }) + if previousVertexID != "" { + edges = append(edges, backend.BuildEdge{From: previousVertexID, To: vertexID}) + } + previousVertexID = vertexID + } } inspect, err := cli.ImageInspect(ctx, req.ImageRef) @@ -164,10 +192,13 @@ func (d *DockerDriver) Build(ctx context.Context, req backend.BuildRequest, prog Outputs: []string{req.ImageRef}, Digest: inspect.ID, CacheStats: backend.CacheStats{ - Hits: 0, - Misses: 0, + Hits: cacheHits, + Misses: cacheMisses, }, - Warnings: warnings, + Vertices: vertices, + Edges: edges, + GraphComplete: false, + Warnings: warnings, }, nil } diff --git a/internal/config/config.go b/internal/config/config.go index 0a7a1df..77f53aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ type Config struct { Endpoint string `yaml:"endpoint" json:"endpoint"` Telemetry TelemetryConfig `yaml:"telemetry" json:"telemetry"` Auth AuthConfig `yaml:"auth" json:"auth"` + CI CIConfig `yaml:"ci" json:"ci"` Defaults DefaultsConfig `yaml:"defaults" json:"defaults"` Profiles map[string]ProfileConfig `yaml:"profiles" json:"profiles"` } @@ -21,6 +22,13 @@ type AuthConfig struct { User string `yaml:"user" json:"user"` } +type CIConfig struct { + BaselineSource string `yaml:"baselineSource" json:"baselineSource"` + BaselineFile string `yaml:"baselineFile" json:"baselineFile"` + BaselineURL string `yaml:"baselineUrl" json:"baselineUrl"` + Thresholds map[string]float64 `yaml:"thresholds" json:"thresholds"` +} + type DefaultsConfig struct { Analyze AnalyzeDefaults `yaml:"analyze" json:"analyze"` Build BuildDefaults `yaml:"build" json:"build"` @@ -81,6 +89,15 @@ func DefaultConfig() Config { ImageRef: "", }, }, + CI: CIConfig{ + Thresholds: map[string]float64{ + "duration_total_pct": 10, + "critical_path_pct": 10, + "cache_hit_ratio_pp_drop": 10, + "cache_miss_count_pct": 15, + "warning_count_delta": 0, + }, + }, Profiles: map[string]ProfileConfig{}, } } diff --git a/internal/config/loader.go b/internal/config/loader.go index 501fd22..33cca32 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -150,6 +150,23 @@ func merge(base, overlay Config) Config { if overlay.Auth.User != "" { result.Auth.User = overlay.Auth.User } + if overlay.CI.BaselineSource != "" { + result.CI.BaselineSource = overlay.CI.BaselineSource + } + if overlay.CI.BaselineFile != "" { + result.CI.BaselineFile = overlay.CI.BaselineFile + } + if overlay.CI.BaselineURL != "" { + result.CI.BaselineURL = overlay.CI.BaselineURL + } + if len(overlay.CI.Thresholds) > 0 { + if result.CI.Thresholds == nil { + result.CI.Thresholds = map[string]float64{} + } + for key, value := range overlay.CI.Thresholds { + result.CI.Thresholds[key] = value + } + } if overlay.Defaults.Analyze.Dockerfile != "" { result.Defaults.Analyze.Dockerfile = overlay.Defaults.Analyze.Dockerfile } @@ -195,6 +212,15 @@ func applyEnv(cfg Config) Config { if v := strings.TrimSpace(os.Getenv("BUILDGRAPH_AUTH_ENDPOINT")); v != "" { cfg.Auth.Endpoint = v } + if v := strings.TrimSpace(os.Getenv("BUILDGRAPH_BASELINE_SOURCE")); v != "" { + cfg.CI.BaselineSource = v + } + if v := strings.TrimSpace(os.Getenv("BUILDGRAPH_BASELINE_FILE")); v != "" { + cfg.CI.BaselineFile = v + } + if v := strings.TrimSpace(os.Getenv("BUILDGRAPH_BASELINE_URL")); v != "" { + cfg.CI.BaselineURL = v + } return cfg } diff --git a/internal/report/baseline.go b/internal/report/baseline.go new file mode 100644 index 0000000..1a34f9f --- /dev/null +++ b/internal/report/baseline.go @@ -0,0 +1,59 @@ +package report + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" +) + +type BaselineOptions struct { + Source string + File string + URL string +} + +func LoadBaseline(opts BaselineOptions) (BuildReport, error) { + source := strings.ToLower(strings.TrimSpace(opts.Source)) + switch source { + case "git", "ci-artifact": + if strings.TrimSpace(opts.File) == "" { + return BuildReport{}, fmt.Errorf("--baseline-file is required for baseline-source=%s", source) + } + return ReadBuildReportFile(opts.File) + case "object-storage": + if strings.TrimSpace(opts.URL) == "" { + return BuildReport{}, fmt.Errorf("--baseline-url is required for baseline-source=object-storage") + } + return readBuildReportURL(opts.URL) + default: + return BuildReport{}, fmt.Errorf("unsupported baseline source %q", opts.Source) + } +} + +func readBuildReportURL(address string) (BuildReport, error) { + if strings.HasPrefix(address, "file://") { + path := strings.TrimPrefix(address, "file://") + return ReadBuildReportFile(path) + } + if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") { + if _, err := os.Stat(address); err == nil { + return ReadBuildReportFile(address) + } + return BuildReport{}, fmt.Errorf("baseline url must be http(s), file://, or existing local file") + } + resp, err := http.Get(address) + if err != nil { + return BuildReport{}, fmt.Errorf("download baseline report: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return BuildReport{}, fmt.Errorf("download baseline report: %s", resp.Status) + } + payload, err := io.ReadAll(resp.Body) + if err != nil { + return BuildReport{}, fmt.Errorf("read baseline response: %w", err) + } + return ParseBuildReportJSON(payload) +} diff --git a/internal/report/compare.go b/internal/report/compare.go new file mode 100644 index 0000000..f00ace1 --- /dev/null +++ b/internal/report/compare.go @@ -0,0 +1,164 @@ +package report + +import ( + "fmt" + "sort" +) + +const ( + ThresholdDurationTotalPct = "duration_total_pct" + ThresholdCriticalPathPct = "critical_path_pct" + ThresholdCacheHitRatioDrop = "cache_hit_ratio_pp_drop" + ThresholdCacheMissCountPct = "cache_miss_count_pct" + ThresholdWarningCountDelta = "warning_count_delta" +) + +func DefaultThresholds() map[string]float64 { + return map[string]float64{ + ThresholdDurationTotalPct: 10, + ThresholdCriticalPathPct: 10, + ThresholdCacheHitRatioDrop: 10, + ThresholdCacheMissCountPct: 15, + ThresholdWarningCountDelta: 0, + } +} + +type MetricDelta struct { + Key string `json:"key"` + Base float64 `json:"base"` + Head float64 `json:"head"` + Delta float64 `json:"delta"` + DeltaPct float64 `json:"deltaPct"` + Threshold float64 `json:"threshold"` + Breached bool `json:"breached"` + Message string `json:"message"` +} + +type CompareReport struct { + BaseRef string `json:"baseRef"` + HeadRef string `json:"headRef"` + Metrics []MetricDelta `json:"metrics"` + Regressions []string `json:"regressions"` + Passed bool `json:"passed"` +} + +func Compare(base, head BuildReport, thresholds map[string]float64, baseRef, headRef string) CompareReport { + effective := mergeThresholds(thresholds) + deltas := make([]MetricDelta, 0, 5) + + deltas = append(deltas, evaluateIncreaseMetric( + ThresholdDurationTotalPct, + float64(base.Summary.DurationMS), + float64(head.Summary.DurationMS), + effective[ThresholdDurationTotalPct], + "total duration", + )) + deltas = append(deltas, evaluateIncreaseMetric( + ThresholdCriticalPathPct, + float64(base.Metrics.CriticalPathMS), + float64(head.Metrics.CriticalPathMS), + effective[ThresholdCriticalPathPct], + "critical path", + )) + deltas = append(deltas, evaluateDecreaseMetric( + ThresholdCacheHitRatioDrop, + base.Metrics.CacheHitRatio*100, + head.Metrics.CacheHitRatio*100, + effective[ThresholdCacheHitRatioDrop], + "cache hit ratio (percentage points)", + )) + deltas = append(deltas, evaluateIncreaseMetric( + ThresholdCacheMissCountPct, + float64(base.Summary.CacheMisses), + float64(head.Summary.CacheMisses), + effective[ThresholdCacheMissCountPct], + "cache misses", + )) + deltas = append(deltas, evaluateAbsoluteIncrease( + ThresholdWarningCountDelta, + float64(base.Summary.WarningCount), + float64(head.Summary.WarningCount), + effective[ThresholdWarningCountDelta], + "warnings", + )) + + regressions := make([]string, 0) + for _, metric := range deltas { + if metric.Breached { + regressions = append(regressions, metric.Message) + } + } + sort.Strings(regressions) + + return CompareReport{ + BaseRef: baseRef, + HeadRef: headRef, + Metrics: deltas, + Regressions: regressions, + Passed: len(regressions) == 0, + } +} + +func mergeThresholds(overrides map[string]float64) map[string]float64 { + effective := DefaultThresholds() + for key, value := range overrides { + effective[key] = value + } + return effective +} + +func evaluateIncreaseMetric(key string, base, head, threshold float64, label string) MetricDelta { + delta := head - base + deltaPct := percentDelta(base, head) + breached := deltaPct > threshold + return MetricDelta{ + Key: key, + Base: base, + Head: head, + Delta: delta, + DeltaPct: deltaPct, + Threshold: threshold, + Breached: breached, + Message: fmt.Sprintf("%s regression %.2f%% exceeds %.2f%% threshold", label, deltaPct, threshold), + } +} + +func evaluateDecreaseMetric(key string, base, head, threshold float64, label string) MetricDelta { + drop := base - head + breached := drop > threshold + return MetricDelta{ + Key: key, + Base: base, + Head: head, + Delta: head - base, + DeltaPct: drop, + Threshold: threshold, + Breached: breached, + Message: fmt.Sprintf("%s dropped %.2f and exceeds %.2f threshold", label, drop, threshold), + } +} + +func evaluateAbsoluteIncrease(key string, base, head, threshold float64, label string) MetricDelta { + delta := head - base + breached := delta > threshold + return MetricDelta{ + Key: key, + Base: base, + Head: head, + Delta: delta, + DeltaPct: delta, + Threshold: threshold, + Breached: breached, + Message: fmt.Sprintf("%s increased by %.2f and exceeds %.2f threshold", label, delta, threshold), + } +} + +func percentDelta(base, head float64) float64 { + if base == 0 { + if head == 0 { + return 0 + } + return 100 + } + return ((head - base) / base) * 100 +} diff --git a/internal/report/compare_test.go b/internal/report/compare_test.go new file mode 100644 index 0000000..2074c31 --- /dev/null +++ b/internal/report/compare_test.go @@ -0,0 +1,28 @@ +package report + +import "testing" + +func TestCompareDetectsRegressions(t *testing.T) { + t.Parallel() + base := BuildReport{} + base.Summary.DurationMS = 100 + base.Summary.CacheMisses = 10 + base.Summary.WarningCount = 0 + base.Metrics.CriticalPathMS = 80 + base.Metrics.CacheHitRatio = 0.9 + + head := BuildReport{} + head.Summary.DurationMS = 130 + head.Summary.CacheMisses = 15 + head.Summary.WarningCount = 1 + head.Metrics.CriticalPathMS = 100 + head.Metrics.CacheHitRatio = 0.7 + + cmp := Compare(base, head, DefaultThresholds(), "base", "head") + if cmp.Passed { + t.Fatalf("expected compare to fail") + } + if len(cmp.Regressions) == 0 { + t.Fatalf("expected regressions") + } +} diff --git a/internal/report/export.go b/internal/report/export.go new file mode 100644 index 0000000..c9fcc83 --- /dev/null +++ b/internal/report/export.go @@ -0,0 +1,91 @@ +package report + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "sort" + "strings" + + "github.com/Makepad-fr/buildgraph/internal/backend" +) + +func RenderDOT(run BuildReport) string { + criticalSet := map[string]bool{} + for _, id := range run.Metrics.CriticalPathVertices { + criticalSet[id] = true + } + + var b strings.Builder + b.WriteString("digraph buildgraph {\n") + b.WriteString(" rankdir=LR;\n") + b.WriteString(" node [shape=box style=filled fillcolor=\"#f8f8f8\"];\n") + + vertices := append([]backend.BuildVertex(nil), run.Build.Vertices...) + sort.Slice(vertices, func(i, j int) bool { + return vertices[i].ID < vertices[j].ID + }) + for _, vertex := range vertices { + color := "#f8f8f8" + if vertex.Cached { + color = "#d9f6dc" + } else { + color = "#ffd9d9" + } + if criticalSet[vertex.ID] { + color = "#ffec99" + } + label := vertex.Name + if label == "" { + label = vertex.ID + } + label = strings.ReplaceAll(label, "\"", "'") + fmt.Fprintf(&b, " \"%s\" [label=\"%s\\n%dms\" fillcolor=\"%s\"];\n", vertex.ID, label, vertex.DurationMS, color) + } + + edges := append([]backend.BuildEdge(nil), run.Build.Edges...) + sort.Slice(edges, func(i, j int) bool { + if edges[i].From == edges[j].From { + return edges[i].To < edges[j].To + } + return edges[i].From < edges[j].From + }) + for _, edge := range edges { + attrs := "" + if criticalSet[edge.From] && criticalSet[edge.To] { + attrs = " [color=\"#d9480f\" penwidth=2]" + } + fmt.Fprintf(&b, " \"%s\" -> \"%s\"%s;\n", edge.From, edge.To, attrs) + } + + b.WriteString("}\n") + return b.String() +} + +func WriteDOTFile(path string, run BuildReport) error { + return os.WriteFile(path, []byte(RenderDOT(run)), 0o644) +} + +func RenderSVG(dot, outPath string) error { + cmd := exec.Command("dot", "-Tsvg", "-o", outPath) + cmd.Stdin = strings.NewReader(dot) + stderr := &bytes.Buffer{} + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return fmt.Errorf("graphviz dot failed: %s", strings.TrimSpace(stderr.String())) + } + return fmt.Errorf("graphviz dot failed: %w", err) + } + return nil +} + +func GraphvizVersion() (string, error) { + cmd := exec.Command("dot", "-V") + out, err := cmd.CombinedOutput() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/report/export_test.go b/internal/report/export_test.go new file mode 100644 index 0000000..a898d62 --- /dev/null +++ b/internal/report/export_test.go @@ -0,0 +1,38 @@ +package report + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/Makepad-fr/buildgraph/internal/backend" +) + +func TestRenderDOTIncludesGraph(t *testing.T) { + t.Parallel() + run := BuildReport{ + Build: backend.BuildResult{ + Vertices: []backend.BuildVertex{ + {ID: "v1", Name: "RUN apt-get update", DurationMS: 40, Cached: false}, + {ID: "v2", Name: "RUN go build", DurationMS: 90, Cached: true}, + }, + Edges: []backend.BuildEdge{{From: "v1", To: "v2"}}, + }, + Metrics: backend.BuildMetrics{CriticalPathVertices: []string{"v1", "v2"}}, + } + dot := RenderDOT(run) + if !strings.Contains(dot, "digraph buildgraph") { + t.Fatalf("expected graph header") + } + if !strings.Contains(dot, "\"v1\" -> \"v2\"") { + t.Fatalf("expected edge in dot output: %s", dot) + } +} + +func TestRenderSVGReturnsErrorWhenDotMissing(t *testing.T) { + t.Setenv("PATH", "") + err := RenderSVG("digraph buildgraph {\n}\n", filepath.Join(t.TempDir(), "graph.svg")) + if err == nil { + t.Fatalf("expected render svg to fail without dot binary") + } +} diff --git a/internal/report/io.go b/internal/report/io.go new file mode 100644 index 0000000..cde6139 --- /dev/null +++ b/internal/report/io.go @@ -0,0 +1,46 @@ +package report + +import ( + "encoding/json" + "fmt" + "os" +) + +type resourceStatus struct { + Result json.RawMessage `json:"result"` +} + +type resourceEnvelope struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Status resourceStatus `json:"status"` +} + +func ReadBuildReportFile(path string) (BuildReport, error) { + payload, err := os.ReadFile(path) + if err != nil { + return BuildReport{}, fmt.Errorf("read report: %w", err) + } + return ParseBuildReportJSON(payload) +} + +func ParseBuildReportJSON(payload []byte) (BuildReport, error) { + var direct BuildReport + if err := json.Unmarshal(payload, &direct); err == nil { + if direct.Command != "" || direct.Backend != "" || direct.GeneratedAt.Unix() != 0 { + return direct, nil + } + } + + var envelope resourceEnvelope + if err := json.Unmarshal(payload, &envelope); err != nil { + return BuildReport{}, fmt.Errorf("parse report: %w", err) + } + if len(envelope.Status.Result) == 0 { + return BuildReport{}, fmt.Errorf("report JSON does not contain status.result") + } + if err := json.Unmarshal(envelope.Status.Result, &direct); err != nil { + return BuildReport{}, fmt.Errorf("parse report payload: %w", err) + } + return direct, nil +} diff --git a/internal/report/metrics.go b/internal/report/metrics.go new file mode 100644 index 0000000..c7ab457 --- /dev/null +++ b/internal/report/metrics.go @@ -0,0 +1,196 @@ +package report + +import ( + "sort" + "strings" + + "github.com/Makepad-fr/buildgraph/internal/backend" +) + +func ComputeBuildMetrics(vertices []backend.BuildVertex, edges []backend.BuildEdge, cache backend.CacheStats) backend.BuildMetrics { + metrics := backend.BuildMetrics{ + StageDistribution: map[string]int64{}, + TimeDistribution: map[string]int64{}, + } + if len(vertices) == 0 { + return metrics + } + + vertexByID := map[string]backend.BuildVertex{} + adj := map[string][]string{} + indegree := map[string]int{} + + var totalMs int64 + var cachedMs int64 + var uncachedMs int64 + missPatternCount := map[string]int{} + slow := make([]backend.SlowVertex, 0, len(vertices)) + + for _, vertex := range vertices { + duration := vertex.DurationMS + if duration < 0 { + duration = 0 + } + vertex.DurationMS = duration + vertexByID[vertex.ID] = vertex + if _, ok := indegree[vertex.ID]; !ok { + indegree[vertex.ID] = 0 + } + + totalMs += duration + stage := strings.TrimSpace(vertex.Stage) + if stage == "" { + stage = "unknown" + } + metrics.StageDistribution[stage] += duration + if vertex.Cached { + cachedMs += duration + } else { + uncachedMs += duration + norm := normalizePattern(vertex.Name) + if norm != "" { + missPatternCount[norm]++ + } + } + slow = append(slow, backend.SlowVertex{ID: vertex.ID, Name: vertex.Name, DurationMS: duration}) + } + + for _, edge := range edges { + if edge.From == "" || edge.To == "" || edge.From == edge.To { + continue + } + if _, ok := vertexByID[edge.From]; !ok { + continue + } + if _, ok := vertexByID[edge.To]; !ok { + continue + } + adj[edge.From] = append(adj[edge.From], edge.To) + indegree[edge.To]++ + } + + queue := make([]string, 0, len(indegree)) + for id, degree := range indegree { + if degree == 0 { + queue = append(queue, id) + } + } + sort.Strings(queue) + topo := make([]string, 0, len(indegree)) + + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + topo = append(topo, id) + for _, next := range adj[id] { + indegree[next]-- + if indegree[next] == 0 { + queue = append(queue, next) + } + } + sort.Strings(queue) + } + + if len(topo) == len(vertexByID) { + metrics.CriticalPathMS, metrics.CriticalPathVertices, metrics.LongestChain = longestPath(topo, vertexByID, adj) + } else { + // Cycles are not expected, but prefer deterministic fallback over failing report generation. + for _, candidate := range slow { + if candidate.DurationMS > metrics.CriticalPathMS { + metrics.CriticalPathMS = candidate.DurationMS + metrics.CriticalPathVertices = []string{candidate.ID} + metrics.LongestChain = 1 + } + } + } + + totalCache := cache.Hits + cache.Misses + if totalCache > 0 { + metrics.CacheHitRatio = float64(cache.Hits) / float64(totalCache) + } + + metrics.TimeDistribution["totalMs"] = totalMs + metrics.TimeDistribution["cachedMs"] = cachedMs + metrics.TimeDistribution["uncachedMs"] = uncachedMs + + sort.Slice(slow, func(i, j int) bool { + if slow[i].DurationMS == slow[j].DurationMS { + return slow[i].ID < slow[j].ID + } + return slow[i].DurationMS > slow[j].DurationMS + }) + if len(slow) > 5 { + slow = slow[:5] + } + metrics.TopSlowVertices = slow + + patterns := make([]string, 0, len(missPatternCount)) + for pattern, count := range missPatternCount { + if count > 1 { + patterns = append(patterns, pattern) + } + } + sort.Strings(patterns) + metrics.RepeatedMissPatterns = patterns + + return metrics +} + +func longestPath(topo []string, vertices map[string]backend.BuildVertex, adj map[string][]string) (int64, []string, int) { + dpWeight := map[string]int64{} + dpLen := map[string]int{} + prev := map[string]string{} + for _, id := range topo { + dpWeight[id] = vertices[id].DurationMS + dpLen[id] = 1 + } + for _, id := range topo { + for _, next := range adj[id] { + candidateWeight := dpWeight[id] + vertices[next].DurationMS + candidateLen := dpLen[id] + 1 + if candidateWeight > dpWeight[next] || (candidateWeight == dpWeight[next] && candidateLen > dpLen[next]) { + dpWeight[next] = candidateWeight + dpLen[next] = candidateLen + prev[next] = id + } + } + } + + bestID := "" + var bestWeight int64 + bestLen := 0 + for _, id := range topo { + if dpWeight[id] > bestWeight || (dpWeight[id] == bestWeight && dpLen[id] > bestLen) { + bestWeight = dpWeight[id] + bestLen = dpLen[id] + bestID = id + } + } + + path := make([]string, 0, bestLen) + cursor := bestID + for cursor != "" { + path = append(path, cursor) + cursor = prev[cursor] + } + for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { + path[i], path[j] = path[j], path[i] + } + return bestWeight, path, bestLen +} + +func normalizePattern(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return "" + } + replacer := strings.NewReplacer( + "0", "", "1", "", "2", "", "3", "", "4", "", "5", "", "6", "", "7", "", "8", "", "9", "", + ) + name = replacer.Replace(name) + name = strings.Join(strings.Fields(name), " ") + if len(name) > 80 { + name = name[:80] + } + return name +} diff --git a/internal/report/metrics_test.go b/internal/report/metrics_test.go new file mode 100644 index 0000000..b02442f --- /dev/null +++ b/internal/report/metrics_test.go @@ -0,0 +1,37 @@ +package report + +import ( + "testing" + + "github.com/Makepad-fr/buildgraph/internal/backend" +) + +func TestComputeBuildMetricsCriticalPath(t *testing.T) { + t.Parallel() + vertices := []backend.BuildVertex{ + {ID: "A", Name: "A", DurationMS: 100, Cached: false, Stage: "build"}, + {ID: "B", Name: "B", DurationMS: 50, Cached: true, Stage: "build"}, + {ID: "C", Name: "C", DurationMS: 25, Cached: false, Stage: "runtime"}, + } + edges := []backend.BuildEdge{{From: "A", To: "B"}, {From: "B", To: "C"}} + metrics := ComputeBuildMetrics(vertices, edges, backend.CacheStats{Hits: 1, Misses: 2}) + + if metrics.CriticalPathMS != 175 { + t.Fatalf("expected critical path 175ms, got %d", metrics.CriticalPathMS) + } + if metrics.LongestChain != 3 { + t.Fatalf("expected longest chain 3, got %d", metrics.LongestChain) + } + if len(metrics.CriticalPathVertices) != 3 || metrics.CriticalPathVertices[0] != "A" || metrics.CriticalPathVertices[2] != "C" { + t.Fatalf("unexpected critical path vertices: %v", metrics.CriticalPathVertices) + } + if metrics.CacheHitRatio != (1.0 / 3.0) { + t.Fatalf("unexpected cache hit ratio: %f", metrics.CacheHitRatio) + } + if metrics.StageDistribution["build"] != 150 { + t.Fatalf("unexpected build stage distribution: %d", metrics.StageDistribution["build"]) + } + if metrics.TimeDistribution["totalMs"] != 175 { + t.Fatalf("unexpected total duration: %d", metrics.TimeDistribution["totalMs"]) + } +} diff --git a/internal/report/model.go b/internal/report/model.go new file mode 100644 index 0000000..cc94a35 --- /dev/null +++ b/internal/report/model.go @@ -0,0 +1,115 @@ +package report + +import ( + "sort" + "time" + + "github.com/Makepad-fr/buildgraph/internal/analyze" + "github.com/Makepad-fr/buildgraph/internal/backend" +) + +type BuildSummary struct { + DurationMS int64 `json:"durationMs"` + CacheHits int `json:"cacheHits"` + CacheMisses int `json:"cacheMisses"` + WarningCount int `json:"warningCount"` + FindingCount int `json:"findingCount"` + FindingsBySeverity map[string]int `json:"findingsBySeverity"` + VertexCount int `json:"vertexCount"` + EdgeCount int `json:"edgeCount"` +} + +type BuildReport struct { + RunID int64 `json:"runId,omitempty"` + GeneratedAt time.Time `json:"generatedAt"` + Command string `json:"command"` + ContextDir string `json:"contextDir,omitempty"` + Dockerfile string `json:"dockerfile,omitempty"` + Backend string `json:"backend"` + Endpoint string `json:"endpoint"` + GraphCompleteness string `json:"graphCompleteness"` + Build backend.BuildResult `json:"build"` + Findings []backend.Finding `json:"findings"` + StageGraph analyze.StageGraph `json:"stageGraph"` + Metrics backend.BuildMetrics `json:"metrics"` + Summary BuildSummary `json:"summary"` +} + +type BuildReportInput struct { + RunID int64 + Command string + ContextDir string + Dockerfile string + Build backend.BuildResult + Findings []backend.Finding + StageGraph analyze.StageGraph + GeneratedAt time.Time +} + +func NewBuildReport(input BuildReportInput) BuildReport { + generatedAt := input.GeneratedAt.UTC() + if generatedAt.IsZero() { + generatedAt = time.Now().UTC() + } + report := BuildReport{ + RunID: input.RunID, + GeneratedAt: generatedAt, + Command: input.Command, + ContextDir: input.ContextDir, + Dockerfile: input.Dockerfile, + Backend: input.Build.Backend, + Endpoint: input.Build.Endpoint, + Build: input.Build, + Findings: append([]backend.Finding(nil), input.Findings...), + StageGraph: input.StageGraph, + Metrics: ComputeBuildMetrics(input.Build.Vertices, input.Build.Edges, input.Build.CacheStats), + } + report.Summary = buildSummary(report) + report.GraphCompleteness = graphCompleteness(input.Build) + sortFindings(report.Findings) + return report +} + +func graphCompleteness(result backend.BuildResult) string { + if len(result.Vertices) == 0 { + return "none" + } + if result.GraphComplete { + return "complete" + } + return "partial" +} + +func sortFindings(findings []backend.Finding) { + sort.SliceStable(findings, func(i, j int) bool { + left := backend.SeverityRank(findings[i].Severity) + right := backend.SeverityRank(findings[j].Severity) + if left == right { + if findings[i].Dimension == findings[j].Dimension { + if findings[i].File == findings[j].File { + return findings[i].Line < findings[j].Line + } + return findings[i].File < findings[j].File + } + return findings[i].Dimension < findings[j].Dimension + } + return left > right + }) +} + +func buildSummary(report BuildReport) BuildSummary { + findingsBySeverity := map[string]int{} + for _, finding := range report.Findings { + findingsBySeverity[finding.Severity]++ + } + return BuildSummary{ + DurationMS: report.Metrics.TimeDistribution["totalMs"], + CacheHits: report.Build.CacheStats.Hits, + CacheMisses: report.Build.CacheStats.Misses, + WarningCount: len(report.Build.Warnings), + FindingCount: len(report.Findings), + FindingsBySeverity: findingsBySeverity, + VertexCount: len(report.Build.Vertices), + EdgeCount: len(report.Build.Edges), + } +} diff --git a/internal/report/trend.go b/internal/report/trend.go new file mode 100644 index 0000000..0848466 --- /dev/null +++ b/internal/report/trend.go @@ -0,0 +1,72 @@ +package report + +import "time" + +type TrendPoint struct { + RunID int64 `json:"runId,omitempty"` + GeneratedAt time.Time `json:"generatedAt"` + DurationMS int64 `json:"durationMs"` + CriticalPathMS int64 `json:"criticalPathMs"` + CacheHitRatio float64 `json:"cacheHitRatio"` + WarningCount int `json:"warningCount"` + GraphCompleteness string `json:"graphCompleteness"` +} + +type TrendReport struct { + Window int `json:"window"` + AverageDurationMS float64 `json:"averageDurationMs"` + AverageCriticalMS float64 `json:"averageCriticalPathMs"` + AverageCacheRatio float64 `json:"averageCacheHitRatio"` + Points []TrendPoint `json:"points"` + Signals []string `json:"signals"` +} + +func BuildTrend(reports []BuildReport) TrendReport { + trend := TrendReport{ + Window: len(reports), + Points: make([]TrendPoint, 0, len(reports)), + Signals: make([]string, 0, 3), + } + if len(reports) == 0 { + return trend + } + + var durationTotal int64 + var criticalTotal int64 + var cacheRatioTotal float64 + for _, run := range reports { + trend.Points = append(trend.Points, TrendPoint{ + RunID: run.RunID, + GeneratedAt: run.GeneratedAt, + DurationMS: run.Summary.DurationMS, + CriticalPathMS: run.Metrics.CriticalPathMS, + CacheHitRatio: run.Metrics.CacheHitRatio, + WarningCount: run.Summary.WarningCount, + GraphCompleteness: run.GraphCompleteness, + }) + durationTotal += run.Summary.DurationMS + criticalTotal += run.Metrics.CriticalPathMS + cacheRatioTotal += run.Metrics.CacheHitRatio + } + + count := float64(len(reports)) + trend.AverageDurationMS = float64(durationTotal) / count + trend.AverageCriticalMS = float64(criticalTotal) / count + trend.AverageCacheRatio = cacheRatioTotal / count + + if len(reports) >= 2 { + last := reports[len(reports)-1] + prev := reports[len(reports)-2] + if last.Summary.DurationMS > prev.Summary.DurationMS { + trend.Signals = append(trend.Signals, "latest run is slower than previous run") + } + if last.Metrics.CacheHitRatio < prev.Metrics.CacheHitRatio { + trend.Signals = append(trend.Signals, "latest cache hit ratio dropped") + } + if last.Metrics.CriticalPathMS > prev.Metrics.CriticalPathMS { + trend.Signals = append(trend.Signals, "latest critical path increased") + } + } + + return trend +} diff --git a/internal/state/sqlite.go b/internal/state/sqlite.go index 1347fa8..76e6e75 100644 --- a/internal/state/sqlite.go +++ b/internal/state/sqlite.go @@ -27,6 +27,13 @@ type RunRecord struct { ErrorText string } +type ReportRecord struct { + RunID int64 + Kind string + ReportJSON string + CreatedAt time.Time +} + func Open(path string) (*Store, error) { if path == "" { return nil, fmt.Errorf("state db path is required") @@ -103,6 +110,15 @@ func (s *Store) init(ctx context.Context) error { created_at TEXT NOT NULL, FOREIGN KEY(run_id) REFERENCES runs(id) );`, + `CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL UNIQUE, + kind TEXT NOT NULL, + report_json TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(run_id) REFERENCES runs(id) + );`, + `CREATE INDEX IF NOT EXISTS idx_reports_created_at ON reports(created_at DESC);`, } for _, stmt := range stmts { @@ -217,6 +233,85 @@ func (s *Store) RecordEvent(ctx context.Context, runID int64, name string, paylo return nil } +func (s *Store) RecordReport(ctx context.Context, runID int64, kind string, report any) error { + if runID <= 0 { + return fmt.Errorf("run id must be greater than zero") + } + if kind == "" { + kind = "BuildReport" + } + payload, err := json.Marshal(report) + if err != nil { + return fmt.Errorf("marshal report: %w", err) + } + _, err = s.db.ExecContext(ctx, + `INSERT INTO reports(run_id, kind, report_json, created_at) VALUES(?, ?, ?, ?) + ON CONFLICT(run_id) DO UPDATE SET kind=excluded.kind, report_json=excluded.report_json, created_at=excluded.created_at`, + runID, + kind, + string(payload), + time.Now().UTC().Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("insert report: %w", err) + } + return nil +} + +func (s *Store) GetReportByRunID(ctx context.Context, runID int64) (ReportRecord, error) { + row := s.db.QueryRowContext(ctx, `SELECT run_id, kind, report_json, created_at FROM reports WHERE run_id = ?`, runID) + return scanReportRecord(row) +} + +func (s *Store) GetLatestReport(ctx context.Context) (ReportRecord, error) { + row := s.db.QueryRowContext(ctx, `SELECT run_id, kind, report_json, created_at FROM reports ORDER BY created_at DESC LIMIT 1`) + return scanReportRecord(row) +} + +func (s *Store) ListRecentReports(ctx context.Context, limit int) ([]ReportRecord, error) { + if limit <= 0 { + limit = 10 + } + rows, err := s.db.QueryContext(ctx, `SELECT run_id, kind, report_json, created_at FROM reports ORDER BY created_at DESC LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("query reports: %w", err) + } + defer rows.Close() + + reports := make([]ReportRecord, 0, limit) + for rows.Next() { + var rec ReportRecord + var createdAt string + if err := rows.Scan(&rec.RunID, &rec.Kind, &rec.ReportJSON, &createdAt); err != nil { + return nil, fmt.Errorf("scan report row: %w", err) + } + if ts, err := time.Parse(time.RFC3339, createdAt); err == nil { + rec.CreatedAt = ts.UTC() + } + reports = append(reports, rec) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate reports: %w", err) + } + return reports, nil +} + +type reportScanner interface { + Scan(dest ...any) error +} + +func scanReportRecord(scanner reportScanner) (ReportRecord, error) { + var rec ReportRecord + var createdAt string + if err := scanner.Scan(&rec.RunID, &rec.Kind, &rec.ReportJSON, &createdAt); err != nil { + return ReportRecord{}, fmt.Errorf("scan report: %w", err) + } + if ts, err := time.Parse(time.RFC3339, createdAt); err == nil { + rec.CreatedAt = ts.UTC() + } + return rec, nil +} + func boolToInt(v bool) int { if v { return 1 From b690c8d3420a52428aa3c5af914234ca2c5fd8fb Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Wed, 4 Mar 2026 11:34:44 +0100 Subject: [PATCH 2/6] feat(cli): add analyze run, report, and ci command trees --- internal/cli/app.go | 1040 +++++++++++++++++++++++++------ internal/output/json.go | 77 ++- internal/output/json_test.go | 32 +- internal/output/report_human.go | 93 +++ 4 files changed, 1007 insertions(+), 235 deletions(-) create mode 100644 internal/output/report_human.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 890e524..6c2fa32 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -2,16 +2,19 @@ package cli import ( "context" + "database/sql" "errors" "flag" "fmt" "io" + "os" "path/filepath" "sort" "strconv" "strings" "time" + "github.com/Makepad-fr/buildgraph/internal/analyze" "github.com/Makepad-fr/buildgraph/internal/backend" "github.com/Makepad-fr/buildgraph/internal/backend/buildkit" "github.com/Makepad-fr/buildgraph/internal/config" @@ -19,8 +22,8 @@ import ( "github.com/Makepad-fr/buildgraph/internal/platform/auth" "github.com/Makepad-fr/buildgraph/internal/platform/capabilities" "github.com/Makepad-fr/buildgraph/internal/platform/events" + "github.com/Makepad-fr/buildgraph/internal/report" "github.com/Makepad-fr/buildgraph/internal/state" - "github.com/Makepad-fr/buildgraph/internal/trace" "github.com/Makepad-fr/buildgraph/internal/version" ) @@ -81,7 +84,7 @@ func (a *App) Run(ctx context.Context, args []string) int { Profile: global.Profile, }) if err != nil { - a.writeError(global.JSON, remaining[0], 0, err) + a.writeError(global.JSON, "config", err) return ExitConfigState } @@ -95,8 +98,11 @@ func (a *App) Run(ctx context.Context, args []string) int { start := time.Now().UTC() exitCode := ExitOK runErr := error(nil) + var recordFindings []backend.Finding var recordBuild *backend.BuildResult + var recordReport *report.BuildReport + commandLabel := command var emit events.Sink = events.NoopSink{} if stateStore != nil { @@ -107,15 +113,23 @@ func (a *App) Run(ctx context.Context, args []string) int { case "help", "--help", "-h": a.printHelp(a.io.Out) case "analyze": - result, findings, code, err := a.runAnalyze(ctx, global, loadedCfg, cmdArgs) + cmd, findings, buildResult, runReport, code, err := a.runAnalyze(ctx, global, loadedCfg, stateStore, cmdArgs) + commandLabel = cmd exitCode = code runErr = err recordFindings = findings + recordBuild = buildResult + recordReport = runReport if err == nil { - _ = emit.Emit(ctx, events.Event{Name: "analyze.completed", Payload: result, CreatedAt: time.Now().UTC()}) + payload := any(runReport) + if payload == nil { + payload = map[string]any{"findingCount": len(findings)} + } + _ = emit.Emit(ctx, events.Event{Name: cmd + ".completed", Payload: payload, CreatedAt: time.Now().UTC()}) } case "build": result, code, err := a.runBuild(ctx, global, loadedCfg, cmdArgs) + commandLabel = "build" exitCode = code runErr = err recordBuild = result @@ -123,18 +137,25 @@ func (a *App) Run(ctx context.Context, args []string) int { _ = emit.Emit(ctx, events.Event{Name: "build.completed", Payload: result, CreatedAt: time.Now().UTC()}) } case "backend": + commandLabel = "backend" exitCode, runErr = a.runBackend(global, cmdArgs) - case "graph": - exitCode, runErr = a.runGraph(global, cmdArgs) - case "top": - exitCode, runErr = a.runTop(global, cmdArgs) case "doctor": + commandLabel = "doctor" exitCode, runErr = a.runDoctor(ctx, global, loadedCfg, stateStore) + case "report": + commandLabel = "report" + exitCode, runErr = a.runReport(ctx, global, loadedCfg, stateStore, cmdArgs) + case "ci": + commandLabel = "ci" + exitCode, runErr = a.runCI(ctx, global, loadedCfg, stateStore, cmdArgs) case "auth": + commandLabel = "auth" exitCode, runErr = a.runAuth(global, cmdArgs) case "config": + commandLabel = "config" exitCode, runErr = a.runConfig(global, loadedCfg, cmdArgs) case "version": + commandLabel = "version" exitCode, runErr = a.runVersion(global) default: runErr = fmt.Errorf("unknown command %q", command) @@ -158,27 +179,35 @@ func (a *App) Run(ctx context.Context, args []string) int { if recordBuild != nil { _ = stateStore.RecordBuild(ctx, runID, *recordBuild) } + if recordReport != nil { + reportCopy := *recordReport + reportCopy.RunID = runID + _ = stateStore.RecordReport(ctx, runID, "BuildReport", reportCopy) + } } } if runErr != nil { if !isReportedError(runErr) { - a.writeError(global.JSON, command, time.Since(start).Milliseconds(), runErr) + a.writeError(global.JSON, commandLabel, runErr) } } return exitCode } -func (a *App) runAnalyze(ctx context.Context, global GlobalOptions, loaded config.Loaded, args []string) (backend.AnalyzeResult, []backend.Finding, int, error) { - startedAt := time.Now().UTC() +func (a *App) runAnalyze(ctx context.Context, global GlobalOptions, loaded config.Loaded, store *state.Store, args []string) (string, []backend.Finding, *backend.BuildResult, *report.BuildReport, int, error) { + if len(args) > 0 && args[0] == "run" { + rep, findings, buildResult, code, err := a.runAnalyzeRun(ctx, global, loaded, args[1:]) + return "analyze run", findings, buildResult, rep, code, err + } allowed, err := a.capabilities.Has(ctx, capabilities.FeatureAnalyze) if err != nil { - return backend.AnalyzeResult{}, nil, ExitAuthDenied, err + return "analyze", nil, nil, nil, ExitAuthDenied, err } if !allowed { - return backend.AnalyzeResult{}, nil, ExitAuthDenied, fmt.Errorf("capability denied: analyze") + return "analyze", nil, nil, nil, ExitAuthDenied, fmt.Errorf("capability denied: analyze") } fs := flag.NewFlagSet("analyze", flag.ContinueOnError) @@ -192,12 +221,12 @@ func (a *App) runAnalyze(ctx context.Context, global GlobalOptions, loaded confi endpoint := fs.String("endpoint", loaded.Config.Endpoint, "BuildKit endpoint") if err := fs.Parse(args); err != nil { - return backend.AnalyzeResult{}, nil, ExitUsage, err + return "analyze", nil, nil, nil, ExitUsage, err } selectedBackend, err := a.resolveBackend(*backendName) if err != nil { - return backend.AnalyzeResult{}, nil, ExitBackend, err + return "analyze", nil, nil, nil, ExitBackend, err } result, err := selectedBackend.Analyze(ctx, backend.AnalyzeRequest{ @@ -212,39 +241,210 @@ func (a *App) runAnalyze(ctx context.Context, global GlobalOptions, loaded confi EnablePolicyChecks: true, }) if err != nil { - return backend.AnalyzeResult{}, nil, ExitBackend, err + return "analyze", nil, nil, nil, ExitBackend, err } failure := shouldFailFindings(result.Findings, strings.ToLower(strings.TrimSpace(*failOn))) failErr := fmt.Errorf("analysis found violations matching fail-on=%s", *failOn) + summary := map[string]any{ + "findingCount": len(result.Findings), + "backend": result.Backend, + "endpoint": result.Endpoint, + } + spec := map[string]any{ + "context": *contextDir, + "file": *dockerfile, + "severityThreshold": *severityThreshold, + "failOn": *failOn, + "backend": *backendName, + "endpoint": *endpoint, + } if global.JSON { - errors := []output.ErrorItem{} if failure { - errors = append(errors, output.ErrorItem{Code: "violation", Message: failErr.Error()}) - } - if err := output.WriteJSON(a.io.Out, output.NewEnvelope("analyze", startedAt, result, errors)); err != nil { - return backend.AnalyzeResult{}, nil, ExitInternal, err + resource := output.Resource{ + APIVersion: output.APIVersion, + Kind: "AnalyzeReport", + Metadata: output.ResourceMetadata{Command: "analyze", GeneratedAt: time.Now().UTC()}, + Spec: spec, + Status: output.ResourceStatus{ + Phase: "failed", + Summary: summary, + Result: result, + Errors: []output.ErrorItem{{Code: "violation", Message: failErr.Error()}}, + }, + } + _ = output.WriteJSON(a.io.Out, resource) + } else { + _ = output.WriteJSON(a.io.Out, output.SuccessResource("AnalyzeReport", "analyze", spec, summary, result, 0)) } } else { - if err := output.WriteAnalyze(a.io.Out, result); err != nil { - return backend.AnalyzeResult{}, nil, ExitInternal, err + _ = output.WriteAnalyze(a.io.Out, result) + } + + if failure { + if global.JSON { + return "analyze", result.Findings, nil, nil, ExitPolicyViolation, markReportedError(failErr) } + return "analyze", result.Findings, nil, nil, ExitPolicyViolation, failErr + } + return "analyze", result.Findings, nil, nil, ExitOK, nil +} + +func (a *App) runAnalyzeRun(ctx context.Context, global GlobalOptions, loaded config.Loaded, args []string) (*report.BuildReport, []backend.Finding, *backend.BuildResult, int, error) { + allowedAnalyze, err := a.capabilities.Has(ctx, capabilities.FeatureAnalyze) + if err != nil { + return nil, nil, nil, ExitAuthDenied, err + } + if !allowedAnalyze { + return nil, nil, nil, ExitAuthDenied, fmt.Errorf("capability denied: analyze") + } + allowedBuild, err := a.capabilities.Has(ctx, capabilities.FeatureBuild) + if err != nil { + return nil, nil, nil, ExitAuthDenied, err + } + if !allowedBuild { + return nil, nil, nil, ExitAuthDenied, fmt.Errorf("capability denied: build") + } + + fs := flag.NewFlagSet("analyze run", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + contextDir := fs.String("context", ".", "Build context path") + dockerfile := fs.String("file", loaded.Config.Defaults.Analyze.Dockerfile, "Dockerfile path") + severityThreshold := fs.String("severity-threshold", loaded.Config.Defaults.Analyze.SeverityThreshold, "Minimum severity: low|medium|high|critical") + failOn := fs.String("fail-on", loaded.Config.Defaults.Analyze.FailOn, "Failure mode: policy|security|any") + target := fs.String("target", "", "Build stage target") + platforms := stringSliceFlag{} + buildArgs := kvSliceFlag{} + secrets := kvSliceFlag{} + outputMode := fs.String("output", loaded.Config.Defaults.Build.OutputMode, "Output mode: image|oci|local") + imageRef := fs.String("image-ref", loaded.Config.Defaults.Build.ImageRef, "Image reference for image output") + ociDest := fs.String("oci-dest", "", "Destination path for OCI tar") + localDest := fs.String("local-dest", "", "Destination directory for local output") + backendName := fs.String("backend", loaded.Config.Backend, "Backend selector") + endpoint := fs.String("endpoint", loaded.Config.Endpoint, "BuildKit endpoint") + + fs.Var(&platforms, "platform", "Target platform (repeatable)") + fs.Var(&buildArgs, "build-arg", "Build arg key=value (repeatable)") + fs.Var(&secrets, "secret", "Build secret id=...,src=... (repeatable)") + + if err := fs.Parse(args); err != nil { + return nil, nil, nil, ExitUsage, err + } + + selectedBackend, err := a.resolveBackend(*backendName) + if err != nil { + return nil, nil, nil, ExitBackend, err + } + + progress := func(event backend.BuildProgressEvent) { + if global.JSON { + return + } + fmt.Fprintf(a.io.Err, "[%s] %s\n", event.Phase, strings.TrimSpace(event.Message)) + } + + buildResult, err := selectedBackend.Build(ctx, backend.BuildRequest{ + ContextDir: *contextDir, + Dockerfile: *dockerfile, + Target: *target, + Platforms: platforms, + BuildArgs: buildArgs.toBuildArgs(), + Secrets: parseSecrets(secrets), + OutputMode: strings.ToLower(strings.TrimSpace(*outputMode)), + ImageRef: *imageRef, + OCIDest: *ociDest, + LocalDest: *localDest, + Backend: *backendName, + Endpoint: *endpoint, + ProjectConfigPath: loaded.Paths.ProjectPath, + GlobalConfigPath: loaded.Paths.GlobalPath, + }, progress) + if err != nil { + return nil, nil, nil, ExitBuildFailed, err + } + + analyzeResult, err := selectedBackend.Analyze(ctx, backend.AnalyzeRequest{ + ContextDir: *contextDir, + Dockerfile: *dockerfile, + SeverityThreshold: normalizeSeverity(*severityThreshold), + FailOn: strings.ToLower(strings.TrimSpace(*failOn)), + Backend: *backendName, + Endpoint: *endpoint, + ProjectConfigPath: loaded.Paths.ProjectPath, + GlobalConfigPath: loaded.Paths.GlobalPath, + EnablePolicyChecks: true, + }) + if err != nil { + return nil, nil, nil, ExitBackend, err + } + + stageGraph, stageErr := analyze.ParseStageGraph(*contextDir, *dockerfile) + if stageErr != nil { + stageGraph = analyze.StageGraph{} + buildResult.Warnings = append(buildResult.Warnings, "unable to parse stage graph: "+stageErr.Error()) + } + + runReport := report.NewBuildReport(report.BuildReportInput{ + RunID: 0, + Command: "analyze run", + ContextDir: *contextDir, + Dockerfile: *dockerfile, + Build: buildResult, + Findings: analyzeResult.Findings, + StageGraph: stageGraph, + GeneratedAt: time.Now().UTC(), + }) + + spec := map[string]any{ + "context": *contextDir, + "file": *dockerfile, + "backend": *backendName, + "endpoint": *endpoint, + "severityThreshold": *severityThreshold, + "failOn": *failOn, + "target": *target, + "platform": []string(platforms), + "output": *outputMode, + } + + failure := shouldFailFindings(analyzeResult.Findings, strings.ToLower(strings.TrimSpace(*failOn))) + failErr := fmt.Errorf("analysis found violations matching fail-on=%s", *failOn) + + if global.JSON { + if failure { + resource := output.Resource{ + APIVersion: output.APIVersion, + Kind: "BuildReport", + Metadata: output.ResourceMetadata{Command: "analyze run", GeneratedAt: time.Now().UTC()}, + Spec: spec, + Status: output.ResourceStatus{ + Phase: "failed", + Summary: runReport.Summary, + Result: runReport, + Errors: []output.ErrorItem{{Code: "violation", Message: failErr.Error()}}, + }, + } + _ = output.WriteJSON(a.io.Out, resource) + } else { + _ = output.WriteJSON(a.io.Out, output.SuccessResource("BuildReport", "analyze run", spec, runReport.Summary, runReport, 0)) + } + } else { + _ = output.WriteBuildReport(a.io.Out, runReport) } if failure { if global.JSON { - return result, result.Findings, ExitPolicyViolation, markReportedError(failErr) + return &runReport, analyzeResult.Findings, &buildResult, ExitPolicyViolation, markReportedError(failErr) } - return result, result.Findings, ExitPolicyViolation, failErr + return &runReport, analyzeResult.Findings, &buildResult, ExitPolicyViolation, failErr } - return result, result.Findings, ExitOK, nil + return &runReport, analyzeResult.Findings, &buildResult, ExitOK, nil } func (a *App) runBuild(ctx context.Context, global GlobalOptions, loaded config.Loaded, args []string) (*backend.BuildResult, int, error) { - startedAt := time.Now().UTC() - allowed, err := a.capabilities.Has(ctx, capabilities.FeatureBuild) if err != nil { return nil, ExitAuthDenied, err @@ -268,8 +468,6 @@ func (a *App) runBuild(ctx context.Context, global GlobalOptions, loaded config. localDest := fs.String("local-dest", "", "Destination directory for local output") backendName := fs.String("backend", loaded.Config.Backend, "Backend selector") endpoint := fs.String("endpoint", loaded.Config.Endpoint, "BuildKit endpoint") - progressModeRaw := fs.String("progress", "auto", "Progress mode: human|json|none") - tracePath := fs.String("trace", "", "Write build trace to JSONL path") fs.Var(&platforms, "platform", "Target platform (repeatable)") fs.Var(&buildArgs, "build-arg", "Build arg key=value (repeatable)") @@ -284,41 +482,11 @@ func (a *App) runBuild(ctx context.Context, global GlobalOptions, loaded config. return nil, ExitBackend, err } - progressMode, err := normalizeProgressMode(*progressModeRaw, global.JSON) - if err != nil { - return nil, ExitUsage, err - } - - var traceFile io.Closer - var traceWriter *trace.Writer - if path := strings.TrimSpace(*tracePath); path != "" { - file, writer, err := trace.OpenFileWriter(path) - if err != nil { - return nil, ExitConfigState, err - } - traceFile = file - traceWriter = writer - defer traceFile.Close() - } - - var stderrTraceWriter *trace.Writer - if progressMode == "json" { - stderrTraceWriter = trace.NewWriter(a.io.Err) - } - progress := func(event backend.BuildProgressEvent) { - record := trace.ProgressRecord("build", event) - if traceWriter != nil { - _ = traceWriter.WriteRecord(record) - } - switch progressMode { - case "human": - fmt.Fprintf(a.io.Err, "[%s] %s\n", event.Phase, strings.TrimSpace(event.Message)) - case "json": - if stderrTraceWriter != nil { - _ = stderrTraceWriter.WriteRecord(record) - } + if global.JSON { + return } + fmt.Fprintf(a.io.Err, "[%s] %s\n", event.Phase, strings.TrimSpace(event.Message)) } result, err := selectedBackend.Build(ctx, backend.BuildRequest{ @@ -338,30 +506,30 @@ func (a *App) runBuild(ctx context.Context, global GlobalOptions, loaded config. GlobalConfigPath: loaded.Paths.GlobalPath, }, progress) if err != nil { - if traceWriter != nil { - _ = traceWriter.WriteRecord(trace.FailureRecord("build", "build_failed", err.Error())) - } return nil, ExitBuildFailed, err } - if traceWriter != nil { - _ = traceWriter.WriteRecord(trace.ResultRecord("build", result)) - } if global.JSON { - if err := output.WriteJSON(a.io.Out, output.NewEnvelope("build", startedAt, result, nil)); err != nil { - return nil, ExitInternal, err + summary := map[string]any{ + "outputs": len(result.Outputs), + "cacheHits": result.CacheStats.Hits, + "cacheMisses": result.CacheStats.Misses, } - } else { - if err := output.WriteBuild(a.io.Out, result); err != nil { - return nil, ExitInternal, err + spec := map[string]any{ + "context": *contextDir, + "file": *dockerfile, + "backend": *backendName, + "endpoint": *endpoint, + "output": *outputMode, } + _ = output.WriteJSON(a.io.Out, output.SuccessResource("BuildExecutionReport", "build", spec, summary, result, 0)) + } else { + _ = output.WriteBuild(a.io.Out, result) } return &result, ExitOK, nil } func (a *App) runBackend(global GlobalOptions, args []string) (int, error) { - startedAt := time.Now().UTC() - if len(args) == 0 { return ExitUsage, fmt.Errorf("backend subcommand is required") } @@ -370,9 +538,9 @@ func (a *App) runBackend(global GlobalOptions, args []string) (int, error) { } names := a.registry.List() if global.JSON { - return ExitOK, output.WriteJSON(a.io.Out, output.NewEnvelope("backend list", startedAt, map[string]any{ - "backends": names, - }, nil)) + summary := map[string]any{"count": len(names)} + result := map[string]any{"backends": names} + return ExitOK, output.WriteJSON(a.io.Out, output.SuccessResource("BackendList", "backend list", nil, summary, result, 0)) } for _, name := range names { fmt.Fprintln(a.io.Out, name) @@ -381,82 +549,513 @@ func (a *App) runBackend(global GlobalOptions, args []string) (int, error) { } func (a *App) runDoctor(ctx context.Context, global GlobalOptions, loaded config.Loaded, store *state.Store) (int, error) { - startedAt := time.Now().UTC() - checks := map[string]string{ "config.global": status(loaded.Paths.GlobalExists, loaded.Paths.GlobalPath), "config.project": status(loaded.Paths.ProjectExists, loaded.Paths.ProjectPath), } - hasError := false if store != nil { checks["state.sqlite"] = "ok: " + store.Path() } else { checks["state.sqlite"] = "error: unavailable" - hasError = true - } - - detect := backend.DetectResult{ - Backend: buildkit.BackendName, - Mode: "none", - Details: "backend detection was not executed", - Metadata: map[string]string{}, } selectedBackend, err := a.resolveBackend(loaded.Config.Backend) if err != nil { checks["backend.detect"] = "error: " + err.Error() - detect.Details = err.Error() - hasError = true } else { - detect, err = selectedBackend.Detect(ctx, backend.DetectRequest{ + detect, detectErr := selectedBackend.Detect(ctx, backend.DetectRequest{ Backend: loaded.Config.Backend, Endpoint: loaded.Config.Endpoint, ProjectConfigPath: loaded.Paths.ProjectPath, GlobalConfigPath: loaded.Paths.GlobalPath, }) - if err != nil { - checks["backend.detect"] = "error: " + err.Error() - hasError = true + if detectErr != nil { + checks["backend.detect"] = "error: " + detectErr.Error() } else { checks["backend.detect"] = fmt.Sprintf("ok: mode=%s endpoint=%s", detect.Mode, detect.Endpoint) } } - report := output.DoctorReport{ - Checks: checks, - Attempts: detect.Attempts, - Found: detect, - ConfigSnippet: doctorConfigSnippet(loaded.Config.Endpoint, detect.Endpoint), - CommonFixes: doctorCommonFixes(loaded.Config.Endpoint, detect), + if version, err := report.GraphvizVersion(); err != nil { + checks["graphviz.dot"] = "error: dot not found" + } else { + checks["graphviz.dot"] = "ok: " + version + } + + source := strings.TrimSpace(loaded.Config.CI.BaselineSource) + if source == "" { + checks["ci.baseline"] = "missing: baselineSource not configured" + } else { + switch source { + case "git", "ci-artifact": + if strings.TrimSpace(loaded.Config.CI.BaselineFile) == "" { + checks["ci.baseline"] = "error: baselineFile required for source=" + source + } else { + checks["ci.baseline"] = "ok: source=" + source + } + case "object-storage": + if strings.TrimSpace(loaded.Config.CI.BaselineURL) == "" { + checks["ci.baseline"] = "error: baselineUrl required for source=object-storage" + } else { + checks["ci.baseline"] = "ok: source=object-storage" + } + default: + checks["ci.baseline"] = "error: unsupported source=" + source + } + } + + summary := map[string]any{"checkCount": len(checks)} + if global.JSON { + if err := output.WriteJSON(a.io.Out, output.SuccessResource("DoctorReport", "doctor", nil, summary, map[string]any{"checks": checks}, 0)); err != nil { + return ExitInternal, err + } + } else { + if err := output.WriteDoctor(a.io.Out, checks); err != nil { + return ExitInternal, err + } + } + + for _, value := range checks { + if strings.HasPrefix(value, "error:") { + err := fmt.Errorf("doctor detected failing checks") + if global.JSON { + return ExitBackend, markReportedError(err) + } + return ExitBackend, err + } + } + return ExitOK, nil +} + +func (a *App) runReport(ctx context.Context, global GlobalOptions, loaded config.Loaded, store *state.Store, args []string) (int, error) { + if len(args) == 0 { + return ExitUsage, fmt.Errorf("report subcommand is required") + } + switch args[0] { + case "show": + return a.runReportShow(ctx, global, store, args[1:]) + case "metrics": + return a.runReportMetrics(ctx, global, store, args[1:]) + case "compare": + return a.runReportCompare(ctx, global, loaded, store, args[1:]) + case "trend": + return a.runReportTrend(ctx, global, store, args[1:]) + case "export": + return a.runReportExport(ctx, global, store, args[1:]) + default: + return ExitUsage, fmt.Errorf("unsupported report subcommand %q", args[0]) + } +} + +func (a *App) runReportShow(ctx context.Context, global GlobalOptions, store *state.Store, args []string) (int, error) { + fs := flag.NewFlagSet("report show", flag.ContinueOnError) + fs.SetOutput(io.Discard) + runID := fs.Int64("run-id", 0, "Run ID") + file := fs.String("file", "", "Path to BuildReport JSON") + if err := fs.Parse(args); err != nil { + return ExitUsage, err + } + + runReport, source, err := a.resolveReportSource(ctx, store, *runID, *file) + if err != nil { + return ExitConfigState, err + } + if global.JSON { + spec := map[string]any{"source": source} + if err := output.WriteJSON(a.io.Out, output.SuccessResource("BuildReport", "report show", spec, runReport.Summary, runReport, runReport.RunID)); err != nil { + return ExitInternal, err + } + } else { + if err := output.WriteBuildReport(a.io.Out, runReport); err != nil { + return ExitInternal, err + } + } + return ExitOK, nil +} + +func (a *App) runReportMetrics(ctx context.Context, global GlobalOptions, store *state.Store, args []string) (int, error) { + fs := flag.NewFlagSet("report metrics", flag.ContinueOnError) + fs.SetOutput(io.Discard) + runID := fs.Int64("run-id", 0, "Run ID") + file := fs.String("file", "", "Path to BuildReport JSON") + if err := fs.Parse(args); err != nil { + return ExitUsage, err + } + + runReport, source, err := a.resolveReportSource(ctx, store, *runID, *file) + if err != nil { + return ExitConfigState, err + } + if global.JSON { + spec := map[string]any{"source": source} + if err := output.WriteJSON(a.io.Out, output.SuccessResource("MetricsReport", "report metrics", spec, runReport.Summary, runReport.Metrics, runReport.RunID)); err != nil { + return ExitInternal, err + } + } else { + if _, err := fmt.Fprintf(a.io.Out, "Critical path: %dms\nCache hit ratio: %.2f%%\n", runReport.Metrics.CriticalPathMS, runReport.Metrics.CacheHitRatio*100); err != nil { + return ExitInternal, err + } + if len(runReport.Metrics.TopSlowVertices) > 0 { + if _, err := fmt.Fprintln(a.io.Out, "Top slow vertices:"); err != nil { + return ExitInternal, err + } + for _, vertex := range runReport.Metrics.TopSlowVertices { + if _, err := fmt.Fprintf(a.io.Out, "- %s (%dms)\n", strings.TrimSpace(vertex.Name), vertex.DurationMS); err != nil { + return ExitInternal, err + } + } + } + } + return ExitOK, nil +} + +func (a *App) runReportCompare(ctx context.Context, global GlobalOptions, loaded config.Loaded, store *state.Store, args []string) (int, error) { + fs := flag.NewFlagSet("report compare", flag.ContinueOnError) + fs.SetOutput(io.Discard) + baseSource := fs.String("base", "", "Base report source (run: or file path)") + headSource := fs.String("head", "", "Head report source (run: or file path)") + thresholds := thresholdFlag{} + fs.Var(&thresholds, "threshold", "Threshold override key=value (repeatable)") + if err := fs.Parse(args); err != nil { + return ExitUsage, err + } + if strings.TrimSpace(*baseSource) == "" || strings.TrimSpace(*headSource) == "" { + return ExitUsage, fmt.Errorf("--base and --head are required") + } + + baseReport, baseRef, err := a.resolveReportNamedSource(ctx, store, *baseSource) + if err != nil { + return ExitConfigState, err + } + headReport, headRef, err := a.resolveReportNamedSource(ctx, store, *headSource) + if err != nil { + return ExitConfigState, err + } + + effectiveThresholds := mergeThresholdMaps(loaded.Config.CI.Thresholds, thresholds) + cmp := report.Compare(baseReport, headReport, effectiveThresholds, baseRef, headRef) + + if global.JSON { + spec := map[string]any{"base": baseRef, "head": headRef, "thresholds": effectiveThresholds} + phase := "completed" + if !cmp.Passed { + phase = "failed" + } + resource := output.Resource{ + APIVersion: output.APIVersion, + Kind: "CompareReport", + Metadata: output.ResourceMetadata{Command: "report compare", GeneratedAt: time.Now().UTC()}, + Spec: spec, + Status: output.ResourceStatus{ + Phase: phase, + Summary: map[string]any{"passed": cmp.Passed, "regressionCount": len(cmp.Regressions)}, + Result: cmp, + }, + } + if !cmp.Passed { + resource.Status.Errors = []output.ErrorItem{{Code: "regression", Message: strings.Join(cmp.Regressions, "; ")}} + } + if err := output.WriteJSON(a.io.Out, resource); err != nil { + return ExitInternal, err + } + } else { + if err := output.WriteCompareReport(a.io.Out, cmp); err != nil { + return ExitInternal, err + } + } + + if !cmp.Passed { + err := fmt.Errorf("regressions detected") + if global.JSON { + return ExitPolicyViolation, markReportedError(err) + } + return ExitPolicyViolation, err + } + return ExitOK, nil +} + +func (a *App) runReportTrend(ctx context.Context, global GlobalOptions, store *state.Store, args []string) (int, error) { + if store == nil { + return ExitConfigState, fmt.Errorf("state store unavailable") + } + fs := flag.NewFlagSet("report trend", flag.ContinueOnError) + fs.SetOutput(io.Discard) + last := fs.Int("last", 10, "Number of recent runs") + _ = fs.String("branch", "", "Branch hint (reserved)") + if err := fs.Parse(args); err != nil { + return ExitUsage, err + } + + recs, err := store.ListRecentReports(ctx, *last) + if err != nil { + return ExitConfigState, err + } + reports := make([]report.BuildReport, 0, len(recs)) + for i := len(recs) - 1; i >= 0; i-- { + runReport, err := report.ParseBuildReportJSON([]byte(recs[i].ReportJSON)) + if err != nil { + continue + } + runReport.RunID = recs[i].RunID + reports = append(reports, runReport) } + trend := report.BuildTrend(reports) if global.JSON { - errors := []output.ErrorItem{} - if hasError { - errors = append(errors, output.ErrorItem{Code: "doctor_failed", Message: "doctor detected failing checks"}) + spec := map[string]any{"last": *last} + if err := output.WriteJSON(a.io.Out, output.SuccessResource("TrendReport", "report trend", spec, map[string]any{"window": trend.Window}, trend, 0)); err != nil { + return ExitInternal, err + } + } else { + if err := output.WriteTrendReport(a.io.Out, trend); err != nil { + return ExitInternal, err + } + } + return ExitOK, nil +} + +func (a *App) runReportExport(ctx context.Context, global GlobalOptions, store *state.Store, args []string) (int, error) { + fs := flag.NewFlagSet("report export", flag.ContinueOnError) + fs.SetOutput(io.Discard) + runID := fs.Int64("run-id", 0, "Run ID") + file := fs.String("file", "", "Path to BuildReport JSON") + format := fs.String("format", "dot", "Output format: dot|svg") + out := fs.String("out", "", "Output path") + if err := fs.Parse(args); err != nil { + return ExitUsage, err + } + if strings.TrimSpace(*out) == "" { + return ExitUsage, fmt.Errorf("--out is required") + } + + runReport, source, err := a.resolveReportSource(ctx, store, *runID, *file) + if err != nil { + return ExitConfigState, err + } + + dot := report.RenderDOT(runReport) + formatValue := strings.ToLower(strings.TrimSpace(*format)) + switch formatValue { + case "dot": + if err := os.WriteFile(*out, []byte(dot), 0o644); err != nil { + return ExitConfigState, fmt.Errorf("write dot export: %w", err) + } + case "svg": + if err := report.RenderSVG(dot, *out); err != nil { + return ExitBackend, err + } + default: + return ExitUsage, fmt.Errorf("unsupported export format %q", *format) + } + + result := map[string]any{"out": *out, "format": formatValue, "source": source} + if global.JSON { + if err := output.WriteJSON(a.io.Out, output.SuccessResource("GraphExportReport", "report export", nil, nil, result, runReport.RunID)); err != nil { + return ExitInternal, err + } + } else { + _, _ = fmt.Fprintf(a.io.Out, "Exported %s graph to %s\n", formatValue, *out) + } + return ExitOK, nil +} + +func (a *App) runCI(ctx context.Context, global GlobalOptions, loaded config.Loaded, store *state.Store, args []string) (int, error) { + if len(args) == 0 { + return ExitUsage, fmt.Errorf("ci subcommand is required") + } + switch args[0] { + case "check": + return a.runCICheck(ctx, global, loaded, store, args[1:]) + case "github-action": + return a.runCIGitHubAction(global, args[1:]) + case "gitlab-ci": + return a.runCIGitLab(global, args[1:]) + default: + return ExitUsage, fmt.Errorf("unsupported ci subcommand %q", args[0]) + } +} + +func (a *App) runCICheck(ctx context.Context, global GlobalOptions, loaded config.Loaded, store *state.Store, args []string) (int, error) { + fs := flag.NewFlagSet("ci check", flag.ContinueOnError) + fs.SetOutput(io.Discard) + baselineSource := fs.String("baseline-source", loaded.Config.CI.BaselineSource, "Baseline source: git|ci-artifact|object-storage") + baselineFile := fs.String("baseline-file", loaded.Config.CI.BaselineFile, "Baseline report file") + baselineURL := fs.String("baseline-url", loaded.Config.CI.BaselineURL, "Baseline report URL") + headRunID := fs.Int64("head-run-id", 0, "Head run ID") + headFile := fs.String("head-file", "", "Head report file") + thresholds := thresholdFlag{} + fs.Var(&thresholds, "threshold", "Threshold override key=value (repeatable)") + if err := fs.Parse(args); err != nil { + return ExitUsage, err + } + + source := strings.TrimSpace(*baselineSource) + if source == "" { + return ExitUsage, fmt.Errorf("--baseline-source is required") + } + + head, headRef, err := a.resolveReportSource(ctx, store, *headRunID, *headFile) + if err != nil { + return ExitConfigState, fmt.Errorf("resolve head report: %w", err) + } + base, err := report.LoadBaseline(report.BaselineOptions{Source: source, File: *baselineFile, URL: *baselineURL}) + if err != nil { + return ExitConfigState, err + } + baseRef := source + if *baselineFile != "" { + baseRef = *baselineFile + } + if *baselineURL != "" { + baseRef = *baselineURL + } + + effectiveThresholds := mergeThresholdMaps(loaded.Config.CI.Thresholds, thresholds) + cmp := report.Compare(base, head, effectiveThresholds, baseRef, headRef) + + if global.JSON { + spec := map[string]any{"baselineSource": source, "base": baseRef, "head": headRef, "thresholds": effectiveThresholds} + phase := "completed" + if !cmp.Passed { + phase = "failed" + } + resource := output.Resource{ + APIVersion: output.APIVersion, + Kind: "CIGateReport", + Metadata: output.ResourceMetadata{Command: "ci check", GeneratedAt: time.Now().UTC()}, + Spec: spec, + Status: output.ResourceStatus{ + Phase: phase, + Summary: map[string]any{"passed": cmp.Passed, "regressionCount": len(cmp.Regressions)}, + Result: cmp, + }, + } + if !cmp.Passed { + resource.Status.Errors = []output.ErrorItem{{Code: "regression", Message: strings.Join(cmp.Regressions, "; ")}} } - if err := output.WriteJSON(a.io.Out, output.NewEnvelope("doctor", startedAt, report, errors)); err != nil { + if err := output.WriteJSON(a.io.Out, resource); err != nil { return ExitInternal, err } } else { - if err := output.WriteDoctor(a.io.Out, report); err != nil { + if err := output.WriteCompareReport(a.io.Out, cmp); err != nil { return ExitInternal, err } } - if hasError { - err := fmt.Errorf("doctor detected failing checks") + if !cmp.Passed { + err := fmt.Errorf("ci regression check failed") if global.JSON { - return ExitBackend, markReportedError(err) + return ExitPolicyViolation, markReportedError(err) } - return ExitBackend, err + return ExitPolicyViolation, err } return ExitOK, nil } -func (a *App) runAuth(global GlobalOptions, args []string) (int, error) { - startedAt := time.Now().UTC() +func (a *App) runCIGitHubAction(global GlobalOptions, args []string) (int, error) { + if len(args) == 0 || args[0] != "init" { + return ExitUsage, fmt.Errorf("supported command: ci github-action init") + } + fs := flag.NewFlagSet("ci github-action init", flag.ContinueOnError) + fs.SetOutput(io.Discard) + writePath := fs.String("write", "", "Write generated workflow to path") + if err := fs.Parse(args[1:]); err != nil { + return ExitUsage, err + } + + template := strings.TrimSpace(`name: Buildgraph CI +on: + pull_request: + push: + branches: [main] +jobs: + buildgraph: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + - run: go build ./cmd/buildgraph + - run: ./buildgraph analyze run --json > buildgraph-report.json + - run: ./buildgraph ci check --baseline-source ci-artifact --baseline-file buildgraph-baseline.json --head-file buildgraph-report.json +`) + "\n" + + if *writePath != "" { + if err := os.MkdirAll(filepath.Dir(*writePath), 0o755); err != nil { + return ExitConfigState, fmt.Errorf("create output directory: %w", err) + } + if err := os.WriteFile(*writePath, []byte(template), 0o644); err != nil { + return ExitConfigState, fmt.Errorf("write template: %w", err) + } + } + + result := map[string]any{"provider": "github", "written": *writePath != "", "path": *writePath, "template": template} + if global.JSON { + if err := output.WriteJSON(a.io.Out, output.SuccessResource("CIGeneratorReport", "ci github-action init", nil, nil, result, 0)); err != nil { + return ExitInternal, err + } + } else { + if *writePath != "" { + _, _ = fmt.Fprintf(a.io.Out, "Wrote GitHub Action template to %s\n", *writePath) + } else { + _, _ = fmt.Fprint(a.io.Out, template) + } + } + return ExitOK, nil +} + +func (a *App) runCIGitLab(global GlobalOptions, args []string) (int, error) { + if len(args) == 0 || args[0] != "init" { + return ExitUsage, fmt.Errorf("supported command: ci gitlab-ci init") + } + fs := flag.NewFlagSet("ci gitlab-ci init", flag.ContinueOnError) + fs.SetOutput(io.Discard) + writePath := fs.String("write", "", "Write generated GitLab CI template to path") + if err := fs.Parse(args[1:]); err != nil { + return ExitUsage, err + } + + template := strings.TrimSpace(`stages: + - analyze +buildgraph_analyze: + stage: analyze + image: golang:1.25 + script: + - go build ./cmd/buildgraph + - ./buildgraph analyze run --json > buildgraph-report.json + - ./buildgraph ci check --baseline-source ci-artifact --baseline-file buildgraph-baseline.json --head-file buildgraph-report.json + artifacts: + when: always + paths: + - buildgraph-report.json +`) + "\n" + + if *writePath != "" { + if err := os.MkdirAll(filepath.Dir(*writePath), 0o755); err != nil { + return ExitConfigState, fmt.Errorf("create output directory: %w", err) + } + if err := os.WriteFile(*writePath, []byte(template), 0o644); err != nil { + return ExitConfigState, fmt.Errorf("write template: %w", err) + } + } + result := map[string]any{"provider": "gitlab", "written": *writePath != "", "path": *writePath, "template": template} + if global.JSON { + if err := output.WriteJSON(a.io.Out, output.SuccessResource("CIGeneratorReport", "ci gitlab-ci init", nil, nil, result, 0)); err != nil { + return ExitInternal, err + } + } else { + if *writePath != "" { + _, _ = fmt.Fprintf(a.io.Out, "Wrote GitLab CI template to %s\n", *writePath) + } else { + _, _ = fmt.Fprint(a.io.Out, template) + } + } + return ExitOK, nil +} + +func (a *App) runAuth(global GlobalOptions, args []string) (int, error) { if len(args) == 0 { return ExitUsage, fmt.Errorf("auth subcommand is required") } @@ -483,31 +1082,29 @@ func (a *App) runAuth(global GlobalOptions, args []string) (int, error) { if err := manager.Save(auth.Credentials{User: *user, Token: *token}); err != nil { return ExitConfigState, err } - return a.writeSimple(global.JSON, "auth login", startedAt, map[string]any{"status": "logged-in", "user": *user}) + return a.writeSimple(global.JSON, "auth login", "AuthReport", map[string]any{"status": "logged-in", "user": *user}) case "logout": if err := manager.Delete(); err != nil { return ExitConfigState, err } - return a.writeSimple(global.JSON, "auth logout", startedAt, map[string]any{"status": "logged-out"}) + return a.writeSimple(global.JSON, "auth logout", "AuthReport", map[string]any{"status": "logged-out"}) case "whoami": creds, err := manager.Load() if err != nil { return ExitAuthDenied, fmt.Errorf("not logged in") } - return a.writeSimple(global.JSON, "auth whoami", startedAt, map[string]any{"user": creds.User, "source": creds.Source, "storedAt": creds.StoredAt}) + return a.writeSimple(global.JSON, "auth whoami", "AuthReport", map[string]any{"user": creds.User, "source": creds.Source, "storedAt": creds.StoredAt}) default: return ExitUsage, fmt.Errorf("unsupported auth subcommand %q", subcommand) } } func (a *App) runConfig(global GlobalOptions, loaded config.Loaded, args []string) (int, error) { - startedAt := time.Now().UTC() - if len(args) == 0 || args[0] != "show" { return ExitUsage, fmt.Errorf("supported config command: show") } if global.JSON { - return ExitOK, output.WriteJSON(a.io.Out, output.NewEnvelope("config show", startedAt, loaded, nil)) + return ExitOK, output.WriteJSON(a.io.Out, output.SuccessResource("ConfigReport", "config show", nil, nil, loaded, 0)) } fmt.Fprintf(a.io.Out, "Global config: %s\n", loaded.Paths.GlobalPath) @@ -519,19 +1116,17 @@ func (a *App) runConfig(global GlobalOptions, loaded config.Loaded, args []strin } func (a *App) runVersion(global GlobalOptions) (int, error) { - startedAt := time.Now().UTC() - payload := map[string]any{ "version": version.Version, "commit": version.Commit, "buildDate": version.BuildDate, } - return a.writeSimple(global.JSON, "version", startedAt, payload) + return a.writeSimple(global.JSON, "version", "VersionReport", payload) } -func (a *App) writeSimple(asJSON bool, command string, startedAt time.Time, result any) (int, error) { +func (a *App) writeSimple(asJSON bool, command, kind string, result any) (int, error) { if asJSON { - if err := output.WriteJSON(a.io.Out, output.NewEnvelope(command, startedAt, result, nil)); err != nil { + if err := output.WriteJSON(a.io.Out, output.SuccessResource(kind, command, nil, nil, result, 0)); err != nil { return ExitInternal, err } } else { @@ -549,6 +1144,62 @@ func (a *App) writeSimple(asJSON bool, command string, startedAt time.Time, resu return ExitOK, nil } +func (a *App) resolveReportSource(ctx context.Context, store *state.Store, runID int64, file string) (report.BuildReport, string, error) { + if runID > 0 { + if store == nil { + return report.BuildReport{}, "", fmt.Errorf("state store unavailable") + } + rec, err := store.GetReportByRunID(ctx, runID) + if err != nil { + return report.BuildReport{}, "", err + } + runReport, err := report.ParseBuildReportJSON([]byte(rec.ReportJSON)) + if err != nil { + return report.BuildReport{}, "", err + } + runReport.RunID = rec.RunID + return runReport, fmt.Sprintf("run:%d", runID), nil + } + if strings.TrimSpace(file) != "" { + runReport, err := report.ReadBuildReportFile(file) + if err != nil { + return report.BuildReport{}, "", err + } + return runReport, file, nil + } + if store == nil { + return report.BuildReport{}, "", fmt.Errorf("state store unavailable") + } + rec, err := store.GetLatestReport(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return report.BuildReport{}, "", fmt.Errorf("no reports available in state store") + } + return report.BuildReport{}, "", err + } + runReport, err := report.ParseBuildReportJSON([]byte(rec.ReportJSON)) + if err != nil { + return report.BuildReport{}, "", err + } + runReport.RunID = rec.RunID + return runReport, fmt.Sprintf("run:%d", rec.RunID), nil +} + +func (a *App) resolveReportNamedSource(ctx context.Context, store *state.Store, source string) (report.BuildReport, string, error) { + source = strings.TrimSpace(source) + if strings.HasPrefix(source, "run:") { + value := strings.TrimPrefix(source, "run:") + runID, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return report.BuildReport{}, "", fmt.Errorf("invalid run source %q", source) + } + runReport, _, err := a.resolveReportSource(ctx, store, runID, "") + return runReport, source, err + } + runReport, _, err := a.resolveReportSource(ctx, store, 0, source) + return runReport, source, err +} + func (a *App) resolveBackend(requested string) (backend.Backend, error) { name := strings.TrimSpace(strings.ToLower(requested)) if name == "" || name == "auto" { @@ -728,54 +1379,6 @@ func status(ok bool, detail string) string { return "missing: " + detail } -func normalizeProgressMode(value string, globalJSON bool) (string, error) { - mode := strings.ToLower(strings.TrimSpace(value)) - switch mode { - case "", "auto": - if globalJSON { - return "none", nil - } - return "human", nil - case "human", "json", "none": - return mode, nil - default: - return "", fmt.Errorf("invalid --progress %q (expected human|json|none)", value) - } -} - -func doctorConfigSnippet(configEndpoint, detectedEndpoint string) string { - endpoint := strings.TrimSpace(detectedEndpoint) - if endpoint == "" { - endpoint = strings.TrimSpace(configEndpoint) - } - if endpoint == "" { - endpoint = "unix:///run/buildkit/buildkitd.sock" - } - return fmt.Sprintf("backend: buildkit\nendpoint: %q\n", endpoint) -} - -func doctorCommonFixes(configEndpoint string, detect backend.DetectResult) []string { - fixes := []string{ - "If using a direct BuildKit socket, verify buildkitd is running and your user can access the socket path.", - "If using Docker Desktop/Engine, confirm the daemon is reachable and the active Docker context matches your expected environment.", - "If endpoint detection is wrong, pin backend and endpoint in .buildgraph.yaml to avoid ambiguous auto-detection.", - } - - if strings.TrimSpace(configEndpoint) != "" { - fixes = append(fixes, fmt.Sprintf("Configured endpoint is %q; remove conflicting BUILDGRAPH_ENDPOINT or BUILDKIT_HOST values if they should not override config.", configEndpoint)) - } - if source := detect.Metadata["resolutionSource"]; source == "env" { - fixes = append(fixes, "Detection resolved from environment; clear BUILDKIT_HOST if this endpoint is stale.") - } - for _, attempt := range detect.Attempts { - if strings.Contains(strings.ToLower(attempt.Error), "permission denied") { - fixes = append(fixes, "Permission denied was reported while probing BuildKit. Add your user to the required group or adjust socket ACLs.") - break - } - } - return fixes -} - type stringSliceFlag []string func (s *stringSliceFlag) String() string { @@ -843,15 +1446,61 @@ func parseSecrets(values kvSliceFlag) []backend.SecretSpec { return secrets } -func (a *App) writeError(asJSON bool, command string, durationMs int64, err error) { +type thresholdFlag map[string]float64 + +func (t *thresholdFlag) String() string { + if t == nil { + return "" + } + keys := make([]string, 0, len(*t)) + for key := range *t { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, fmt.Sprintf("%s=%v", key, (*t)[key])) + } + return strings.Join(parts, ",") +} + +func (t *thresholdFlag) Set(value string) error { + if *t == nil { + *t = map[string]float64{} + } + parts := strings.SplitN(strings.TrimSpace(value), "=", 2) + if len(parts) != 2 { + return fmt.Errorf("threshold must be key=value") + } + parsed, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) + if err != nil { + return fmt.Errorf("invalid threshold value %q", parts[1]) + } + (*t)[strings.TrimSpace(parts[0])] = parsed + return nil +} + +func mergeThresholdMaps(base map[string]float64, overrides map[string]float64) map[string]float64 { + result := map[string]float64{} + for key, value := range report.DefaultThresholds() { + result[key] = value + } + for key, value := range base { + result[key] = value + } + for key, value := range overrides { + result[key] = value + } + return result +} + +func (a *App) writeError(asJSON bool, command string, err error) { if err == nil { return } if asJSON { - _ = output.WriteJSON(a.io.Err, output.NewEnvelopeWithDuration(command, durationMs, nil, []output.ErrorItem{{ - Code: "error", - Message: err.Error(), - }})) + resource := output.ErrorResource("ErrorReport", command, nil, nil, []output.ErrorItem{{Code: "error", Message: err.Error()}}, 0) + _ = output.WriteJSON(a.io.Err, resource) return } fmt.Fprintf(a.io.Err, "Error: %v\n", err) @@ -864,24 +1513,31 @@ func (a *App) printHelp(w io.Writer) { fmt.Fprintln(w, " buildgraph [global flags] [command flags]") fmt.Fprintln(w, "") fmt.Fprintln(w, "Commands:") - fmt.Fprintln(w, " analyze Analyze Dockerfile and build context") - fmt.Fprintln(w, " build Execute BuildKit build (--progress, --trace)") - fmt.Fprintln(w, " graph Build graph artifact from trace (--from, --format, --output)") - fmt.Fprintln(w, " top Show slowest vertices and critical path from trace") - fmt.Fprintln(w, " backend list List available backends") - fmt.Fprintln(w, " doctor Run environment diagnostics") - fmt.Fprintln(w, " auth Manage SaaS authentication state") - fmt.Fprintln(w, " config show Show effective config") - fmt.Fprintln(w, " version Print version information") + fmt.Fprintln(w, " analyze Analyze Dockerfile and build context") + fmt.Fprintln(w, " analyze run Execute build and emit BuildReport") + fmt.Fprintln(w, " build Execute BuildKit build") + fmt.Fprintln(w, " report show Show BuildReport") + fmt.Fprintln(w, " report metrics Show BuildReport metrics") + fmt.Fprintln(w, " report compare Compare BuildReports") + fmt.Fprintln(w, " report trend Show trend across recent BuildReports") + fmt.Fprintln(w, " report export Export BuildReport graph to DOT/SVG") + fmt.Fprintln(w, " ci check Evaluate CI regression policy") + fmt.Fprintln(w, " ci github-action Generate GitHub Action template") + fmt.Fprintln(w, " ci gitlab-ci Generate GitLab CI template") + fmt.Fprintln(w, " backend list List available backends") + fmt.Fprintln(w, " doctor Run environment diagnostics") + fmt.Fprintln(w, " auth Manage SaaS authentication state") + fmt.Fprintln(w, " config show Show effective config") + fmt.Fprintln(w, " version Print version information") fmt.Fprintln(w, "") fmt.Fprintln(w, "Global Flags:") - fmt.Fprintln(w, " --json Render JSON output") - fmt.Fprintln(w, " --no-color Disable color") - fmt.Fprintln(w, " --verbose Verbose mode") - fmt.Fprintln(w, " --config PATH Global config path") + fmt.Fprintln(w, " --json Render JSON output (buildgraph.dev/v2)") + fmt.Fprintln(w, " --no-color Disable color") + fmt.Fprintln(w, " --verbose Verbose mode") + fmt.Fprintln(w, " --config PATH Global config path") fmt.Fprintln(w, " --project-config PATH Project config path") - fmt.Fprintln(w, " --profile NAME Config profile") - fmt.Fprintln(w, " --non-interactive Disable prompts") + fmt.Fprintln(w, " --profile NAME Config profile") + fmt.Fprintln(w, " --non-interactive Disable prompts") } type reportedError struct { diff --git a/internal/output/json.go b/internal/output/json.go index 9476338..8e5ca82 100644 --- a/internal/output/json.go +++ b/internal/output/json.go @@ -6,44 +6,67 @@ import ( "time" ) -const APIVersion = "buildgraph.dev/v1" -const SchemaVersion = "1" +const APIVersion = "buildgraph.dev/v2" type ErrorItem struct { Code string `json:"code"` Message string `json:"message"` } -type Envelope struct { - APIVersion string `json:"apiVersion"` - Command string `json:"command"` - SchemaVersion string `json:"schemaVersion"` - Timestamp time.Time `json:"timestamp"` - DurationMS int64 `json:"durationMs"` - Result any `json:"result"` - Errors []ErrorItem `json:"errors"` +type ResourceMetadata struct { + Command string `json:"command"` + GeneratedAt time.Time `json:"generatedAt"` + RunID int64 `json:"runId,omitempty"` } -func NewEnvelope(command string, startedAt time.Time, result any, errors []ErrorItem) Envelope { - duration := time.Since(startedAt).Milliseconds() - if duration < 0 { - duration = 0 - } - return NewEnvelopeWithDuration(command, duration, result, errors) +type ResourceStatus struct { + Phase string `json:"phase"` + Summary any `json:"summary,omitempty"` + Result any `json:"result,omitempty"` + Errors []ErrorItem `json:"errors,omitempty"` +} + +type Resource struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata ResourceMetadata `json:"metadata"` + Spec any `json:"spec,omitempty"` + Status ResourceStatus `json:"status"` } -func NewEnvelopeWithDuration(command string, durationMS int64, result any, errors []ErrorItem) Envelope { - if errors == nil { - errors = []ErrorItem{} +func SuccessResource(kind, command string, spec, summary, result any, runID int64) Resource { + return Resource{ + APIVersion: APIVersion, + Kind: kind, + Metadata: ResourceMetadata{ + Command: command, + GeneratedAt: time.Now().UTC(), + RunID: runID, + }, + Spec: spec, + Status: ResourceStatus{ + Phase: "completed", + Summary: summary, + Result: result, + }, } - return Envelope{ - APIVersion: APIVersion, - Command: command, - SchemaVersion: SchemaVersion, - Timestamp: time.Now().UTC(), - DurationMS: durationMS, - Result: result, - Errors: errors, +} + +func ErrorResource(kind, command string, spec, summary any, errs []ErrorItem, runID int64) Resource { + return Resource{ + APIVersion: APIVersion, + Kind: kind, + Metadata: ResourceMetadata{ + Command: command, + GeneratedAt: time.Now().UTC(), + RunID: runID, + }, + Spec: spec, + Status: ResourceStatus{ + Phase: "failed", + Summary: summary, + Errors: errs, + }, } } diff --git a/internal/output/json_test.go b/internal/output/json_test.go index d7716cb..588c164 100644 --- a/internal/output/json_test.go +++ b/internal/output/json_test.go @@ -10,28 +10,28 @@ import ( func TestWriteJSONEnvelope(t *testing.T) { t.Parallel() buf := &bytes.Buffer{} - env := Envelope{ - APIVersion: APIVersion, - Command: "analyze", - SchemaVersion: SchemaVersion, - Timestamp: time.Unix(0, 0).UTC(), - DurationMS: 12, - Result: map[string]any{ - "ok": true, + resource := Resource{ + APIVersion: APIVersion, + Kind: "AnalyzeReport", + Metadata: ResourceMetadata{ + Command: "analyze", + GeneratedAt: time.Unix(0, 0).UTC(), + }, + Status: ResourceStatus{ + Phase: "completed", + Result: map[string]any{ + "ok": true, + }, }, - Errors: []ErrorItem{}, } - if err := WriteJSON(buf, env); err != nil { + if err := WriteJSON(buf, resource); err != nil { t.Fatalf("write json: %v", err) } text := buf.String() - if !strings.Contains(text, `"apiVersion": "buildgraph.dev/v1"`) { + if !strings.Contains(text, `"apiVersion": "buildgraph.dev/v2"`) { t.Fatalf("apiVersion missing: %s", text) } - if !strings.Contains(text, `"schemaVersion": "1"`) { - t.Fatalf("schemaVersion missing: %s", text) - } - if !strings.Contains(text, `"errors": []`) { - t.Fatalf("errors array missing: %s", text) + if !strings.Contains(text, `"kind": "AnalyzeReport"`) { + t.Fatalf("kind missing: %s", text) } } diff --git a/internal/output/report_human.go b/internal/output/report_human.go new file mode 100644 index 0000000..4b7f09a --- /dev/null +++ b/internal/output/report_human.go @@ -0,0 +1,93 @@ +package output + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/Makepad-fr/buildgraph/internal/report" +) + +func WriteBuildReport(w io.Writer, run report.BuildReport) error { + if _, err := fmt.Fprintf(w, "Report generated: %s\n", run.GeneratedAt.Format("2006-01-02T15:04:05Z07:00")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Command: %s\nBackend: %s\nEndpoint: %s\n", run.Command, run.Backend, run.Endpoint); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Duration: %dms\nCritical path: %dms\nCache hit ratio: %.2f%%\n", run.Summary.DurationMS, run.Metrics.CriticalPathMS, run.Metrics.CacheHitRatio*100); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Graph: %s (%d vertices, %d edges)\n", run.GraphCompleteness, run.Summary.VertexCount, run.Summary.EdgeCount); err != nil { + return err + } + if len(run.Metrics.TopSlowVertices) > 0 { + if _, err := fmt.Fprintln(w, "Top slow vertices:"); err != nil { + return err + } + for _, vertex := range run.Metrics.TopSlowVertices { + if _, err := fmt.Fprintf(w, "- %s (%dms)\n", strings.TrimSpace(vertex.Name), vertex.DurationMS); err != nil { + return err + } + } + } + if len(run.Findings) > 0 { + if _, err := fmt.Fprintf(w, "Findings: %d\n", len(run.Findings)); err != nil { + return err + } + } + return nil +} + +func WriteCompareReport(w io.Writer, cmp report.CompareReport) error { + status := "PASS" + if !cmp.Passed { + status = "FAIL" + } + if _, err := fmt.Fprintf(w, "Compare: %s -> %s\nStatus: %s\n", cmp.BaseRef, cmp.HeadRef, status); err != nil { + return err + } + for _, metric := range cmp.Metrics { + marker := "ok" + if metric.Breached { + marker = "regression" + } + if _, err := fmt.Fprintf(w, "- %s: base=%.2f head=%.2f delta=%.2f%% threshold=%.2f [%s]\n", metric.Key, metric.Base, metric.Head, metric.DeltaPct, metric.Threshold, marker); err != nil { + return err + } + } + if len(cmp.Regressions) > 0 { + if _, err := fmt.Fprintln(w, "Regressions:"); err != nil { + return err + } + for _, msg := range cmp.Regressions { + if _, err := fmt.Fprintf(w, "- %s\n", msg); err != nil { + return err + } + } + } + return nil +} + +func WriteTrendReport(w io.Writer, trend report.TrendReport) error { + if _, err := fmt.Fprintf(w, "Trend window: %d runs\n", trend.Window); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "Average duration: %.2fms\nAverage critical path: %.2fms\nAverage cache hit ratio: %.2f%%\n", trend.AverageDurationMS, trend.AverageCriticalMS, trend.AverageCacheRatio*100); err != nil { + return err + } + if len(trend.Signals) > 0 { + signals := append([]string(nil), trend.Signals...) + sort.Strings(signals) + if _, err := fmt.Fprintln(w, "Signals:"); err != nil { + return err + } + for _, signal := range signals { + if _, err := fmt.Fprintf(w, "- %s\n", signal); err != nil { + return err + } + } + } + return nil +} From fc0eb74fd35f637873e9a43541e38de20a9d3e67 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Wed, 4 Mar 2026 11:34:51 +0100 Subject: [PATCH 3/6] docs: add v0.2 quickstart, website mvp, and v2 schemas --- README.md | 219 ++++++++++++------------------ docs/index.html | 90 ++++++++++++ docs/styles.css | 198 +++++++++++++++++++++++++++ schema/v2/buildreport.schema.json | 59 ++++++++ schema/v2/resource.schema.json | 60 ++++++++ 5 files changed, 497 insertions(+), 129 deletions(-) create mode 100644 docs/index.html create mode 100644 docs/styles.css create mode 100644 schema/v2/buildreport.schema.json create mode 100644 schema/v2/resource.schema.json diff --git a/README.md b/README.md index 7cb2bef..51f35bd 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,119 @@ # buildgraph -`buildgraph` is a Build Intelligence CLI for BuildKit-first workflows. +`buildgraph` is a BuildKit execution intelligence CLI: understand what your build is actually doing. -## 30-Second Quickstart +## Quick Start -### Direct BuildKit socket +### Prerequisites +- BuildKit endpoint reachable (`buildkitd` or Docker BuildKit). +- Go 1.25+ to build from source. +- Graphviz `dot` only if you want `report export --format svg`. ```bash go build ./cmd/buildgraph - -./buildgraph build \ - --context integration/fixtures \ - --file Dockerfile.integration \ - --output local \ - --local-dest /tmp/buildgraph-out \ - --endpoint unix:///run/buildkit/buildkitd.sock \ - --progress=json \ - --trace /tmp/buildgraph.trace.jsonl - -./buildgraph graph --from /tmp/buildgraph.trace.jsonl --format dot --output /tmp/buildgraph.dot -./buildgraph top --from /tmp/buildgraph.trace.jsonl +./buildgraph analyze run --context . --file Dockerfile --output local --local-dest ./out +./buildgraph report metrics --json ``` -### Docker Desktop / Docker Engine - -```bash -go build ./cmd/buildgraph - -./buildgraph build \ - --context integration/fixtures \ - --file Dockerfile.integration \ - --image-ref buildgraph/quickstart:dev \ - --progress=human \ - --trace ./buildgraph.trace.jsonl - -./buildgraph graph --from ./buildgraph.trace.jsonl --format json -./buildgraph top --from ./buildgraph.trace.jsonl --limit 5 -``` +## Example Output -## Example Output (Sample) +### Human output (`buildgraph report show`) ```text -$ buildgraph top --from ./buildgraph.trace.jsonl -Vertices analyzed: 9 - -Slowest vertices: -1. 4823 ms RUN apk add --no-cache build-base (sha256:...) -2. 2111 ms RUN go build ./cmd/buildgraph (sha256:...) - -Critical path: 7034 ms -1. FROM golang:1.26-alpine (112 ms) -2. RUN apk add --no-cache build-base (4823 ms) -3. RUN go build ./cmd/buildgraph (2111 ms) +Report generated: 2026-03-04T11:48:00Z +Command: analyze run +Backend: buildkit +Endpoint: unix:///run/buildkit/buildkitd.sock +Duration: 8234ms +Critical path: 5120ms +Cache hit ratio: 66.67% +Graph: complete (39 vertices, 41 edges) +Top slow vertices: +- [builder 6/8] RUN go test ./... (2140ms) +- [builder 7/8] RUN go build ./cmd/buildgraph (1660ms) +Findings: 2 ``` -## Commands - -```bash -buildgraph analyze [--context .] [--file Dockerfile] [--severity-threshold low|medium|high|critical] [--fail-on policy|security|any] [--json] -buildgraph build [--context .] [--file Dockerfile] [--target NAME] [--platform linux/amd64] [--build-arg KEY=VALUE] [--secret id=foo,src=./foo.txt] [--output image|oci|local] [--image-ref REF] [--oci-dest PATH] [--local-dest PATH] [--backend auto|buildkit] [--endpoint URL] [--progress human|json|none] [--trace out.jsonl] [--json] -buildgraph graph --from out.jsonl [--format dot|svg|json] [--output PATH] [--json] -buildgraph top --from out.jsonl [--limit N] [--json] -buildgraph backend list -buildgraph doctor -buildgraph auth login --user --token -buildgraph auth logout -buildgraph auth whoami -buildgraph config show -buildgraph version -``` - -## JSON Output Contract (`--json`) - -All machine-readable command output uses a versioned envelope: +### JSON output (`--json`) ```json { - "apiVersion": "buildgraph.dev/v1", - "command": "build", - "schemaVersion": "1", - "timestamp": "2026-02-26T00:00:00Z", - "durationMs": 1234, - "result": {}, - "errors": [] + "apiVersion": "buildgraph.dev/v2", + "kind": "BuildReport", + "metadata": { + "command": "analyze run", + "generatedAt": "2026-03-04T11:48:00Z" + }, + "spec": { + "context": ".", + "file": "Dockerfile", + "backend": "auto", + "output": "local" + }, + "status": { + "phase": "completed", + "summary": { + "durationMs": 8234, + "cacheHits": 24, + "cacheMisses": 12 + }, + "result": { + "command": "analyze run", + "graphCompleteness": "complete" + } + } } ``` -## Rule Documentation - -Rule pages backing finding links are tracked in this repository: - -- [Rules Index](./docs/rules/index.md) - -Docs are published from `docs/` using the GitHub Actions workflow: - -- [docs.yml](./.github/workflows/docs.yml) - -## What Data Is Collected - -`buildgraph` stores local state in a SQLite database to support diagnostics and history: -- run metadata (`command`, duration, exit code, success/failure) -- analysis findings -- build result metadata -- local events - -`buildgraph` can also write local build traces (`--trace`) as JSONL. - -## What Is Never Uploaded By Default - -- no build context files are uploaded by default -- no findings/build metadata are uploaded by default -- no telemetry is sent unless explicitly enabled (`telemetry.enabled: true`) - -Auth credentials are stored locally via OS keyring when available, with local file fallback. - -## Install from Source - -Requires Go 1.26+. +## Command Surface ```bash -go build ./cmd/buildgraph -``` - -## Download Prebuilt Binaries +buildgraph analyze +buildgraph analyze run +buildgraph build -Artifacts are published automatically for every GitHub release. +buildgraph report show --run-id | --file +buildgraph report metrics --run-id | --file +buildgraph report compare --base run:| --head run:| +buildgraph report trend --last 10 +buildgraph report export --run-id --format dot|svg --out -### Linux (amd64) +buildgraph ci check --baseline-source git|ci-artifact|object-storage [...] +buildgraph ci github-action init [--write path] +buildgraph ci gitlab-ci init [--write path] -```bash -curl -sSfL -o buildgraph_linux_amd64.tar.gz \ - https://github.com/Makepad-fr/buildgraph/releases/latest/download/buildgraph_linux_amd64.tar.gz -tar -xzf buildgraph_linux_amd64.tar.gz -sudo install -m 0755 buildgraph /usr/local/bin/buildgraph +buildgraph backend list +buildgraph doctor +buildgraph auth login --user --token +buildgraph auth logout +buildgraph auth whoami +buildgraph config show +buildgraph version ``` -### macOS (amd64) - -```bash -curl -sSfL -o buildgraph_darwin_amd64.tar.gz \ - https://github.com/Makepad-fr/buildgraph/releases/latest/download/buildgraph_darwin_amd64.tar.gz -tar -xzf buildgraph_darwin_amd64.tar.gz -chmod +x buildgraph -mv buildgraph /usr/local/bin/buildgraph -``` +## JSON Contract -### Windows (amd64) +All `--json` outputs use the v2 resource contract: +- `apiVersion: buildgraph.dev/v2` +- `kind` +- `metadata` +- `spec` +- `status` -```powershell -Invoke-WebRequest -Uri "https://github.com/Makepad-fr/buildgraph/releases/latest/download/buildgraph_windows_amd64.zip" -OutFile "buildgraph_windows_amd64.zip" -Expand-Archive -Path ".\\buildgraph_windows_amd64.zip" -DestinationPath ".\\buildgraph" -``` +Schemas are published under [`schema/v2`](./schema/v2): +- `resource.schema.json` +- `buildreport.schema.json` ## Configuration -Default merge precedence: +Merge precedence: 1. flags 2. environment variables 3. project config (`.buildgraph.yaml`) 4. global config (`$XDG_CONFIG_HOME/buildgraph/config.yaml` or OS equivalent) 5. defaults -Sample config: +Sample: ```yaml backend: auto @@ -169,6 +121,15 @@ endpoint: "" telemetry: enabled: false sink: noop +ci: + baselineSource: git + baselineFile: ./buildgraph-baseline.json + thresholds: + duration_total_pct: 10 + critical_path_pct: 10 + cache_hit_ratio_pp_drop: 10 + cache_miss_count_pct: 15 + warning_count_delta: 0 defaults: analyze: dockerfile: Dockerfile @@ -191,6 +152,6 @@ go test ./... ``` ## Notes - -- Build execution avoids shelling out to Docker/BuildKit CLIs. -- Docker-backed mode supports image export; direct BuildKit mode supports image/OCI/local exports. +- Build execution and detection use Go APIs, not shell wrappers. +- Telemetry remains opt-in. +- BuildKit is the primary v0/v0.2 backend, with pluggable backend architecture for future providers. diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..c41b98b --- /dev/null +++ b/docs/index.html @@ -0,0 +1,90 @@ + + + + + + buildgraph.dev + + + + + + +
+
+
+
+

BUILDGRAPH

+

BuildKit execution intelligence.

+

Understand what your container build is actually doing: critical path, cache efficiency, graph bottlenecks, and regressions.

+ +
+ +
+

Live CLI Examples

+
+
+

Analyze + Runtime Report

+
buildgraph analyze run \
+  --context . \
+  --file Dockerfile \
+  --output local \
+  --local-dest ./out
+
+
+

Regression Gate in CI

+
buildgraph ci check \
+  --baseline-source ci-artifact \
+  --baseline-file buildgraph-baseline.json \
+  --head-file buildgraph-report.json
+
+
+
+ +
+

Example Output

+
+
+

Human

+
Duration: 8234ms
+Critical path: 5120ms
+Cache hit ratio: 66.67%
+Graph: complete (39 vertices, 41 edges)
+Top slow vertices:
+- [builder 6/8] RUN go test ./... (2140ms)
+
+
+

JSON (v2)

+
{
+  "apiVersion": "buildgraph.dev/v2",
+  "kind": "BuildReport",
+  "metadata": { "command": "analyze run" },
+  "status": { "phase": "completed" }
+}
+
+
+
+ +
+

Capability Matrix

+
+ + + + + + + + + + + +
CapabilityStatusNotes
Critical pathYesRuntime DAG from BuildKit Solve status.
Cache efficiencyYesHit/miss ratio, repeated miss patterns, stage distribution.
Graph exportYesDOT always, SVG via Graphviz dot.
CI regression gateYesConfigurable thresholds and baseline sources.
Buildah backendPlannedArchitecture remains backend-pluggable.
+
+
+
+ + diff --git a/docs/styles.css b/docs/styles.css new file mode 100644 index 0000000..72aeebf --- /dev/null +++ b/docs/styles.css @@ -0,0 +1,198 @@ +:root { + --bg: #f7f4ef; + --surface: rgba(255, 255, 255, 0.78); + --ink: #1e2228; + --muted: #5f6874; + --primary: #0f766e; + --accent: #e76f51; + --line: rgba(30, 34, 40, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Space Grotesk", "Segoe UI", sans-serif; + color: var(--ink); + background: radial-gradient(circle at 12% 10%, #ffdcb0 0, transparent 38%), radial-gradient(circle at 90% 85%, #c8f4ef 0, transparent 36%), var(--bg); + overflow-x: hidden; +} + +main { + width: min(1080px, 92vw); + margin: 0 auto; + padding: 3rem 0 4rem; + display: grid; + gap: 2.2rem; +} + +.hero { + background: linear-gradient(150deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.64)); + border: 1px solid var(--line); + border-radius: 1.25rem; + padding: clamp(1.2rem, 3.5vw, 2.7rem); + box-shadow: 0 20px 45px rgba(30, 34, 40, 0.09); + animation: rise 480ms ease-out; +} + +.eyebrow { + margin: 0; + letter-spacing: 0.14em; + color: var(--primary); + font-weight: 700; + font-size: 0.8rem; +} + +h1 { + margin: 0.7rem 0; + font-size: clamp(1.9rem, 5vw, 3.4rem); + line-height: 1.05; +} + +.subtitle { + margin: 0; + color: var(--muted); + max-width: 64ch; +} + +.cta-row { + margin-top: 1.2rem; + display: flex; + flex-wrap: wrap; + gap: 0.7rem; +} + +.btn { + text-decoration: none; + border-radius: 999px; + padding: 0.62rem 1rem; + font-weight: 700; + font-size: 0.92rem; + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + color: #fff; + background: var(--primary); + box-shadow: 0 8px 22px rgba(15, 118, 110, 0.28); +} + +.btn-ghost { + color: var(--ink); + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.7); +} + +section h2 { + margin: 0 0 0.8rem; + font-size: 1.45rem; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; +} + +.card { + background: var(--surface); + backdrop-filter: blur(6px); + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1rem; + animation: rise 550ms ease-out; +} + +.card h3 { + margin-top: 0; + margin-bottom: 0.65rem; + font-size: 1rem; +} + +pre { + margin: 0; + white-space: pre-wrap; + background: #1f2430; + color: #f5f7fa; + border-radius: 0.8rem; + padding: 0.8rem; + font: 0.82rem/1.45 "IBM Plex Mono", "Consolas", monospace; +} + +.table-wrap { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 1rem; + overflow: hidden; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 0.82rem; + border-bottom: 1px solid var(--line); + font-size: 0.92rem; +} + +th { + background: rgba(30, 34, 40, 0.05); +} + +.bg-shape { + position: fixed; + z-index: -1; + filter: blur(40px); + opacity: 0.55; +} + +.bg-shape-a { + width: 260px; + height: 260px; + top: -90px; + left: -60px; + background: #ffd28a; +} + +.bg-shape-b { + width: 280px; + height: 280px; + right: -80px; + bottom: -110px; + background: #6edfd3; +} + +@keyframes rise { + from { + transform: translateY(12px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@media (max-width: 720px) { + main { + padding-top: 1.3rem; + gap: 1.3rem; + } + + th, + td { + padding: 0.62rem; + font-size: 0.84rem; + } +} diff --git a/schema/v2/buildreport.schema.json b/schema/v2/buildreport.schema.json new file mode 100644 index 0000000..b2bebc2 --- /dev/null +++ b/schema/v2/buildreport.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://buildgraph.dev/schema/v2/buildreport.schema.json", + "title": "Buildgraph BuildReport v2", + "allOf": [ + { + "$ref": "./resource.schema.json" + }, + { + "type": "object", + "properties": { + "kind": { + "const": "BuildReport" + }, + "status": { + "type": "object", + "required": ["phase", "result"], + "properties": { + "phase": { + "type": "string", + "enum": ["completed", "failed"] + }, + "result": { + "type": "object", + "required": [ + "generatedAt", + "command", + "backend", + "endpoint", + "graphCompleteness", + "build", + "findings", + "stageGraph", + "metrics", + "summary" + ], + "properties": { + "runId": { "type": "integer", "minimum": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "command": { "type": "string" }, + "contextDir": { "type": "string" }, + "dockerfile": { "type": "string" }, + "backend": { "type": "string" }, + "endpoint": { "type": "string" }, + "graphCompleteness": { "type": "string", "enum": ["none", "partial", "complete"] }, + "build": { "type": "object" }, + "findings": { "type": "array", "items": { "type": "object" } }, + "stageGraph": { "type": "object" }, + "metrics": { "type": "object" }, + "summary": { "type": "object" } + }, + "additionalProperties": false + } + } + } + } + } + ] +} diff --git a/schema/v2/resource.schema.json b/schema/v2/resource.schema.json new file mode 100644 index 0000000..fc088a5 --- /dev/null +++ b/schema/v2/resource.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://buildgraph.dev/schema/v2/resource.schema.json", + "title": "Buildgraph Resource v2", + "type": "object", + "required": ["apiVersion", "kind", "metadata", "status"], + "properties": { + "apiVersion": { + "type": "string", + "const": "buildgraph.dev/v2" + }, + "kind": { + "type": "string", + "minLength": 1 + }, + "metadata": { + "type": "object", + "required": ["command", "generatedAt"], + "properties": { + "command": { "type": "string", "minLength": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "runId": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + "spec": { + "type": ["object", "array", "string", "number", "boolean", "null"] + }, + "status": { + "type": "object", + "required": ["phase"], + "properties": { + "phase": { + "type": "string", + "enum": ["completed", "failed"] + }, + "summary": { + "type": ["object", "array", "string", "number", "boolean", "null"] + }, + "result": { + "type": ["object", "array", "string", "number", "boolean", "null"] + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} From 0de467c1df36ab3706336368dbbcd442468f55a7 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Wed, 4 Mar 2026 11:34:56 +0100 Subject: [PATCH 4/6] ci: add graphviz smoke checks and pages deploy workflow --- .github/workflows/ci.yml | 11 +++++++++++ .github/workflows/pages.yml | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 515ba94..d954ea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,17 @@ jobs: with: go-version: '1.26.x' + - name: Install Graphviz + run: | + sudo apt-get update + sudo apt-get install -y graphviz + + - name: Build CLI + run: go build ./cmd/buildgraph + + - name: Doctor JSON smoke test + run: ./buildgraph doctor --json + - name: Start buildkitd run: | docker run -d --name buildkitd --privileged -p 1234:1234 moby/buildkit:v0.27.1 --addr tcp://0.0.0.0:1234 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..fc0b748 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,39 @@ +name: Docs (GitHub Pages) + +on: + push: + branches: + - main + paths: + - docs/** + - .github/workflows/pages.yml + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: docs + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 From 47c2585018c2e160a17b3f3762214b4d9ecdb4f7 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Wed, 4 Mar 2026 11:36:53 +0100 Subject: [PATCH 5/6] docs(legal): add license and funding metadata --- .github/FUNDING.yml | 2 ++ LICENSE.md | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 LICENSE.md diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..8588dd4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [Makepad-fr] +custom: ["https://github.com/sponsors/Makepad-fr"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a4c2a4a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Makepad-fr contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From ba52563d1b3b95184490ebdfa2a895f41ff63359 Mon Sep 17 00:00:00 2001 From: Kaan Yagci Date: Wed, 4 Mar 2026 13:16:02 +0100 Subject: [PATCH 6/6] fix(cli): restore graph and doctor output compatibility --- internal/cli/app.go | 30 ++++++++++++++++++++++++++++-- internal/output/json.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 6c2fa32..8affe21 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -553,6 +553,14 @@ func (a *App) runDoctor(ctx context.Context, global GlobalOptions, loaded config "config.global": status(loaded.Paths.GlobalExists, loaded.Paths.GlobalPath), "config.project": status(loaded.Paths.ProjectExists, loaded.Paths.ProjectPath), } + doctorReport := output.DoctorReport{ + Checks: checks, + CommonFixes: []string{ + "Set --endpoint or BUILDKIT_HOST to a reachable BuildKit daemon.", + "Run buildkitd locally or start Docker Desktop with BuildKit enabled.", + "Set ci.baselineSource with matching baselineFile/baselineUrl in config for CI checks.", + }, + } if store != nil { checks["state.sqlite"] = "ok: " + store.Path() } else { @@ -573,6 +581,9 @@ func (a *App) runDoctor(ctx context.Context, global GlobalOptions, loaded config checks["backend.detect"] = "error: " + detectErr.Error() } else { checks["backend.detect"] = fmt.Sprintf("ok: mode=%s endpoint=%s", detect.Mode, detect.Endpoint) + doctorReport.Found = detect + doctorReport.Attempts = detect.Attempts + doctorReport.ConfigSnippet = fmt.Sprintf("backend: buildkit\nendpoint: %q\n", detect.Endpoint) } } @@ -606,11 +617,11 @@ func (a *App) runDoctor(ctx context.Context, global GlobalOptions, loaded config summary := map[string]any{"checkCount": len(checks)} if global.JSON { - if err := output.WriteJSON(a.io.Out, output.SuccessResource("DoctorReport", "doctor", nil, summary, map[string]any{"checks": checks}, 0)); err != nil { + if err := output.WriteJSON(a.io.Out, output.SuccessResource("DoctorReport", "doctor", nil, summary, doctorReport, 0)); err != nil { return ExitInternal, err } } else { - if err := output.WriteDoctor(a.io.Out, checks); err != nil { + if err := output.WriteDoctor(a.io.Out, doctorReport); err != nil { return ExitInternal, err } } @@ -1365,6 +1376,21 @@ func normalizeSeverity(value string) string { } } +func normalizeProgressMode(value string, globalJSON bool) (string, error) { + mode := strings.ToLower(strings.TrimSpace(value)) + switch mode { + case "", "auto": + if globalJSON { + return "none", nil + } + return "human", nil + case "human", "json", "none": + return mode, nil + default: + return "", fmt.Errorf("invalid progress mode %q", value) + } +} + func errString(err error) string { if err == nil { return "" diff --git a/internal/output/json.go b/internal/output/json.go index 8e5ca82..cc00068 100644 --- a/internal/output/json.go +++ b/internal/output/json.go @@ -7,6 +7,7 @@ import ( ) const APIVersion = "buildgraph.dev/v2" +const SchemaVersion = "2" type ErrorItem struct { Code string `json:"code"` @@ -34,6 +35,17 @@ type Resource struct { Status ResourceStatus `json:"status"` } +// Envelope is kept for compatibility with existing command helpers. +type Envelope struct { + APIVersion string `json:"apiVersion"` + Command string `json:"command"` + SchemaVersion string `json:"schemaVersion"` + Timestamp time.Time `json:"timestamp"` + DurationMS int64 `json:"durationMs"` + Result any `json:"result"` + Errors []ErrorItem `json:"errors"` +} + func SuccessResource(kind, command string, spec, summary, result any, runID int64) Resource { return Resource{ APIVersion: APIVersion, @@ -70,6 +82,29 @@ func ErrorResource(kind, command string, spec, summary any, errs []ErrorItem, ru } } +func NewEnvelope(command string, startedAt time.Time, result any, errors []ErrorItem) Envelope { + duration := time.Since(startedAt).Milliseconds() + if duration < 0 { + duration = 0 + } + return NewEnvelopeWithDuration(command, duration, result, errors) +} + +func NewEnvelopeWithDuration(command string, durationMS int64, result any, errors []ErrorItem) Envelope { + if errors == nil { + errors = []ErrorItem{} + } + return Envelope{ + APIVersion: APIVersion, + Command: command, + SchemaVersion: SchemaVersion, + Timestamp: time.Now().UTC(), + DurationMS: durationMS, + Result: result, + Errors: errors, + } +} + func WriteJSON(w io.Writer, v any) error { enc := json.NewEncoder(w) enc.SetIndent("", " ")