已合并
Fix formatted repository YAML configuration parsing #146
urandon创建于 28 天前
Fix formatted repository YAML configuration parsing #146
已合并
urandon创建于 28 天前
共 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+ 
294func TestEntrypointRepoLocalCacheDirectoryHandling(t *testing.T) {328func 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```yaml42```yaml
32cache_path: /path/to/cache/gitcode-mcp/cache.db43cache_path: /path/to/cache/gitcode-mcp/cache.db
33lock_path: /path/to/cache/gitcode-mcp/cache.db.lock44lock_path: /path/to/cache/gitcode-mcp/cache.db.lock
@@ -144,30 +144,30 @@ func RedactDiagnostic(message string, src Source) string {
144}144}
145 145 
146type fileConfig struct {146type 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 
165type feedbackFileConfig struct {165type 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 
173func defaultWithSource(src Source) Config {173func defaultWithSource(src Source) Config {
@@ -1,15 +1,19 @@
1package config1package config
2 2 
3import (3import (
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 
15const (19const (
@@ -35,17 +39,17 @@ type ConfigLocation struct {
35}39}
36 40 
37type CredentialConfig struct {41type 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 
43type MCPToolsConfig struct {47type MCPToolsConfig struct {
44- Access string `json:"access"`48+ Access string `json:"access" yaml:"access"`
45}49}
46 50 
47type MCPConfig struct {51type MCPConfig struct {
48- Tools MCPToolsConfig `json:"tools"`52+ Tools MCPToolsConfig `json:"tools" yaml:"tools"`
49}53}
50 54 
51type EffectiveConfig struct {55type 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 int514+// alias. repository_docs is intentionally owned by its dedicated policy parser.
511- name string515+type yamlFileConfig struct {
516+ File fileConfig `yaml:",inline"`
517+ Cache *struct {
518+ Mode *string `yaml:"mode"`
519+ } `yaml:"cache"`
512}520}
513 521 
514func parseYAMLConfig(data []byte, path string) (fileConfig, CredentialConfig, error) {522func parseYAMLConfig(data []byte, path string) (fileConfig, CredentialConfig, error) {
515- var cfg fileConfig523+ 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 CredentialConfig558 var cred CredentialConfig
517- var stack []yamlSection559+ 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, nil562 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 nil572 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] = provider581+ *values = decoded
784 return nil582 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- 
850func splitYAMLList(value string) []string {585func 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 
91type serviceFileConfig struct {91type 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 
96type serviceJobRetentionFileConfig struct {96type 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 
104type ragFileConfig struct {104type 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 
113type ragProviderFileConfig struct {113type 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 
126type ragModelStorageFileConfig struct {126type 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 
132type ragProfileFileConfig struct {132type 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 
140type ragIndexingFileConfig struct {140type 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 
147type ragSearchFileConfig struct {147type 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 
153func defaultServiceConfig(cacheBaseDir string) ServiceConfig {153func 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 &copy, 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 &copy, 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+ 
116func TestDisabledPolicy(t *testing.T) {133func 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 {