Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/rules/go-coding-rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ When developing or modifying Go code in the KHI project, you **must** adhere to
- Add GoDoc comments for private types, functions, and methods when their names are not self-explanatory or usage is not intuitive.
- Add `var _ Interface = (*Implementation)(nil);` after the type definition to show that it's implementing the interface explicitly.

2. **Error and Test Assertion Messages**:
- Do not capitalize the first character of messages in `fmt.Errorf`, `t.Error`, `t.Errorf`, `t.Fatal`, `t.Fatalf`, etc. (e.g., use lowercase "failed to ..." instead of "Failed to ...").
- This rule does not apply if the message starts with a capitalized name, such as a method name or proper acronym (e.g. `MyFunction() mismatch (-want +got)`).

## Testing Practices

1. **Table-Driven Tests**: Tests must be written using the table-driven testing pattern. Define a slice of anonymous structs representing the test cases, and iterate over them using `t.Run()`.
Expand Down
2 changes: 2 additions & 0 deletions pkg/core/init/default/defaultextension.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/GoogleCloudPlatform/khi/pkg/core/inspection/tracing"
"github.com/GoogleCloudPlatform/khi/pkg/generated"
"github.com/GoogleCloudPlatform/khi/pkg/model/k8s"
"github.com/GoogleCloudPlatform/khi/pkg/model/khifile/v6/style"
"github.com/GoogleCloudPlatform/khi/pkg/parameters"
"github.com/GoogleCloudPlatform/khi/pkg/server"
"github.com/GoogleCloudPlatform/khi/pkg/server/option"
Expand Down Expand Up @@ -147,6 +148,7 @@ func (d *DefaultInitExtension) ConfigureInspectionTaskServer(taskServer *coreins
return err
}
}
style.LockRegistry()
if *parameters.Auth.QuotaProjectID != "" {
taskServer.AddRunContextOption(coreinspection.RunContextOptionArrayElementFromValue(googlecloudcommon_contract.APIClientFactoryOptionsContextKey, options.QuotaProject(*parameters.Auth.QuotaProjectID)))
}
Expand Down
65 changes: 65 additions & 0 deletions pkg/model/khifile/v6/style/colorutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package style

import (
"strconv"
)

// ColorWhite represents the white color (1.0, 1.0, 1.0, 1.0).
var ColorWhite = Color{R: 1, G: 1, B: 1, A: 1}

// ColorBlack represents the black color (0.0, 0.0, 0.0, 1.0).
var ColorBlack = Color{R: 0, G: 0, B: 0, A: 1}

// MustForceConvertSRGBHex converts an sRGB hex color string to a Color.
//
// Deprecated:
// The timeline style uses the display-p3 color space. Directly mapping sRGB
// hex values to the 0.0-1.0 range in display-p3 is mathematically incorrect.
// Ideally, you should define a Color struct directly with proper display-p3 values.
func MustForceConvertSRGBHex(hex string) Color {
if len(hex) != 7 && len(hex) != 9 {
panic("invalid hex color length: " + hex)
}
if hex[0] != '#' {
panic("invalid hex color format (must start with '#'): " + hex)
}
r, err := strconv.ParseInt(hex[1:3], 16, 32)
if err != nil {
panic(err)
}
g, err := strconv.ParseInt(hex[3:5], 16, 32)
if err != nil {
panic(err)
}
b, err := strconv.ParseInt(hex[5:7], 16, 32)
if err != nil {
panic(err)
}
var a int64 = 255
if len(hex) == 9 {
a, err = strconv.ParseInt(hex[7:9], 16, 32)
if err != nil {
panic(err)
}
}
return Color{
R: float32(r) / 255.0,
G: float32(g) / 255.0,
B: float32(b) / 255.0,
A: float32(a) / 255.0,
}
}
74 changes: 74 additions & 0 deletions pkg/model/khifile/v6/style/colorutil_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package style

import (
"testing"

"github.com/google/go-cmp/cmp"
)

