a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
353 lines
11 KiB
Go
353 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
"pgregory.net/rapid"
|
|
)
|
|
|
|
// Feature: gortex-enhancements, Property 3: Guard rule config round-trip
|
|
|
|
// genGuardRule generates a random GuardRule with valid field values.
|
|
func genGuardRule() *rapid.Generator[GuardRule] {
|
|
return rapid.Custom(func(t *rapid.T) GuardRule {
|
|
kind := rapid.SampledFrom([]string{"co-change", "boundary"}).Draw(t, "kind")
|
|
return GuardRule{
|
|
Name: rapid.StringMatching(`[a-z][a-z0-9\-]{0,29}`).Draw(t, "name"),
|
|
Kind: kind,
|
|
Source: rapid.StringMatching(`[a-z][a-z0-9/]{0,49}`).Draw(t, "source"),
|
|
Target: rapid.StringMatching(`[a-z][a-z0-9/]{0,49}`).Draw(t, "target"),
|
|
Message: rapid.StringMatching(`[A-Za-z0-9 .,!?]{1,100}`).Draw(t, "message"),
|
|
}
|
|
})
|
|
}
|
|
|
|
// genGuardsConfig generates a random GuardsConfig with 0-10 rules.
|
|
func genGuardsConfig() *rapid.Generator[GuardsConfig] {
|
|
return rapid.Custom(func(t *rapid.T) GuardsConfig {
|
|
n := rapid.IntRange(0, 10).Draw(t, "numRules")
|
|
rules := make([]GuardRule, n)
|
|
for i := range n {
|
|
rules[i] = genGuardRule().Draw(t, "rule")
|
|
}
|
|
return GuardsConfig{Rules: rules}
|
|
})
|
|
}
|
|
|
|
// yamlConfig is a helper struct for writing YAML that viper can load.
|
|
// We use explicit yaml tags to ensure the keys match what viper/mapstructure expects.
|
|
type yamlConfig struct {
|
|
Guards yamlGuardsConfig `yaml:"guards"`
|
|
}
|
|
|
|
type yamlGuardsConfig struct {
|
|
Rules []yamlGuardRule `yaml:"rules"`
|
|
}
|
|
|
|
type yamlGuardRule struct {
|
|
Name string `yaml:"name"`
|
|
Kind string `yaml:"kind"`
|
|
Source string `yaml:"source"`
|
|
Target string `yaml:"target"`
|
|
Message string `yaml:"message"`
|
|
Severity string `yaml:"severity"`
|
|
Except []string `yaml:"except"`
|
|
}
|
|
|
|
func toYAMLConfig(gc GuardsConfig) yamlConfig {
|
|
rules := make([]yamlGuardRule, len(gc.Rules))
|
|
for i, r := range gc.Rules {
|
|
rules[i] = yamlGuardRule(r)
|
|
}
|
|
return yamlConfig{Guards: yamlGuardsConfig{Rules: rules}}
|
|
}
|
|
|
|
func TestPropertyGuardConfigRoundTrip(t *testing.T) {
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
original := genGuardsConfig().Draw(rt, "guardsConfig")
|
|
|
|
// Serialize to YAML
|
|
yc := toYAMLConfig(original)
|
|
data, err := yaml.Marshal(yc)
|
|
if err != nil {
|
|
rt.Fatalf("failed to marshal YAML: %v", err)
|
|
}
|
|
|
|
// Write to a temp file named .gortex.yaml so viper can find it
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, ".gortex.yaml")
|
|
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
|
rt.Fatalf("failed to write config file: %v", err)
|
|
}
|
|
|
|
// Load via config.Load
|
|
loaded, err := Load(configPath)
|
|
if err != nil {
|
|
rt.Fatalf("failed to load config: %v", err)
|
|
}
|
|
|
|
// Assert the loaded GuardsConfig matches the original
|
|
if len(loaded.Guards.Rules) != len(original.Rules) {
|
|
rt.Fatalf("rule count mismatch: got %d, want %d",
|
|
len(loaded.Guards.Rules), len(original.Rules))
|
|
}
|
|
|
|
for i, want := range original.Rules {
|
|
got := loaded.Guards.Rules[i]
|
|
if got.Name != want.Name {
|
|
rt.Errorf("rule[%d].Name: got %q, want %q", i, got.Name, want.Name)
|
|
}
|
|
if got.Kind != want.Kind {
|
|
rt.Errorf("rule[%d].Kind: got %q, want %q", i, got.Kind, want.Kind)
|
|
}
|
|
if got.Source != want.Source {
|
|
rt.Errorf("rule[%d].Source: got %q, want %q", i, got.Source, want.Source)
|
|
}
|
|
if got.Target != want.Target {
|
|
rt.Errorf("rule[%d].Target: got %q, want %q", i, got.Target, want.Target)
|
|
}
|
|
if got.Message != want.Message {
|
|
rt.Errorf("rule[%d].Message: got %q, want %q", i, got.Message, want.Message)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Feature: multi-repo-support, Property 17: Backward-compatible config loading
|
|
|
|
// yamlLegacyConfig is a helper struct representing a .gortex.yaml file
|
|
// that only contains existing (pre-multi-repo) fields — no repos, projects,
|
|
// workspace, or active_project sections.
|
|
type yamlLegacyConfig struct {
|
|
Index *yamlLegacyIndex `yaml:"index,omitempty"`
|
|
Watch *yamlLegacyWatch `yaml:"watch,omitempty"`
|
|
Guards *yamlGuardsConfig `yaml:"guards,omitempty"`
|
|
}
|
|
|
|
type yamlLegacyIndex struct {
|
|
Exclude []string `yaml:"exclude,omitempty"`
|
|
Workers int `yaml:"workers,omitempty"`
|
|
}
|
|
|
|
type yamlLegacyWatch struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
DebounceMs int `yaml:"debounce_ms,omitempty"`
|
|
Exclude []string `yaml:"exclude,omitempty"`
|
|
}
|
|
|
|
// genLegacyConfig generates a random .gortex.yaml content using only
|
|
// existing fields (no repos/projects/workspace/active_project).
|
|
func genLegacyConfig() *rapid.Generator[yamlLegacyConfig] {
|
|
return rapid.Custom(func(t *rapid.T) yamlLegacyConfig {
|
|
var cfg yamlLegacyConfig
|
|
|
|
// Optionally include index section.
|
|
if rapid.Bool().Draw(t, "hasIndex") {
|
|
idx := &yamlLegacyIndex{}
|
|
if rapid.Bool().Draw(t, "hasExclude") {
|
|
idx.Exclude = genExcludePatterns().Draw(t, "indexExclude")
|
|
}
|
|
if rapid.Bool().Draw(t, "hasWorkers") {
|
|
idx.Workers = rapid.IntRange(1, 32).Draw(t, "workers")
|
|
}
|
|
cfg.Index = idx
|
|
}
|
|
|
|
// Optionally include watch section.
|
|
if rapid.Bool().Draw(t, "hasWatch") {
|
|
w := &yamlLegacyWatch{}
|
|
w.Enabled = rapid.Bool().Draw(t, "watchEnabled")
|
|
if rapid.Bool().Draw(t, "hasDebounce") {
|
|
w.DebounceMs = rapid.IntRange(50, 1000).Draw(t, "debounceMs")
|
|
}
|
|
if rapid.Bool().Draw(t, "hasWatchExclude") {
|
|
w.Exclude = genExcludePatterns().Draw(t, "watchExclude")
|
|
}
|
|
cfg.Watch = w
|
|
}
|
|
|
|
// Optionally include guards section.
|
|
if rapid.Bool().Draw(t, "hasGuards") {
|
|
gc := genGuardsConfig().Draw(t, "guards")
|
|
yg := &yamlGuardsConfig{Rules: make([]yamlGuardRule, len(gc.Rules))}
|
|
for i, r := range gc.Rules {
|
|
yg.Rules[i] = yamlGuardRule(r)
|
|
}
|
|
cfg.Guards = yg
|
|
}
|
|
|
|
return cfg
|
|
})
|
|
}
|
|
|
|
// TestPropertyBackwardCompatibleConfigLoading verifies that existing .gortex.yaml files
|
|
// without repos/projects/workspace/active_project sections load successfully via config.Load()
|
|
// and return a valid Config with defaults for any new fields.
|
|
func TestPropertyBackwardCompatibleConfigLoading(t *testing.T) {
|
|
rapid.Check(t, func(rt *rapid.T) {
|
|
legacy := genLegacyConfig().Draw(rt, "legacyConfig")
|
|
|
|
// Serialize to YAML.
|
|
data, err := yaml.Marshal(legacy)
|
|
if err != nil {
|
|
rt.Fatalf("failed to marshal legacy config: %v", err)
|
|
}
|
|
|
|
// Write to a temp .gortex.yaml file.
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, ".gortex.yaml")
|
|
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
|
rt.Fatalf("failed to write config file: %v", err)
|
|
}
|
|
|
|
// Load via config.Load — this must succeed without error.
|
|
loaded, err := Load(configPath)
|
|
if err != nil {
|
|
rt.Fatalf("config.Load() failed on legacy config: %v\nYAML content:\n%s", err, string(data))
|
|
}
|
|
|
|
// Verify loaded config is non-nil.
|
|
if loaded == nil {
|
|
rt.Fatal("config.Load() returned nil Config")
|
|
}
|
|
|
|
// Verify existing fields match generated values.
|
|
if legacy.Index != nil {
|
|
if legacy.Index.Exclude != nil {
|
|
if len(loaded.Index.Exclude) != len(legacy.Index.Exclude) {
|
|
rt.Errorf("index.exclude count: got %d, want %d",
|
|
len(loaded.Index.Exclude), len(legacy.Index.Exclude))
|
|
}
|
|
for i, want := range legacy.Index.Exclude {
|
|
if i < len(loaded.Index.Exclude) && loaded.Index.Exclude[i] != want {
|
|
rt.Errorf("index.exclude[%d]: got %q, want %q",
|
|
i, loaded.Index.Exclude[i], want)
|
|
}
|
|
}
|
|
}
|
|
if legacy.Index.Workers > 0 {
|
|
if loaded.Index.Workers != legacy.Index.Workers {
|
|
rt.Errorf("index.workers: got %d, want %d",
|
|
loaded.Index.Workers, legacy.Index.Workers)
|
|
}
|
|
}
|
|
}
|
|
|
|
if legacy.Watch != nil {
|
|
if loaded.Watch.Enabled != legacy.Watch.Enabled {
|
|
rt.Errorf("watch.enabled: got %v, want %v",
|
|
loaded.Watch.Enabled, legacy.Watch.Enabled)
|
|
}
|
|
if legacy.Watch.DebounceMs > 0 {
|
|
if loaded.Watch.DebounceMs != legacy.Watch.DebounceMs {
|
|
rt.Errorf("watch.debounce_ms: got %d, want %d",
|
|
loaded.Watch.DebounceMs, legacy.Watch.DebounceMs)
|
|
}
|
|
}
|
|
if legacy.Watch.Exclude != nil {
|
|
if len(loaded.Watch.Exclude) != len(legacy.Watch.Exclude) {
|
|
rt.Errorf("watch.exclude count: got %d, want %d",
|
|
len(loaded.Watch.Exclude), len(legacy.Watch.Exclude))
|
|
}
|
|
for i, want := range legacy.Watch.Exclude {
|
|
if i < len(loaded.Watch.Exclude) && loaded.Watch.Exclude[i] != want {
|
|
rt.Errorf("watch.exclude[%d]: got %q, want %q",
|
|
i, loaded.Watch.Exclude[i], want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if legacy.Guards != nil {
|
|
if len(loaded.Guards.Rules) != len(legacy.Guards.Rules) {
|
|
rt.Errorf("guards.rules count: got %d, want %d",
|
|
len(loaded.Guards.Rules), len(legacy.Guards.Rules))
|
|
}
|
|
for i, want := range legacy.Guards.Rules {
|
|
if i < len(loaded.Guards.Rules) {
|
|
got := loaded.Guards.Rules[i]
|
|
if got.Name != want.Name {
|
|
rt.Errorf("guards.rules[%d].name: got %q, want %q", i, got.Name, want.Name)
|
|
}
|
|
if got.Kind != want.Kind {
|
|
rt.Errorf("guards.rules[%d].kind: got %q, want %q", i, got.Kind, want.Kind)
|
|
}
|
|
if got.Source != want.Source {
|
|
rt.Errorf("guards.rules[%d].source: got %q, want %q", i, got.Source, want.Source)
|
|
}
|
|
if got.Target != want.Target {
|
|
rt.Errorf("guards.rules[%d].target: got %q, want %q", i, got.Target, want.Target)
|
|
}
|
|
if got.Message != want.Message {
|
|
rt.Errorf("guards.rules[%d].message: got %q, want %q", i, got.Message, want.Message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify that the YAML content does NOT contain multi-repo fields.
|
|
// This is a sanity check on the generator — the generated YAML should
|
|
// never include repos, projects, workspace, or active_project.
|
|
content := string(data)
|
|
for _, forbidden := range []string{"repos:", "projects:", "workspace:", "active_project:"} {
|
|
if containsField(content, forbidden) {
|
|
rt.Errorf("generated YAML unexpectedly contains %q", forbidden)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// containsField checks if a YAML string contains a top-level field key.
|
|
func containsField(yamlContent, field string) bool {
|
|
// Simple check: the field appears at the start of a line.
|
|
for _, line := range splitLines(yamlContent) {
|
|
if len(line) >= len(field) && line[:len(field)] == field {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestPubsubCoverageDomain(t *testing.T) {
|
|
// Default-off: like observability, the pub/sub extractor is a
|
|
// heuristic with a non-zero false-positive surface, so it stays
|
|
// off until a repo opts in.
|
|
var cov CoverageConfig
|
|
if cov.IsEnabled("pubsub") {
|
|
t.Errorf("pubsub coverage domain should default to off")
|
|
}
|
|
|
|
// Explicit enable in YAML flips it on through the tri-state pointer.
|
|
enabled := true
|
|
cov.Pubsub.Enabled = &enabled
|
|
if !cov.IsEnabled("pubsub") {
|
|
t.Errorf("pubsub coverage domain should be on when explicitly enabled")
|
|
}
|
|
|
|
// Explicit disable is distinguishable from unset.
|
|
disabled := false
|
|
cov.Pubsub.Enabled = &disabled
|
|
if cov.IsEnabled("pubsub") {
|
|
t.Errorf("pubsub coverage domain should be off when explicitly disabled")
|
|
}
|
|
}
|
|
|
|
// splitLines splits a string into lines.
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
lines = append(lines, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
lines = append(lines, s[start:])
|
|
}
|
|
return lines
|
|
}
|