delimit-cli 4.1.44 → 4.1.48
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.
- package/CHANGELOG.md +22 -0
- package/bin/delimit-cli.js +365 -30
- package/bin/delimit-setup.js +115 -81
- package/gateway/ai/activate_helpers.py +253 -7
- package/gateway/ai/backends/gateway_core.py +236 -13
- package/gateway/ai/backends/repo_bridge.py +80 -16
- package/gateway/ai/backends/tools_infra.py +49 -32
- package/gateway/ai/checksums.sha256 +6 -0
- package/gateway/ai/continuity.py +462 -0
- package/gateway/ai/deliberation.pyi +53 -0
- package/gateway/ai/governance.pyi +32 -0
- package/gateway/ai/governance_hardening.py +569 -0
- package/gateway/ai/hot_reload.py +445 -0
- package/gateway/ai/inbox_daemon_runner.py +217 -0
- package/gateway/ai/ledger_manager.py +40 -0
- package/gateway/ai/license.py +104 -3
- package/gateway/ai/license_core.py +177 -36
- package/gateway/ai/license_core.pyi +50 -0
- package/gateway/ai/loop_engine.py +786 -22
- package/gateway/ai/reddit_scanner.py +150 -5
- package/gateway/ai/server.py +301 -19
- package/gateway/ai/swarm.py +87 -0
- package/gateway/ai/swarm_infra.py +656 -0
- package/gateway/ai/tweet_corpus_schema.sql +76 -0
- package/gateway/core/diff_engine_v2.py +6 -2
- package/gateway/core/generator_drift.py +242 -0
- package/gateway/core/json_schema_diff.py +375 -0
- package/gateway/core/openapi_version.py +124 -0
- package/gateway/core/spec_detector.py +47 -7
- package/gateway/core/spec_health.py +5 -2
- package/lib/cross-model-hooks.js +31 -17
- package/lib/delimit-template.js +19 -85
- package/package.json +9 -2
- package/scripts/sync-gateway.sh +13 -1
|
@@ -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
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAPI version detection and compatibility metadata.
|
|
3
|
+
|
|
4
|
+
Centralizes the list of OpenAPI specification versions Delimit's diff/lint
|
|
5
|
+
engines accept. The diff engine itself is structurally version-agnostic --
|
|
6
|
+
it walks the spec dict regardless of declared version -- but having an
|
|
7
|
+
explicit allowlist lets us:
|
|
8
|
+
|
|
9
|
+
1. Warn users early when a spec uses an unknown version (typo, future
|
|
10
|
+
version we have not validated yet, etc).
|
|
11
|
+
2. Surface the detected version in tool responses so CI systems can log it.
|
|
12
|
+
3. Centralize "what does Delimit support" so README / docs / detector all
|
|
13
|
+
read from the same source of truth.
|
|
14
|
+
|
|
15
|
+
LED-290: bumped to include OpenAPI 3.2.0 (released 2025).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from typing import Any, Dict, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("delimit.core.openapi_version")
|
|
24
|
+
|
|
25
|
+
# Major.minor families Delimit's diff/lint engines have been validated against.
|
|
26
|
+
# Patch versions inside each family (e.g., 3.0.0, 3.0.1, 3.0.3) are all accepted.
|
|
27
|
+
SUPPORTED_OPENAPI_FAMILIES: Tuple[str, ...] = (
|
|
28
|
+
"3.0",
|
|
29
|
+
"3.1",
|
|
30
|
+
"3.2", # LED-290 -- OpenAPI 3.2.0 released 2025
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Swagger 2.0 is supported via the legacy `swagger` top-level key.
|
|
34
|
+
SUPPORTED_SWAGGER_VERSIONS: Tuple[str, ...] = ("2.0",)
|
|
35
|
+
|
|
36
|
+
# Human-readable string used in README and tool output.
|
|
37
|
+
SUPPORTED_VERSIONS_DISPLAY = "OpenAPI 3.0, 3.1, 3.2 and Swagger 2.0"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def detect_version(spec: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
41
|
+
"""Detect and validate the version of an OpenAPI / Swagger spec.
|
|
42
|
+
|
|
43
|
+
Returns a dict with:
|
|
44
|
+
family: "openapi" | "swagger" | "unknown"
|
|
45
|
+
version: the raw version string from the spec, or None
|
|
46
|
+
major_minor: e.g. "3.2" for openapi, "2.0" for swagger, or None
|
|
47
|
+
supported: True if the version is in our allowlist
|
|
48
|
+
warning: optional human-readable warning if unsupported
|
|
49
|
+
|
|
50
|
+
The function is non-throwing -- a missing or weird spec returns
|
|
51
|
+
{family: "unknown", supported: False, ...}. Callers decide whether
|
|
52
|
+
to log/skip/error.
|
|
53
|
+
"""
|
|
54
|
+
if not isinstance(spec, dict):
|
|
55
|
+
return {
|
|
56
|
+
"family": "unknown",
|
|
57
|
+
"version": None,
|
|
58
|
+
"major_minor": None,
|
|
59
|
+
"supported": False,
|
|
60
|
+
"warning": "spec is not a mapping",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
raw = spec.get("openapi")
|
|
64
|
+
if isinstance(raw, str) and raw.strip():
|
|
65
|
+
family = "openapi"
|
|
66
|
+
version = raw.strip()
|
|
67
|
+
parts = version.split(".")
|
|
68
|
+
major_minor = ".".join(parts[:2]) if len(parts) >= 2 else version
|
|
69
|
+
supported = major_minor in SUPPORTED_OPENAPI_FAMILIES
|
|
70
|
+
warning = None
|
|
71
|
+
if not supported:
|
|
72
|
+
warning = (
|
|
73
|
+
f"OpenAPI version {version!r} is not in Delimit's validated "
|
|
74
|
+
f"set ({', '.join(SUPPORTED_OPENAPI_FAMILIES)}). The diff "
|
|
75
|
+
f"engine is structurally version-agnostic and will still run, "
|
|
76
|
+
f"but some new keywords may be ignored."
|
|
77
|
+
)
|
|
78
|
+
return {
|
|
79
|
+
"family": family,
|
|
80
|
+
"version": version,
|
|
81
|
+
"major_minor": major_minor,
|
|
82
|
+
"supported": supported,
|
|
83
|
+
"warning": warning,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
raw = spec.get("swagger")
|
|
87
|
+
if isinstance(raw, str) and raw.strip():
|
|
88
|
+
version = raw.strip()
|
|
89
|
+
supported = version in SUPPORTED_SWAGGER_VERSIONS
|
|
90
|
+
warning = None
|
|
91
|
+
if not supported:
|
|
92
|
+
warning = (
|
|
93
|
+
f"Swagger version {version!r} is not supported. "
|
|
94
|
+
f"Delimit supports {', '.join(SUPPORTED_SWAGGER_VERSIONS)}."
|
|
95
|
+
)
|
|
96
|
+
return {
|
|
97
|
+
"family": "swagger",
|
|
98
|
+
"version": version,
|
|
99
|
+
"major_minor": version,
|
|
100
|
+
"supported": supported,
|
|
101
|
+
"warning": warning,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"family": "unknown",
|
|
106
|
+
"version": None,
|
|
107
|
+
"major_minor": None,
|
|
108
|
+
"supported": False,
|
|
109
|
+
"warning": "spec has no 'openapi' or 'swagger' top-level version key",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def assert_supported(spec: Dict[str, Any], strict: bool = False) -> Dict[str, Any]:
|
|
114
|
+
"""Run version detection and emit a logger warning for unsupported versions.
|
|
115
|
+
|
|
116
|
+
When ``strict=True``, raise ValueError instead of warning. Defaults to
|
|
117
|
+
non-strict so existing CI flows are not broken by an unknown version.
|
|
118
|
+
"""
|
|
119
|
+
info = detect_version(spec)
|
|
120
|
+
if not info["supported"] and info["warning"]:
|
|
121
|
+
if strict:
|
|
122
|
+
raise ValueError(info["warning"])
|
|
123
|
+
logger.warning(info["warning"])
|
|
124
|
+
return info
|
|
@@ -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.
|
|
@@ -32,8 +32,11 @@ PII_PATTERNS = [
|
|
|
32
32
|
re.compile(r"password\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE), # Hardcoded passwords
|
|
33
33
|
]
|
|
34
34
|
|
|
35
|
-
# HTTP methods that are standard for REST
|
|
36
|
-
|
|
35
|
+
# HTTP methods that are standard for REST.
|
|
36
|
+
# LED-290: "trace" (OpenAPI 3.x) and "query" (OpenAPI 3.2.0) are recognized
|
|
37
|
+
# but not required. The QUERY method allows safe, idempotent requests with
|
|
38
|
+
# a request body, so it is intentionally absent from NO_BODY_METHODS.
|
|
39
|
+
STANDARD_METHODS = {"get", "post", "put", "patch", "delete", "head", "options", "trace", "query"}
|
|
37
40
|
|
|
38
41
|
# Methods that should not have request bodies per HTTP semantics
|
|
39
42
|
NO_BODY_METHODS = {"get", "head", "delete"}
|
package/lib/cross-model-hooks.js
CHANGED
|
@@ -380,18 +380,10 @@ if [ -d "$SESSIONS" ]; then
|
|
|
380
380
|
[ -n "$LATEST" ] && python3 -c "import json; d=json.load(open('$LATEST')); s=d.get('summary','')[:150]; print(f'Last session: {s}')" 2>/dev/null
|
|
381
381
|
fi
|
|
382
382
|
echo " === Delimit Ready ==="
|
|
383
|
-
#
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
# Claude binary exists but is not our shim — re-wrap silently
|
|
388
|
-
SHIM="$DELIMIT_HOME/shims/claude"
|
|
389
|
-
if [ -f "$SHIM" ]; then
|
|
390
|
-
DIR=$(dirname "$CLAUDE_BIN")
|
|
391
|
-
mv "$CLAUDE_BIN" "$DIR/claude-real" 2>/dev/null && cp "$SHIM" "$CLAUDE_BIN" && chmod 755 "$CLAUDE_BIN" && echo "Shim: re-wrapped after update"
|
|
392
|
-
fi
|
|
393
|
-
fi
|
|
394
|
-
fi
|
|
383
|
+
# Note: shim governance works via PATH ordering ($HOME/.delimit/shims first).
|
|
384
|
+
# We deliberately do NOT mv claude → claude-real or copy the shim into /usr/bin/claude.
|
|
385
|
+
# That race-prone workaround caused "[Delimit] claude not found in PATH" failures
|
|
386
|
+
# when npm reinstalls clobbered /usr/bin/claude mid-operation.
|
|
395
387
|
`;
|
|
396
388
|
fs.writeFileSync(hookScript, scriptContent);
|
|
397
389
|
fs.chmodSync(hookScript, '755');
|
|
@@ -674,21 +666,43 @@ function installGeminiHooks(tool, hookConfig) {
|
|
|
674
666
|
} catch { config = {}; }
|
|
675
667
|
}
|
|
676
668
|
|
|
677
|
-
// LED-213:
|
|
669
|
+
// LED-213: canonical governance template (condensed for JSON).
|
|
670
|
+
// Detect via the stable <!-- delimit:start --> marker, not a prose phrase
|
|
671
|
+
// that may change between versions.
|
|
678
672
|
const govInstructions = getDelimitSectionCondensed();
|
|
673
|
+
const DELIMIT_MARKER = '<!-- delimit:start';
|
|
679
674
|
|
|
680
|
-
if (!config.customInstructions || !config.customInstructions.includes(
|
|
675
|
+
if (!config.customInstructions || !config.customInstructions.includes(DELIMIT_MARKER)) {
|
|
681
676
|
config.customInstructions = govInstructions;
|
|
682
677
|
changes.push('customInstructions');
|
|
683
678
|
}
|
|
684
679
|
|
|
685
680
|
fs.writeFileSync(tool.configPath, JSON.stringify(config, null, 2));
|
|
686
681
|
|
|
687
|
-
//
|
|
682
|
+
// GEMINI.md: use the same upsert pattern as CLAUDE.md so user content
|
|
683
|
+
// outside the managed markers is preserved across delimit-cli upgrades.
|
|
688
684
|
const geminiMd = path.join(geminiDir, 'GEMINI.md');
|
|
689
|
-
|
|
690
|
-
|
|
685
|
+
const managedSection = getDelimitSection();
|
|
686
|
+
if (!fs.existsSync(geminiMd)) {
|
|
687
|
+
fs.writeFileSync(geminiMd, managedSection + '\n');
|
|
691
688
|
changes.push('GEMINI.md');
|
|
689
|
+
} else {
|
|
690
|
+
const existing = fs.readFileSync(geminiMd, 'utf-8');
|
|
691
|
+
if (existing.includes(DELIMIT_MARKER) && existing.includes('<!-- delimit:end -->')) {
|
|
692
|
+
// Replace only the managed region
|
|
693
|
+
const before = existing.substring(0, existing.indexOf(DELIMIT_MARKER));
|
|
694
|
+
const after = existing.substring(existing.indexOf('<!-- delimit:end -->') + '<!-- delimit:end -->'.length);
|
|
695
|
+
const updated = before + managedSection + after;
|
|
696
|
+
if (updated !== existing) {
|
|
697
|
+
fs.writeFileSync(geminiMd, updated);
|
|
698
|
+
changes.push('GEMINI.md');
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
// Append managed section below existing user content
|
|
702
|
+
const sep = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
703
|
+
fs.writeFileSync(geminiMd, existing + sep + managedSection + '\n');
|
|
704
|
+
changes.push('GEMINI.md');
|
|
705
|
+
}
|
|
692
706
|
}
|
|
693
707
|
|
|
694
708
|
return changes;
|