delimit-cli 4.1.50 → 4.1.52

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.
@@ -0,0 +1,375 @@
1
+ """
2
+ JSON Schema diff engine (LED-713).
3
+
4
+ Sibling to core/diff_engine_v2.py. Handles bare JSON Schema files
5
+ (Draft 4+), resolving internal $ref to #/definitions. Deliberately
6
+ excludes anyOf/oneOf/allOf composition, external refs, discriminators,
7
+ and if/then/else — those are deferred past v1.
8
+
9
+ Dispatched from spec_detector when a file contains a top-level
10
+ "$schema" key or a top-level "definitions" key without OpenAPI markers.
11
+
12
+ Designed for the agents-oss/agentspec integration (issue #21) but
13
+ general across any single-file JSON Schema.
14
+ """
15
+
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from typing import Any, Dict, List, Optional
19
+
20
+
21
+ class JSONSchemaChangeType(Enum):
22
+ # Breaking
23
+ PROPERTY_REMOVED = "property_removed"
24
+ REQUIRED_ADDED = "required_added"
25
+ TYPE_NARROWED = "type_narrowed"
26
+ ENUM_VALUE_REMOVED = "enum_value_removed"
27
+ CONST_CHANGED = "const_changed"
28
+ ADDITIONAL_PROPERTIES_TIGHTENED = "additional_properties_tightened"
29
+ PATTERN_TIGHTENED = "pattern_tightened"
30
+ MIN_LENGTH_INCREASED = "min_length_increased"
31
+ MAX_LENGTH_DECREASED = "max_length_decreased"
32
+ MINIMUM_INCREASED = "minimum_increased"
33
+ MAXIMUM_DECREASED = "maximum_decreased"
34
+ ITEMS_TYPE_NARROWED = "items_type_narrowed"
35
+
36
+ # Non-breaking
37
+ PROPERTY_ADDED = "property_added"
38
+ REQUIRED_REMOVED = "required_removed"
39
+ TYPE_WIDENED = "type_widened"
40
+ ENUM_VALUE_ADDED = "enum_value_added"
41
+ ADDITIONAL_PROPERTIES_LOOSENED = "additional_properties_loosened"
42
+ PATTERN_LOOSENED = "pattern_loosened"
43
+ MIN_LENGTH_DECREASED = "min_length_decreased"
44
+ MAX_LENGTH_INCREASED = "max_length_increased"
45
+ MINIMUM_DECREASED = "minimum_decreased"
46
+ MAXIMUM_INCREASED = "maximum_increased"
47
+ ITEMS_TYPE_WIDENED = "items_type_widened"
48
+ DESCRIPTION_CHANGED = "description_changed"
49
+
50
+
51
+ _BREAKING_TYPES = {
52
+ JSONSchemaChangeType.PROPERTY_REMOVED,
53
+ JSONSchemaChangeType.REQUIRED_ADDED,
54
+ JSONSchemaChangeType.TYPE_NARROWED,
55
+ JSONSchemaChangeType.ENUM_VALUE_REMOVED,
56
+ JSONSchemaChangeType.CONST_CHANGED,
57
+ JSONSchemaChangeType.ADDITIONAL_PROPERTIES_TIGHTENED,
58
+ JSONSchemaChangeType.PATTERN_TIGHTENED,
59
+ JSONSchemaChangeType.MIN_LENGTH_INCREASED,
60
+ JSONSchemaChangeType.MAX_LENGTH_DECREASED,
61
+ JSONSchemaChangeType.MINIMUM_INCREASED,
62
+ JSONSchemaChangeType.MAXIMUM_DECREASED,
63
+ JSONSchemaChangeType.ITEMS_TYPE_NARROWED,
64
+ }
65
+
66
+
67
+ @dataclass
68
+ class JSONSchemaChange:
69
+ type: JSONSchemaChangeType
70
+ path: str
71
+ details: Dict[str, Any] = field(default_factory=dict)
72
+ message: str = ""
73
+
74
+ @property
75
+ def is_breaking(self) -> bool:
76
+ return self.type in _BREAKING_TYPES
77
+
78
+ @property
79
+ def severity(self) -> str:
80
+ return "high" if self.is_breaking else "low"
81
+
82
+
83
+ # Type widening hierarchy: a change from "integer" to "number" is widening
84
+ # (non-breaking for consumers). The reverse narrows and is breaking.
85
+ _TYPE_SUPERSETS = {
86
+ "number": {"integer"},
87
+ }
88
+
89
+
90
+ def _is_type_widening(old: str, new: str) -> bool:
91
+ return old in _TYPE_SUPERSETS.get(new, set())
92
+
93
+
94
+ def _is_type_narrowing(old: str, new: str) -> bool:
95
+ return new in _TYPE_SUPERSETS.get(old, set())
96
+
97
+
98
+ class JSONSchemaDiffEngine:
99
+ """Compare two JSON Schema documents.
100
+
101
+ Handles internal $ref to #/definitions by resolving refs against the
102
+ document's own definitions block during traversal. External refs
103
+ (http://, file://) are out of scope for v1.
104
+ """
105
+
106
+ def __init__(self) -> None:
107
+ self.changes: List[JSONSchemaChange] = []
108
+ self._old_defs: Dict[str, Any] = {}
109
+ self._new_defs: Dict[str, Any] = {}
110
+
111
+ # ------------------------------------------------------------------
112
+ # public API
113
+ # ------------------------------------------------------------------
114
+
115
+ def compare(self, old_schema: Dict[str, Any], new_schema: Dict[str, Any]) -> List[JSONSchemaChange]:
116
+ self.changes = []
117
+ old_schema = old_schema or {}
118
+ new_schema = new_schema or {}
119
+ self._old_defs = old_schema.get("definitions", {}) or {}
120
+ self._new_defs = new_schema.get("definitions", {}) or {}
121
+
122
+ # If the root is a $ref shim (common pattern: {"$ref": "#/definitions/Foo", "definitions": {...}})
123
+ # unwrap both sides so we diff the actual shape.
124
+ old_root = self._resolve(old_schema, self._old_defs)
125
+ new_root = self._resolve(new_schema, self._new_defs)
126
+
127
+ self._compare_schema(old_root, new_root, path="")
128
+ return self.changes
129
+
130
+ # ------------------------------------------------------------------
131
+ # $ref resolution
132
+ # ------------------------------------------------------------------
133
+
134
+ def _resolve(self, node: Any, defs: Dict[str, Any]) -> Any:
135
+ """Resolve internal $ref to #/definitions. Returns node unchanged otherwise."""
136
+ if not isinstance(node, dict):
137
+ return node
138
+ ref = node.get("$ref")
139
+ if not ref or not isinstance(ref, str) or not ref.startswith("#/definitions/"):
140
+ return node
141
+ key = ref[len("#/definitions/"):]
142
+ resolved = defs.get(key)
143
+ if resolved is None:
144
+ return node
145
+ # Merge sibling keys from the ref node (e.g. description) onto the resolved.
146
+ merged = dict(resolved)
147
+ for k, v in node.items():
148
+ if k != "$ref":
149
+ merged.setdefault(k, v)
150
+ return merged
151
+
152
+ # ------------------------------------------------------------------
153
+ # recursive traversal
154
+ # ------------------------------------------------------------------
155
+
156
+ def _compare_schema(self, old: Any, new: Any, path: str) -> None:
157
+ if not isinstance(old, dict) or not isinstance(new, dict):
158
+ return
159
+ old = self._resolve(old, self._old_defs)
160
+ new = self._resolve(new, self._new_defs)
161
+
162
+ self._compare_type(old, new, path)
163
+ self._compare_const(old, new, path)
164
+ self._compare_enum(old, new, path)
165
+ self._compare_pattern(old, new, path)
166
+ self._compare_numeric_bounds(old, new, path)
167
+ self._compare_string_length(old, new, path)
168
+ self._compare_additional_properties(old, new, path)
169
+ self._compare_required(old, new, path)
170
+ self._compare_properties(old, new, path)
171
+ self._compare_items(old, new, path)
172
+
173
+ # ------------------------------------------------------------------
174
+ # individual comparisons
175
+ # ------------------------------------------------------------------
176
+
177
+ def _compare_type(self, old: Dict, new: Dict, path: str) -> None:
178
+ old_t = old.get("type")
179
+ new_t = new.get("type")
180
+ if old_t == new_t or old_t is None or new_t is None:
181
+ return
182
+ if isinstance(old_t, str) and isinstance(new_t, str):
183
+ if _is_type_widening(old_t, new_t):
184
+ self._add(JSONSchemaChangeType.TYPE_WIDENED, path,
185
+ {"old": old_t, "new": new_t},
186
+ f"Type widened at {path or '/'}: {old_t} → {new_t}")
187
+ return
188
+ if _is_type_narrowing(old_t, new_t):
189
+ self._add(JSONSchemaChangeType.TYPE_NARROWED, path,
190
+ {"old": old_t, "new": new_t},
191
+ f"Type narrowed at {path or '/'}: {old_t} → {new_t}")
192
+ return
193
+ # Unrelated type change — treat as narrowing (breaking)
194
+ self._add(JSONSchemaChangeType.TYPE_NARROWED, path,
195
+ {"old": old_t, "new": new_t},
196
+ f"Type changed at {path or '/'}: {old_t} → {new_t}")
197
+
198
+ def _compare_const(self, old: Dict, new: Dict, path: str) -> None:
199
+ if "const" in old and "const" in new and old["const"] != new["const"]:
200
+ self._add(JSONSchemaChangeType.CONST_CHANGED, path,
201
+ {"old": old["const"], "new": new["const"]},
202
+ f"const value changed at {path or '/'}: {old['const']!r} → {new['const']!r}")
203
+
204
+ def _compare_enum(self, old: Dict, new: Dict, path: str) -> None:
205
+ old_enum = old.get("enum")
206
+ new_enum = new.get("enum")
207
+ if not isinstance(old_enum, list) or not isinstance(new_enum, list):
208
+ return
209
+ old_set = {repr(v) for v in old_enum}
210
+ new_set = {repr(v) for v in new_enum}
211
+ for removed in old_set - new_set:
212
+ self._add(JSONSchemaChangeType.ENUM_VALUE_REMOVED, path,
213
+ {"value": removed},
214
+ f"enum value removed at {path or '/'}: {removed}")
215
+ for added in new_set - old_set:
216
+ self._add(JSONSchemaChangeType.ENUM_VALUE_ADDED, path,
217
+ {"value": added},
218
+ f"enum value added at {path or '/'}: {added}")
219
+
220
+ def _compare_pattern(self, old: Dict, new: Dict, path: str) -> None:
221
+ old_p = old.get("pattern")
222
+ new_p = new.get("pattern")
223
+ if old_p == new_p or (old_p is None and new_p is None):
224
+ return
225
+ # We can't prove regex subset relationships, so any pattern change
226
+ # on an existing constraint is conservatively breaking; adding a
227
+ # brand-new pattern is breaking; removing a pattern is non-breaking.
228
+ if old_p and not new_p:
229
+ self._add(JSONSchemaChangeType.PATTERN_LOOSENED, path,
230
+ {"old": old_p},
231
+ f"pattern removed at {path or '/'}: {old_p}")
232
+ elif not old_p and new_p:
233
+ self._add(JSONSchemaChangeType.PATTERN_TIGHTENED, path,
234
+ {"new": new_p},
235
+ f"pattern added at {path or '/'}: {new_p}")
236
+ else:
237
+ self._add(JSONSchemaChangeType.PATTERN_TIGHTENED, path,
238
+ {"old": old_p, "new": new_p},
239
+ f"pattern changed at {path or '/'}: {old_p} → {new_p}")
240
+
241
+ def _compare_numeric_bounds(self, old: Dict, new: Dict, path: str) -> None:
242
+ for key, tight_type, loose_type in (
243
+ ("minimum", JSONSchemaChangeType.MINIMUM_INCREASED, JSONSchemaChangeType.MINIMUM_DECREASED),
244
+ ("maximum", JSONSchemaChangeType.MAXIMUM_DECREASED, JSONSchemaChangeType.MAXIMUM_INCREASED),
245
+ ):
246
+ old_v = old.get(key)
247
+ new_v = new.get(key)
248
+ if old_v is None or new_v is None or old_v == new_v:
249
+ continue
250
+ try:
251
+ delta = float(new_v) - float(old_v)
252
+ except (TypeError, ValueError):
253
+ continue
254
+ if key == "minimum":
255
+ if delta > 0:
256
+ self._add(tight_type, path, {"old": old_v, "new": new_v},
257
+ f"minimum increased at {path or '/'}: {old_v} → {new_v}")
258
+ else:
259
+ self._add(loose_type, path, {"old": old_v, "new": new_v},
260
+ f"minimum decreased at {path or '/'}: {old_v} → {new_v}")
261
+ else: # maximum
262
+ if delta < 0:
263
+ self._add(tight_type, path, {"old": old_v, "new": new_v},
264
+ f"maximum decreased at {path or '/'}: {old_v} → {new_v}")
265
+ else:
266
+ self._add(loose_type, path, {"old": old_v, "new": new_v},
267
+ f"maximum increased at {path or '/'}: {old_v} → {new_v}")
268
+
269
+ def _compare_string_length(self, old: Dict, new: Dict, path: str) -> None:
270
+ for key, tight_type, loose_type in (
271
+ ("minLength", JSONSchemaChangeType.MIN_LENGTH_INCREASED, JSONSchemaChangeType.MIN_LENGTH_DECREASED),
272
+ ("maxLength", JSONSchemaChangeType.MAX_LENGTH_DECREASED, JSONSchemaChangeType.MAX_LENGTH_INCREASED),
273
+ ):
274
+ old_v = old.get(key)
275
+ new_v = new.get(key)
276
+ if old_v is None or new_v is None or old_v == new_v:
277
+ continue
278
+ if key == "minLength":
279
+ if new_v > old_v:
280
+ self._add(tight_type, path, {"old": old_v, "new": new_v},
281
+ f"minLength increased at {path or '/'}: {old_v} → {new_v}")
282
+ else:
283
+ self._add(loose_type, path, {"old": old_v, "new": new_v},
284
+ f"minLength decreased at {path or '/'}: {old_v} → {new_v}")
285
+ else: # maxLength
286
+ if new_v < old_v:
287
+ self._add(tight_type, path, {"old": old_v, "new": new_v},
288
+ f"maxLength decreased at {path or '/'}: {old_v} → {new_v}")
289
+ else:
290
+ self._add(loose_type, path, {"old": old_v, "new": new_v},
291
+ f"maxLength increased at {path or '/'}: {old_v} → {new_v}")
292
+
293
+ def _compare_additional_properties(self, old: Dict, new: Dict, path: str) -> None:
294
+ old_ap = old.get("additionalProperties")
295
+ new_ap = new.get("additionalProperties")
296
+ # Default in JSON Schema is True (additional allowed). Only flag
297
+ # explicit transitions that change the answer.
298
+ if old_ap is None and new_ap is None:
299
+ return
300
+ old_allows = True if old_ap is None else bool(old_ap)
301
+ new_allows = True if new_ap is None else bool(new_ap)
302
+ if old_allows and not new_allows:
303
+ self._add(JSONSchemaChangeType.ADDITIONAL_PROPERTIES_TIGHTENED, path,
304
+ {"old": old_ap, "new": new_ap},
305
+ f"additionalProperties tightened at {path or '/'}: {old_ap} → {new_ap}")
306
+ elif not old_allows and new_allows:
307
+ self._add(JSONSchemaChangeType.ADDITIONAL_PROPERTIES_LOOSENED, path,
308
+ {"old": old_ap, "new": new_ap},
309
+ f"additionalProperties loosened at {path or '/'}: {old_ap} → {new_ap}")
310
+
311
+ def _compare_required(self, old: Dict, new: Dict, path: str) -> None:
312
+ old_req = set(old.get("required", []) or [])
313
+ new_req = set(new.get("required", []) or [])
314
+ for added in new_req - old_req:
315
+ self._add(JSONSchemaChangeType.REQUIRED_ADDED, f"{path}/required/{added}",
316
+ {"field": added},
317
+ f"required field added at {path or '/'}: {added}")
318
+ for removed in old_req - new_req:
319
+ self._add(JSONSchemaChangeType.REQUIRED_REMOVED, f"{path}/required/{removed}",
320
+ {"field": removed},
321
+ f"required field removed at {path or '/'}: {removed}")
322
+
323
+ def _compare_properties(self, old: Dict, new: Dict, path: str) -> None:
324
+ old_props = old.get("properties", {}) or {}
325
+ new_props = new.get("properties", {}) or {}
326
+ if not isinstance(old_props, dict) or not isinstance(new_props, dict):
327
+ return
328
+ for removed in set(old_props) - set(new_props):
329
+ self._add(JSONSchemaChangeType.PROPERTY_REMOVED, f"{path}/properties/{removed}",
330
+ {"field": removed},
331
+ f"property removed: {path or '/'}.{removed}")
332
+ for added in set(new_props) - set(old_props):
333
+ self._add(JSONSchemaChangeType.PROPERTY_ADDED, f"{path}/properties/{added}",
334
+ {"field": added},
335
+ f"property added: {path or '/'}.{added}")
336
+ for name in set(old_props) & set(new_props):
337
+ self._compare_schema(old_props[name], new_props[name], f"{path}/properties/{name}")
338
+
339
+ def _compare_items(self, old: Dict, new: Dict, path: str) -> None:
340
+ old_items = old.get("items")
341
+ new_items = new.get("items")
342
+ if not isinstance(old_items, dict) or not isinstance(new_items, dict):
343
+ return
344
+ self._compare_schema(old_items, new_items, f"{path}/items")
345
+
346
+ # ------------------------------------------------------------------
347
+ # helpers
348
+ # ------------------------------------------------------------------
349
+
350
+ def _add(self, change_type: JSONSchemaChangeType, path: str,
351
+ details: Dict[str, Any], message: str) -> None:
352
+ self.changes.append(JSONSchemaChange(
353
+ type=change_type, path=path or "/", details=details, message=message))
354
+
355
+
356
+ def is_json_schema(doc: Dict[str, Any]) -> bool:
357
+ """Detect whether a parsed document should be routed to this engine.
358
+
359
+ Heuristic: top-level "$schema" key referencing json-schema.org, OR a
360
+ top-level "definitions" block without OpenAPI markers (paths, components,
361
+ openapi, swagger).
362
+ """
363
+ if not isinstance(doc, dict):
364
+ return False
365
+ if any(marker in doc for marker in ("openapi", "swagger", "paths")):
366
+ return False
367
+ schema_url = doc.get("$schema")
368
+ if isinstance(schema_url, str) and "json-schema.org" in schema_url:
369
+ return True
370
+ if "definitions" in doc and isinstance(doc["definitions"], dict):
371
+ return True
372
+ # Agentspec pattern: {"$ref": "#/definitions/...", "definitions": {...}}
373
+ if doc.get("$ref", "").startswith("#/definitions/"):
374
+ return True
375
+ return False
@@ -3,7 +3,7 @@ Automatic OpenAPI specification detector for zero-config installation.
3
3
  """
4
4
 
5
5
  import os
6
- from typing import List, Optional, Tuple
6
+ from typing import Any, List, Optional, Tuple
7
7
  from pathlib import Path
8
8
  import yaml
9
9
 
@@ -77,7 +77,7 @@ class SpecDetector:
77
77
  """Check if file is a valid OpenAPI specification."""
78
78
  if not file_path.is_file():
79
79
  return False
80
-
80
+
81
81
  try:
82
82
  with open(file_path, 'r') as f:
83
83
  data = yaml.safe_load(f)
@@ -86,26 +86,66 @@ class SpecDetector:
86
86
  return 'openapi' in data or 'swagger' in data
87
87
  except:
88
88
  return False
89
-
89
+
90
90
  return False
91
-
91
+
92
92
  def get_default_specs(self) -> Tuple[Optional[str], Optional[str]]:
93
93
  """
