Skip to content

Commit 3f89371

Browse files
gustavobertoiclaude
andcommitted
fix(ai): compare .mcp.json by key, not by bytes
devstack owns ONE KEY in .mcp.json, not the file's formatting, but --check byte-compared the whole file. Any reformat by an editor, a formatter or another tool therefore marked the artifact stale forever — and since `ai check` gates `make ci`, a purely cosmetic change would fail the build while `ai install` fought the other tool on every commit. This is not hypothetical: it happened during the dogfood commit. The JSON-key mode now compares the value at the key path semantically and rewrites the file only when that value actually differs, so a user's formatting survives. MergeWhole and MergeFence keep byte comparison, which is correct there because devstack owns exactly the bytes it writes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 066c8d7 commit 3f89371

2 files changed

Lines changed: 161 additions & 19 deletions

File tree

internal/ai/ai_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,84 @@ func TestOutputDoesNotDependOnVersion(t *testing.T) {
503503
}
504504
}
505505
}
506+
507+
// TestJSONKeyIgnoresFormatting is a regression test for a real failure: another
508+
// tool reformatted .mcp.json (same JSON, different whitespace) and `ai check`
509+
// reported it stale — which, with ai-check gating CI, would fail the build on a
510+
// purely cosmetic change and make devstack fight the other formatter on every
511+
// commit. devstack owns ONE KEY in that file, not its formatting.
512+
func TestJSONKeyIgnoresFormatting(t *testing.T) {
513+
dir := t.TempDir()
514+
path := filepath.Join(dir, ".mcp.json")
515+
516+
// Write it once the normal way.
517+
if _, err := Write(buildAll(t, dir)); err != nil {
518+
t.Fatalf("Write: %v", err)
519+
}
520+
521+
// Now reformat the file the way a different tool would: identical JSON,
522+
// compact array, extra key ordering churn.
523+
reformatted := `{
524+
"mcpServers": {
525+
"devstack": { "command": "devstack", "args": ["ai", "mcp"] }
526+
}
527+
}
528+
`
529+
if err := os.WriteFile(path, []byte(reformatted), 0o644); err != nil {
530+
t.Fatal(err)
531+
}
532+
533+
stale, err := Stale(buildAll(t, dir))
534+
if err != nil {
535+
t.Fatalf("Stale: %v", err)
536+
}
537+
for _, a := range stale {
538+
if a.Rel == ".mcp.json" {
539+
t.Error(".mcp.json reported stale after a cosmetic reformat; devstack owns the key, not the formatting")
540+
}
541+
}
542+
543+
// And a write must leave the reformatted file alone.
544+
results, err := Write(buildAll(t, dir))
545+
if err != nil {
546+
t.Fatalf("Write: %v", err)
547+
}
548+
for _, r := range results {
549+
if r.Path == ".mcp.json" && r.Changed {
550+
t.Error("devstack rewrote .mcp.json purely to reformat it")
551+
}
552+
}
553+
if got := readFile(t, path); got != reformatted {
554+
t.Errorf("the user's formatting was not preserved:\n%s", got)
555+
}
556+
}
557+
558+
// TestJSONKeyDetectsARealChange is the other half: a genuinely wrong value must
559+
// still be corrected.
560+
func TestJSONKeyDetectsARealChange(t *testing.T) {
561+
dir := t.TempDir()
562+
path := filepath.Join(dir, ".mcp.json")
563+
if err := os.WriteFile(path, []byte(
564+
`{"mcpServers":{"devstack":{"command":"WRONG","args":["ai","mcp"]}}}`), 0o644); err != nil {
565+
t.Fatal(err)
566+
}
567+
stale, err := Stale(buildAll(t, dir))
568+
if err != nil {
569+
t.Fatalf("Stale: %v", err)
570+
}
571+
var found bool
572+
for _, a := range stale {
573+
if a.Rel == ".mcp.json" {
574+
found = true
575+
}
576+
}
577+
if !found {
578+
t.Fatal("a wrong command value should be reported stale")
579+
}
580+
if _, err := Write(buildAll(t, dir)); err != nil {
581+
t.Fatalf("Write: %v", err)
582+
}
583+
if strings.Contains(readFile(t, path), "WRONG") {
584+
t.Error("the wrong value was not corrected")
585+
}
586+
}

internal/ai/writeio.go

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9+
"reflect"
910
"strings"
1011
)
1112

