已合并
Fix formatted repository YAML configuration parsing #146
urandon创建于 28 天前
Fix formatted repository YAML configuration parsing #146
已合并
共 8 个文件变更+482-390
| @@ -291,6 +291,40 @@ func TestEntrypointDiscoversRepoLocalCache(t *testing.T) { | |||
| 291 | } | 291 | } |
| 292 | } | 292 | } |
| 293 | 293 | ||
| 294 | +func TestEntrypointFormattedRepositoryConfigSupportsCLIAndMCP(t *testing.T) { | ||
| 295 | + for _, content := range []string{ | ||
| 296 | + "cache_mode: repo-local\nrepository_docs:\n include:\n - README.md\n", | ||
| 297 | + "cache_mode: repo-local\nrepository_docs: {include: [README.md]}\n", | ||
| 298 | + } { | ||
| 299 | + src := newTestSource(t) | ||
| 300 | + root := filepath.Join(t.TempDir(), "example-worktree") | ||
| 301 | + configureRepoLocalSource(src, root, filepath.Join(root, "nested")) | ||
| 302 | + src.files[filepath.Join(root, ".gitcode", "gitcode-mcp.yaml")] = []byte(content) | ||
| 303 | + global := filepath.Join(src.configDir, "isolated.yaml") | ||
| 304 | + src.env[config.EnvMCPConfigPath] = global | ||
| 305 | + src.files[global] = []byte("credential: {store: env}\n") | ||
| 306 | + var stdout, stderr bytes.Buffer | ||
| 307 | + if code := run([]string{"config", "show", "--redacted", "--format", "json"}, strings.NewReader(""), &stdout, &stderr, src); code != 0 { | ||
| 308 | + t.Fatalf("formatted config show failed: code=%d stderr=%q", code, stderr.String()) | ||
| 309 | + } | ||
| 310 | + var shown struct { | ||
| 311 | + Effective config.EffectiveConfig `json:"effective"` | ||
| 312 | + } | ||
| 313 | + if err := json.Unmarshal(stdout.Bytes(), &shown); err != nil || shown.Effective.Config.CacheMode != config.CacheModeRepoLocal || shown.Effective.RepoRoot != root { | ||
| 314 | + t.Fatalf("config show lost repository intent: %v", err) | ||
| 315 | + } | ||
| 316 | + stdout.Reset() | ||
| 317 | + stderr.Reset() | ||
| 318 | + stdin := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n") | ||
| 319 | + if code := run([]string{"--mcp", "--offline"}, stdin, &stdout, &stderr, src); code != 0 { | ||
| 320 | + t.Fatalf("formatted MCP startup failed: code=%d stderr=%q", code, stderr.String()) | ||
| 321 | + } | ||
| 322 | + if !entrypointMCPToolNames(t, strings.TrimSpace(stdout.String()))["search_sources"] { | ||
| 323 | + t.Fatal("formatted repo config degraded MCP to recovery-only tools") | ||
| 324 | + } | ||
| 325 | + } | ||
| 326 | +} | ||
| 327 | + | ||
| 294 | func TestEntrypointRepoLocalCacheDirectoryHandling(t *testing.T) { | 328 | func TestEntrypointRepoLocalCacheDirectoryHandling(t *testing.T) { |
| 295 | src := newTestSource(t) | 329 | src := newTestSource(t) |
| 296 | root := filepath.Join(src.homeDir, "workspace", "repo") | 330 | root := filepath.Join(src.homeDir, "workspace", "repo") |
| @@ -28,6 +28,17 @@ export GITCODE_MCP_CONFIG=/path/to/custom/config.yaml | |||
| 28 | 28 | ||
| 29 | ## Config file format (YAML) | 29 | ## Config file format (YAML) |
| 30 | 30 | ||
| 31 | +Configuration uses standard YAML mappings, quoted scalars, comments, and | ||
| 32 | +block/flow sequences. Formatting a repository config does not change its | ||
| 33 | +effective settings. Known fields are type-checked; malformed YAML, duplicate | ||
| 34 | +keys, and multiple documents are rejected without echoing scalar content. | ||
| 35 | +The `repository_docs` section is interpreted by the documentation policy | ||
| 36 | +parser, not by the runtime settings loader. Legacy pipe/semicolon scalar lists | ||
| 37 | +remain accepted for feedback labels and provider install hints; standard YAML | ||
| 38 | +sequences are also accepted. | ||
| 39 | +Legacy quoted numeric/boolean settings remain accepted; string fields retain | ||
| 40 | +their literal content even when an anchor is shared with a typed setting. | ||
| 41 | + | ||
| 31 | ```yaml | 42 | ```yaml |
| 32 | cache_path: /path/to/cache/gitcode-mcp/cache.db | 43 | cache_path: /path/to/cache/gitcode-mcp/cache.db |
| 33 | lock_path: /path/to/cache/gitcode-mcp/cache.db.lock | 44 | lock_path: /path/to/cache/gitcode-mcp/cache.db.lock |
| @@ -144,30 +144,30 @@ func RedactDiagnostic(message string, src Source) string { | |||
| 144 | } | 144 | } |
| 145 | 145 | ||
| 146 | type fileConfig struct { | 146 | type fileConfig struct { |
| 147 | - CachePath *string `json:"cache_path"` | 147 | + CachePath *string `json:"cache_path" yaml:"cache_path"` |
| 148 | - LockPath *string `json:"lock_path"` | 148 | + LockPath *string `json:"lock_path" yaml:"lock_path"` |
| 149 | - CacheMode *string `json:"cache_mode"` | 149 | + CacheMode *string `json:"cache_mode" yaml:"cache_mode"` |
| 150 | - GitCodeBaseURL *string `json:"gitcode_base_url"` | 150 | + GitCodeBaseURL *string `json:"gitcode_base_url" yaml:"gitcode_base_url"` |
| 151 | - DefaultTimeout *string `json:"default_timeout"` | 151 | + DefaultTimeout *string `json:"default_timeout" yaml:"default_timeout"` |
| 152 | - MaxResponseSize *int64 `json:"max_response_size"` | 152 | + MaxResponseSize *int64 `json:"max_response_size" yaml:"max_response_size"` |
| 153 | - MaxRetries *int `json:"max_retries"` | 153 | + MaxRetries *int `json:"max_retries" yaml:"max_retries"` |
| 154 | - RateLimitRPS *float64 `json:"rate_limit_rps"` | 154 | + RateLimitRPS *float64 `json:"rate_limit_rps" yaml:"rate_limit_rps"` |
| 155 | - RateLimitBurst *int `json:"rate_limit_burst"` | 155 | + RateLimitBurst *int `json:"rate_limit_burst" yaml:"rate_limit_burst"` |
| 156 | - Format *string `json:"format"` | 156 | + Format *string `json:"format" yaml:"format"` |
| 157 | - MCPToolAccess *string `json:"mcp_tool_access"` | 157 | + MCPToolAccess *string `json:"mcp_tool_access" yaml:"mcp_tool_access"` |
| 158 | - MCP *MCPConfig `json:"mcp"` | 158 | + MCP *MCPConfig `json:"mcp" yaml:"mcp"` |
| 159 | - Credential *CredentialConfig `json:"credential"` | 159 | + Credential *CredentialConfig `json:"credential" yaml:"credential"` |
| 160 | - Service *serviceFileConfig `json:"service"` | 160 | + Service *serviceFileConfig `json:"service" yaml:"service"` |
| 161 | - RAG *ragFileConfig `json:"rag"` | 161 | + RAG *ragFileConfig `json:"rag" yaml:"rag"` |
| 162 | - Feedback *feedbackFileConfig `json:"feedback"` | 162 | + Feedback *feedbackFileConfig `json:"feedback" yaml:"feedback"` |
| 163 | } | 163 | } |
| 164 | 164 | ||
| 165 | type feedbackFileConfig struct { | 165 | type feedbackFileConfig struct { |
| 166 | - Enabled *bool `json:"enabled"` | 166 | + Enabled *bool `json:"enabled" yaml:"enabled"` |
| 167 | - Sink *string `json:"sink"` | 167 | + Sink *string `json:"sink" yaml:"sink"` |
| 168 | - RepoID *string `json:"repo_id"` | 168 | + RepoID *string `json:"repo_id" yaml:"repo_id"` |
| 169 | - Labels []string `json:"labels"` | 169 | + Labels yamlStringList `json:"labels" yaml:"labels"` |
| 170 | - DuplicatePolicy *string `json:"duplicate_policy"` | 170 | + DuplicatePolicy *string `json:"duplicate_policy" yaml:"duplicate_policy"` |
| 171 | } | 171 | } |
| 172 | 172 | ||
| 173 | func defaultWithSource(src Source) Config { | 173 | func defaultWithSource(src Source) Config { |
| @@ -1,15 +1,19 @@ | |||
| 1 | package config | 1 | package config |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | + "bytes" | ||
| 4 | "context" | 5 | "context" |
| 5 | "encoding/json" | 6 | "encoding/json" |
| 6 | "errors" | 7 | "errors" |
| 7 | "fmt" | 8 | "fmt" |
| 9 | + "io" | ||
| 8 | "os" | 10 | "os" |
| 9 | "path/filepath" | 11 | "path/filepath" |
| 12 | + "reflect" | ||
| 10 | "sort" | 13 | "sort" |
| 11 | - "strconv" | ||
| 12 | "strings" | 14 | "strings" |
| 15 | + | ||
| 16 | + "gopkg.in/yaml.v3" | ||
| 13 | ) | 17 | ) |
| 14 | 18 | ||
| 15 | const ( | 19 | const ( |
| @@ -35,17 +39,17 @@ type ConfigLocation struct { | |||
| 35 | } | 39 | } |
| 36 | 40 | ||
| 37 | type CredentialConfig struct { | 41 | type CredentialConfig struct { |
| 38 | - Store string `json:"store"` | 42 | + Store string `json:"store" yaml:"store"` |
| 39 | - KeyringService string `json:"keyring_service,omitempty"` | 43 | + KeyringService string `json:"keyring_service,omitempty" yaml:"keyring_service"` |
| 40 | - KeyringAccount string `json:"keyring_account,omitempty"` | 44 | + KeyringAccount string `json:"keyring_account,omitempty" yaml:"keyring_account"` |
| 41 | } | 45 | } |
| 42 | 46 | ||
| 43 | type MCPToolsConfig struct { | 47 | type MCPToolsConfig struct { |
| 44 | - Access string `json:"access"` | 48 | + Access string `json:"access" yaml:"access"` |
| 45 | } | 49 | } |
| 46 | 50 | ||
| 47 | type MCPConfig struct { | 51 | type MCPConfig struct { |
| 48 | - Tools MCPToolsConfig `json:"tools"` | 52 | + Tools MCPToolsConfig `json:"tools" yaml:"tools"` |
| 49 | } | 53 | } |
| 50 | 54 | ||
| 51 | type EffectiveConfig struct { | 55 | type EffectiveConfig struct { |
| @@ -506,347 +510,78 @@ func readLocatedConfig(src Source, loc ConfigLocation) (fileConfig, CredentialCo | |||
| 506 | return parseYAMLConfig(data, loc.Path) | 510 | return parseYAMLConfig(data, loc.Path) |
| 507 | } | 511 | } |
| 508 | 512 | ||
| 509 | -type yamlSection struct { | 513 | +// yamlFileConfig shares the JSON merge schema and keeps cache.mode as a legacy |
| 510 | - indent int | 514 | +// alias. repository_docs is intentionally owned by its dedicated policy parser. |
| 511 | - name string | 515 | +type yamlFileConfig struct { |
| 516 | + File fileConfig `yaml:",inline"` | ||
| 517 | + Cache *struct { | ||
| 518 | + Mode *string `yaml:"mode"` | ||
| 519 | + } `yaml:"cache"` | ||
| 512 | } | 520 | } |
| 513 | 521 | ||
| 514 | func parseYAMLConfig(data []byte, path string) (fileConfig, CredentialConfig, error) { | 522 | func parseYAMLConfig(data []byte, path string) (fileConfig, CredentialConfig, error) { |
| 515 | - var cfg fileConfig | 523 | + invalid := func() (fileConfig, CredentialConfig, error) { |
| 524 | + // Decoder errors can echo scalar bytes; never publish those diagnostics. | ||
| 525 | + return fileConfig{}, CredentialConfig{}, fmt.Errorf("config: malformed config file %s (invalid YAML or field type)", path) | ||
| 526 | + } | ||
| 527 | + decoder := yaml.NewDecoder(bytes.NewReader(data)) | ||
| 528 | + var document yaml.Node | ||
| 529 | + if err := decoder.Decode(&document); err != nil { | ||
| 530 | + if errors.Is(err, io.EOF) { | ||
| 531 | + return fileConfig{}, CredentialConfig{}, nil | ||
| 532 | + } | ||
| 533 | + return invalid() | ||
| 534 | + } | ||
| 535 | + var extra yaml.Node | ||
| 536 | + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { | ||
| 537 | + return invalid() // A config is one document, never a silently ignored suffix. | ||
| 538 | + } | ||
| 539 | + if len(document.Content) == 1 && document.Content[0].Kind == yaml.ScalarNode && document.Content[0].Tag == "!!null" && document.Content[0].Value == "" { | ||
| 540 | + return fileConfig{}, CredentialConfig{}, nil // An explicitly empty document. | ||
| 541 | + } | ||
| 542 | + if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { | ||
| 543 | + return invalid() | ||
| 544 | + } | ||
| 545 | + var decoded yamlFileConfig | ||
| 546 | + budget := 16384 | ||
| 547 | + root, err := legacyYAMLScalars(document.Content[0], reflect.TypeOf(decoded), 0, &budget) | ||
| 548 | + if err != nil { | ||
| 549 | + return invalid() | ||
| 550 | + } | ||
| 551 | + if err := root.Decode(&decoded); err != nil { | ||
| 552 | + return invalid() | ||
| 553 | + } | ||
| 554 | + cfg := decoded.File | ||
| 555 | + if decoded.Cache != nil && decoded.Cache.Mode != nil { | ||
| 556 | + cfg.CacheMode = decoded.Cache.Mode | ||
| 557 | + } | ||
| 516 | var cred CredentialConfig | 558 | var cred CredentialConfig |
| 517 | - var stack []yamlSection | 559 | + if cfg.Credential != nil { |
| 518 | - for _, raw := range strings.Split(string(data), "\n") { | 560 | + cred = *cfg.Credential |
| 519 | - line := strings.TrimSpace(raw) | ||
| 520 | - if line == "" || strings.HasPrefix(line, "#") { | ||
| 521 | - continue | ||
| 522 | - } | ||
| 523 | - indent := len(raw) - len(strings.TrimLeft(raw, " ")) | ||
| 524 | - for len(stack) > 0 && indent <= stack[len(stack)-1].indent { | ||
| 525 | - stack = stack[:len(stack)-1] | ||
| 526 | - } | ||
| 527 | - if strings.HasSuffix(line, ":") { | ||
| 528 | - stack = append(stack, yamlSection{indent: indent, name: strings.TrimSpace(strings.TrimSuffix(line, ":"))}) | ||
| 529 | - continue | ||
| 530 | - } | ||
| 531 | - parts := strings.SplitN(line, ":", 2) | ||
| 532 | - if len(parts) != 2 { | ||
| 533 | - return fileConfig{}, CredentialConfig{}, fmt.Errorf("config: malformed config file %s", path) | ||
| 534 | - } | ||
| 535 | - key := strings.TrimSpace(parts[0]) | ||
| 536 | - value := strings.Trim(strings.TrimSpace(parts[1]), "\"") | ||
| 537 | - section := yamlSectionPath(stack) | ||
| 538 | - if section == "mcp.tools" && key == "access" { | ||
| 539 | - cfg.MCPToolAccess = &value | ||
| 540 | - continue | ||
| 541 | - } | ||
| 542 | - if section == "cache" && key == "mode" { | ||
| 543 | - cfg.CacheMode = &value | ||
| 544 | - continue | ||
| 545 | - } | ||
| 546 | - if section == "credential" && key == "store" { | ||
| 547 | - cred.Store = value | ||
| 548 | - continue | ||
| 549 | - } | ||
| 550 | - if section == "credential" && key == "keyring_service" { | ||
| 551 | - cred.KeyringService = value | ||
| 552 | - continue | ||
| 553 | - } | ||
| 554 | - if section == "credential" && key == "keyring_account" { | ||
| 555 | - cred.KeyringAccount = value | ||
| 556 | - continue | ||
| 557 | - } | ||
| 558 | - if section == "feedback" { | ||
| 559 | - if cfg.Feedback == nil { | ||
| 560 | - cfg.Feedback = &feedbackFileConfig{} | ||
| 561 | - } | ||
| 562 | - switch key { | ||
| 563 | - case "enabled": | ||
| 564 | - v, err := parseBoolYAML(section, key, value) | ||
| 565 | - if err != nil { | ||
| 566 | - return fileConfig{}, CredentialConfig{}, err | ||
| 567 | - } | ||
| 568 | - cfg.Feedback.Enabled = &v | ||
| 569 | - case "sink": | ||
| 570 | - cfg.Feedback.Sink = &value | ||
| 571 | - case "repo_id": | ||
| 572 | - cfg.Feedback.RepoID = &value | ||
| 573 | - case "labels": | ||
| 574 | - cfg.Feedback.Labels = splitYAMLList(value) | ||
| 575 | - case "duplicate_policy": | ||
| 576 | - cfg.Feedback.DuplicatePolicy = &value | ||
| 577 | - } | ||
| 578 | - continue | ||
| 579 | - } | ||
| 580 | - if err := setYAMLRAGValue(&cfg, section, key, value); err != nil { | ||
| 581 | - return fileConfig{}, CredentialConfig{}, err | ||
| 582 | - } | ||
| 583 | - if isRAGYAMLSection(section) || section == "service" { | ||
| 584 | - continue | ||
| 585 | - } | ||
| 586 | - if section != "" { | ||
| 587 | - continue | ||
| 588 | - } | ||
| 589 | - switch key { | ||
| 590 | - case "cache_path": | ||
| 591 | - cfg.CachePath = &value | ||
| 592 | - case "lock_path": | ||
| 593 | - cfg.LockPath = &value | ||
| 594 | - case "cache_mode": | ||
| 595 | - cfg.CacheMode = &value | ||
| 596 | - case "gitcode_base_url": | ||
| 597 | - cfg.GitCodeBaseURL = &value | ||
| 598 | - case "default_timeout": | ||
| 599 | - cfg.DefaultTimeout = &value | ||
| 600 | - case "max_response_size": | ||
| 601 | - n, err := strconv.ParseInt(value, 10, 64) | ||
| 602 | - if err != nil { | ||
| 603 | - return fileConfig{}, CredentialConfig{}, fmt.Errorf("config: invalid max_response_size %q: %w", value, err) | ||
| 604 | - } | ||
| 605 | - cfg.MaxResponseSize = &n | ||
| 606 | - case "max_retries": | ||
| 607 | - n, err := strconv.Atoi(value) | ||
| 608 | - if err != nil { | ||
| 609 | - return fileConfig{}, CredentialConfig{}, fmt.Errorf("config: invalid max_retries %q: %w", value, err) | ||
| 610 | - } | ||
| 611 | - cfg.MaxRetries = &n | ||
| 612 | - case "rate_limit_rps": | ||
| 613 | - n, err := strconv.ParseFloat(value, 64) | ||
| 614 | - if err != nil { | ||
| 615 | - return fileConfig{}, CredentialConfig{}, fmt.Errorf("config: invalid rate_limit_rps %q: %w", value, err) | ||
| 616 | - } | ||
| 617 | - cfg.RateLimitRPS = &n | ||
| 618 | - case "rate_limit_burst": | ||
| 619 | - n, err := strconv.Atoi(value) | ||
| 620 | - if err != nil { | ||
| 621 | - return fileConfig{}, CredentialConfig{}, fmt.Errorf("config: invalid rate_limit_burst %q: %w", value, err) | ||
| 622 | - } | ||
| 623 | - cfg.RateLimitBurst = &n | ||
| 624 | - case "format": | ||
| 625 | - cfg.Format = &value | ||
| 626 | - } | ||
| 627 | } | 561 | } |
| 628 | return cfg, cred, nil | 562 | return cfg, cred, nil |
| 629 | } | 563 | } |
| 630 | 564 | ||
| 631 | -func yamlSectionPath(stack []yamlSection) string { | 565 | +// yamlStringList accepts standard block/flow sequences and the historical |
| 632 | - names := make([]string, 0, len(stack)) | 566 | +// pipe/semicolon scalar notation. JSON decoding remains an ordinary string list. |
| 633 | - for _, section := range stack { | 567 | +type yamlStringList []string |
| 634 | - names = append(names, section.name) | ||
| 635 | - } | ||
| 636 | - return strings.Join(names, ".") | ||
| 637 | -} | ||
| 638 | 568 | ||
| 639 | -func setYAMLRAGValue(cfg *fileConfig, section, key, value string) error { | 569 | +func (values *yamlStringList) UnmarshalYAML(node *yaml.Node) error { |
| 640 | - switch { | 570 | + if node.Kind == yaml.ScalarNode && node.Tag == "!!str" { |
| 641 | - case section == "service": | 571 | + *values = splitYAMLList(node.Value) |
| 642 | - if cfg.Service == nil { | ||
| 643 | - cfg.Service = &serviceFileConfig{} | ||
| 644 | - } | ||
| 645 | - if key == "runtime_dir" { | ||
| 646 | - cfg.Service.RuntimeDir = &value | ||
| 647 | - } | ||
| 648 | - case section == "service.job_retention": | ||
| 649 | - if cfg.Service == nil { | ||
| 650 | - cfg.Service = &serviceFileConfig{} | ||
| 651 | - } | ||
| 652 | - if cfg.Service.JobRetention == nil { | ||
| 653 | - cfg.Service.JobRetention = &serviceJobRetentionFileConfig{} | ||
| 654 | - } | ||
| 655 | - retention := cfg.Service.JobRetention | ||
| 656 | - switch key { | ||
| 657 | - case "success_ttl": | ||
| 658 | - retention.SuccessTTL = &value | ||
| 659 | - case "diagnostic_ttl": | ||
| 660 | - retention.DiagnosticTTL = &value | ||
| 661 | - case "max_terminal_jobs", "max_diagnostic_jobs", "max_progress_events": | ||
| 662 | - n, err := strconv.Atoi(value) | ||
| 663 | - if err != nil { | ||
| 664 | - return fmt.Errorf("config: invalid %s.%s %q: %w", section, key, value, err) | ||
| 665 | - } | ||
| 666 | - switch key { | ||
| 667 | - case "max_terminal_jobs": | ||
| 668 | - retention.MaxTerminalJobs = &n | ||
| 669 | - case "max_diagnostic_jobs": | ||
| 670 | - retention.MaxDiagnosticJobs = &n | ||
| 671 | - case "max_progress_events": | ||
| 672 | - retention.MaxProgressEvents = &n | ||
| 673 | - } | ||
| 674 | - } | ||
| 675 | - case section == "rag": | ||
| 676 | - rag := ensureRAGFile(cfg) | ||
| 677 | - switch key { | ||
| 678 | - case "model_store_path": | ||
| 679 | - rag.ModelStorePath = &value | ||
| 680 | - case "default_profile": | ||
| 681 | - rag.DefaultProfile = &value | ||
| 682 | - } | ||
| 683 | - case section == "rag.indexing": | ||
| 684 | - rag := ensureRAGFile(cfg) | ||
| 685 | - if rag.Indexing == nil { | ||
| 686 | - rag.Indexing = &ragIndexingFileConfig{} | ||
| 687 | - } | ||
| 688 | - n, err := parseOptionalIntYAML(section, key, value) | ||
| 689 | - if err != nil { | ||
| 690 | - return err | ||
| 691 | - } | ||
| 692 | - switch key { | ||
| 693 | - case "profile": | ||
| 694 | - rag.Indexing.Profile = &value | ||
| 695 | - case "chunk_tokens": | ||
| 696 | - rag.Indexing.ChunkTokens = n | ||
| 697 | - case "overlap": | ||
| 698 | - rag.Indexing.Overlap = n | ||
| 699 | - case "batch_size": | ||
| 700 | - rag.Indexing.BatchSize = n | ||
| 701 | - } | ||
| 702 | - case section == "rag.search": | ||
| 703 | - rag := ensureRAGFile(cfg) | ||
| 704 | - if rag.Search == nil { | ||
| 705 | - rag.Search = &ragSearchFileConfig{} | ||
| 706 | - } | ||
| 707 | - switch key { | ||
| 708 | - case "profile": | ||
| 709 | - rag.Search.Profile = &value | ||
| 710 | - case "top_k": | ||
| 711 | - n, err := parseOptionalIntYAML(section, key, value) | ||
| 712 | - if err != nil { | ||
| 713 | - return err | ||
| 714 | - } | ||
| 715 | - rag.Search.TopK = n | ||
| 716 | - case "hybrid": | ||
| 717 | - v, err := parseBoolYAML(section, key, value) | ||
| 718 | - if err != nil { | ||
| 719 | - return err | ||
| 720 | - } | ||
| 721 | - rag.Search.Hybrid = &v | ||
| 722 | - } | ||
| 723 | - case strings.HasPrefix(section, "rag.providers."): | ||
| 724 | - return setYAMLRAGProviderValue(cfg, section, key, value) | ||
| 725 | - case strings.HasPrefix(section, "rag.profiles."): | ||
| 726 | - return setYAMLRAGProfileValue(cfg, section, key, value) | ||
| 727 | - } | ||
| 728 | - return nil | ||
| 729 | -} | ||
| 730 | - | ||
| 731 | -func setYAMLRAGProviderValue(cfg *fileConfig, section, key, value string) error { | ||
| 732 | - parts := strings.Split(section, ".") | ||
| 733 | - if len(parts) < 3 { | ||
| 734 | return nil | 572 | return nil |
| 735 | } | 573 | } |
| 736 | - rag := ensureRAGFile(cfg) | 574 | + if node.Kind != yaml.SequenceNode { |
| 737 | - if rag.Providers == nil { | 575 | + return fmt.Errorf("expected a string list") |
| 738 | - rag.Providers = map[string]ragProviderFileConfig{} | ||
| 739 | } | 576 | } |
| 740 | - name := parts[2] | 577 | + var decoded []string |
| 741 | - provider := rag.Providers[name] | 578 | + if err := node.Decode(&decoded); err != nil { |
| 742 | - if len(parts) == 3 { | 579 | + return fmt.Errorf("expected a string list") |
| 743 | - switch key { | ||
| 744 | - case "type": | ||
| 745 | - provider.Type = &value | ||
| 746 | - case "data_boundary": | ||
| 747 | - provider.DataBoundary = &value | ||
| 748 | - case "endpoint": | ||
| 749 | - provider.Endpoint = &value | ||
| 750 | - case "executable": | ||
| 751 | - provider.Executable = &value | ||
| 752 | - case "startup": | ||
| 753 | - provider.Startup = &value | ||
| 754 | - case "autostart": | ||
| 755 | - v, err := parseBoolYAML(section, key, value) | ||
| 756 | - if err != nil { | ||
| 757 | - return err | ||
| 758 | - } | ||
| 759 | - provider.Autostart = &v | ||
| 760 | - case "install_hints": | ||
| 761 | - provider.InstallHints = splitYAMLList(value) | ||
| 762 | - case "timeout": | ||
| 763 | - provider.Timeout = &value | ||
| 764 | - } | ||
| 765 | - } else if len(parts) == 4 && parts[3] == "env" { | ||
| 766 | - if provider.Env == nil { | ||
| 767 | - provider.Env = map[string]string{} | ||
| 768 | - } | ||
| 769 | - provider.Env[key] = value | ||
| 770 | - } else if len(parts) == 4 && parts[3] == "model_storage" { | ||
| 771 | - if provider.ModelStorage == nil { | ||
| 772 | - provider.ModelStorage = &ragModelStorageFileConfig{} | ||
| 773 | - } | ||
| 774 | - switch key { | ||
| 775 | - case "mode": | ||
| 776 | - provider.ModelStorage.Mode = &value | ||
| 777 | - case "path": | ||
| 778 | - provider.ModelStorage.Path = &value | ||
| 779 | - case "env": | ||
| 780 | - provider.ModelStorage.Env = &value | ||
| 781 | - } | ||
| 782 | } | 580 | } |
| 783 | - rag.Providers[name] = provider | 581 | + *values = decoded |
| 784 | return nil | 582 | return nil |
| 785 | } | 583 | } |
| 786 | 584 | ||
| 787 | -func setYAMLRAGProfileValue(cfg *fileConfig, section, key, value string) error { | ||
| 788 | - parts := strings.Split(section, ".") | ||
| 789 | - if len(parts) != 3 { | ||
| 790 | - return nil | ||
| 791 | - } | ||
| 792 | - rag := ensureRAGFile(cfg) | ||
| 793 | - if rag.Profiles == nil { | ||
| 794 | - rag.Profiles = map[string]ragProfileFileConfig{} | ||
| 795 | - } | ||
| 796 | - name := parts[2] | ||
| 797 | - profile := rag.Profiles[name] | ||
| 798 | - n, err := parseOptionalIntYAML(section, key, value) | ||
| 799 | - if err != nil { | ||
| 800 | - return err | ||
| 801 | - } | ||
| 802 | - switch key { | ||
| 803 | - case "provider": | ||
| 804 | - profile.Provider = &value | ||
| 805 | - case "model": | ||
| 806 | - profile.Model = &value | ||
| 807 | - case "dimensions": | ||
| 808 | - profile.Dimensions = n | ||
| 809 | - case "max_input_tokens": | ||
| 810 | - profile.MaxInputTokens = n | ||
| 811 | - case "batch_size": | ||
| 812 | - profile.BatchSize = n | ||
| 813 | - } | ||
| 814 | - rag.Profiles[name] = profile | ||
| 815 | - return nil | ||
| 816 | -} | ||
| 817 | - | ||
| 818 | -func ensureRAGFile(cfg *fileConfig) *ragFileConfig { | ||
| 819 | - if cfg.RAG == nil { | ||
| 820 | - cfg.RAG = &ragFileConfig{} | ||
| 821 | - } | ||
| 822 | - return cfg.RAG | ||
| 823 | -} | ||
| 824 | - | ||
| 825 | -func isRAGYAMLSection(section string) bool { | ||
| 826 | - return section == "rag" || strings.HasPrefix(section, "rag.") | ||
| 827 | -} | ||
| 828 | - | ||
| 829 | -func parseOptionalIntYAML(section, key, value string) (*int, error) { | ||
| 830 | - switch key { | ||
| 831 | - case "chunk_tokens", "overlap", "batch_size", "top_k", "dimensions", "max_input_tokens": | ||
| 832 | - n, err := strconv.Atoi(value) | ||
| 833 | - if err != nil { | ||
| 834 | - return nil, fmt.Errorf("config: invalid %s.%s %q: %w", section, key, value, err) | ||
| 835 | - } | ||
| 836 | - return &n, nil | ||
| 837 | - default: | ||
| 838 | - return nil, nil | ||
| 839 | - } | ||
| 840 | -} | ||
| 841 | - | ||
| 842 | -func parseBoolYAML(section, key, value string) (bool, error) { | ||
| 843 | - v, err := strconv.ParseBool(value) | ||
| 844 | - if err != nil { | ||
| 845 | - return false, fmt.Errorf("config: invalid %s.%s %q: %w", section, key, value, err) | ||
| 846 | - } | ||
| 847 | - return v, nil | ||
| 848 | -} | ||
| 849 | - | ||
| 850 | func splitYAMLList(value string) []string { | 585 | func splitYAMLList(value string) []string { |
| 851 | parts := strings.FieldsFunc(value, func(r rune) bool { | 586 | parts := strings.FieldsFunc(value, func(r rune) bool { |
| 852 | return r == ';' || r == '|' | 587 | return r == ';' || r == '|' |
| @@ -89,65 +89,65 @@ type RAGSearchConfig struct { | |||
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | type serviceFileConfig struct { | 91 | type serviceFileConfig struct { |
| 92 | - RuntimeDir *string `json:"runtime_dir"` | 92 | + RuntimeDir *string `json:"runtime_dir" yaml:"runtime_dir"` |
| 93 | - JobRetention *serviceJobRetentionFileConfig `json:"job_retention"` | 93 | + JobRetention *serviceJobRetentionFileConfig `json:"job_retention" yaml:"job_retention"` |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | type serviceJobRetentionFileConfig struct { | 96 | type serviceJobRetentionFileConfig struct { |
| 97 | - SuccessTTL *string `json:"success_ttl"` | 97 | + SuccessTTL *string `json:"success_ttl" yaml:"success_ttl"` |
| 98 | - DiagnosticTTL *string `json:"diagnostic_ttl"` | 98 | + DiagnosticTTL *string `json:"diagnostic_ttl" yaml:"diagnostic_ttl"` |
| 99 | - MaxTerminalJobs *int `json:"max_terminal_jobs"` | 99 | + MaxTerminalJobs *int `json:"max_terminal_jobs" yaml:"max_terminal_jobs"` |
| 100 | - MaxDiagnosticJobs *int `json:"max_diagnostic_jobs"` | 100 | + MaxDiagnosticJobs *int `json:"max_diagnostic_jobs" yaml:"max_diagnostic_jobs"` |
| 101 | - MaxProgressEvents *int `json:"max_progress_events"` | 101 | + MaxProgressEvents *int `json:"max_progress_events" yaml:"max_progress_events"` |
| 102 | } | 102 | } |
| 103 | 103 | ||
| 104 | type ragFileConfig struct { | 104 | type ragFileConfig struct { |
| 105 | - ModelStorePath *string `json:"model_store_path"` | 105 | + ModelStorePath *string `json:"model_store_path" yaml:"model_store_path"` |
| 106 | - DefaultProfile *string `json:"default_profile"` | 106 | + DefaultProfile *string `json:"default_profile" yaml:"default_profile"` |
| 107 | - Providers map[string]ragProviderFileConfig `json:"providers"` | 107 | + Providers map[string]ragProviderFileConfig `json:"providers" yaml:"providers"` |
| 108 | - Profiles map[string]ragProfileFileConfig `json:"profiles"` | 108 | + Profiles map[string]ragProfileFileConfig `json:"profiles" yaml:"profiles"` |
| 109 | - Indexing *ragIndexingFileConfig `json:"indexing"` | 109 | + Indexing *ragIndexingFileConfig `json:"indexing" yaml:"indexing"` |
| 110 | - Search *ragSearchFileConfig `json:"search"` | 110 | + Search *ragSearchFileConfig `json:"search" yaml:"search"` |
| 111 | } | 111 | } |
| 112 | 112 | ||
| 113 | type ragProviderFileConfig struct { | 113 | type ragProviderFileConfig struct { |
| 114 | - Type *string `json:"type"` | 114 | + Type *string `json:"type" yaml:"type"` |
| 115 | - DataBoundary *string `json:"data_boundary"` | 115 | + DataBoundary *string `json:"data_boundary" yaml:"data_boundary"` |
| 116 | - Endpoint *string `json:"endpoint"` | 116 | + Endpoint *string `json:"endpoint" yaml:"endpoint"` |
| 117 | - Executable *string `json:"executable"` | 117 | + Executable *string `json:"executable" yaml:"executable"` |
| 118 | - Startup *string `json:"startup"` | 118 | + Startup *string `json:"startup" yaml:"startup"` |
| 119 | - Autostart *bool `json:"autostart"` | 119 | + Autostart *bool `json:"autostart" yaml:"autostart"` |
| 120 | - Env map[string]string `json:"env"` | 120 | + Env map[string]string `json:"env" yaml:"env"` |
| 121 | - InstallHints []string `json:"install_hints"` | 121 | + InstallHints yamlStringList `json:"install_hints" yaml:"install_hints"` |
| 122 | - Timeout *string `json:"timeout"` | 122 | + Timeout *string `json:"timeout" yaml:"timeout"` |
| 123 | - ModelStorage *ragModelStorageFileConfig `json:"model_storage"` | 123 | + ModelStorage *ragModelStorageFileConfig `json:"model_storage" yaml:"model_storage"` |
| 124 | } | 124 | } |
| 125 | 125 | ||
| 126 | type ragModelStorageFileConfig struct { | 126 | type ragModelStorageFileConfig struct { |
| 127 | - Mode *string `json:"mode"` | 127 | + Mode *string `json:"mode" yaml:"mode"` |
| 128 | - Path *string `json:"path"` | 128 | + Path *string `json:"path" yaml:"path"` |
| 129 | - Env *string `json:"env"` | 129 | + Env *string `json:"env" yaml:"env"` |
| 130 | } | 130 | } |
| 131 | 131 | ||
| 132 | type ragProfileFileConfig struct { | 132 | type ragProfileFileConfig struct { |
| 133 | - Provider *string `json:"provider"` | 133 | + Provider *string `json:"provider" yaml:"provider"` |
| 134 | - Model *string `json:"model"` | 134 | + Model *string `json:"model" yaml:"model"` |
| 135 | - Dimensions *int `json:"dimensions"` | 135 | + Dimensions *int `json:"dimensions" yaml:"dimensions"` |
| 136 | - MaxInputTokens *int `json:"max_input_tokens"` | 136 | + MaxInputTokens *int `json:"max_input_tokens" yaml:"max_input_tokens"` |
| 137 | - BatchSize *int `json:"batch_size"` | 137 | + BatchSize *int `json:"batch_size" yaml:"batch_size"` |
| 138 | } | 138 | } |
| 139 | 139 | ||
| 140 | type ragIndexingFileConfig struct { | 140 | type ragIndexingFileConfig struct { |
| 141 | - Profile *string `json:"profile"` | 141 | + Profile *string `json:"profile" yaml:"profile"` |
| 142 | - ChunkTokens *int `json:"chunk_tokens"` | 142 | + ChunkTokens *int `json:"chunk_tokens" yaml:"chunk_tokens"` |
| 143 | - Overlap *int `json:"overlap"` | 143 | + Overlap *int `json:"overlap" yaml:"overlap"` |
| 144 | - BatchSize *int `json:"batch_size"` | 144 | + BatchSize *int `json:"batch_size" yaml:"batch_size"` |
| 145 | } | 145 | } |
| 146 | 146 | ||
| 147 | type ragSearchFileConfig struct { | 147 | type ragSearchFileConfig struct { |
| 148 | - Profile *string `json:"profile"` | 148 | + Profile *string `json:"profile" yaml:"profile"` |
| 149 | - TopK *int `json:"top_k"` | 149 | + TopK *int `json:"top_k" yaml:"top_k"` |
| 150 | - Hybrid *bool `json:"hybrid"` | 150 | + Hybrid *bool `json:"hybrid" yaml:"hybrid"` |
| 151 | } | 151 | } |
| 152 | 152 | ||
| 153 | func defaultServiceConfig(cacheBaseDir string) ServiceConfig { | 153 | func defaultServiceConfig(cacheBaseDir string) ServiceConfig { |
| @@ -0,0 +1,191 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "path/filepath" | ||
| 6 | + "reflect" | ||
| 7 | + "strings" | ||
| 8 | + "testing" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +func TestEffectiveRepoConfigFormattedDocumentationSequences(t *testing.T) { | ||
| 12 | + for _, content := range []string{ | ||
| 13 | + "repository_docs:\n include:\n - README.md\n", | ||
| 14 | + "repository_docs: {include: [README.md]}\n", | ||
| 15 | + } { | ||
| 16 | + src := newMemorySource(t) | ||
| 17 | + root := filepath.Join(src.homeDir, "example-worktree") | ||
| 18 | + src.cwd = filepath.Join(root, "nested") | ||
| 19 | + src.dirs[filepath.Join(root, ".git")] = true | ||
| 20 | + src.files[filepath.Join(root, ".gitcode", "gitcode-mcp.yaml")] = []byte(content) | ||
| 21 | + eff, err := LoadEffective(src, Overrides{}) | ||
| 22 | + if err != nil { | ||
| 23 | + t.Fatalf("valid formatted repository config rejected: %v", err) | ||
| 24 | + } | ||
| 25 | + if eff.RepoRoot != root || eff.Config.CacheMode != CacheModeGlobal { | ||
| 26 | + t.Fatal("documentation intent changed default cache authority") | ||
| 27 | + } | ||
| 28 | + } | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +func TestYAMLConfigStandardFormsAndLegacyLists(t *testing.T) { | ||
| 32 | + forms := []string{ | ||
| 33 | + `# formatted block YAML | ||
| 34 | +cache: | ||
| 35 | + mode: global # inline comment | ||
| 36 | +mcp: | ||
| 37 | + tools: {access: read} | ||
| 38 | +credential: {store: env, keyring_account: 'agent: reports'} | ||
| 39 | +feedback: | ||
| 40 | + enabled: true | ||
| 41 | + labels: | ||
| 42 | + - dogfood | ||
| 43 | + - feedback | ||
| 44 | +rag: | ||
| 45 | + providers: | ||
| 46 | + example: | ||
| 47 | + endpoint: 'http://127.0.0.1:1234' | ||
| 48 | + env: {EXAMPLE_VALUE: 'a: b # literal'} | ||
| 49 | + install_hints: | ||
| 50 | + - 'one: hint|literal; text' | ||
| 51 | + - second | ||
| 52 | + search: {hybrid: false, top_k: 9} | ||
| 53 | +service: {job_retention: {success_ttl: 24h}} | ||
| 54 | +repository_docs: | ||
| 55 | + include: [README.md] | ||
| 56 | +unrelated: {nested: [a, {b: c}]} | ||
| 57 | +`, | ||
| 58 | + `cache: {mode: global} | ||
| 59 | +mcp: {tools: {access: read}} | ||
| 60 | +credential: {store: env, keyring_account: "agent: reports"} | ||
| 61 | +feedback: {enabled: true, labels: [dogfood, feedback]} | ||
| 62 | +rag: {providers: {example: {endpoint: 'http://127.0.0.1:1234', env: {EXAMPLE_VALUE: 'a: b # literal'}, install_hints: ['one: hint|literal; text', second]}}, search: {hybrid: false, top_k: 9}} | ||
| 63 | +service: {job_retention: {success_ttl: 24h}} | ||
| 64 | +repository_docs: {include: [README.md]} | ||
| 65 | +unrelated: {nested: [a, {b: c}]} | ||
| 66 | +`, | ||
| 67 | + } | ||
| 68 | + var first fileConfig | ||
| 69 | + for i, content := range forms { | ||
| 70 | + cfg, cred, err := parseYAMLConfig([]byte(content), "") | ||
| 71 | + if err != nil { | ||
| 72 | + t.Fatal(err) | ||
| 73 | + } | ||
| 74 | + if cred.Store != "env" || cred.KeyringAccount != "agent: reports" || cfg.MCP.Tools.Access != "read" { | ||
| 75 | + t.Fatal("quoted scalar or nested mapping changed") | ||
| 76 | + } | ||
| 77 | + provider := cfg.RAG.Providers["example"] | ||
| 78 | + if *provider.Endpoint != "http://127.0.0.1:1234" || provider.Env["EXAMPLE_VALUE"] != "a: b # literal" || !reflect.DeepEqual([]string(provider.InstallHints), []string{"one: hint|literal; text", "second"}) { | ||
| 79 | + t.Fatal("nested provider scalar/list changed") | ||
| 80 | + } | ||
| 81 | + if i == 0 { | ||
| 82 | + first = cfg | ||
| 83 | + } else if !reflect.DeepEqual(first, cfg) { | ||
| 84 | + t.Fatal("block and flow configurations differ") | ||
| 85 | + } | ||
| 86 | + } | ||
| 87 | + for _, input := range []string{ | ||
| 88 | + "feedback:\n labels: dogfood|feedback\n", | ||
| 89 | + "feedback:\n labels: dogfood;feedback\n", | ||
| 90 | + "labels: &labels [dogfood, feedback]\nfeedback: {labels: *labels}\n", | ||
| 91 | + } { | ||
| 92 | + cfg, _, err := parseYAMLConfig([]byte(input), "") | ||
| 93 | + if err != nil || !reflect.DeepEqual([]string(cfg.Feedback.Labels), []string{"dogfood", "feedback"}) { | ||
| 94 | + t.Fatalf("legacy/alias list rejected: %v", err) | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | + var legacy fileConfig | ||
| 98 | + if err := json.Unmarshal([]byte(`{"feedback":{"labels":["dogfood","feedback"]},"rag":{"providers":{"example":{"install_hints":["hint"]}}}}`), &legacy); err != nil || len(legacy.Feedback.Labels) != 2 || len(legacy.RAG.Providers["example"].InstallHints) != 1 { | ||
| 99 | + t.Fatal("YAML list compatibility changed JSON decoding") | ||
| 100 | + } | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +func TestYAMLConfigRejectsInvalidDocumentsWithoutScalarDisclosure(t *testing.T) { | ||
| 104 | + for _, input := range []string{ | ||
| 105 | + "cache_mode: [invalid-secret-value]\n", | ||
| 106 | + "max_retries: invalid-secret-value\n", | ||
| 107 | + "mcp: [invalid-secret-value]\n", | ||
| 108 | + "feedback: {enabled: invalid-secret-value}\n", | ||
| 109 | + "feedback: {labels: {bad: invalid-secret-value}}\n", | ||
| 110 | + "rag: {providers: {example: {install_hints: {bad: invalid-secret-value}}}}\n", | ||
| 111 | + "format: text\nformat: json\n", | ||
| 112 | + "rag: {search: {top_k: 1, top_k: 2}}\n", | ||
| 113 | + "[invalid-secret-value]\n", | ||
| 114 | + "null\n", | ||
| 115 | + "format: [invalid-secret-value\n", | ||
| 116 | + "format: json\n---\ncache_mode: global\n", | ||
| 117 | + "format: json\n---\n[invalid-secret-value\n", | ||
| 118 | + "&root {<<: *root}\n", | ||
| 119 | + "feedback: &self {enabled: *self}\n", | ||
| 120 | + } { | ||
| 121 | + _, _, err := parseYAMLConfig([]byte(input), "") | ||
| 122 | + if err == nil || !strings.Contains(err.Error(), "malformed config") || strings.Contains(err.Error(), "invalid-secret-value") { | ||
| 123 | + t.Fatalf("unsafe/absent validation error: %v", err) | ||
| 124 | + } | ||
| 125 | + } | ||
| 126 | + for _, input := range []string{"", "# empty\n", "---\n# empty\n", "{}\n"} { | ||
| 127 | + if _, _, err := parseYAMLConfig([]byte(input), ""); err != nil { | ||
| 128 | + t.Fatalf("empty config rejected: %v", err) | ||
| 129 | + } | ||
| 130 | + } | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +func TestYAMLConfigLegacyQuotedScalarsAndSharedAnchors(t *testing.T) { | ||
| 134 | + cfg, cred, err := parseYAMLConfig([]byte(`number: &number "2" | ||
| 135 | +boolean: &boolean "T" | ||
| 136 | +max_retries: *number | ||
| 137 | +max_response_size: "1024" | ||
| 138 | +rate_limit_rps: "1.25" | ||
| 139 | +rate_limit_burst: "3" | ||
| 140 | +credential: {keyring_account: *boolean} | ||
| 141 | +feedback: {enabled: *boolean} | ||
| 142 | +rag: | ||
| 143 | + providers: {example: {autostart: "1"}} | ||
| 144 | + profiles: {example: {dimensions: "128", batch_size: "8"}} | ||
| 145 | + search: {top_k: "4", hybrid: "false"} | ||
| 146 | +service: {job_retention: {max_terminal_jobs: "32"}} | ||
| 147 | +`), "") | ||
| 148 | + if err != nil { | ||
| 149 | + t.Fatal(err) | ||
| 150 | + } | ||
| 151 | + if *cfg.MaxRetries != 2 || *cfg.MaxResponseSize != 1024 || *cfg.RateLimitRPS != 1.25 || *cfg.RateLimitBurst != 3 || !*cfg.Feedback.Enabled || cred.KeyringAccount != "T" || !*cfg.RAG.Providers["example"].Autostart || *cfg.RAG.Search.Hybrid || *cfg.RAG.Search.TopK != 4 || *cfg.RAG.Profiles["example"].Dimensions != 128 || *cfg.Service.JobRetention.MaxTerminalJobs != 32 { | ||
| 152 | + t.Fatal("legacy scalar coercion or shared string anchor changed") | ||
| 153 | + } | ||
| 154 | + for _, input := range []string{ | ||
| 155 | + "defaults: &defaults {max_retries: '2'}\n<<: *defaults\n", | ||
| 156 | + "defaults: &defaults {max_retries: '2'}\n<<: [*defaults]\n", | ||
| 157 | + } { | ||
| 158 | + cfg, _, err := parseYAMLConfig([]byte(input), "") | ||
| 159 | + if err != nil || *cfg.MaxRetries != 2 { | ||
| 160 | + t.Fatalf("quoted scalar merge failed: %v", err) | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + for _, spelling := range []string{"1", "0", "T", "F"} { | ||
| 164 | + cfg, _, err := parseYAMLConfig([]byte("feedback: {enabled: "+spelling+"}\n"), "") | ||
| 165 | + if err != nil || *cfg.Feedback.Enabled != (spelling == "1" || spelling == "T") { | ||
| 166 | + t.Fatalf("legacy unquoted boolean rejected: %v", err) | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +func TestFormattedYAMLRetainsLayerPrecedenceAndLocalPathRestrictions(t *testing.T) { | ||
| 172 | + src := newMemorySource(t) | ||
| 173 | + root := filepath.Join(src.homeDir, "example-worktree") | ||
| 174 | + src.cwd = root | ||
| 175 | + src.dirs[filepath.Join(root, ".git")] = true | ||
| 176 | + local := filepath.Join(root, ".gitcode", "gitcode-mcp.yaml") | ||
| 177 | + src.files[local] = []byte("cache: {mode: repo-local}\nrag: {search: {top_k: 4}}\nrepository_docs: {include: [README.md]}\n") | ||
| 178 | + global := filepath.Join(src.configDir, "global.yaml") | ||
| 179 | + src.env[EnvMCPConfigPath] = global | ||
| 180 | + src.files[global] = []byte("rag: {search: {top_k: 7}}\n") | ||
| 181 | + eff, err := LoadEffective(src, Overrides{}) | ||
| 182 | + if err != nil || eff.Config.RAG.Search.TopK != 4 || eff.Config.CacheMode != CacheModeRepoLocal || eff.FieldSources["rag.search.top_k"] != "repo-local:"+local { | ||
| 183 | + t.Fatalf("layer precedence/source changed: %v", err) | ||
| 184 | + } | ||
| 185 | + for _, policy := range []string{"service: {runtime_dir: /example/runtime}\n", "rag: {model_store_path: /example/models}\n"} { | ||
| 186 | + src.files[local] = []byte(policy) | ||
| 187 | + if _, err := LoadEffective(src, Overrides{}); err == nil || !strings.Contains(err.Error(), "repo-local") { | ||
| 188 | + t.Fatalf("formatted YAML bypassed machine path restriction: %v", err) | ||
| 189 | + } | ||
| 190 | + } | ||
| 191 | +} | ||
| @@ -0,0 +1,104 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "reflect" | ||
| 6 | + "strconv" | ||
| 7 | + "strings" | ||
| 8 | + | ||
| 9 | + "gopkg.in/yaml.v3" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// legacyYAMLScalars preserves the old loader's quoted numbers and strconv bool | ||
| 13 | +// spellings without parsing YAML syntax ourselves. Coercion is schema-scoped: | ||
| 14 | +// string settings and unknown product sections keep their original bytes. | ||
| 15 | +// Copy-on-write also keeps shared anchors intact for differently typed fields. | ||
| 16 | +func legacyYAMLScalars(node *yaml.Node, schema reflect.Type, depth int, budget *int) (*yaml.Node, error) { | ||
| 17 | + (*budget)-- | ||
| 18 | + if depth > 64 || *budget < 0 { | ||
| 19 | + return nil, fmt.Errorf("configuration scalar traversal limit") | ||
| 20 | + } | ||
| 21 | + for schema.Kind() == reflect.Pointer { | ||
| 22 | + schema = schema.Elem() | ||
| 23 | + } | ||
| 24 | + if node.Kind == yaml.AliasNode { | ||
| 25 | + return legacyYAMLScalars(node.Alias, schema, depth+1, budget) | ||
| 26 | + } | ||
| 27 | + copy := *node | ||
| 28 | + if node.Kind == yaml.ScalarNode && (node.Tag == "!!str" || schema.Kind() == reflect.Bool && node.Tag == "!!int") { | ||
| 29 | + switch schema.Kind() { | ||
| 30 | + case reflect.Int, reflect.Int64: | ||
| 31 | + if value, err := strconv.ParseInt(node.Value, 10, 64); err == nil { | ||
| 32 | + copy.Tag, copy.Value = "!!int", strconv.FormatInt(value, 10) | ||
| 33 | + } | ||
| 34 | + case reflect.Float64: | ||
| 35 | + if value, err := strconv.ParseFloat(node.Value, 64); err == nil { | ||
| 36 | + copy.Tag, copy.Value = "!!float", strconv.FormatFloat(value, 'g', -1, 64) | ||
| 37 | + switch copy.Value { | ||
| 38 | + case "NaN": | ||
| 39 | + copy.Value = ".nan" | ||
| 40 | + case "+Inf": | ||
| 41 | + copy.Value = ".inf" | ||
| 42 | + case "-Inf": | ||
| 43 | + copy.Value = "-.inf" | ||
| 44 | + } | ||
| 45 | + } | ||
| 46 | + case reflect.Bool: | ||
| 47 | + if value, err := strconv.ParseBool(node.Value); err == nil { | ||
| 48 | + copy.Tag, copy.Value = "!!bool", strconv.FormatBool(value) | ||
| 49 | + } | ||
| 50 | + } | ||
| 51 | + return ©, nil | ||
| 52 | + } | ||
| 53 | + if node.Kind != yaml.MappingNode || schema.Kind() != reflect.Struct && schema.Kind() != reflect.Map { | ||
| 54 | + return node, nil | ||
| 55 | + } | ||
| 56 | + copy.Content = append([]*yaml.Node(nil), node.Content...) | ||
| 57 | + fields := map[string]reflect.Type{} | ||
| 58 | + if schema.Kind() == reflect.Struct { | ||
| 59 | + yamlScalarFields(schema, fields) | ||
| 60 | + } | ||
| 61 | + for index := 0; index < len(copy.Content); index += 2 { | ||
| 62 | + key, value := copy.Content[index], copy.Content[index+1] | ||
| 63 | + target := fields[key.Value] | ||
| 64 | + if schema.Kind() == reflect.Map { | ||
| 65 | + target = schema.Elem() | ||
| 66 | + } | ||
| 67 | + if key.Tag == "!!merge" { | ||
| 68 | + target = schema | ||
| 69 | + if value.Kind == yaml.SequenceNode { | ||
| 70 | + merged := *value | ||
| 71 | + merged.Content = append([]*yaml.Node(nil), value.Content...) | ||
| 72 | + for i, item := range merged.Content { | ||
| 73 | + next, err := legacyYAMLScalars(item, schema, depth+1, budget) | ||
| 74 | + if err != nil { | ||
| 75 | + return nil, err | ||
| 76 | + } | ||
| 77 | + merged.Content[i] = next | ||
| 78 | + } | ||
| 79 | + copy.Content[index+1] = &merged | ||
| 80 | + continue | ||
| 81 | + } | ||
| 82 | + } | ||
| 83 | + if target != nil { | ||
| 84 | + next, err := legacyYAMLScalars(value, target, depth+1, budget) | ||
| 85 | + if err != nil { | ||
| 86 | + return nil, err | ||
| 87 | + } | ||
| 88 | + copy.Content[index+1] = next | ||
| 89 | + } | ||
| 90 | + } | ||
| 91 | + return ©, nil | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +func yamlScalarFields(schema reflect.Type, fields map[string]reflect.Type) { | ||
| 95 | + for i := 0; i < schema.NumField(); i++ { | ||
| 96 | + field := schema.Field(i) | ||
| 97 | + tag := field.Tag.Get("yaml") | ||
| 98 | + if strings.Contains(tag, ",inline") { | ||
| 99 | + yamlScalarFields(field.Type, fields) | ||
| 100 | + } else if name := strings.Split(tag, ",")[0]; name != "" && name != "-" { | ||
| 101 | + fields[name] = field.Type | ||
| 102 | + } | ||
| 103 | + } | ||
| 104 | +} | ||
| @@ -113,6 +113,23 @@ func TestParsePolicySupportsStandardYAMLForms(t *testing.T) { | |||
| 113 | } | 113 | } |
| 114 | } | 114 | } |
| 115 | 115 | ||
| 116 | +func TestMinimalFormattedIncludeUsesSameDocumentationPolicy(t *testing.T) { | ||
| 117 | + var policyHash string | ||
| 118 | + for _, content := range []string{ | ||
| 119 | + "repository_docs:\n include:\n - README.md\n", | ||
| 120 | + "repository_docs: {include: [README.md]}\n", | ||
| 121 | + } { | ||
| 122 | + result, err := ParsePolicy([]byte(content), PolicySourceCommitted) | ||
| 123 | + if err != nil || result.Status != PolicyStatusReady || !result.Policy.Matches("README.md") { | ||
| 124 | + t.Fatalf("valid include policy failed: %v", err) | ||
| 125 | + } | ||
| 126 | + if policyHash != "" && policyHash != result.PolicyHash { | ||
| 127 | + t.Fatal("formatting changed documentation policy identity") | ||
| 128 | + } | ||
| 129 | + policyHash = result.PolicyHash | ||
| 130 | + } | ||
| 131 | +} | ||
| 132 | + | ||
| 116 | func TestDisabledPolicy(t *testing.T) { | 133 | func TestDisabledPolicy(t *testing.T) { |
| 117 | result, err := ParsePolicy([]byte("repository_docs:\n enabled: false\n"), PolicySourceCommitted) | 134 | result, err := ParsePolicy([]byte("repository_docs:\n enabled: false\n"), PolicySourceCommitted) |
| 118 | if err != nil { | 135 | if err != nil { |