mcp_diff 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d20d7f13aa54a40862cb9ea199572103518e6eabf8b5c02aa4933577f5d6b892
4
+ data.tar.gz: b02ae4c511d28b2ce69d5f08940fc51469747f869bab4ec8d947c97507b0dd6f
5
+ SHA512:
6
+ metadata.gz: 33bf269d6727eb2d17267aca121692e1a520cc590ac5d4489a6f198d5a605342064704081ac6bf78b1bed661fc4b905c374cda0f443abdc7fe801f2657e5b25d
7
+ data.tar.gz: d69c25fa290c62f9ec81fd315029c377a3ac2b8e7aeee29444212c40e61f0ab76a22df3b6cb8a6eed8ad0fb1e43da5a60961af8c25b79530a50971b0ba232af9
data/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # mcp-diff — Ruby port (in progress)
2
+
3
+ Parallel port of mcp-diff to Ruby, on the `ruby-port` branch. The TypeScript
4
+ packages under `../packages` stay the shippable version until this reaches
5
+ feature parity (24 rules + CLI + Action + green tests).
6
+
7
+ ## Decisions driving this port
8
+
9
+ - **Language: Ruby** (user decision, supersedes the TS default for this branch).
10
+ - **MCP SDK: the official `mcp` gem** (`modelcontextprotocol/ruby-sdk`) — honors
11
+ the project's "anchor only to the official SDK" rule. Its client subsystem
12
+ (`MCP::Client` + `MCP::Client::Stdio` / `http`) covers the full capture
13
+ surface: connect/InitializeResult (capabilities, protocolVersion, serverInfo,
14
+ instructions), `#tools` / `#resources` / `#resource_templates` / `#prompts`,
15
+ all auto-paginated.
16
+ - **Distribution: RubyGems** (`gem install`) + a Ruby-based GitHub Action —
17
+ replaces the TS `npx` flow. (Verify the gem name `mcp_diff` / `mcp-diff` on
18
+ RubyGems, mirroring the npm-naming saga.)
19
+
20
+ ## Module boundaries (mirror the TS split; keep core pure)
21
+
22
+ - `McpDiff::Core` — normalize → diff → classify + rule catalog. **Zero I/O.**
23
+ - `McpDiff::Client` — capture over the official `mcp` gem. The only server I/O.
24
+ - `McpDiff::Cli` — init/snapshot/check/update/diff/explain; exit codes 0/1/2.
25
+
26
+ ## Spike result (verified — Ruby 3.3.12, `mcp` 1.0.0)
27
+
28
+ Capture works and is faithful. **Gotcha found & handled:** the gem's public
29
+ `#tools`/`#resources`/… wrappers deserialize into objects that DROP `annotations`
30
+ and `title` — both part of mcp-diff's contract (BEHAVIOR class). The capture
31
+ layer reads the raw JSON one layer down via `MCP::Client`'s own private
32
+ `request` (full JSON-RPC envelope + id correlation), so nothing is lost.
33
+ `capture_test.rb` locks this in (asserts `add_doc`'s annotations survive).
34
+
35
+ ## Done on this branch (green — 35 tests / 94 assertions)
36
+
37
+ - `McpDiff::Core::Canonical` + `Normalize` — deterministic snapshot + contractHash.
38
+ - `McpDiff::Client.capture` — capability-gated, cursor-paginated capture over the
39
+ official gem (stdio verified against two real servers; HTTP wired via
40
+ `MCP::Client::HTTP`, not yet exercised by a test).
41
+ - `Core::Diff` + `Classify` + `SchemaDiff` (D9) + the **full 24-rule catalog** —
42
+ verified against the SAME fixtures the TS suite uses (`test/fixtures/`).
43
+ - `Cli` — init/snapshot/check/update/diff/explain/rules, exit codes 0/1/2,
44
+ pretty/JSON/MD. Output is byte-identical to the TS CLI (verified on the demo).
45
+ - `exe/mcp-diff`, gemspec builds clean (`mcp_diff-0.1.0.gem`).
46
+
47
+ ## Done since (green — full suite: 45 tests / 157 assertions)
48
+
49
+ - `test/schema_diff_test.rb` — the D9 variance primitives (port of the TS units).
50
+ - `test/catalog_test.rb` — D8 no-drift: the catalog vs context/RULES.md.
51
+ - `action/action.yml` — Ruby-native composite Action (setup-ruby → `gem install
52
+ mcp_diff` → `mcp-diff --md check` → PR comment + gate).
53
+ - `.github/workflows/ruby-ci.yml` — runs the Ruby suite on CI.
54
+
55
+ ## Release artifact validated
56
+
57
+ Built + installed the gem and ran the **installed** `mcp-diff` binary end-to-end:
58
+ `init` (live capture) wrote config + lockfile, `check` returned "✓ contract
59
+ unchanged" (exit 0). So `gem install mcp_diff` yields a working CLI.
60
+
61
+ ## Standalone
62
+
63
+ `test/fixtures/server.rb` is a hand-rolled MCP stdio server mirroring the TS
64
+ fixture — the whole suite runs with **only Ruby** (no Node), and `ruby-ci.yml`
65
+ reflects that.
66
+
67
+ ## Remaining before a Ruby release
68
+
69
+ - **Publish to RubyGems** — the name `mcp_diff` (and `mcp-diff`) is FREE on
70
+ RubyGems (verified), so no npm-style workaround. The release is wired:
71
+ `.github/workflows/ruby-release.yml` builds + `gem push` on a `v*` tag. It
72
+ needs the repo secret **`RUBYGEMS_API_KEY`** (your RubyGems API key — the
73
+ analogue of NPM_TOKEN). To ship: add the secret, then `git tag v0.1.0 && git push --tags`.
74
+ - Nice-to-have: split rules into one-file-per-rule (contributor funnel); an
75
+ HTTP-transport capture test.
76
+
77
+ ## Port stream tracking
78
+
79
+ Mirrors `../context/STREAMS.md` S1–S5. Formal D-decisions land in `context/`
80
+ once the spike validates the foundation (not before — the rewrite isn't final
81
+ until capture is proven in Ruby).
data/exe/mcp-diff ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ begin
5
+ require "mcp_diff"
6
+ rescue LoadError
7
+ require_relative "../lib/mcp_diff"
8
+ end
9
+
10
+ exit McpDiff::Cli.run(ARGV)
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require_relative "config"
6
+ require_relative "render"
7
+
8
+ module McpDiff
9
+ # Command dispatcher: init/snapshot/check/update/diff/explain/rules, with
10
+ # pretty/JSON/MD output and the 0/1/2 exit-code contract. Port of
11
+ # cli/src/index.ts. run() returns the process exit code.
12
+ module Cli
13
+ module_function
14
+
15
+ def run(argv)
16
+ globals, command, args, opts = parse(argv)
17
+ return 0 if globals[:handled]
18
+
19
+ dispatch(command, args, globals, opts)
20
+ rescue StandardError => e
21
+ warn "✗ #{e.message}"
22
+ 1
23
+ end
24
+
25
+ def parse(argv)
26
+ globals = { config: Config::DEFAULT_CONFIG_PATH, profile: nil, json: false, md: false, handled: false }
27
+ opts = {}
28
+ command = nil
29
+ args = []
30
+ i = 0
31
+ while i < argv.length
32
+ a = argv[i]
33
+ case a
34
+ when "-c", "--config" then globals[:config] = argv[i += 1]
35
+ when "-p", "--profile" then globals[:profile] = argv[i += 1]
36
+ when "--json" then globals[:json] = true
37
+ when "--md" then globals[:md] = true
38
+ when "--stdio" then opts[:stdio] = argv[i += 1]
39
+ when "--url" then opts[:url] = argv[i += 1]
40
+ when "-V", "--version" then (puts VERSION; globals[:handled] = true)
41
+ when "-h", "--help" then (puts help_text; globals[:handled] = true)
42
+ else
43
+ if command.nil? && !a.start_with?("-")
44
+ command = a
45
+ else
46
+ args << a
47
+ end
48
+ end
49
+ i += 1
50
+ end
51
+ [globals, command, args, opts]
52
+ end
53
+
54
+ def dispatch(command, args, globals, opts)
55
+ case command
56
+ when "init" then cmd_init(globals, opts)
57
+ when "snapshot" then cmd_snapshot(globals)
58
+ when "check" then return cmd_check(globals)
59
+ when "update" then cmd_update(globals)
60
+ when "diff" then return cmd_diff(globals, args[0], args[1])
61
+ when "explain" then cmd_explain(args[0])
62
+ when "rules" then cmd_rules(globals)
63
+ when nil then (puts help_text)
64
+ else raise "unknown command \"#{command}\" — run: mcp-diff --help"
65
+ end
66
+ 0
67
+ end
68
+
69
+ # ── shared helpers ──────────────────────────────────────────────
70
+
71
+ def globals_config(globals)
72
+ Config.load_config(globals[:config], globals[:profile])
73
+ end
74
+
75
+ def output_format(globals)
76
+ raise "choose one output format — pass --json or --md, not both" if globals[:json] && globals[:md]
77
+ return "json" if globals[:json]
78
+ return "md" if globals[:md]
79
+
80
+ "pretty"
81
+ end
82
+
83
+ def lockfile_path(cfg)
84
+ cfg["lockfile"] || Config::DEFAULT_LOCKFILE
85
+ end
86
+
87
+ def capture_options_from(cfg)
88
+ server = cfg["server"] || {}
89
+ opts = {}
90
+ opts[:timeout_ms] = cfg["timeoutMs"] if cfg["timeoutMs"]
91
+ if server["stdio"]
92
+ parts = Config.shell_split(server["stdio"])
93
+ command = parts.shift
94
+ raise "config server.stdio is empty" if command.nil?
95
+
96
+ opts[:stdio] = { command: command, args: parts }
97
+ opts[:stdio][:env] = cfg["env"] if cfg["env"]
98
+ elsif server["url"]
99
+ opts[:url] = server["url"]
100
+ else
101
+ raise "config has no server target — set server.stdio or server.url"
102
+ end
103
+ opts
104
+ end
105
+
106
+ def live_snapshot(cfg)
107
+ raw = Client.capture(**capture_options_from(cfg))
108
+ Core::Normalize.normalize(raw, ignore: cfg["ignore"] || [])
109
+ end
110
+
111
+ def read_lockfile(path, verify: true)
112
+ text = begin
113
+ File.read(path)
114
+ rescue SystemCallError
115
+ raise "cannot read lockfile \"#{path}\" — run: npx @mcp-diff/cli snapshot"
116
+ end
117
+ data = begin
118
+ JSON.parse(text)
119
+ rescue JSON::ParserError => e
120
+ raise "lockfile \"#{path}\" is not valid JSON (#{e.message}) — run: npx @mcp-diff/cli snapshot"
121
+ end
122
+ snapshot = Core::Normalize.parse_snapshot(data)
123
+ if verify
124
+ body = snapshot.reject { |k, _| k == "contractHash" }
125
+ if Core::Canonical.contract_hash_of(body) != snapshot["contractHash"]
126
+ raise "lockfile \"#{path}\" does not match its contractHash (hand-edited or bad merge?) — " \
127
+ "run: npx @mcp-diff/cli update"
128
+ end
129
+ end
130
+ snapshot
131
+ end
132
+
133
+ def read_snapshot_file(path)
134
+ text = begin
135
+ File.read(path)
136
+ rescue SystemCallError
137
+ raise "cannot read snapshot file \"#{path}\""
138
+ end
139
+ begin
140
+ Core::Normalize.parse_snapshot(JSON.parse(text))
141
+ rescue JSON::ParserError => e
142
+ raise "snapshot file \"#{path}\": #{e.message}"
143
+ end
144
+ end
145
+
146
+ def write_lockfile(path, snapshot)
147
+ File.write(path, Core::Canonical.canonical_stringify(snapshot))
148
+ end
149
+
150
+ # ── commands ────────────────────────────────────────────────────
151
+
152
+ def cmd_init(globals, opts)
153
+ config_path = globals[:config]
154
+ raise "#{config_path} already exists — delete it first to re-init" if File.exist?(config_path)
155
+
156
+ server = init_server_target(opts)
157
+ cfg = { "server" => server, "strict" => "breaking" }
158
+ write_lockfile(config_path, cfg)
159
+ puts "wrote #{config_path}"
160
+ snapshot = live_snapshot(cfg)
161
+ write_lockfile(lockfile_path(cfg), snapshot)
162
+ puts "wrote #{lockfile_path(cfg)} (#{snapshot['tools'].length} tools) — commit both files"
163
+ end
164
+
165
+ def init_server_target(opts)
166
+ if opts[:url]
167
+ valid = begin
168
+ u = URI.parse(opts[:url])
169
+ u.scheme && u.host
170
+ rescue URI::InvalidURIError
171
+ false
172
+ end
173
+ raise "\"#{opts[:url]}\" is not a valid URL — expected e.g. http://localhost:3000/mcp" unless valid
174
+
175
+ { "url" => opts[:url] }
176
+ elsif opts[:stdio]
177
+ { "stdio" => opts[:stdio] }
178
+ else
179
+ detected = Config.detect_mcp_json
180
+ raise "no server target — pass --stdio \"<command>\" or --url <url> (no .mcp.json found to auto-detect)" if detected.nil?
181
+
182
+ puts "detected server from .mcp.json: #{detected['stdio']}"
183
+ detected
184
+ end
185
+ end
186
+
187
+ def cmd_snapshot(globals)
188
+ cfg = globals_config(globals)
189
+ snapshot = live_snapshot(cfg)
190
+ write_lockfile(lockfile_path(cfg), snapshot)
191
+ puts "wrote #{lockfile_path(cfg)} (#{snapshot['tools'].length} tools)"
192
+ end
193
+
194
+ def cmd_check(globals)
195
+ cfg = globals_config(globals)
196
+ locked = read_lockfile(lockfile_path(cfg))
197
+ live = live_snapshot(cfg)
198
+ if locked["contractHash"] == live["contractHash"]
199
+ puts Render.render(output_format(globals), [], 0)
200
+ return 0
201
+ end
202
+ findings = Core::Classify.classify(Core::Diff.diff(locked, live))
203
+ exit_code = Core::Classify.compute_exit_code(findings, cfg["strict"] || "breaking")
204
+ puts Render.render(output_format(globals), findings, exit_code)
205
+ if findings.empty?
206
+ puts "ℹ contract hash changed but no rule classified the change (compatible) — run: mcp-diff update"
207
+ end
208
+ exit_code
209
+ end
210
+
211
+ def cmd_update(globals)
212
+ cfg = globals_config(globals)
213
+ path = lockfile_path(cfg)
214
+ live = live_snapshot(cfg)
215
+ if File.exist?(path)
216
+ locked = read_lockfile(path, verify: false)
217
+ findings = Core::Classify.classify(Core::Diff.diff(locked, live))
218
+ puts Render.render(output_format(globals), findings, 0) unless findings.empty?
219
+ end
220
+ write_lockfile(path, live)
221
+ puts "updated #{path}"
222
+ end
223
+
224
+ def cmd_diff(globals, before_path, after_path)
225
+ raise "diff needs two snapshot files: mcp-diff diff <before> <after>" if before_path.nil? || after_path.nil?
226
+
227
+ before = read_snapshot_file(before_path)
228
+ after = read_snapshot_file(after_path)
229
+ findings = Core::Classify.classify(Core::Diff.diff(before, after))
230
+ exit_code = Core::Classify.compute_exit_code(findings)
231
+ puts Render.render(output_format(globals), findings, exit_code)
232
+ exit_code
233
+ end
234
+
235
+ def cmd_explain(rule_id)
236
+ entry = Core::Rules::CATALOG.find { |r| r["id"] == rule_id }
237
+ unless entry
238
+ known = Core::Rules::CATALOG.map { |r| r["id"] }.join("\n ")
239
+ raise "unknown rule \"#{rule_id}\" — known rules:\n #{known}"
240
+ end
241
+ puts "#{entry['id']} [#{entry['class'].upcase}]\n\n#{entry['description']}"
242
+ end
243
+
244
+ def cmd_rules(globals)
245
+ if globals[:json]
246
+ puts JSON.pretty_generate(Core::Rules::CATALOG)
247
+ return
248
+ end
249
+ Core::Rules::CATALOG.each { |r| puts "#{r['class'].upcase.ljust(9)} #{r['id']}" }
250
+ end
251
+
252
+ def help_text
253
+ <<~HELP
254
+ mcp-diff — lockfile + CI gate for your MCP server's contract
255
+
256
+ Usage: mcp-diff [--config <path>] [--profile <name>] [--json|--md] <command>
257
+
258
+ Commands:
259
+ init [--stdio "<cmd>" | --url <url>] write config + first lockfile (auto-detects .mcp.json)
260
+ snapshot (re)write mcp.lock.json
261
+ check live server vs lockfile; exit 0/1/2
262
+ update accept changes, rewrite lockfile
263
+ diff <a.json> <b.json> compare two snapshots
264
+ explain <rule-id> explain one rule
265
+ rules list the rule catalog
266
+ HELP
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require "mcp"
5
+
6
+ module McpDiff
7
+ # Capture an MCP server's complete public surface over the official `mcp` gem.
8
+ # The ONLY component that talks to servers (keeps Core pure). Port of
9
+ # client/src/index.ts.
10
+ #
11
+ # Note: the gem's `MCP::Client#tools/#resources/...` wrap results into objects
12
+ # that DROP `annotations` and `title`. Those are part of the contract (BEHAVIOR
13
+ # class), so we read the raw JSON one layer down via the client's own private
14
+ # `request` (full JSON-RPC envelope + id correlation, nothing dropped). This is
15
+ # covered by capture_test.rb — if a future `mcp` version changes it, that fails.
16
+ module Client
17
+ DEFAULT_TIMEOUT_MS = 30_000
18
+
19
+ module_function
20
+
21
+ # stdio: { command:, args: [], env: {} } | url: "http://…/mcp"
22
+ # Returns a RawCapture Hash (string keys) matching Core's normalize input.
23
+ def capture(stdio: nil, url: nil, timeout_ms: DEFAULT_TIMEOUT_MS)
24
+ transport = make_transport(stdio: stdio, url: url)
25
+ client = MCP::Client.new(transport: transport)
26
+
27
+ Timeout.timeout(timeout_ms / 1000.0, nil, "server did not respond within #{timeout_ms}ms") do
28
+ client.connect
29
+ info = as_hash(client.server_info)
30
+ caps = as_hash(info["capabilities"])
31
+
32
+ {
33
+ "protocolVersion" => info["protocolVersion"],
34
+ "serverInfo" => info["serverInfo"],
35
+ "capabilities" => caps,
36
+ "instructions" => info["instructions"],
37
+ "tools" => caps["tools"] ? raw_list(client, "tools/list", "tools") : [],
38
+ "resources" => caps["resources"] ? raw_list(client, "resources/list", "resources") : [],
39
+ "resourceTemplates" => caps["resources"] ? raw_list(client, "resources/templates/list", "resourceTemplates") : [],
40
+ "prompts" => caps["prompts"] ? raw_list(client, "prompts/list", "prompts") : []
41
+ }
42
+ end
43
+ rescue Timeout::Error
44
+ raise
45
+ rescue StandardError => e
46
+ raise "cannot capture server (#{target_desc(stdio, url)}) — #{e.message} — " \
47
+ "check the command/URL and that the server builds & starts"
48
+ ensure
49
+ transport.close if transport.respond_to?(:close)
50
+ end
51
+
52
+ # Follow nextCursor to the end, reading raw hashes. Guards against a server
53
+ # that returns a repeating cursor (would loop forever).
54
+ def raw_list(client, method, key)
55
+ items = []
56
+ cursor = nil
57
+ seen = {}
58
+ loop do
59
+ params = cursor ? { cursor: cursor } : {}
60
+ result = as_hash(client.send(:request, method: method, params: params)["result"])
61
+ items.concat(result[key] || [])
62
+ cursor = result["nextCursor"]
63
+ break if cursor.nil?
64
+ raise "server returned a repeating pagination cursor" if seen[cursor]
65
+
66
+ seen[cursor] = true
67
+ end
68
+ items
69
+ end
70
+
71
+ def make_transport(stdio:, url:)
72
+ if stdio
73
+ MCP::Client::Stdio.new(command: stdio[:command], args: stdio[:args] || [], env: stdio[:env])
74
+ elsif url
75
+ MCP::Client::HTTP.new(url: url)
76
+ else
77
+ raise "no server target: provide either stdio or url"
78
+ end
79
+ end
80
+
81
+ def target_desc(stdio, url)
82
+ if stdio
83
+ %(stdio: "#{[stdio[:command], *(stdio[:args] || [])].join(' ')}")
84
+ else
85
+ url || "no target"
86
+ end
87
+ end
88
+
89
+ def as_hash(value)
90
+ value.is_a?(Hash) ? value : {}
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module McpDiff
7
+ # Config loading + validation (port of cli/src/config.ts). Actionable error
8
+ # messages: every failure says how to fix it. Config uses string keys (JSON).
9
+ module Config
10
+ DEFAULT_CONFIG_PATH = "mcp-diff.config.json"
11
+ DEFAULT_LOCKFILE = "mcp.lock.json"
12
+
13
+ module_function
14
+
15
+ def load_config(path, profile = nil)
16
+ text = read_config_text(path)
17
+ begin
18
+ config = JSON.parse(text)
19
+ rescue JSON::ParserError => e
20
+ raise "config \"#{path}\" is not valid JSON: #{e.message}"
21
+ end
22
+ config = apply_profile(config, profile) unless profile.nil?
23
+ validate_config(path, config)
24
+ config
25
+ end
26
+
27
+ def read_config_text(path)
28
+ File.read(path)
29
+ rescue SystemCallError
30
+ raise "cannot read config \"#{path}\" — run: npx @mcp-diff/cli init"
31
+ end
32
+
33
+ def apply_profile(config, profile)
34
+ overlay = (config["profiles"] || {})[profile]
35
+ if overlay.nil?
36
+ known = (config["profiles"] || {}).keys.join(", ")
37
+ known = "none defined" if known.empty?
38
+ raise "unknown profile \"#{profile}\" — known profiles: #{known}"
39
+ end
40
+ merged = config.merge(overlay)
41
+ merged["env"] = (config["env"] || {}).merge(overlay["env"] || {})
42
+ merged["server"] = overlay["server"] || config["server"]
43
+ merged
44
+ end
45
+
46
+ def validate_config(path, config)
47
+ invalid = ->(msg) { raise "config \"#{path}\": #{msg}" }
48
+ validate_server(config["server"], invalid) if config.key?("server")
49
+ invalid.call('"lockfile" must be a string path') if config.key?("lockfile") && !config["lockfile"].is_a?(String)
50
+ validate_timeout(config["timeoutMs"], invalid) if config.key?("timeoutMs")
51
+ validate_ignore(config["ignore"], invalid) if config.key?("ignore")
52
+ if config.key?("strict") && !%w[breaking behavior].include?(config["strict"])
53
+ invalid.call('"strict" must be "breaking" or "behavior"')
54
+ end
55
+ validate_env(config["env"], invalid) if config.key?("env")
56
+ end
57
+
58
+ def validate_server(server, invalid)
59
+ invalid.call('"server" must be an object with "stdio" or "url"') unless server.is_a?(Hash)
60
+ if server.key?("stdio") && !server["stdio"].is_a?(String)
61
+ invalid.call('"server.stdio" must be a string command line')
62
+ end
63
+ return unless server.key?("url")
64
+
65
+ invalid.call('"server.url" must be a string') unless server["url"].is_a?(String)
66
+ begin
67
+ u = URI.parse(server["url"])
68
+ invalid.call("\"server.url\" is not a valid URL: #{server['url']}") unless u.scheme && u.host
69
+ rescue URI::InvalidURIError
70
+ invalid.call("\"server.url\" is not a valid URL: #{server['url']}")
71
+ end
72
+ end
73
+
74
+ def validate_timeout(value, invalid)
75
+ invalid.call('"timeoutMs" must be a positive number') unless value.is_a?(Numeric) && value.finite? && value.positive?
76
+ end
77
+
78
+ def validate_ignore(value, invalid)
79
+ invalid.call('"ignore" must be an array of string patterns') unless value.is_a?(Array) && value.all?(String)
80
+ end
81
+
82
+ def validate_env(value, invalid)
83
+ ok = value.is_a?(Hash) && value.values.all?(String)
84
+ invalid.call('"env" must be an object of string values') unless ok
85
+ end
86
+
87
+ # Split a command line into argv, honoring single/double quotes.
88
+ def shell_split(command)
89
+ command.scan(/"([^"]*)"|'([^']*)'|(\S+)/).map { |dq, sq, bare| dq || sq || bare }
90
+ end
91
+
92
+ def detect_mcp_json
93
+ return nil unless File.exist?(".mcp.json")
94
+
95
+ parsed = JSON.parse(File.read(".mcp.json"))
96
+ servers = parsed["mcpServers"] || {}
97
+ _name, server = servers.first
98
+ return nil unless server.is_a?(Hash) && server["command"].is_a?(String)
99
+
100
+ args = (server["args"] || []).map { |a| a.match?(/\s/) ? "\"#{a}\"" : a }.join(" ")
101
+ { "stdio" => args.empty? ? server["command"] : "#{server['command']} #{args}" }
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ module McpDiff
7
+ module Core
8
+ # Deterministic serialization: sorted keys, array order preserved (it is
9
+ # meaningful), sha256 contract hash. Port of core/src/canonical.ts (D11).
10
+ module Canonical
11
+ module_function
12
+
13
+ # Recursively sort object keys; stringify keys so string/symbol inputs
14
+ # canonicalize identically. Array order is preserved.
15
+ def sort_keys_deep(value)
16
+ case value
17
+ when Array
18
+ value.map { |v| sort_keys_deep(v) }
19
+ when Hash
20
+ value.map { |k, v| [k.to_s, sort_keys_deep(v)] }.sort_by(&:first).to_h
21
+ else
22
+ value
23
+ end
24
+ end
25
+
26
+ # Canonical lockfile text: sorted keys, 2-space indent, trailing newline.
27
+ def canonical_stringify(value)
28
+ "#{JSON.pretty_generate(sort_keys_deep(value))}\n"
29
+ end
30
+
31
+ # Structural equality via canonical serialization (array order preserved).
32
+ def deep_equal(a, b)
33
+ JSON.generate(sort_keys_deep(a)) == JSON.generate(sort_keys_deep(b))
34
+ end
35
+
36
+ # sha256 of the snapshot's canonical (compact) form, hash field excluded.
37
+ def contract_hash_of(snapshot_without_hash)
38
+ compact = JSON.generate(sort_keys_deep(snapshot_without_hash))
39
+ "sha256-#{Digest::SHA256.hexdigest(compact)}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rules"
4
+
5
+ module McpDiff
6
+ module Core
7
+ # Apply the ruleset to a set of changes, then compute the exit code.
8
+ # Port of core/src/classify.ts.
9
+ module Classify
10
+ module_function
11
+
12
+ CLASS_RANK = { "breaking" => 0, "behavior" => 1, "compat" => 2, "info" => 3 }.freeze
13
+
14
+ # Sorted worst-first, then by rule, target, message (deterministic order).
15
+ def classify(changes, rules = Rules::ALL)
16
+ findings = []
17
+ changes.each do |change|
18
+ rules.each do |rule|
19
+ matched = rule.match(change)
20
+ next if matched.nil?
21
+
22
+ findings.concat(matched.is_a?(Array) ? matched : [matched])
23
+ end
24
+ end
25
+ findings.sort_by { |f| [CLASS_RANK[f["class"]], f["ruleId"], f["target"], f["message"]] }
26
+ end
27
+
28
+ # Exit-code contract (D4): 0 clean/compat/info, 1 breaking, 2 behavior-only.
29
+ # strict: "behavior" promotes behavior findings to exit 1.
30
+ def compute_exit_code(findings, strict = "breaking")
31
+ has_breaking = findings.any? { |f| f["class"] == "breaking" }
32
+ has_behavior = findings.any? { |f| f["class"] == "behavior" }
33
+ return 1 if has_breaking || (strict == "behavior" && has_behavior)
34
+ return 2 if has_behavior
35
+
36
+ 0
37
+ end
38
+ end
39
+ end
40
+ end