@@ -63,6 +64,18 @@ type WriteResult struct {
6364
func Write(arts []Artifact) ([]WriteResult, error) {
6465
out := make([]WriteResult, 0, len(arts))
6566
for _, a := range arts {
67+
// Nothing to do when the artifact's own content already matches. Checking
68+
// first — rather than relying on writeIfChanged's byte compare — is what
69+
// keeps devstack from reformatting a user-owned JSON file whose devstack
70+
// key is already correct.
71+
ok, err := satisfied(a)
72+
if err != nil {
73+
return out, err
74+
}
75+
if ok {
76+
out = append(out, WriteResult{Path: a.Rel, Kind: a.Kind, Changed: false})
77+
continue
78+
}
6679
want, err := merged(a)
6780
if err != nil {
6881
return out, err
@@ -76,40 +89,88 @@ func Write(arts []Artifact) ([]WriteResult, error) {
7689
return out, nil
7790
}
7891

79-
// UpToDate reports whether every artifact already matches disk. This is the basis
80-
// for --check, so it must apply the same merge each mode would perform rather
81-
// than comparing raw bytes.
92+
// UpToDate reports whether every artifact is already satisfied on disk.
8293
func UpToDate(arts []Artifact) (bool, error) {
83-
for _, a := range arts {
84-
want, err := merged(a)
85-
if err != nil {
86-
return false, err
87-
}
88-
existing, err := os.ReadFile(a.Path)
89-
if err != nil || !bytes.Equal(existing, want) {
90-
return false, nil
91-
}
92-
}
93-
return true, nil
94+
stale, err := Stale(arts)
95+
return len(stale) == 0, err
9496
}
9597

96-
// Stale returns the artifacts whose on-disk content differs, so --check can name
97-
// them instead of just failing.
98+
// Stale returns the artifacts that are not satisfied, so --check can name them
99+
// instead of just failing.
98100
func Stale(arts []Artifact) ([]Artifact, error) {
99101
var out []Artifact
100102
for _, a := range arts {
101-
want, err := merged(a)
103+
ok, err := satisfied(a)
102104
if err != nil {
103105
return nil, err
104106
}
105-
existing, err := os.ReadFile(a.Path)
106-
if err != nil || !bytes.Equal(existing, want) {
107+
if !ok {
107108
out = append(out, a)
108109
}
109110
}
110111
return out, nil
111112
}
112113

114+
// satisfied reports whether an artifact's contribution is already present on
115+
// disk. What counts as "present" depends on how much of the file devstack owns.
116+
//
117+
// For MergeWhole and MergeFence devstack owns the bytes it writes (the whole file
118+
// or the fenced block, with everything outside preserved verbatim), so a byte
119+
// comparison of the merged result is exactly right.
120+
//
121+
// For MergeJSONKey devstack owns ONE KEY, not the file's formatting. Comparing
122+
// bytes there would mean any reformat by an editor, a formatter or another tool
123+
// marks the artifact stale forever — and since `ai check` gates CI, a purely
124+
// cosmetic change would fail the build and re-running `ai install` would fight
125+
// the other tool on every commit. So the key is compared semantically, and the
126+
// file is rewritten only when the key's VALUE actually differs.
127+
func satisfied(a Artifact) (bool, error) {
128+
existing, err := os.ReadFile(a.Path)
129+
if os.IsNotExist(err) {
130+
return false, nil
131+
}
132+
if err != nil {
133+
return false, fmt.Errorf("read %s: %w", a.Path, err)
134+
}
135+
if a.Merge == MergeJSONKey {
136+
return jsonKeyMatches(existing, a.JSONPath, a.Data)
137+
}
138+
want, err := merged(a)
139+
if err != nil {
140+
return false, err
141+
}
142+
return bytes.Equal(existing, want), nil
143+
}
144+
145+
// jsonKeyMatches reports whether the value at path already equals want, compared
146+
// as JSON values rather than as text.
147+
func jsonKeyMatches(existing []byte, path []string, want []byte) (bool, error) {
148+
if len(bytes.TrimSpace(existing)) == 0 {
149+
return false, nil
150+
}
151+
var doc any
152+
if err := json.Unmarshal(existing, &doc); err != nil {
153+
// A malformed file is not "satisfied"; Write surfaces the parse error.
154+
return false, nil
155+
}
156+
cur := doc
157+
for _, key := range path {
158+
obj, ok := cur.(map[string]any)
159+
if !ok {
160+
return false, nil
161+
}
162+
cur, ok = obj[key]
163+
if !ok {
164+
return false, nil
165+
}
166+
}
167+
var wantVal any
168+
if err := json.Unmarshal(want, &wantVal); err != nil {
169+
return false, fmt.Errorf("decode desired value for %s: %w", strings.Join(path, "."), err)
170+
}
171+
return reflect.DeepEqual(cur, wantVal), nil
172+
}
173+
113174
// merged computes the full file content an artifact should produce, reading the
114175
// current file for the two merge modes that preserve user content.
115176
func merged(a Artifact) ([]byte, error) {

0 commit comments

Comments
 (0)