From 1777da6eba591a2ce9560d33e759b1cfd19efcc8 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 9 Feb 2026 22:48:49 +0100 Subject: [PATCH 1/3] Add feature-flagged compare_file_contents tool with semantic diffs Add a new compare_file_contents MCP tool that compares two versions of a file across refs (branches, tags, or SHAs). For structured data formats (JSON, YAML, CSV, TOML), it produces semantic diffs that show only meaningful changes, ignoring formatting differences. For unsupported formats, it falls back to unified diff. Key features: - Semantic diffs for JSON, YAML, CSV, TOML files - Unified diff fallback for code and other text files - Handles new files (base not found) and deleted files (head not found) - 1MB max file size to prevent excessive server-side processing - Gated behind 'compare_file_contents' feature flag This helps AI models by: - Reducing token usage (formatting noise eliminated) - Providing unambiguous before/after semantics - Enabling self-verification of edits to config/data files Refs: #1973 --- .../__toolsnaps__/compare_file_contents.snap | 40 ++ pkg/github/compare_file_contents.go | 159 +++++ pkg/github/compare_file_contents_test.go | 275 +++++++++ pkg/github/semantic_diff.go | 547 ++++++++++++++++++ pkg/github/semantic_diff_test.go | 460 +++++++++++++++ pkg/github/tools.go | 1 + 6 files changed, 1482 insertions(+) create mode 100644 pkg/github/__toolsnaps__/compare_file_contents.snap create mode 100644 pkg/github/compare_file_contents.go create mode 100644 pkg/github/compare_file_contents_test.go create mode 100644 pkg/github/semantic_diff.go create mode 100644 pkg/github/semantic_diff_test.go diff --git a/pkg/github/__toolsnaps__/compare_file_contents.snap b/pkg/github/__toolsnaps__/compare_file_contents.snap new file mode 100644 index 000000000..88bc283ad --- /dev/null +++ b/pkg/github/__toolsnaps__/compare_file_contents.snap @@ -0,0 +1,40 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Compare file contents between revisions" + }, + "description": "Compare two versions of a file in a GitHub repository.\nFor structured formats (JSON, YAML, CSV, TOML), produces a semantic diff that shows only meaningful changes, ignoring formatting differences.\nFor other file types, produces a standard unified diff.\nThis is useful for understanding what actually changed between two versions of a file, especially for configuration files and data files where reformatting can obscure real changes.", + "inputSchema": { + "properties": { + "base": { + "description": "Base ref to compare from (commit SHA, branch name, or tag name)", + "type": "string" + }, + "head": { + "description": "Head ref to compare to (commit SHA, branch name, or tag name)", + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "path": { + "description": "Path to the file to compare", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "path", + "base", + "head" + ], + "type": "object" + }, + "name": "compare_file_contents" +} \ No newline at end of file diff --git a/pkg/github/compare_file_contents.go b/pkg/github/compare_file_contents.go new file mode 100644 index 000000000..63855baca --- /dev/null +++ b/pkg/github/compare_file_contents.go @@ -0,0 +1,159 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v79/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// FeatureFlagCompareFileContents is the feature flag for the compare_file_contents tool. +const FeatureFlagCompareFileContents = "mcp_compare_file_contents" + +// CompareFileContents creates a tool to compare two versions of a file in a GitHub repository. +// For supported formats (JSON, YAML, CSV, TOML), it produces semantic diffs showing +// only meaningful changes. For other formats, it falls back to unified diff. +func CompareFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "compare_file_contents", + Description: t("TOOL_COMPARE_FILE_CONTENTS_DESCRIPTION", `Compare two versions of a file in a GitHub repository. +For structured formats (JSON, YAML, CSV, TOML), produces a semantic diff that shows only meaningful changes, ignoring formatting differences. +For other file types, produces a standard unified diff. +This is useful for understanding what actually changed between two versions of a file, especially for configuration files and data files where reformatting can obscure real changes.`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_COMPARE_FILE_CONTENTS_USER_TITLE", "Compare file contents between revisions"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "path": { + Type: "string", + Description: "Path to the file to compare", + }, + "base": { + Type: "string", + Description: "Base ref to compare from (commit SHA, branch name, or tag name)", + }, + "head": { + Type: "string", + Description: "Head ref to compare to (commit SHA, branch name, or tag name)", + }, + }, + Required: []string{"owner", "repo", "path", "base", "head"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + path, err := RequiredParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + base, err := RequiredParam[string](args, "base") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + head, err := RequiredParam[string](args, "head") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + baseContent, baseErr := getFileAtRef(ctx, client, owner, repo, path, base) + headContent, headErr := getFileAtRef(ctx, client, owner, repo, path, head) + + // If both sides fail, report the errors + if baseErr != nil && headErr != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get file at both refs: base %q: %s, head %q: %s", base, baseErr, head, headErr)), nil, nil + } + + // A nil content with no error won't happen from getFileAtRef, + // but a non-nil error on one side means the file doesn't exist at that ref. + // Pass nil to SemanticDiff to indicate added/deleted file. + if baseErr != nil { + baseContent = nil + } + if headErr != nil { + headContent = nil + } + + result := SemanticDiff(path, baseContent, headContent) + + output, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal diff result: %w", err) + } + + return utils.NewToolResultText(string(output)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagCompareFileContents + return tool +} + +// getFileAtRef fetches file content from a GitHub repository at a specific ref. +func getFileAtRef(ctx context.Context, client *github.Client, owner, repo, path, ref string) ([]byte, error) { + opts := &github.RepositoryContentGetOptions{Ref: ref} + fileContent, _, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) + if err != nil { + return nil, err + } + if resp == nil { + return nil, fmt.Errorf("no response received") + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + if fileContent == nil { + return nil, fmt.Errorf("path %q is a directory, not a file", path) + } + + content, err := fileContent.GetContent() + if err != nil { + return nil, fmt.Errorf("failed to decode file content: %w", err) + } + + if len(content) > MaxSemanticDiffFileSize { + return nil, fmt.Errorf("file exceeds maximum size of %d bytes", MaxSemanticDiffFileSize) + } + + return []byte(content), nil +} diff --git a/pkg/github/compare_file_contents_test.go b/pkg/github/compare_file_contents_test.go new file mode 100644 index 000000000..af0b9d5a2 --- /dev/null +++ b/pkg/github/compare_file_contents_test.go @@ -0,0 +1,275 @@ +package github + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v79/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_CompareFileContents(t *testing.T) { + serverTool := CompareFileContents(translations.NullTranslationHelper) + tool := serverTool.Tool + + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "compare_file_contents", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint, "compare_file_contents should be read-only") + assert.Equal(t, FeatureFlagCompareFileContents, serverTool.FeatureFlagEnable) + + // Helper to create a mock handler that returns file content for a specific ref + mockContentsForRef := func(contentsByRef map[string]string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ref := r.URL.Query().Get("ref") + content, ok := contentsByRef[ref] + if !ok { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + return + } + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("config.json"), + Path: github.Ptr("config.json"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Encoding: github.Ptr("base64"), + Content: github.Ptr(encoded), + } + w.WriteHeader(http.StatusOK) + data, _ := json.Marshal(fileContent) + _, _ = w.Write(data) + } + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectFormat string + expectDiff string + }{ + { + name: "JSON semantic diff - value change", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": `{"theme": "light", "version": "1.0"}`, + "feature": `{"theme": "dark", "version": "1.0"}`, + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectFormat: "json", + expectDiff: `theme: "light" → "dark"`, + }, + { + name: "JSON semantic diff - no changes after reformatting", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": `{"key":"value","num":42}`, + "feature": "{\n \"key\": \"value\",\n \"num\": 42\n}", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectFormat: "json", + expectDiff: "no changes detected", + }, + { + name: "YAML semantic diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "v1": "host: localhost\nport: 5432\n", + "v2": "host: production.db\nport: 5432\n", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.yaml", + "base": "v1", + "head": "v2", + }, + expectFormat: "yaml", + expectDiff: `host: "localhost" → "production.db"`, + }, + { + name: "unsupported format falls back to unified diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": "func main() {}\n", + "feature": "func main() {\n\tfmt.Println(\"hello\")\n}\n", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "main.go", + "base": "main", + "head": "feature", + }, + expectFormat: "unified", + expectDiff: "--- a/main.go", + }, + { + name: "missing required parameter - owner", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter - base", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "head": "feature", + }, + expectError: true, + expectedErrMsg: "missing required parameter: base", + }, + { + name: "new file - base not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "feature": `{"key": "value"}`, + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectFormat: "json", + expectDiff: "file added", + }, + { + name: "deleted file - head not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": `{"key": "value"}`, + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectFormat: "json", + expectDiff: "file deleted", + }, + { + name: "both refs not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{}), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectError: true, + expectedErrMsg: "failed to get file at both refs", + }, + { + name: "CSV semantic diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": "name,status\nAlice,active\nBob,pending\n", + "feature": "name,status\nAlice,active\nBob,shipped\n", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "data.csv", + "base": "main", + "head": "feature", + }, + expectFormat: "csv", + expectDiff: `row 2.status: "pending" → "shipped"`, + }, + { + name: "TOML semantic diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": "[database]\nhost = \"localhost\"\n", + "feature": "[database]\nhost = \"production.db\"\n", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.toml", + "base": "main", + "head": "feature", + }, + expectFormat: "toml", + expectDiff: `database.host: "localhost" → "production.db"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectError { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + var diffResult SemanticDiffResult + err = json.Unmarshal([]byte(textContent.Text), &diffResult) + require.NoError(t, err) + + assert.Equal(t, DiffFormat(tc.expectFormat), diffResult.Format) + assert.Contains(t, diffResult.Diff, tc.expectDiff) + }) + } +} diff --git a/pkg/github/semantic_diff.go b/pkg/github/semantic_diff.go new file mode 100644 index 000000000..f48d3625a --- /dev/null +++ b/pkg/github/semantic_diff.go @@ -0,0 +1,547 @@ +package github + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/pelletier/go-toml/v2" + "gopkg.in/yaml.v3" +) + +// MaxSemanticDiffFileSize is the maximum file size (in bytes) for semantic diff processing. +// Files larger than this fall back to unified diff to prevent excessive server-side processing. +const MaxSemanticDiffFileSize = 1024 * 1024 // 1MB + +// DiffFormat represents the format used for diffing. +type DiffFormat string + +const ( + DiffFormatJSON DiffFormat = "json" + DiffFormatYAML DiffFormat = "yaml" + DiffFormatCSV DiffFormat = "csv" + DiffFormatTOML DiffFormat = "toml" + DiffFormatUnified DiffFormat = "unified" + DiffFormatFallback DiffFormat = "fallback" +) + +// SemanticDiffResult holds the output of a semantic diff operation. +type SemanticDiffResult struct { + Format DiffFormat `json:"format"` + Diff string `json:"diff"` + Message string `json:"message,omitempty"` +} + +// SemanticDiff compares two versions of a file and returns a semantic diff +// for supported formats, or a unified diff as a fallback. +// A nil base indicates a new file; a nil head indicates a deleted file. +func SemanticDiff(path string, base, head []byte) SemanticDiffResult { + if base == nil && head == nil { + return SemanticDiffResult{ + Format: DiffFormatUnified, + Diff: "no changes detected", + } + } + + if base == nil { + return SemanticDiffResult{ + Format: DetectDiffFormat(path), + Diff: "file added", + } + } + + if head == nil { + return SemanticDiffResult{ + Format: DetectDiffFormat(path), + Diff: "file deleted", + } + } + + if len(base) > MaxSemanticDiffFileSize || len(head) > MaxSemanticDiffFileSize { + return SemanticDiffResult{ + Format: DiffFormatFallback, + Diff: unifiedDiff(path, base, head), + Message: "file exceeds maximum size for semantic diff, using unified diff", + } + } + + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".json": + return semanticDiffJSON(path, base, head) + case ".yaml", ".yml": + return semanticDiffYAML(path, base, head) + case ".csv": + return semanticDiffCSV(path, base, head) + case ".toml": + return semanticDiffTOML(path, base, head) + default: + return SemanticDiffResult{ + Format: DiffFormatUnified, + Diff: unifiedDiff(path, base, head), + } + } +} + +// semanticDiffJSON parses both versions as JSON and produces a path-based diff. +func semanticDiffJSON(path string, base, head []byte) SemanticDiffResult { + var baseVal, headVal any + if err := json.Unmarshal(base, &baseVal); err != nil { + return fallbackResult(path, base, head, "failed to parse base as JSON") + } + if err := json.Unmarshal(head, &headVal); err != nil { + return fallbackResult(path, base, head, "failed to parse head as JSON") + } + + changes := compareValues("", baseVal, headVal) + if len(changes) == 0 { + return SemanticDiffResult{ + Format: DiffFormatJSON, + Diff: "no changes detected", + } + } + + return SemanticDiffResult{ + Format: DiffFormatJSON, + Diff: strings.Join(changes, "\n"), + } +} + +// semanticDiffYAML parses both versions as YAML and produces a path-based diff. +func semanticDiffYAML(path string, base, head []byte) SemanticDiffResult { + var baseVal, headVal any + if err := yaml.Unmarshal(base, &baseVal); err != nil { + return fallbackResult(path, base, head, "failed to parse base as YAML") + } + if err := yaml.Unmarshal(head, &headVal); err != nil { + return fallbackResult(path, base, head, "failed to parse head as YAML") + } + + changes := compareValues("", baseVal, headVal) + if len(changes) == 0 { + return SemanticDiffResult{ + Format: DiffFormatYAML, + Diff: "no changes detected", + } + } + + return SemanticDiffResult{ + Format: DiffFormatYAML, + Diff: strings.Join(changes, "\n"), + } +} + +// semanticDiffCSV parses both versions as CSV and produces row/cell-level diffs. +func semanticDiffCSV(path string, base, head []byte) SemanticDiffResult { + baseRows, err := csv.NewReader(bytes.NewReader(base)).ReadAll() + if err != nil { + return fallbackResult(path, base, head, "failed to parse base as CSV") + } + headRows, err := csv.NewReader(bytes.NewReader(head)).ReadAll() + if err != nil { + return fallbackResult(path, base, head, "failed to parse head as CSV") + } + + changes := compareCSV(baseRows, headRows) + if len(changes) == 0 { + return SemanticDiffResult{ + Format: DiffFormatCSV, + Diff: "no changes detected", + } + } + + return SemanticDiffResult{ + Format: DiffFormatCSV, + Diff: strings.Join(changes, "\n"), + } +} + +// semanticDiffTOML parses both versions as TOML and produces a path-based diff. +func semanticDiffTOML(path string, base, head []byte) SemanticDiffResult { + var baseVal, headVal map[string]any + if err := toml.Unmarshal(base, &baseVal); err != nil { + return fallbackResult(path, base, head, "failed to parse base as TOML") + } + if err := toml.Unmarshal(head, &headVal); err != nil { + return fallbackResult(path, base, head, "failed to parse head as TOML") + } + + changes := compareValues("", any(baseVal), any(headVal)) + if len(changes) == 0 { + return SemanticDiffResult{ + Format: DiffFormatTOML, + Diff: "no changes detected", + } + } + + return SemanticDiffResult{ + Format: DiffFormatTOML, + Diff: strings.Join(changes, "\n"), + } +} + +// compareValues recursively compares two decoded values and returns change descriptions. +// Note: JSON integers larger than 2^53 may lose precision due to float64 representation. +func compareValues(path string, base, head any) []string { + // Normalize numeric types from different decoders (JSON uses float64, YAML may use int) + base = normalizeValue(base) + head = normalizeValue(head) + + baseIsNil := base == nil + headIsNil := head == nil + + if baseIsNil && headIsNil { + return nil + } + if baseIsNil { + return []string{formatChange(path, "changed", formatValue(base), formatValue(head))} + } + if headIsNil { + return []string{formatChange(path, "changed", formatValue(base), formatValue(head))} + } + + switch b := base.(type) { + case map[string]any: + h, ok := head.(map[string]any) + if !ok { + return []string{formatChange(path, "changed type", formatValue(base), formatValue(head))} + } + return compareMaps(path, b, h) + + case []any: + h, ok := head.([]any) + if !ok { + return []string{formatChange(path, "changed type", formatValue(base), formatValue(head))} + } + return compareSlices(path, b, h) + + default: + if fmt.Sprintf("%v", base) != fmt.Sprintf("%v", head) { + return []string{formatChange(path, "changed", formatValue(base), formatValue(head))} + } + return nil + } +} + +// normalizeValue converts numeric types to float64 for consistent comparison. +func normalizeValue(v any) any { + switch n := v.(type) { + case int: + return float64(n) + case int64: + return float64(n) + case int32: + return float64(n) + case float32: + return float64(n) + case uint: + return float64(n) + case uint64: + return float64(n) + case map[any]any: + // YAML can produce map[any]any, convert to map[string]any + result := make(map[string]any, len(n)) + for k, val := range n { + result[fmt.Sprintf("%v", k)] = val + } + return result + default: + return v + } +} + +// compareMaps compares two maps and returns change descriptions. +func compareMaps(path string, base, head map[string]any) []string { + var changes []string + + // Collect all keys from both maps + allKeys := make(map[string]bool) + for k := range base { + allKeys[k] = true + } + for k := range head { + allKeys[k] = true + } + + // Sort keys for deterministic output + sortedKeys := make([]string, 0, len(allKeys)) + for k := range allKeys { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + + for _, key := range sortedKeys { + childPath := joinPath(path, key) + baseVal, inBase := base[key] + headVal, inHead := head[key] + + switch { + case inBase && !inHead: + changes = append(changes, formatChange(childPath, "removed", formatValue(baseVal), "")) + case !inBase && inHead: + changes = append(changes, formatChange(childPath, "added", "", formatValue(headVal))) + default: + changes = append(changes, compareValues(childPath, baseVal, headVal)...) + } + } + + return changes +} + +// compareSlices compares two slices and returns change descriptions. +func compareSlices(path string, base, head []any) []string { + var changes []string + + maxLen := len(base) + if len(head) > maxLen { + maxLen = len(head) + } + + for i := range maxLen { + childPath := fmt.Sprintf("%s[%d]", path, i) + switch { + case i >= len(base): + changes = append(changes, formatChange(childPath, "added", "", formatValue(head[i]))) + case i >= len(head): + changes = append(changes, formatChange(childPath, "removed", formatValue(base[i]), "")) + default: + changes = append(changes, compareValues(childPath, base[i], head[i])...) + } + } + + return changes +} + +// compareCSV compares CSV data with header awareness. +func compareCSV(base, head [][]string) []string { + var changes []string + + // Use headers from base if available + var headers []string + if len(base) > 0 { + headers = base[0] + } else if len(head) > 0 { + headers = head[0] + } + + // Check if headers changed + if len(base) > 0 && len(head) > 0 { + baseHeaders := base[0] + headHeaders := head[0] + if !slicesEqual(baseHeaders, headHeaders) { + changes = append(changes, fmt.Sprintf("headers changed: %v → %v", baseHeaders, headHeaders)) + // If headers changed, fall back to row-level comparison + headers = nil + } + } + + // Compare data rows (skip header row) + baseStart, headStart := 1, 1 + if len(base) == 0 { + baseStart = 0 + } + if len(head) == 0 { + headStart = 0 + } + + baseData := safeSlice(base, baseStart) + headData := safeSlice(head, headStart) + + maxRows := len(baseData) + if len(headData) > maxRows { + maxRows = len(headData) + } + + for i := range maxRows { + rowLabel := fmt.Sprintf("row %d", i+1) + switch { + case i >= len(baseData): + changes = append(changes, fmt.Sprintf("%s: added %v", rowLabel, headData[i])) + case i >= len(headData): + changes = append(changes, fmt.Sprintf("%s: removed %v", rowLabel, baseData[i])) + default: + rowChanges := compareCSVRow(rowLabel, headers, baseData[i], headData[i]) + changes = append(changes, rowChanges...) + } + } + + return changes +} + +// compareCSVRow compares individual CSV rows cell by cell. +func compareCSVRow(rowLabel string, headers, base, head []string) []string { + var changes []string + + maxCols := len(base) + if len(head) > maxCols { + maxCols = len(head) + } + + for i := range maxCols { + var colLabel string + if headers != nil && i < len(headers) { + colLabel = fmt.Sprintf("%s.%s", rowLabel, headers[i]) + } else { + colLabel = fmt.Sprintf("%s[%d]", rowLabel, i) + } + + var baseVal, headVal string + if i < len(base) { + baseVal = base[i] + } + if i < len(head) { + headVal = head[i] + } + + if baseVal != headVal { + changes = append(changes, formatChange(colLabel, "changed", quote(baseVal), quote(headVal))) + } + } + + return changes +} + +// formatChange formats a single change entry. +func formatChange(path, changeType, oldVal, newVal string) string { + switch changeType { + case "added": + return fmt.Sprintf("%s: added %s", path, newVal) + case "removed": + return fmt.Sprintf("%s: removed (was %s)", path, oldVal) + case "changed", "changed type": + return fmt.Sprintf("%s: %s → %s", path, oldVal, newVal) + default: + return fmt.Sprintf("%s: %s", path, changeType) + } +} + +// formatValue formats a value for display in a diff. +func formatValue(v any) string { + switch val := v.(type) { + case string: + return quote(val) + case nil: + return "null" + case map[string]any, []any: + b, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("%v", val) + } + return string(b) + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%g", val) + default: + return fmt.Sprintf("%v", val) + } +} + +// quote wraps a string in double quotes. +func quote(s string) string { + return fmt.Sprintf("%q", s) +} + +// joinPath creates a dotted path, handling the root case. +func joinPath(parent, child string) string { + if parent == "" { + return child + } + return parent + "." + child +} + +// unifiedDiff produces a simple unified diff between two byte slices. +func unifiedDiff(path string, base, head []byte) string { + baseLines := splitLines(string(base)) + headLines := splitLines(string(head)) + + var buf strings.Builder + buf.WriteString(fmt.Sprintf("--- a/%s\n", path)) + buf.WriteString(fmt.Sprintf("+++ b/%s\n", path)) + + // Simple line-by-line comparison (not a full Myers diff, but sufficient for context) + maxLines := len(baseLines) + if len(headLines) > maxLines { + maxLines = len(headLines) + } + + for i := range maxLines { + switch { + case i >= len(baseLines): + buf.WriteString(fmt.Sprintf("+%s\n", headLines[i])) + case i >= len(headLines): + buf.WriteString(fmt.Sprintf("-%s\n", baseLines[i])) + case baseLines[i] != headLines[i]: + buf.WriteString(fmt.Sprintf("-%s\n", baseLines[i])) + buf.WriteString(fmt.Sprintf("+%s\n", headLines[i])) + } + } + + return buf.String() +} + +// splitLines splits text into lines, handling various line endings. +func splitLines(s string) []string { + if s == "" { + return nil + } + s = strings.ReplaceAll(s, "\r\n", "\n") + lines := strings.Split(s, "\n") + // Remove trailing empty line from final newline + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +// slicesEqual checks if two string slices are equal. +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// safeSlice returns a sub-slice starting at index, or empty if index is out of bounds. +func safeSlice(s [][]string, start int) [][]string { + if start >= len(s) { + return nil + } + return s[start:] +} + +// fallbackResult returns a unified diff with a message explaining why semantic diff failed. +func fallbackResult(path string, base, head []byte, message string) SemanticDiffResult { + return SemanticDiffResult{ + Format: DiffFormatFallback, + Diff: unifiedDiff(path, base, head), + Message: message + ", using unified diff", + } +} + +// DetectDiffFormat returns the DiffFormat for a file path based on extension. +func DetectDiffFormat(path string) DiffFormat { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".json": + return DiffFormatJSON + case ".yaml", ".yml": + return DiffFormatYAML + case ".csv": + return DiffFormatCSV + case ".toml": + return DiffFormatTOML + default: + return DiffFormatUnified + } +} diff --git a/pkg/github/semantic_diff_test.go b/pkg/github/semantic_diff_test.go new file mode 100644 index 000000000..147547822 --- /dev/null +++ b/pkg/github/semantic_diff_test.go @@ -0,0 +1,460 @@ +package github + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSemanticDiffJSON(t *testing.T) { + tests := []struct { + name string + path string + base string + head string + expectedFormat DiffFormat + expectedDiff string + notContains string + }{ + { + name: "no changes", + path: "config.json", + base: `{"key": "value"}`, + head: `{"key": "value"}`, + expectedFormat: DiffFormatJSON, + expectedDiff: "no changes detected", + }, + { + name: "simple value change", + path: "config.json", + base: `{"theme": "light"}`, + head: `{"theme": "dark"}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `theme: "light" → "dark"`, + }, + { + name: "added key", + path: "config.json", + base: `{"a": 1}`, + head: `{"a": 1, "b": 2}`, + expectedFormat: DiffFormatJSON, + expectedDiff: "b: added 2", + }, + { + name: "removed key", + path: "config.json", + base: `{"a": 1, "b": 2}`, + head: `{"a": 1}`, + expectedFormat: DiffFormatJSON, + expectedDiff: "b: removed (was 2)", + }, + { + name: "nested object change", + path: "config.json", + base: `{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]}`, + head: `{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bobby"}]}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `users[1].name: "Bob" → "Bobby"`, + }, + { + name: "reformatting only - no semantic change", + path: "config.json", + base: `{"key":"value","number":42}`, + head: "{\n \"key\": \"value\",\n \"number\": 42\n}", + expectedFormat: DiffFormatJSON, + expectedDiff: "no changes detected", + }, + { + name: "array element added", + path: "data.json", + base: `[1, 2, 3]`, + head: `[1, 2, 3, 4]`, + expectedFormat: DiffFormatJSON, + expectedDiff: "[3]: added 4", + }, + { + name: "array element removed", + path: "data.json", + base: `[1, 2, 3]`, + head: `[1, 2]`, + expectedFormat: DiffFormatJSON, + expectedDiff: "[2]: removed (was 3)", + }, + { + name: "type change", + path: "config.json", + base: `{"val": "string"}`, + head: `{"val": 123}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `val: "string" → 123`, + }, + { + name: "null values", + path: "config.json", + base: `{"val": null}`, + head: `{"val": "something"}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `val: null → "something"`, + }, + { + name: "boolean change", + path: "config.json", + base: `{"enabled": true}`, + head: `{"enabled": false}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `enabled: true → false`, + }, + { + name: "invalid base JSON falls back", + path: "config.json", + base: `not json`, + head: `{"key": "value"}`, + expectedFormat: DiffFormatFallback, + }, + { + name: "invalid head JSON falls back", + path: "config.json", + base: `{"key": "value"}`, + head: `not json`, + expectedFormat: DiffFormatFallback, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff(tc.path, []byte(tc.base), []byte(tc.head)) + assert.Equal(t, tc.expectedFormat, result.Format) + if tc.expectedDiff != "" { + assert.Contains(t, result.Diff, tc.expectedDiff) + } + if tc.notContains != "" { + assert.NotContains(t, result.Diff, tc.notContains) + } + }) + } +} + +func TestSemanticDiffYAML(t *testing.T) { + tests := []struct { + name string + path string + base string + head string + expectedFormat DiffFormat + expectedDiff string + }{ + { + name: "no changes", + path: "config.yaml", + base: "key: value\n", + head: "key: value\n", + expectedFormat: DiffFormatYAML, + expectedDiff: "no changes detected", + }, + { + name: "simple value change", + path: "config.yml", + base: "theme: light\n", + head: "theme: dark\n", + expectedFormat: DiffFormatYAML, + expectedDiff: `theme: "light" → "dark"`, + }, + { + name: "nested key change", + path: "config.yaml", + base: "database:\n host: localhost\n port: 5432\n", + head: "database:\n host: production.db\n port: 5432\n", + expectedFormat: DiffFormatYAML, + expectedDiff: `database.host: "localhost" → "production.db"`, + }, + { + name: "added key", + path: "config.yaml", + base: "a: 1\n", + head: "a: 1\nb: 2\n", + expectedFormat: DiffFormatYAML, + expectedDiff: "b: added 2", + }, + { + name: "invalid YAML falls back", + path: "config.yaml", + base: ":\n bad:\nyaml", + head: "key: value\n", + expectedFormat: DiffFormatFallback, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff(tc.path, []byte(tc.base), []byte(tc.head)) + assert.Equal(t, tc.expectedFormat, result.Format) + if tc.expectedDiff != "" { + assert.Contains(t, result.Diff, tc.expectedDiff) + } + }) + } +} + +func TestSemanticDiffCSV(t *testing.T) { + tests := []struct { + name string + base string + head string + expectedFormat DiffFormat + expectedDiff string + }{ + { + name: "no changes", + base: "name,age\nAlice,30\nBob,25\n", + head: "name,age\nAlice,30\nBob,25\n", + expectedFormat: DiffFormatCSV, + expectedDiff: "no changes detected", + }, + { + name: "cell value change", + base: "name,status\nAlice,active\nBob,pending\n", + head: "name,status\nAlice,active\nBob,shipped\n", + expectedFormat: DiffFormatCSV, + expectedDiff: `row 2.status: "pending" → "shipped"`, + }, + { + name: "row added", + base: "name,age\nAlice,30\n", + head: "name,age\nAlice,30\nBob,25\n", + expectedFormat: DiffFormatCSV, + expectedDiff: "row 2: added", + }, + { + name: "row removed", + base: "name,age\nAlice,30\nBob,25\n", + head: "name,age\nAlice,30\n", + expectedFormat: DiffFormatCSV, + expectedDiff: "row 2: removed", + }, + { + name: "header change", + base: "name,age\nAlice,30\n", + head: "name,email\nAlice,alice@example.com\n", + expectedFormat: DiffFormatCSV, + expectedDiff: "headers changed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff("data.csv", []byte(tc.base), []byte(tc.head)) + assert.Equal(t, tc.expectedFormat, result.Format) + if tc.expectedDiff != "" { + assert.Contains(t, result.Diff, tc.expectedDiff) + } + }) + } +} + +func TestSemanticDiffTOML(t *testing.T) { + tests := []struct { + name string + base string + head string + expectedFormat DiffFormat + expectedDiff string + }{ + { + name: "no changes", + base: "key = \"value\"\n", + head: "key = \"value\"\n", + expectedFormat: DiffFormatTOML, + expectedDiff: "no changes detected", + }, + { + name: "value change", + base: "title = \"old\"\n", + head: "title = \"new\"\n", + expectedFormat: DiffFormatTOML, + expectedDiff: `title: "old" → "new"`, + }, + { + name: "nested table change", + base: "[database]\nhost = \"localhost\"\nport = 5432\n", + head: "[database]\nhost = \"production.db\"\nport = 5432\n", + expectedFormat: DiffFormatTOML, + expectedDiff: `database.host: "localhost" → "production.db"`, + }, + { + name: "invalid TOML falls back", + base: "not valid toml [[[", + head: "key = \"value\"\n", + expectedFormat: DiffFormatFallback, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff("config.toml", []byte(tc.base), []byte(tc.head)) + assert.Equal(t, tc.expectedFormat, result.Format) + if tc.expectedDiff != "" { + assert.Contains(t, result.Diff, tc.expectedDiff) + } + }) + } +} + +func TestSemanticDiffUnifiedFallback(t *testing.T) { + tests := []struct { + name string + path string + base string + head string + expectedDiff string + }{ + { + name: "unsupported extension uses unified diff", + path: "main.go", + base: "func main() {\n}\n", + head: "func main() {\n\tfmt.Println(\"hello\")\n}\n", + expectedDiff: "--- a/main.go", + }, + { + name: "no extension uses unified diff", + path: "Makefile", + base: "all:\n\techo hello\n", + head: "all:\n\techo world\n", + expectedDiff: "--- a/Makefile", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff(tc.path, []byte(tc.base), []byte(tc.head)) + assert.Equal(t, DiffFormatUnified, result.Format) + assert.Contains(t, result.Diff, tc.expectedDiff) + }) + } +} + +func TestSemanticDiffFileSizeLimit(t *testing.T) { + path := "config.json" + // Create data larger than MaxSemanticDiffFileSize + large := strings.Repeat("x", MaxSemanticDiffFileSize+1) + + t.Run("large base file", func(t *testing.T) { + result := SemanticDiff(path, []byte(large), []byte(`{"key":"value"}`)) + assert.Equal(t, DiffFormatFallback, result.Format) + assert.Contains(t, result.Message, "exceeds maximum size") + }) + + t.Run("large head file", func(t *testing.T) { + result := SemanticDiff(path, []byte(`{"key":"value"}`), []byte(large)) + assert.Equal(t, DiffFormatFallback, result.Format) + assert.Contains(t, result.Message, "exceeds maximum size") + }) +} + +func TestSemanticDiffNewAndDeletedFiles(t *testing.T) { + t.Run("new JSON file", func(t *testing.T) { + result := SemanticDiff("config.json", nil, []byte(`{"key":"value"}`)) + assert.Equal(t, DiffFormatJSON, result.Format) + assert.Equal(t, "file added", result.Diff) + }) + + t.Run("deleted JSON file", func(t *testing.T) { + result := SemanticDiff("config.json", []byte(`{"key":"value"}`), nil) + assert.Equal(t, DiffFormatJSON, result.Format) + assert.Equal(t, "file deleted", result.Diff) + }) + + t.Run("new YAML file", func(t *testing.T) { + result := SemanticDiff("config.yaml", nil, []byte("key: value\n")) + assert.Equal(t, DiffFormatYAML, result.Format) + assert.Equal(t, "file added", result.Diff) + }) + + t.Run("deleted Go file", func(t *testing.T) { + result := SemanticDiff("main.go", []byte("package main\n"), nil) + assert.Equal(t, DiffFormatUnified, result.Format) + assert.Equal(t, "file deleted", result.Diff) + }) + + t.Run("both nil", func(t *testing.T) { + result := SemanticDiff("config.json", nil, nil) + assert.Equal(t, "no changes detected", result.Diff) + }) +} + +func TestDetectDiffFormat(t *testing.T) { + tests := []struct { + path string + expected DiffFormat + }{ + {"config.json", DiffFormatJSON}, + {"config.JSON", DiffFormatJSON}, + {"config.yaml", DiffFormatYAML}, + {"config.yml", DiffFormatYAML}, + {"data.csv", DiffFormatCSV}, + {"config.toml", DiffFormatTOML}, + {"main.go", DiffFormatUnified}, + {"README.md", DiffFormatUnified}, + {"Makefile", DiffFormatUnified}, + } + + for _, tc := range tests { + t.Run(tc.path, func(t *testing.T) { + assert.Equal(t, tc.expected, DetectDiffFormat(tc.path)) + }) + } +} + +func TestCompareValuesDeepNesting(t *testing.T) { + base := `{ + "level1": { + "level2": { + "level3": { + "value": "old" + } + } + } + }` + head := `{ + "level1": { + "level2": { + "level3": { + "value": "new" + } + } + } + }` + + result := SemanticDiff("config.json", []byte(base), []byte(head)) + require.Equal(t, DiffFormatJSON, result.Format) + assert.Contains(t, result.Diff, `level1.level2.level3.value: "old" → "new"`) +} + +func TestSemanticDiffMultipleChanges(t *testing.T) { + base := `{ + "name": "my-app", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.20", + "express": "4.17.1" + } + }` + head := `{ + "name": "my-app", + "version": "1.1.0", + "dependencies": { + "lodash": "4.17.21", + "express": "4.17.1", + "axios": "0.21.1" + } + }` + + result := SemanticDiff("package.json", []byte(base), []byte(head)) + require.Equal(t, DiffFormatJSON, result.Format) + assert.Contains(t, result.Diff, `version: "1.0.0" → "1.1.0"`) + assert.Contains(t, result.Diff, `dependencies.lodash: "4.17.20" → "4.17.21"`) + assert.Contains(t, result.Diff, `dependencies.axios: added "0.21.1"`) + assert.NotContains(t, result.Diff, "express") + assert.NotContains(t, result.Diff, "name") +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 676976140..d8165b65c 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -164,6 +164,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { // Repository tools SearchRepositories(t), GetFileContents(t), + CompareFileContents(t), ListCommits(t), SearchCode(t), GetCommit(t), From 020a51f04c4d1ad2db97993b8aab9aa4bd05726d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Feb 2026 21:53:00 +0000 Subject: [PATCH 2/3] chore: regenerate license files Auto-generated by license-check workflow --- third-party-licenses.darwin.md | 1 + third-party-licenses.linux.md | 1 + third-party-licenses.windows.md | 1 + third-party/gopkg.in/yaml.v3/LICENSE | 50 ++++++++++++++++++++++++++++ third-party/gopkg.in/yaml.v3/NOTICE | 13 ++++++++ 5 files changed, 66 insertions(+) create mode 100644 third-party/gopkg.in/yaml.v3/LICENSE create mode 100644 third-party/gopkg.in/yaml.v3/NOTICE diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 6028ecfda..0842fe85f 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -49,5 +49,6 @@ The following packages are included for the amd64, arm64 architectures. - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) + - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 3d7b8b3fe..2dd75ef7d 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -49,5 +49,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) + - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 48bad011e..0f2dc931c 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -50,5 +50,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/sys/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) + - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party/gopkg.in/yaml.v3/LICENSE b/third-party/gopkg.in/yaml.v3/LICENSE new file mode 100644 index 000000000..2683e4bb1 --- /dev/null +++ b/third-party/gopkg.in/yaml.v3/LICENSE @@ -0,0 +1,50 @@ + +This project is covered by two different licenses: MIT and Apache. + +#### MIT License #### + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original MIT license, with the additional +copyright staring in 2011 when the project was ported over: + + apic.go emitterc.go parserc.go readerc.go scannerc.go + writerc.go yamlh.go yamlprivateh.go + +Copyright (c) 2006-2010 Kirill Simonov +Copyright (c) 2006-2011 Kirill Simonov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Apache License ### + +All the remaining project files are covered by the Apache license: + +Copyright (c) 2011-2019 Canonical Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/third-party/gopkg.in/yaml.v3/NOTICE b/third-party/gopkg.in/yaml.v3/NOTICE new file mode 100644 index 000000000..866d74a7a --- /dev/null +++ b/third-party/gopkg.in/yaml.v3/NOTICE @@ -0,0 +1,13 @@ +Copyright 2011-2016 Canonical Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. From cec7b00dfb61ee07969364fe4dbec7cbd64f8377 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 9 Feb 2026 22:54:12 +0100 Subject: [PATCH 3/3] Promote yaml.v3 and go-toml/v2 to direct dependencies go mod tidy correctly marks these as direct imports now that semantic_diff.go uses them for YAML and TOML parsing. --- go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index c6c6e2967..a02997d6c 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/lithammer/fuzzysearch v1.1.8 github.com/mailru/easyjson v0.7.7 // indirect github.com/modelcontextprotocol/go-sdk v1.2.0 - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -50,5 +50,5 @@ require ( golang.org/x/text v0.28.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 )