b01e3c487f
The doc-map YAML config was previously read from the local workspace (the PR branch checkout). A malicious PR author could modify .review-bot/doc-map.yml to map any path glob to sensitive design docs, causing review-bot to fetch and inject those docs into the LLM prompt. Fix: add --doc-map-trusted-ref (DOC_MAP_TRUSTED_REF) flag. When set to a trusted ref (e.g. 'main'), the doc-map config is fetched from the VCS API at that ref instead of from local workspace. A 404 from VCS is a hard error (no silent fallback to local copy). When unset, the local workspace is used with a security warning in the logs pointing operators to the new flag. Changes: - review/docmap.go: add ParseDocMapConfigContent + parseDocMapBytes helper to parse from in-memory content (fetched via VCS API) - cmd/review-bot/main.go: add --doc-map-trusted-ref flag; Step 6c branches on trusted-ref to fetch vs local-workspace load - .gitea/actions/review/action.yml: add doc-map-trusted-ref input - README.md: document new input - CHANGELOG.md: security and feature entries Tests: - TestParseDocMapConfigContent_Valid/Empty/InvalidYAML/UnknownKeys in review/docmap_test.go Coverage: 53.0% cmd/review-bot
573 lines
16 KiB
Go
573 lines
16 KiB
Go
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeDocFetcher is a mock DocFetcher for tests.
|
|
type fakeDocFetcher struct {
|
|
files map[string]string // path -> content
|
|
dirs map[string]map[string]string // dir path -> (file path -> content)
|
|
}
|
|
|
|
func (f *fakeDocFetcher) GetFileContent(_ context.Context, _, _, path string) (string, error) {
|
|
if content, ok := f.files[path]; ok {
|
|
return content, nil
|
|
}
|
|
return "", errors.New("file not found: " + path)
|
|
}
|
|
|
|
func (f *fakeDocFetcher) GetAllFilesInPath(_ context.Context, _, _, path string) (map[string]string, error) {
|
|
if files, ok := f.dirs[path]; ok {
|
|
return files, nil
|
|
}
|
|
// Return empty (not an error) for unknown directories.
|
|
return nil, nil
|
|
}
|
|
|
|
// ============================================================
|
|
// ParseDocMapConfig
|
|
// ============================================================
|
|
|
|
func TestParseDocMapConfig_Valid(t *testing.T) {
|
|
yaml := `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
- paths:
|
|
- "lib/bar/**"
|
|
- "lib/baz.go"
|
|
docs:
|
|
- docs/bar.md
|
|
- docs/shared/
|
|
`
|
|
f := writeTempYAML(t, yaml)
|
|
cfg, err := ParseDocMapConfig(f)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Mappings) != 2 {
|
|
t.Fatalf("expected 2 mappings, got %d", len(cfg.Mappings))
|
|
}
|
|
if cfg.Mappings[0].Paths[0] != "lib/foo/**" {
|
|
t.Errorf("unexpected path: %q", cfg.Mappings[0].Paths[0])
|
|
}
|
|
if cfg.Mappings[1].Docs[1] != "docs/shared/" {
|
|
t.Errorf("unexpected doc: %q", cfg.Mappings[1].Docs[1])
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfig_InvalidYAML(t *testing.T) {
|
|
f := writeTempYAML(t, "mappings: [{{invalid")
|
|
_, err := ParseDocMapConfig(f)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid YAML, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfig_EmptyMappings(t *testing.T) {
|
|
f := writeTempYAML(t, "mappings: []\n")
|
|
cfg, err := ParseDocMapConfig(f)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Mappings) != 0 {
|
|
t.Errorf("expected 0 mappings, got %d", len(cfg.Mappings))
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfig_UnknownKeys(t *testing.T) {
|
|
// Unknown keys should produce a warning but not fail.
|
|
yaml := `
|
|
mappings:
|
|
- paths: ["lib/foo/**"]
|
|
docs: ["docs/foo.md"]
|
|
extra_key: ignored
|
|
`
|
|
f := writeTempYAML(t, yaml)
|
|
// Should succeed (lenient parsing).
|
|
cfg, err := ParseDocMapConfig(f)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for unknown keys: %v", err)
|
|
}
|
|
if len(cfg.Mappings) != 1 {
|
|
t.Errorf("expected 1 mapping, got %d", len(cfg.Mappings))
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfig_FileNotFound(t *testing.T) {
|
|
_, err := ParseDocMapConfig("/nonexistent/path/doc-map.yml")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing file, got nil")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// MatchDocs
|
|
// ============================================================
|
|
|
|
func TestMatchDocs_NoMatch(t *testing.T) {
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/foo/**"}, Docs: []string{"docs/foo.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/bar/baz.go"})
|
|
if len(got) != 0 {
|
|
t.Errorf("expected no matches, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_SingleMatch(t *testing.T) {
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/foo/**"}, Docs: []string{"docs/foo.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/foo/bar.go"})
|
|
if len(got) != 1 || got[0] != "docs/foo.md" {
|
|
t.Errorf("expected [docs/foo.md], got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_MultipleMatchesDeduplicated(t *testing.T) {
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/foo/**"}, Docs: []string{"docs/shared.md", "docs/foo.md"}},
|
|
{Paths: []string{"lib/bar/**"}, Docs: []string{"docs/shared.md", "docs/bar.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/foo/a.go", "lib/bar/b.go"})
|
|
// Both match; docs/shared.md should appear only once.
|
|
wantSet := map[string]bool{
|
|
"docs/shared.md": true,
|
|
"docs/foo.md": true,
|
|
"docs/bar.md": true,
|
|
}
|
|
if len(got) != 3 {
|
|
t.Errorf("expected 3 docs, got %d: %v", len(got), got)
|
|
}
|
|
for _, d := range got {
|
|
if !wantSet[d] {
|
|
t.Errorf("unexpected doc: %q", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_EmptyPaths(t *testing.T) {
|
|
// Mapping with empty paths list should not match anything.
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{}, Docs: []string{"docs/foo.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/foo/bar.go"})
|
|
if len(got) != 0 {
|
|
t.Errorf("expected no matches for empty paths, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_EmptyDocs(t *testing.T) {
|
|
// Mapping with empty docs list should produce nothing.
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/foo/**"}, Docs: []string{}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/foo/bar.go"})
|
|
if len(got) != 0 {
|
|
t.Errorf("expected no docs for empty docs list, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchDocs_ExactMatch(t *testing.T) {
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{Paths: []string{"lib/baz.go"}, Docs: []string{"docs/baz.md"}},
|
|
},
|
|
}
|
|
got := MatchDocs(cfg, []string{"lib/baz.go"})
|
|
if len(got) != 1 || got[0] != "docs/baz.md" {
|
|
t.Errorf("expected [docs/baz.md], got %v", got)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// globMatch
|
|
// ============================================================
|
|
|
|
func TestGlobMatch(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pattern string
|
|
path string
|
|
want bool
|
|
}{
|
|
{"exact match", "lib/foo/bar.go", "lib/foo/bar.go", true},
|
|
{"exact no match", "lib/foo/bar.go", "lib/foo/baz.go", false},
|
|
{"star wildcard", "lib/foo/*.go", "lib/foo/bar.go", true},
|
|
{"star no match cross-dir", "lib/foo/*.go", "lib/foo/sub/bar.go", false},
|
|
{"trailing doublestar", "lib/foo/**", "lib/foo/bar.go", true},
|
|
{"trailing doublestar nested", "lib/foo/**", "lib/foo/sub/deep/bar.go", true},
|
|
// Note: trailing ** matches the parent path too; PR file lists contain file paths
|
|
// (not directories), so this corner case does not arise in practice.
|
|
{"trailing doublestar matches parent", "lib/foo/**", "lib/foo", true},
|
|
{"doublestar in middle", "lib/**/bar.go", "lib/foo/sub/bar.go", true},
|
|
{"doublestar in middle no match", "lib/**/bar.go", "lib/foo/sub/baz.go", false},
|
|
{"leading doublestar", "**/bar.go", "lib/foo/bar.go", true},
|
|
{"leading doublestar top-level", "**/bar.go", "bar.go", true},
|
|
{"question mark", "lib/foo/ba?.go", "lib/foo/bar.go", true},
|
|
{"question mark no match", "lib/foo/ba?.go", "lib/foo/ba.go", false},
|
|
{"star matches none in segment", "lib/*/bar.go", "lib/bar.go", false},
|
|
{"star single segment", "lib/*/bar.go", "lib/foo/bar.go", true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := globMatch(tc.pattern, tc.path)
|
|
if got != tc.want {
|
|
t.Errorf("globMatch(%q, %q) = %v, want %v", tc.pattern, tc.path, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// LoadMatchingDocs
|
|
// ============================================================
|
|
|
|
func TestLoadMatchingDocs_FileInjection(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"docs/foo.md": "# Foo Design\n\nThis is the foo doc.",
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/foo.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(content, "# Foo Design") {
|
|
t.Errorf("expected doc content, got: %q", content)
|
|
}
|
|
if !strings.Contains(content, "### docs/foo.md") {
|
|
t.Errorf("expected heading with path, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_MissingFileSkipped(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"docs/present.md": "present",
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/missing.md", "docs/present.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(content, "present") {
|
|
t.Errorf("expected present doc content, got: %q", content)
|
|
}
|
|
// Missing file should be skipped, not cause a failure.
|
|
}
|
|
|
|
func TestLoadMatchingDocs_DirectoryExpansion(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
dirs: map[string]map[string]string{
|
|
"docs/domain/": {
|
|
"docs/domain/a.md": "# A",
|
|
"docs/domain/b.md": "# B",
|
|
"docs/domain/c.go": "package domain", // should be skipped (not .md)
|
|
},
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/domain/"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(content, "# A") {
|
|
t.Errorf("expected doc A content, got: %q", content)
|
|
}
|
|
if !strings.Contains(content, "# B") {
|
|
t.Errorf("expected doc B content, got: %q", content)
|
|
}
|
|
if strings.Contains(content, "package domain") {
|
|
t.Errorf("non-.md file should not be injected, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_DirectoryNoMDFiles(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
dirs: map[string]map[string]string{
|
|
"src/": {
|
|
"src/main.go": "package main",
|
|
},
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"src/"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if content != "" {
|
|
t.Errorf("expected empty content for dir with no .md files, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_NoMatchingPaths(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if content != "" {
|
|
t.Errorf("expected empty content for no paths, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_ContextSizeGuard(t *testing.T) {
|
|
bigContent := strings.Repeat("x", 200)
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"docs/a.md": bigContent,
|
|
"docs/b.md": bigContent,
|
|
"docs/c.md": bigContent,
|
|
},
|
|
}
|
|
// Limit to 350 bytes — enough for a.md fully and part of b.md.
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/a.md", "docs/b.md", "docs/c.md"}, DocMapOptions{MaxBytes: 350})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(content) > 600 {
|
|
t.Errorf("content too large, expected ≤600 bytes total, got %d", len(content))
|
|
}
|
|
if !strings.Contains(content, "truncated") {
|
|
t.Errorf("expected truncation notice, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_Deduplication(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"docs/shared.md": "shared content",
|
|
},
|
|
}
|
|
// MatchDocs deduplicates before calling LoadMatchingDocs, but test it with
|
|
// duplicates in input too.
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"docs/shared.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(content, "shared content") {
|
|
t.Errorf("expected shared content, got: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestValidateDocPath(t *testing.T) {
|
|
valid := []string{
|
|
"docs/design.md",
|
|
"docs/domain/contexts/risk/risk-controls.md",
|
|
"README.md",
|
|
"a/b/c",
|
|
}
|
|
for _, p := range valid {
|
|
if err := ValidateDocPath(p); err != nil {
|
|
t.Errorf("expected valid path %q to pass, got error: %v", p, err)
|
|
}
|
|
}
|
|
|
|
invalid := []string{
|
|
"/etc/passwd",
|
|
"/docs/design.md",
|
|
"docs/../../../etc/passwd",
|
|
"../sibling-repo/file.md",
|
|
"a/b/../c",
|
|
// Backslashes must be rejected (Finding #3 — Windows platform edge cases).
|
|
`docs\foo.md`,
|
|
`docs\..\secret`,
|
|
`\absolute`,
|
|
}
|
|
for _, p := range invalid {
|
|
if err := ValidateDocPath(p); err == nil {
|
|
t.Errorf("expected path %q to be rejected, but it was accepted", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadMatchingDocs_PathTraversalRejected(t *testing.T) {
|
|
fetcher := &fakeDocFetcher{
|
|
files: map[string]string{
|
|
"../secret.md": "should not be fetched",
|
|
},
|
|
}
|
|
content, err := LoadMatchingDocs(context.Background(), fetcher, "owner", "repo",
|
|
[]string{"../secret.md"}, DocMapOptions{MaxBytes: DefaultDocMapMaxBytes})
|
|
if err != nil {
|
|
t.Fatalf("unexpected hard error: %v", err)
|
|
}
|
|
// Bad path should be skipped (warned), not injected.
|
|
if strings.Contains(content, "should not be fetched") {
|
|
t.Errorf("path traversal doc was injected, expected it to be skipped")
|
|
}
|
|
}
|
|
|
|
// TestValidateDocPath_Backslash verifies that backslash-bearing paths are
|
|
// rejected to prevent Windows platform edge cases where a path separator
|
|
// could be normalised differently by the host OS or VCS backend.
|
|
func TestValidateDocPath_Backslash(t *testing.T) {
|
|
backslashPaths := []string{
|
|
`docs\foo.md`,
|
|
`docs\subdir\file.md`,
|
|
`\absolute`,
|
|
}
|
|
for _, p := range backslashPaths {
|
|
if err := ValidateDocPath(p); err == nil {
|
|
t.Errorf("expected backslash path %q to be rejected, but it was accepted", p)
|
|
}
|
|
}
|
|
|
|
// Sanity: forward-slash path must still be accepted.
|
|
if err := ValidateDocPath("docs/foo.md"); err != nil {
|
|
t.Errorf("expected forward-slash path to be accepted, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Helpers
|
|
// ============================================================
|
|
|
|
func writeTempYAML(t *testing.T, content string) string {
|
|
t.Helper()
|
|
f, err := os.CreateTemp(t.TempDir(), "doc-map-*.yml")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.WriteString(content); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
return filepath.Clean(f.Name())
|
|
}
|
|
|
|
// ============================================================
|
|
// FileCoveredByDocMap
|
|
// ============================================================
|
|
|
|
func TestFileCoveredByDocMap(t *testing.T) {
|
|
cfg := &DocMapConfig{
|
|
Mappings: []DocMapping{
|
|
{
|
|
Paths: []string{"lib/foo/**", "lib/bar/*.go"},
|
|
Docs: []string{"docs/foo.md"},
|
|
},
|
|
{
|
|
Paths: []string{"cmd/**"},
|
|
Docs: []string{"docs/cmd.md"},
|
|
},
|
|
},
|
|
}
|
|
|
|
cases := []struct {
|
|
file string
|
|
covered bool
|
|
}{
|
|
{"lib/foo/baz.ex", true},
|
|
{"lib/foo/sub/deep.ex", true},
|
|
{"lib/bar/util.go", true},
|
|
{"lib/bar/sub/util.go", false}, // *.go only matches one level
|
|
{"cmd/main.go", true},
|
|
{"cmd/sub/main.go", true},
|
|
{"internal/secret.go", false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.file, func(t *testing.T) {
|
|
got := FileCoveredByDocMap(cfg, tc.file)
|
|
if got != tc.covered {
|
|
t.Errorf("FileCoveredByDocMap(%q) = %v, want %v", tc.file, got, tc.covered)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFileCoveredByDocMap_EmptyConfig(t *testing.T) {
|
|
cfg := &DocMapConfig{}
|
|
if FileCoveredByDocMap(cfg, "lib/foo/bar.go") {
|
|
t.Error("expected false for empty config, got true")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// ParseDocMapConfigContent
|
|
// ============================================================
|
|
|
|
func TestParseDocMapConfigContent_Valid(t *testing.T) {
|
|
content := `
|
|
mappings:
|
|
- paths:
|
|
- "lib/foo/**"
|
|
docs:
|
|
- docs/foo.md
|
|
`
|
|
cfg, err := ParseDocMapConfigContent(content, "owner/repo@main:.review-bot/doc-map.yml")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Mappings) != 1 {
|
|
t.Fatalf("expected 1 mapping, got %d", len(cfg.Mappings))
|
|
}
|
|
if len(cfg.Mappings[0].Docs) != 1 || cfg.Mappings[0].Docs[0] != "docs/foo.md" {
|
|
t.Errorf("unexpected mapping: %+v", cfg.Mappings[0])
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfigContent_EmptyContent(t *testing.T) {
|
|
cfg, err := ParseDocMapConfigContent("", "test-source")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for empty content: %v", err)
|
|
}
|
|
if len(cfg.Mappings) != 0 {
|
|
t.Errorf("expected 0 mappings for empty content, got %d", len(cfg.Mappings))
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfigContent_InvalidYAML(t *testing.T) {
|
|
_, err := ParseDocMapConfigContent("mappings: [{{invalid", "test-source")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid YAML, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParseDocMapConfigContent_UnknownKeys(t *testing.T) {
|
|
content := `
|
|
mappings:
|
|
- paths:
|
|
- "lib/**"
|
|
docs:
|
|
- docs/foo.md
|
|
unknown_top_level_key: "should be warned but not fatal"
|
|
`
|
|
// Unknown top-level keys produce a warning but not an error.
|
|
cfg, err := ParseDocMapConfigContent(content, "test-source")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for unknown keys: %v", err)
|
|
}
|
|
if len(cfg.Mappings) == 0 {
|
|
t.Error("expected mappings to be parsed despite unknown key")
|
|
}
|
|
}
|