94
94
  Get default old_spec and new_spec for auto-detection.
95
-
95
+
96
96
  Returns:
97
97
  (old_spec, new_spec): Paths or None if not found
98
98
  """
99
99
  specs, _ = self.detect_specs()
100
-
100
+
101
101
  if len(specs) == 0:
102
102
  return None, None
103
-
103
+
104
104
  # Use the first found spec as both old and new (baseline mode)
105
105
  default_spec = specs[0]
106
106
  return default_spec, default_spec
107
107
 
108
108
 
109
+ def detect_spec_type(doc: Any) -> str:
110
+ """Classify a parsed spec document for engine dispatch (LED-713).
111
+
112
+ Returns:
113
+ "openapi" — OpenAPI 3.x / Swagger 2.x (route to OpenAPIDiffEngine)
114
+ "json_schema" — bare JSON Schema Draft 4+ (route to JSONSchemaDiffEngine)
115
+ "unknown" — no recognized markers
116
+ """
117
+ if not isinstance(doc, dict):
118
+ return "unknown"
119
+ if "openapi" in doc or "swagger" in doc or "paths" in doc:
120
+ return "openapi"
121
+ # JSON Schema markers: $schema URL, top-level definitions, or ref-shim root
122
+ schema_url = doc.get("$schema")
123
+ if isinstance(schema_url, str) and "json-schema.org" in schema_url:
124
+ return "json_schema"
125
+ if isinstance(doc.get("definitions"), dict):
126
+ return "json_schema"
127
+ ref = doc.get("$ref")
128
+ if isinstance(ref, str) and ref.startswith("#/definitions/"):
129
+ return "json_schema"
130
+ return "unknown"
131
+
132
+
133
+ def get_diff_engine(doc: Any):
134
+ """Factory: return the right diff engine instance for a parsed doc.
135
+
136
+ Callers: action.yml inline Python, policy_engine, npm-delimit api-engine.
137
+ The returned engine exposes .compare(old, new) -> List[Change].
138
+ """
139
+ spec_type = detect_spec_type(doc)
140
+ if spec_type == "json_schema":
141
+ from .json_schema_diff import JSONSchemaDiffEngine
142
+ return JSONSchemaDiffEngine()
143
+ # Default to OpenAPI for "openapi" and "unknown" (back-compat: existing
144
+ # specs without explicit markers still hit the OpenAPI engine)
145
+ from .diff_engine_v2 import OpenAPIDiffEngine
146
+ return OpenAPIDiffEngine()
147
+
148
+
109
149
  def auto_detect_specs(root_path: str = ".") -> dict:
110
150
  """
111
151
  Main entry point for spec auto-detection.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
3
  "mcpName": "io.github.delimit-ai/delimit-mcp-server",
4
- "version": "4.1.50",
4
+ "version": "4.1.52",
5
5
  "description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
6
6
  "main": "index.js",
7
7
  "files": [