func TestMustForceConvertSRGBHex(t *testing.T) {
testCases := []struct {
name string
hex string
want Color
wantPanic bool
}{
{
name: "valid 7-char hex",
hex: "#CC33CC",
want: Color{R: 204.0 / 255.0, G: 51.0 / 255.0, B: 204.0 / 255.0, A: 1.0},
},
{
name: "valid 9-char hex with alpha",
hex: "#0000FF80",
want: Color{R: 0.0, G: 0.0, B: 1.0, A: 128.0 / 255.0},
},
{
name: "invalid length 5-char",
hex: "#FFFF",
wantPanic: true,
},
{
name: "invalid characters",
hex: "#GGGGGG",
wantPanic: true,
},
{
name: "missing hash prefix",
hex: "CC33CC",
wantPanic: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if tc.wantPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("expected panic, but did not panic")
}
}()
}
got := MustForceConvertSRGBHex(tc.hex)
if !tc.wantPanic {
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("MustForceConvertSRGBHex() mismatch (-want +got):\n%s", diff)
}
}
})
}
}
96 changes: 86 additions & 10 deletions pkg/model/khifile/v6/style/timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package style
import (
_ "embed"
"encoding/json"
"fmt"
"slices"
"sync"

Expand Down Expand Up @@ -58,6 +59,7 @@ func GetIconAtlas() *pb.IconAtlas {

var (
mu sync.RWMutex
locked bool
severities []*pb.Severity
verbs []*pb.Verb
logTypes []*pb.LogType
Expand All @@ -75,13 +77,40 @@ func reset() {
logTypes = nil
revisionStates = nil
timelineTypes = nil
locked = false
}

// LockRegistry locks the timeline style registry, preventing any further style registrations.
// This must be called after task registration has completed.
func LockRegistry() {
mu.Lock()
defer mu.Unlock()
locked = true
}

// Color represents a color with high dynamic range (HDR) capabilities.
type Color struct {
R, G, B, A float32
}

// Verify checks if R, G, B, and A are within [0.0, 1.0] range.
// Returns an error if any channel value is invalid.
func (c Color) Verify() error {
if c.R < 0 || c.R > 1 {
return fmt.Errorf("R channel %f is out of range [0, 1]", c.R)
}
if c.G < 0 || c.G > 1 {
return fmt.Errorf("G channel %f is out of range [0, 1]", c.G)
}
if c.B < 0 || c.B > 1 {
return fmt.Errorf("B channel %f is out of range [0, 1]", c.B)
}
if c.A < 0 || c.A > 1 {
return fmt.Errorf("A channel %f is out of range [0, 1]", c.A)
}
return nil
}

func (c Color) toProto() *pb.HDRColor4 {
return &pb.HDRColor4{
R: proto.Float32(c.R),
Expand All @@ -91,12 +120,22 @@ func (c Color) toProto() *pb.HDRColor4 {
}
}

// RegisterTimelineType registers a TimelineType, assigns a unique ID to it,
// MustRegisterTimelineType registers a TimelineType, assigns a unique ID to it,
// and returns the generated pointer. This allows for global inline initialization in plugins.
func RegisterTimelineType(label string, description string, backgroundColor Color, foregroundColor Color, visible bool, sortPriority int32) *pb.TimelineType {
func MustRegisterTimelineType(label string, description string, backgroundColor Color, foregroundColor Color, visible bool, sortPriority int32) *pb.TimelineType {
Comment thread
kyasbal marked this conversation as resolved.
if err := backgroundColor.Verify(); err != nil {
panic(fmt.Sprintf("invalid background color for timeline type %q: %v", label, err))
}
if err := foregroundColor.Verify(); err != nil {
panic(fmt.Sprintf("invalid foreground color for timeline type %q: %v", label, err))
}
mu.Lock()
defer mu.Unlock()

if locked {
panic(fmt.Sprintf("failed to register timeline type style %q: style-related registrations must be done in task/inspection/**/contract packages during package initialization. Did you call it from outside of the contract or not at package initialization timing?", label))
}

id := uint32(len(timelineTypes) + 1)
t := &pb.TimelineType{
Id: proto.Uint32(id),
Expand All @@ -111,12 +150,22 @@ func RegisterTimelineType(label string, description string, backgroundColor Colo
return t
}

// RegisterSeverity registers a Severity, assigns a unique ID to it,
// MustRegisterSeverity registers a Severity, assigns a unique ID to it,
// and returns the generated pointer.
func RegisterSeverity(label string, shortLabel string, backgroundColor Color, foregroundColor Color, order int32) *pb.Severity {
func MustRegisterSeverity(label string, shortLabel string, backgroundColor Color, foregroundColor Color, order int32) *pb.Severity {
if err := backgroundColor.Verify(); err != nil {
panic(fmt.Sprintf("invalid background color for severity %q: %v", label, err))
}
if err := foregroundColor.Verify(); err != nil {
panic(fmt.Sprintf("invalid foreground color for severity %q: %v", label, err))
}
mu.Lock()
defer mu.Unlock()

if locked {
panic(fmt.Sprintf("failed to register severity style %q: style-related registrations must be done in task/inspection/**/contract packages during package initialization. Did you call it from outside of the contract or not at package initialization timing?", label))
}

id := uint32(len(severities) + 1)
s := &pb.Severity{
Id: proto.Uint32(id),
Expand All @@ -130,12 +179,22 @@ func RegisterSeverity(label string, shortLabel string, backgroundColor Color, fo
return s
}

// RegisterVerb registers a Verb, assigns a unique ID to it,
// MustRegisterVerb registers a Verb, assigns a unique ID to it,
// and returns the generated pointer.
func RegisterVerb(label string, backgroundColor Color, foregroundColor Color, visible bool) *pb.Verb {
func MustRegisterVerb(label string, backgroundColor Color, foregroundColor Color, visible bool) *pb.Verb {
if err := backgroundColor.Verify(); err != nil {
panic(fmt.Sprintf("invalid background color for verb %q: %v", label, err))
}
if err := foregroundColor.Verify(); err != nil {
panic(fmt.Sprintf("invalid foreground color for verb %q: %v", label, err))
}
mu.Lock()
defer mu.Unlock()

if locked {
panic(fmt.Sprintf("failed to register verb style %q: style-related registrations must be done in task/inspection/**/contract packages during package initialization. Did you call it from outside of the contract or not at package initialization timing?", label))
}

id := uint32(len(verbs) + 1)
v := &pb.Verb{
Id: proto.Uint32(id),
Expand All @@ -148,12 +207,22 @@ func RegisterVerb(label string, backgroundColor Color, foregroundColor Color, vi
return v
}

// RegisterLogType registers a LogType, assigns a unique ID to it,
// MustRegisterLogType registers a LogType, assigns a unique ID to it,
// and returns the generated pointer.
func RegisterLogType(label string, description string, backgroundColor Color, foregroundColor Color) *pb.LogType {
func MustRegisterLogType(label string, description string, backgroundColor Color, foregroundColor Color) *pb.LogType {
if err := backgroundColor.Verify(); err != nil {
panic(fmt.Sprintf("invalid background color for log type %q: %v", label, err))
}
if err := foregroundColor.Verify(); err != nil {
panic(fmt.Sprintf("invalid foreground color for log type %q: %v", label, err))
}
mu.Lock()
defer mu.Unlock()

if locked {
panic(fmt.Sprintf("failed to register log type style %q: style-related registrations must be done in task/inspection/**/contract packages during package initialization. Did you call it from outside of the contract or not at package initialization timing?", label))
}

id := uint32(len(logTypes) + 1)
l := &pb.LogType{
Id: proto.Uint32(id),
Expand All @@ -166,12 +235,19 @@ func RegisterLogType(label string, description string, backgroundColor Color, fo
return l
}

// RegisterRevisionState registers a RevisionState, assigns a unique ID to it,
// MustRegisterRevisionState registers a RevisionState, assigns a unique ID to it,
// and returns the generated pointer.
func RegisterRevisionState(label string, icon string, description string, backgroundColor Color, style pb.RevisionStateStyle) *pb.RevisionState {
func MustRegisterRevisionState(label string, icon string, description string, backgroundColor Color, style pb.RevisionStateStyle) *pb.RevisionState {
if err := backgroundColor.Verify(); err != nil {
panic(fmt.Sprintf("invalid background color for revision state %q: %v", label, err))
}
mu.Lock()
defer mu.Unlock()

if locked {
panic(fmt.Sprintf("failed to register revision state style %q: style-related registrations must be done in task/inspection/**/contract packages during package initialization. Did you call it from outside of the contract or not at package initialization timing?", label))
}

id := uint32(len(revisionStates) + 1)
rs := &pb.RevisionState{
Id: proto.Uint32(id),
Expand Down
Loading
Loading