delimit-cli 4.1.50 → 4.1.51
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 +21 -0
- package/gateway/ai/backends/gateway_core.py +222 -13
- package/gateway/ai/backends/repo_bridge.py +80 -16
- package/gateway/ai/loop_engine.py +36 -2
- package/gateway/core/generator_drift.py +242 -0
- package/gateway/core/json_schema_diff.py +375 -0
- package/gateway/core/spec_detector.py +47 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.1.51] - 2026-04-09
|
|
4
|
+
|
|
5
|
+
### Fixed (gateway loop engine — LED-814)
|
|
6
|
+
- **`ai/loop_engine.run_governed_iteration` mishandled swarm dispatch statuses.** Only `status=='completed'` was treated as success. The swarm dispatcher returns `'dispatched'` for async handoff, so every build-loop tick fell into the failure branch and logged "Dispatch failed" even though the underlying work shipped. Session `build-loop-2026-04-09` accumulated 6 spurious failures (LED-787 / 788 / 755 / 762 / 799 / 807) for tasks that all actually shipped. Now:
|
|
7
|
+
- `'completed'` → close ledger + notify deploy loop (unchanged)
|
|
8
|
+
- `'dispatched'` → mark ledger `in_progress` with the swarm `task_id`, NOT a failure
|
|
9
|
+
- `'blocked'` → record a founder-approval gate without tripping the circuit breaker
|
|
10
|
+
- anything else → genuine failure, error message includes the unexpected status string for debuggability
|
|
11
|
+
- Verified live against the running MCP session before this release: `iterations 6→7`, `errors 0`, `LED-814` recorded as `dispatched` with `swarm_task_id task-449ecdf9`.
|
|
12
|
+
- Picked up via the standard `npm run sync-gateway` step in `prepublishOnly` (gateway commit `ce802cd` is now on `delimit-ai/delimit-gateway` main).
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **`tests/test_loop_engine_dispatch_status.py`** in the gateway — covers all four dispatch status branches (`completed` / `dispatched` / `blocked` / unknown), 154 lines, ships with the bundled gateway.
|
|
16
|
+
|
|
17
|
+
### Scope
|
|
18
|
+
- Single-purpose patch: gateway loop engine only. This is the deferred half of the multi-model deliberation that produced 4.1.50 — the deliberation explicitly required splitting the gateway fix from the CLAUDE.md regex fix so each ship has a clean rollback story.
|
|
19
|
+
|
|
20
|
+
### Tests
|
|
21
|
+
- npm CLI: 134/134 still passing (no CLI changes — bundled gateway only).
|
|
22
|
+
- Gateway: new `test_loop_engine_dispatch_status.py` suite passing.
|
|
23
|
+
|
|
3
24
|
## [4.1.50] - 2026-04-09
|
|
4
25
|
|
|
5
26
|
### Fixed (CRITICAL — CLAUDE.md in-prose marker clobber)
|
|
@@ -23,10 +23,11 @@ if str(GATEWAY_ROOT) not in sys.path:
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def _load_specs(spec_path: str) -> Dict[str, Any]:
|
|
26
|
-
"""Load an
|
|
26
|
+
"""Load an API spec (OpenAPI or JSON Schema) from a file path.
|
|
27
27
|
|
|
28
28
|
Performs a non-fatal version compatibility check (LED-290) so that
|
|
29
29
|
unknown OpenAPI versions log a warning instead of silently parsing.
|
|
30
|
+
JSON Schema documents skip the OpenAPI version assert.
|
|
30
31
|
"""
|
|
31
32
|
import yaml
|
|
32
33
|
|
|
@@ -41,15 +42,146 @@ def _load_specs(spec_path: str) -> Dict[str, Any]:
|
|
|
41
42
|
spec = json.loads(content)
|
|
42
43
|
|
|
43
44
|
# LED-290: warn (non-fatal) if version is outside the validated set.
|
|
45
|
+
# Only applies to OpenAPI/Swagger documents — bare JSON Schema files
|
|
46
|
+
# have no "openapi"/"swagger" key and would otherwise trip the assert.
|
|
44
47
|
try:
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
if isinstance(spec, dict) and ("openapi" in spec or "swagger" in spec):
|
|
49
|
+
from core.openapi_version import assert_supported
|
|
50
|
+
assert_supported(spec, strict=False)
|
|
47
51
|
except Exception as exc: # pragma: no cover -- defensive only
|
|
48
52
|
logger.debug("openapi version check skipped: %s", exc)
|
|
49
53
|
|
|
50
54
|
return spec
|
|
51
55
|
|
|
52
56
|
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# LED-713: JSON Schema spec-type dispatch helpers
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _spec_type(doc: Any) -> str:
|
|
63
|
+
"""Classify a loaded spec doc. 'openapi' or 'json_schema'."""
|
|
64
|
+
from core.spec_detector import detect_spec_type
|
|
65
|
+
t = detect_spec_type(doc)
|
|
66
|
+
# Fallback to openapi for unknown so we never break existing flows.
|
|
67
|
+
return "json_schema" if t == "json_schema" else "openapi"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _json_schema_changes_to_dicts(changes: List[Any]) -> List[Dict[str, Any]]:
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
"type": c.type.value,
|
|
74
|
+
"path": c.path,
|
|
75
|
+
"message": c.message,
|
|
76
|
+
"is_breaking": c.is_breaking,
|
|
77
|
+
"details": c.details,
|
|
78
|
+
}
|
|
79
|
+
for c in changes
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _json_schema_semver(changes: List[Any]) -> Dict[str, Any]:
|
|
84
|
+
"""Build an OpenAPI-compatible semver result from JSON Schema changes.
|
|
85
|
+
|
|
86
|
+
Mirrors core.semver_classifier.classify_detailed shape so downstream
|
|
87
|
+
consumers (PR comment, CI formatter, ledger) don't need to branch.
|
|
88
|
+
"""
|
|
89
|
+
breaking = [c for c in changes if c.is_breaking]
|
|
90
|
+
non_breaking = [c for c in changes if not c.is_breaking]
|
|
91
|
+
if breaking:
|
|
92
|
+
bump = "major"
|
|
93
|
+
elif non_breaking:
|
|
94
|
+
bump = "minor"
|
|
95
|
+
else:
|
|
96
|
+
bump = "none"
|
|
97
|
+
return {
|
|
98
|
+
"bump": bump,
|
|
99
|
+
"is_breaking": bool(breaking),
|
|
100
|
+
"counts": {
|
|
101
|
+
"breaking": len(breaking),
|
|
102
|
+
"non_breaking": len(non_breaking),
|
|
103
|
+
"total": len(changes),
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _bump_semver_version(current: str, bump: str) -> Optional[str]:
|
|
109
|
+
"""Minimal semver bump for JSON Schema path (core.semver_classifier
|
|
110
|
+
only understands OpenAPI ChangeType enums)."""
|
|
111
|
+
if not current:
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
parts = current.lstrip("v").split(".")
|
|
115
|
+
major, minor, patch = (int(parts[0]), int(parts[1]), int(parts[2]))
|
|
116
|
+
except Exception:
|
|
117
|
+
return None
|
|
118
|
+
if bump == "major":
|
|
119
|
+
return f"{major + 1}.0.0"
|
|
120
|
+
if bump == "minor":
|
|
121
|
+
return f"{major}.{minor + 1}.0"
|
|
122
|
+
if bump == "patch":
|
|
123
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
124
|
+
return current
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _run_json_schema_lint(
|
|
128
|
+
old_doc: Dict[str, Any],
|
|
129
|
+
new_doc: Dict[str, Any],
|
|
130
|
+
current_version: Optional[str] = None,
|
|
131
|
+
api_name: Optional[str] = None,
|
|
132
|
+
) -> Dict[str, Any]:
|
|
133
|
+
"""Build an evaluate_with_policy-compatible result for JSON Schema.
|
|
134
|
+
|
|
135
|
+
Policy rules in Delimit are defined against OpenAPI ChangeType values,
|
|
136
|
+
so they do not apply here. We return zero violations and rely on the
|
|
137
|
+
breaking-change count + semver bump to drive the governance gate.
|
|
138
|
+
"""
|
|
139
|
+
from core.json_schema_diff import JSONSchemaDiffEngine
|
|
140
|
+
|
|
141
|
+
engine = JSONSchemaDiffEngine()
|
|
142
|
+
changes = engine.compare(old_doc, new_doc)
|
|
143
|
+
semver = _json_schema_semver(changes)
|
|
144
|
+
|
|
145
|
+
if current_version:
|
|
146
|
+
semver["current_version"] = current_version
|
|
147
|
+
semver["next_version"] = _bump_semver_version(current_version, semver["bump"])
|
|
148
|
+
|
|
149
|
+
breaking_count = semver["counts"]["breaking"]
|
|
150
|
+
total = semver["counts"]["total"]
|
|
151
|
+
|
|
152
|
+
decision = "pass"
|
|
153
|
+
exit_code = 0
|
|
154
|
+
# No policy rules apply to JSON Schema, but breaking changes still
|
|
155
|
+
# flag MAJOR semver and the downstream gate uses that to block.
|
|
156
|
+
# Mirror the shape of evaluate_with_policy so the action/CLI renderers
|
|
157
|
+
# need no JSON Schema-specific branch.
|
|
158
|
+
result: Dict[str, Any] = {
|
|
159
|
+
"spec_type": "json_schema",
|
|
160
|
+
"api_name": api_name or new_doc.get("title") or old_doc.get("title") or "JSON Schema",
|
|
161
|
+
"decision": decision,
|
|
162
|
+
"exit_code": exit_code,
|
|
163
|
+
"violations": [],
|
|
164
|
+
"summary": {
|
|
165
|
+
"total_changes": total,
|
|
166
|
+
"breaking_changes": breaking_count,
|
|
167
|
+
"violations": 0,
|
|
168
|
+
"errors": 0,
|
|
169
|
+
"warnings": 0,
|
|
170
|
+
},
|
|
171
|
+
"all_changes": [
|
|
172
|
+
{
|
|
173
|
+
"type": c.type.value,
|
|
174
|
+
"path": c.path,
|
|
175
|
+
"message": c.message,
|
|
176
|
+
"is_breaking": c.is_breaking,
|
|
177
|
+
}
|
|
178
|
+
for c in changes
|
|
179
|
+
],
|
|
180
|
+
"semver": semver,
|
|
181
|
+
}
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
53
185
|
def _read_jsonl(path: Path) -> List[Dict[str, Any]]:
|
|
54
186
|
"""Read JSONL entries from a file, skipping malformed lines."""
|
|
55
187
|
items: List[Dict[str, Any]] = []
|
|
@@ -115,29 +247,51 @@ def run_lint(old_spec: str, new_spec: str, policy_file: Optional[str] = None) ->
|
|
|
115
247
|
"""Run the full lint pipeline: diff + policy evaluation.
|
|
116
248
|
|
|
117
249
|
This is the Tier 1 primary tool — combines diff detection with
|
|
118
|
-
policy enforcement into a single pass/fail decision.
|
|
250
|
+
policy enforcement into a single pass/fail decision. Auto-detects
|
|
251
|
+
spec type (OpenAPI vs JSON Schema, LED-713) and dispatches to the
|
|
252
|
+
matching engine.
|
|
119
253
|
"""
|
|
120
254
|
from core.policy_engine import evaluate_with_policy
|
|
121
255
|
|
|
122
256
|
old = _load_specs(old_spec)
|
|
123
257
|
new = _load_specs(new_spec)
|
|
124
258
|
|
|
259
|
+
# LED-713: JSON Schema dispatch. Policy rules are OpenAPI-specific,
|
|
260
|
+
# so JSON Schema takes the no-policy (breaking-count + semver) path.
|
|
261
|
+
if _spec_type(new) == "json_schema" or _spec_type(old) == "json_schema":
|
|
262
|
+
return _run_json_schema_lint(old, new)
|
|
263
|
+
|
|
125
264
|
return evaluate_with_policy(old, new, policy_file)
|
|
126
265
|
|
|
127
266
|
|
|
128
267
|
def run_diff(old_spec: str, new_spec: str) -> Dict[str, Any]:
|
|
129
|
-
"""Run diff engine only — no policy evaluation.
|
|
130
|
-
from core.diff_engine_v2 import OpenAPIDiffEngine
|
|
268
|
+
"""Run diff engine only — no policy evaluation.
|
|
131
269
|
|
|
270
|
+
Auto-detects OpenAPI vs JSON Schema and dispatches (LED-713).
|
|
271
|
+
"""
|
|
132
272
|
old = _load_specs(old_spec)
|
|
133
273
|
new = _load_specs(new_spec)
|
|
134
274
|
|
|
275
|
+
if _spec_type(new) == "json_schema" or _spec_type(old) == "json_schema":
|
|
276
|
+
from core.json_schema_diff import JSONSchemaDiffEngine
|
|
277
|
+
engine = JSONSchemaDiffEngine()
|
|
278
|
+
changes = engine.compare(old, new)
|
|
279
|
+
breaking = [c for c in changes if c.is_breaking]
|
|
280
|
+
return {
|
|
281
|
+
"spec_type": "json_schema",
|
|
282
|
+
"total_changes": len(changes),
|
|
283
|
+
"breaking_changes": len(breaking),
|
|
284
|
+
"changes": _json_schema_changes_to_dicts(changes),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
from core.diff_engine_v2 import OpenAPIDiffEngine
|
|
135
288
|
engine = OpenAPIDiffEngine()
|
|
136
289
|
changes = engine.compare(old, new)
|
|
137
290
|
|
|
138
291
|
breaking = [c for c in changes if c.is_breaking]
|
|
139
292
|
|
|
140
293
|
return {
|
|
294
|
+
"spec_type": "openapi",
|
|
141
295
|
"total_changes": len(changes),
|
|
142
296
|
"breaking_changes": len(breaking),
|
|
143
297
|
"changes": [
|
|
@@ -164,13 +318,20 @@ def run_changelog(
|
|
|
164
318
|
Uses the diff engine to detect changes, then formats them into
|
|
165
319
|
a human-readable changelog grouped by category.
|
|
166
320
|
"""
|
|
167
|
-
from core.diff_engine_v2 import OpenAPIDiffEngine
|
|
168
321
|
from datetime import datetime, timezone
|
|
169
322
|
|
|
170
323
|
old = _load_specs(old_spec)
|
|
171
324
|
new = _load_specs(new_spec)
|
|
172
325
|
|
|
173
|
-
|
|
326
|
+
# LED-713: dispatch on spec type. JSONSchemaChange / Change share the
|
|
327
|
+
# (.type.value, .path, .message, .is_breaking) duck type.
|
|
328
|
+
if _spec_type(new) == "json_schema" or _spec_type(old) == "json_schema":
|
|
329
|
+
from core.json_schema_diff import JSONSchemaDiffEngine
|
|
330
|
+
engine = JSONSchemaDiffEngine()
|
|
331
|
+
else:
|
|
332
|
+
from core.diff_engine_v2 import OpenAPIDiffEngine
|
|
333
|
+
engine = OpenAPIDiffEngine()
|
|
334
|
+
|
|
174
335
|
changes = engine.compare(old, new)
|
|
175
336
|
|
|
176
337
|
# Categorize changes
|
|
@@ -808,14 +969,26 @@ def run_semver(
|
|
|
808
969
|
"""Classify the semver bump for a spec change.
|
|
809
970
|
|
|
810
971
|
Returns detailed breakdown: bump level, per-category counts,
|
|
811
|
-
and optionally the bumped version string.
|
|
972
|
+
and optionally the bumped version string. Auto-detects OpenAPI vs
|
|
973
|
+
JSON Schema (LED-713).
|
|
812
974
|
"""
|
|
813
|
-
from core.diff_engine_v2 import OpenAPIDiffEngine
|
|
814
|
-
from core.semver_classifier import classify_detailed, bump_version, classify
|
|
815
|
-
|
|
816
975
|
old = _load_specs(old_spec)
|
|
817
976
|
new = _load_specs(new_spec)
|
|
818
977
|
|
|
978
|
+
# LED-713: JSON Schema path
|
|
979
|
+
if _spec_type(new) == "json_schema" or _spec_type(old) == "json_schema":
|
|
980
|
+
from core.json_schema_diff import JSONSchemaDiffEngine
|
|
981
|
+
engine = JSONSchemaDiffEngine()
|
|
982
|
+
changes = engine.compare(old, new)
|
|
983
|
+
result = _json_schema_semver(changes)
|
|
984
|
+
if current_version:
|
|
985
|
+
result["current_version"] = current_version
|
|
986
|
+
result["next_version"] = _bump_semver_version(current_version, result["bump"])
|
|
987
|
+
return result
|
|
988
|
+
|
|
989
|
+
from core.diff_engine_v2 import OpenAPIDiffEngine
|
|
990
|
+
from core.semver_classifier import classify_detailed, bump_version, classify
|
|
991
|
+
|
|
819
992
|
engine = OpenAPIDiffEngine()
|
|
820
993
|
changes = engine.compare(old, new)
|
|
821
994
|
result = classify_detailed(changes)
|
|
@@ -946,7 +1119,6 @@ def run_diff_report(
|
|
|
946
1119
|
"""
|
|
947
1120
|
from datetime import datetime, timezone
|
|
948
1121
|
|
|
949
|
-
from core.diff_engine_v2 import OpenAPIDiffEngine
|
|
950
1122
|
from core.policy_engine import PolicyEngine
|
|
951
1123
|
from core.semver_classifier import classify_detailed, classify
|
|
952
1124
|
from core.spec_health import score_spec
|
|
@@ -955,6 +1127,43 @@ def run_diff_report(
|
|
|
955
1127
|
old = _load_specs(old_spec)
|
|
956
1128
|
new = _load_specs(new_spec)
|
|
957
1129
|
|
|
1130
|
+
# LED-713: JSON Schema dispatch — short-circuit to a minimal report
|
|
1131
|
+
# shape compatible with the JSON renderer (HTML renderer remains
|
|
1132
|
+
# OpenAPI-only; JSON Schema callers should use fmt="json").
|
|
1133
|
+
if _spec_type(new) == "json_schema" or _spec_type(old) == "json_schema":
|
|
1134
|
+
from core.json_schema_diff import JSONSchemaDiffEngine
|
|
1135
|
+
js_engine = JSONSchemaDiffEngine()
|
|
1136
|
+
js_changes = js_engine.compare(old, new)
|
|
1137
|
+
js_breaking = [c for c in js_changes if c.is_breaking]
|
|
1138
|
+
js_semver = _json_schema_semver(js_changes)
|
|
1139
|
+
now_js = datetime.now(timezone.utc)
|
|
1140
|
+
return {
|
|
1141
|
+
"format": fmt,
|
|
1142
|
+
"spec_type": "json_schema",
|
|
1143
|
+
"generated_at": now_js.isoformat(),
|
|
1144
|
+
"old_spec": old_spec,
|
|
1145
|
+
"new_spec": new_spec,
|
|
1146
|
+
"old_title": old.get("title", "") if isinstance(old, dict) else "",
|
|
1147
|
+
"new_title": new.get("title", "") if isinstance(new, dict) else "",
|
|
1148
|
+
"semver": js_semver,
|
|
1149
|
+
"changes": _json_schema_changes_to_dicts(js_changes),
|
|
1150
|
+
"breaking_count": len(js_breaking),
|
|
1151
|
+
"non_breaking_count": len(js_changes) - len(js_breaking),
|
|
1152
|
+
"total_changes": len(js_changes),
|
|
1153
|
+
"policy": {
|
|
1154
|
+
"decision": "pass",
|
|
1155
|
+
"violations": [],
|
|
1156
|
+
"errors": 0,
|
|
1157
|
+
"warnings": 0,
|
|
1158
|
+
},
|
|
1159
|
+
"health": None,
|
|
1160
|
+
"migration": "",
|
|
1161
|
+
"output_file": output_file,
|
|
1162
|
+
"note": "JSON Schema report (policy rules and HTML report are OpenAPI-only in v1)",
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
from core.diff_engine_v2 import OpenAPIDiffEngine
|
|
1166
|
+
|
|
958
1167
|
# -- Diff --
|
|
959
1168
|
engine = OpenAPIDiffEngine()
|
|
960
1169
|
changes = engine.compare(old, new)
|
|
@@ -158,21 +158,80 @@ def config_audit(target: str = ".", options: Optional[Dict] = None) -> Dict[str,
|
|
|
158
158
|
# ─── EvidencePack ───────────────────────────────────────────────────────
|
|
159
159
|
|
|
160
160
|
def evidence_collect(target: str = ".", options: Optional[Dict] = None) -> Dict[str, Any]:
|
|
161
|
-
"""Collect project evidence: git log, test files, configs, governance data.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
161
|
+
"""Collect project evidence: git log, test files, configs, governance data.
|
|
162
|
+
|
|
163
|
+
Accepts either a local filesystem path (repo directory) or a remote
|
|
164
|
+
reference (GitHub URL, owner/repo#N, or any non-filesystem string).
|
|
165
|
+
Remote targets skip the filesystem walk and store reference metadata.
|
|
166
|
+
"""
|
|
167
|
+
import re
|
|
168
|
+
import subprocess
|
|
169
|
+
import time as _time
|
|
170
|
+
|
|
171
|
+
opts = options or {}
|
|
172
|
+
evidence_type = opts.get("evidence_type", "")
|
|
173
|
+
|
|
174
|
+
# Detect non-filesystem targets: URLs, owner/repo#N, bare issue refs, etc.
|
|
175
|
+
is_remote = (
|
|
176
|
+
"://" in target
|
|
177
|
+
or target.startswith("http")
|
|
178
|
+
or re.match(r"^[\w.-]+/[\w.-]+#\d+$", target) is not None
|
|
179
|
+
or "#" in target
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
evidence: Dict[str, Any] = {"collected_at": _time.time(), "target": target}
|
|
183
|
+
if evidence_type:
|
|
184
|
+
evidence["evidence_type"] = evidence_type
|
|
185
|
+
|
|
186
|
+
if is_remote:
|
|
187
|
+
# Remote/reference target — no filesystem walk, just record metadata.
|
|
188
|
+
evidence["target_type"] = "remote"
|
|
170
189
|
evidence["git_log"] = []
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
190
|
+
evidence["test_directories"] = []
|
|
191
|
+
evidence["configs"] = []
|
|
192
|
+
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", target)
|
|
193
|
+
if m:
|
|
194
|
+
evidence["repo"] = f"{m.group(1)}/{m.group(2)}"
|
|
195
|
+
evidence["issue_number"] = int(m.group(3))
|
|
196
|
+
else:
|
|
197
|
+
root = Path(target).resolve()
|
|
198
|
+
evidence["target"] = str(root)
|
|
199
|
+
evidence["target_type"] = "local"
|
|
200
|
+
|
|
201
|
+
if not root.exists():
|
|
202
|
+
return {
|
|
203
|
+
"tool": "evidence.collect",
|
|
204
|
+
"status": "error",
|
|
205
|
+
"error": "target_not_found",
|
|
206
|
+
"message": f"Path {root} does not exist. For remote targets, pass a URL or owner/repo#N.",
|
|
207
|
+
"target": target,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# Git log (safe for non-git dirs)
|
|
211
|
+
try:
|
|
212
|
+
r = subprocess.run(
|
|
213
|
+
["git", "-C", str(root), "log", "--oneline", "-10"],
|
|
214
|
+
capture_output=True, text=True, timeout=10,
|
|
215
|
+
)
|
|
216
|
+
evidence["git_log"] = r.stdout.strip().splitlines() if r.returncode == 0 else []
|
|
217
|
+
except Exception:
|
|
218
|
+
evidence["git_log"] = []
|
|
219
|
+
|
|
220
|
+
# Test dirs + configs (only if target is a directory)
|
|
221
|
+
if root.is_dir():
|
|
222
|
+
test_dirs = [d for d in ["tests", "test", "__tests__", "spec"] if (root / d).exists()]
|
|
223
|
+
evidence["test_directories"] = test_dirs
|
|
224
|
+
try:
|
|
225
|
+
evidence["configs"] = [
|
|
226
|
+
f.name for f in root.iterdir()
|
|
227
|
+
if f.is_file() and (f.suffix in [".json", ".yaml", ".yml", ".toml"] or f.name.startswith("."))
|
|
228
|
+
]
|
|
229
|
+
except (PermissionError, OSError):
|
|
230
|
+
evidence["configs"] = []
|
|
231
|
+
else:
|
|
232
|
+
evidence["test_directories"] = []
|
|
233
|
+
evidence["configs"] = []
|
|
234
|
+
|
|
176
235
|
# Save bundle
|
|
177
236
|
ev_dir = Path(os.environ.get("DELIMIT_HOME", str(Path.home() / ".delimit"))) / "evidence"
|
|
178
237
|
ev_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -180,8 +239,13 @@ def evidence_collect(target: str = ".", options: Optional[Dict] = None) -> Dict[
|
|
|
180
239
|
bundle_path = ev_dir / f"{bundle_id}.json"
|
|
181
240
|
evidence["bundle_id"] = bundle_id
|
|
182
241
|
bundle_path.write_text(json.dumps(evidence, indent=2))
|
|
183
|
-
return {
|
|
184
|
-
|
|
242
|
+
return {
|
|
243
|
+
"tool": "evidence.collect",
|
|
244
|
+
"status": "ok",
|
|
245
|
+
"bundle_id": bundle_id,
|
|
246
|
+
"bundle_path": str(bundle_path),
|
|
247
|
+
"summary": {k: len(v) if isinstance(v, list) else v for k, v in evidence.items()},
|
|
248
|
+
}
|
|
185
249
|
|
|
186
250
|
|
|
187
251
|
def evidence_verify(bundle_id: Optional[str] = None, bundle_path: Optional[str] = None, options: Optional[Dict] = None) -> Dict[str, Any]:
|
|
@@ -941,7 +941,12 @@ def run_governed_iteration(session_id: str, hardening: Optional[Any] = None) ->
|
|
|
941
941
|
session["cost_incurred"] += cost
|
|
942
942
|
|
|
943
943
|
from ai.ledger_manager import update_item
|
|
944
|
-
|
|
944
|
+
dispatch_status = dispatch_result.get("status")
|
|
945
|
+
# "completed" = synchronous success (loop engine closes the ledger).
|
|
946
|
+
# "dispatched" = swarm handed the task to an agent; the ledger stays
|
|
947
|
+
# in_progress until the agent reports back via delimit_agent_complete.
|
|
948
|
+
# Both are success outcomes from the loop's perspective.
|
|
949
|
+
if dispatch_status == "completed":
|
|
945
950
|
update_item(
|
|
946
951
|
item_id=task["id"],
|
|
947
952
|
status="done",
|
|
@@ -964,6 +969,35 @@ def run_governed_iteration(session_id: str, hardening: Optional[Any] = None) ->
|
|
|
964
969
|
)
|
|
965
970
|
except Exception as e:
|
|
966
971
|
logger.warning("Failed to notify deploy loop for %s: %s", task.get("id"), e)
|
|
972
|
+
elif dispatch_status == "dispatched":
|
|
973
|
+
# Async handoff: mark ledger in_progress, leave closure to the agent.
|
|
974
|
+
dispatched_task_id = dispatch_result.get("task_id", "")
|
|
975
|
+
try:
|
|
976
|
+
update_item(
|
|
977
|
+
item_id=task["id"],
|
|
978
|
+
status="in_progress",
|
|
979
|
+
note=(
|
|
980
|
+
f"Dispatched to swarm agent via governed build loop "
|
|
981
|
+
f"(swarm task_id={dispatched_task_id}). Awaiting agent completion."
|
|
982
|
+
),
|
|
983
|
+
project_path=str(ROOT_LEDGER_PATH),
|
|
984
|
+
)
|
|
985
|
+
except Exception as e:
|
|
986
|
+
logger.warning("Failed to mark %s in_progress after dispatch: %s", task.get("id"), e)
|
|
987
|
+
session["tasks_completed"].append({
|
|
988
|
+
"id": task["id"],
|
|
989
|
+
"status": "dispatched",
|
|
990
|
+
"swarm_task_id": dispatched_task_id,
|
|
991
|
+
"duration": duration,
|
|
992
|
+
"cost": cost,
|
|
993
|
+
})
|
|
994
|
+
elif dispatch_status == "blocked":
|
|
995
|
+
# Founder-approval gate — not a failure, don't trip the breaker.
|
|
996
|
+
session["tasks_completed"].append({
|
|
997
|
+
"id": task["id"],
|
|
998
|
+
"status": "blocked",
|
|
999
|
+
"reason": dispatch_result.get("reason", "Requires founder approval"),
|
|
1000
|
+
})
|
|
967
1001
|
else:
|
|
968
1002
|
session["errors"] += 1
|
|
969
1003
|
if session["errors"] >= session["error_threshold"]:
|
|
@@ -971,7 +1005,7 @@ def run_governed_iteration(session_id: str, hardening: Optional[Any] = None) ->
|
|
|
971
1005
|
session["tasks_completed"].append({
|
|
972
1006
|
"id": task["id"],
|
|
973
1007
|
"status": "failed",
|
|
974
|
-
"error": dispatch_result.get("error", "Dispatch failed")
|
|
1008
|
+
"error": dispatch_result.get("error", f"Dispatch failed (status={dispatch_status!r})"),
|
|
975
1009
|
})
|
|
976
1010
|
|
|
977
1011
|
_save_session(session)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Generator drift detection (LED-713).
|
|
2
|
+
|
|
3
|
+
Detects when a committed generated artifact (e.g. agentspec's
|
|
4
|
+
schemas/v1/agent.schema.json regenerated from a Zod source) has drifted
|
|
5
|
+
from what its generator script would produce today.
|
|
6
|
+
|
|
7
|
+
Use case: a maintainer changes the source of truth (Zod schema, OpenAPI
|
|
8
|
+
generator, protobuf, etc.) but forgets to regenerate and commit the
|
|
9
|
+
artifact. CI catches the drift before the stale generated file ships.
|
|
10
|
+
|
|
11
|
+
Generic over generators — caller supplies the regen command and the
|
|
12
|
+
artifact path. Returns a structured drift report that can be merged into
|
|
13
|
+
the standard delimit-action PR comment.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shlex
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import tempfile
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Dict, List, Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DriftResult:
|
|
31
|
+
drifted: bool
|
|
32
|
+
artifact_path: str
|
|
33
|
+
regen_command: str
|
|
34
|
+
changes: List[Any] = field(default_factory=list) # JSONSchemaChange list when drift detected
|
|
35
|
+
error: Optional[str] = None
|
|
36
|
+
runtime_seconds: float = 0.0
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"drifted": self.drifted,
|
|
41
|
+
"artifact_path": self.artifact_path,
|
|
42
|
+
"regen_command": self.regen_command,
|
|
43
|
+
"change_count": len(self.changes),
|
|
44
|
+
"changes": [
|
|
45
|
+
{
|
|
46
|
+
"type": c.type.value,
|
|
47
|
+
"path": c.path,
|
|
48
|
+
"message": c.message,
|
|
49
|
+
"is_breaking": c.is_breaking,
|
|
50
|
+
}
|
|
51
|
+
for c in self.changes
|
|
52
|
+
],
|
|
53
|
+
"error": self.error,
|
|
54
|
+
"runtime_seconds": round(self.runtime_seconds, 3),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def detect_drift(
|
|
59
|
+
repo_root: str,
|
|
60
|
+
artifact_path: str,
|
|
61
|
+
regen_command: str,
|
|
62
|
+
timeout_seconds: int = 60,
|
|
63
|
+
) -> DriftResult:
|
|
64
|
+
"""Check whether the committed artifact matches its generator output.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
repo_root: Absolute path to the repo checkout.
|
|
68
|
+
artifact_path: Path to the generated artifact, relative to repo_root.
|
|
69
|
+
regen_command: Shell command that regenerates the artifact in place.
|
|
70
|
+
Example: "pnpm -r run build" or "node packages/sdk/dist/scripts/export-schema.js"
|
|
71
|
+
timeout_seconds: Hard timeout for the generator (default 60).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
DriftResult with drift status, classified changes, and runtime.
|
|
75
|
+
"""
|
|
76
|
+
import time
|
|
77
|
+
|
|
78
|
+
repo_root_p = Path(repo_root).resolve()
|
|
79
|
+
artifact_p = (repo_root_p / artifact_path).resolve()
|
|
80
|
+
|
|
81
|
+
if not artifact_p.exists():
|
|
82
|
+
return DriftResult(
|
|
83
|
+
drifted=False,
|
|
84
|
+
artifact_path=artifact_path,
|
|
85
|
+
regen_command=regen_command,
|
|
86
|
+
error=f"Artifact not found: {artifact_path}",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Snapshot the committed artifact before regen
|
|
90
|
+
try:
|
|
91
|
+
committed_text = artifact_p.read_text()
|
|
92
|
+
committed_doc = json.loads(committed_text)
|
|
93
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
94
|
+
return DriftResult(
|
|
95
|
+
drifted=False,
|
|
96
|
+
artifact_path=artifact_path,
|
|
97
|
+
regen_command=regen_command,
|
|
98
|
+
error=f"Failed to read committed artifact: {e}",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Parse the command safely — shell=False to avoid command injection.
|
|
102
|
+
# Users needing shell features (&&, |, env vars, etc.) should point
|
|
103
|
+
# generator_command at a script file instead of an inline chain.
|
|
104
|
+
try:
|
|
105
|
+
argv = shlex.split(regen_command)
|
|
106
|
+
except ValueError as e:
|
|
107
|
+
return DriftResult(
|
|
108
|
+
drifted=False,
|
|
109
|
+
artifact_path=artifact_path,
|
|
110
|
+
regen_command=regen_command,
|
|
111
|
+
error=f"Could not parse generator_command: {e}",
|
|
112
|
+
)
|
|
113
|
+
if not argv:
|
|
114
|
+
return DriftResult(
|
|
115
|
+
drifted=False,
|
|
116
|
+
artifact_path=artifact_path,
|
|
117
|
+
regen_command=regen_command,
|
|
118
|
+
error="generator_command is empty",
|
|
119
|
+
)
|
|
120
|
+
# Reject obvious shell metacharacters — force users to use a script
|
|
121
|
+
# file if they need chaining or redirection.
|
|
122
|
+
SHELL_META = set("&|;><`$")
|
|
123
|
+
if any(ch in token for token in argv for ch in SHELL_META):
|
|
124
|
+
return DriftResult(
|
|
125
|
+
drifted=False,
|
|
126
|
+
artifact_path=artifact_path,
|
|
127
|
+
regen_command=regen_command,
|
|
128
|
+
error="generator_command contains shell metacharacters (&|;><`$). Point it at a script file instead of chaining inline.",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Run the regenerator
|
|
132
|
+
start = time.time()
|
|
133
|
+
try:
|
|
134
|
+
result = subprocess.run(
|
|
135
|
+
argv,
|
|
136
|
+
shell=False,
|
|
137
|
+
cwd=str(repo_root_p),
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
timeout=timeout_seconds,
|
|
141
|
+
)
|
|
142
|
+
except subprocess.TimeoutExpired:
|
|
143
|
+
return DriftResult(
|
|
144
|
+
drifted=False,
|
|
145
|
+
artifact_path=artifact_path,
|
|
146
|
+
regen_command=regen_command,
|
|
147
|
+
error=f"Generator timed out after {timeout_seconds}s",
|
|
148
|
+
runtime_seconds=time.time() - start,
|
|
149
|
+
)
|
|
150
|
+
except FileNotFoundError as e:
|
|
151
|
+
return DriftResult(
|
|
152
|
+
drifted=False,
|
|
153
|
+
artifact_path=artifact_path,
|
|
154
|
+
regen_command=regen_command,
|
|
155
|
+
error=f"Generator executable not found: {e}",
|
|
156
|
+
runtime_seconds=time.time() - start,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
runtime = time.time() - start
|
|
160
|
+
|
|
161
|
+
if result.returncode != 0:
|
|
162
|
+
return DriftResult(
|
|
163
|
+
drifted=False,
|
|
164
|
+
artifact_path=artifact_path,
|
|
165
|
+
regen_command=regen_command,
|
|
166
|
+
error=f"Generator exited {result.returncode}: {result.stderr.strip()[:500]}",
|
|
167
|
+
runtime_seconds=runtime,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Read the regenerated artifact
|
|
171
|
+
try:
|
|
172
|
+
regen_text = artifact_p.read_text()
|
|
173
|
+
regen_doc = json.loads(regen_text)
|
|
174
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
175
|
+
# Restore committed version so we don't leave the workspace dirty
|
|
176
|
+
artifact_p.write_text(committed_text)
|
|
177
|
+
return DriftResult(
|
|
178
|
+
drifted=False,
|
|
179
|
+
artifact_path=artifact_path,
|
|
180
|
+
regen_command=regen_command,
|
|
181
|
+
error=f"Failed to read regenerated artifact: {e}",
|
|
182
|
+
runtime_seconds=runtime,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Restore the committed file before diffing — leave the workspace clean
|
|
186
|
+
artifact_p.write_text(committed_text)
|
|
187
|
+
|
|
188
|
+
# Quick equality check first
|
|
189
|
+
if committed_doc == regen_doc:
|
|
190
|
+
return DriftResult(
|
|
191
|
+
drifted=False,
|
|
192
|
+
artifact_path=artifact_path,
|
|
193
|
+
regen_command=regen_command,
|
|
194
|
+
runtime_seconds=runtime,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Drift detected — classify the changes via the JSON Schema diff engine
|
|
198
|
+
from .json_schema_diff import JSONSchemaDiffEngine
|
|
199
|
+
|
|
200
|
+
engine = JSONSchemaDiffEngine()
|
|
201
|
+
changes = engine.compare(committed_doc, regen_doc)
|
|
202
|
+
return DriftResult(
|
|
203
|
+
drifted=True,
|
|
204
|
+
artifact_path=artifact_path,
|
|
205
|
+
regen_command=regen_command,
|
|
206
|
+
changes=changes,
|
|
207
|
+
runtime_seconds=runtime,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def format_drift_report(result: DriftResult) -> str:
|
|
212
|
+
"""Render a drift report as a markdown block for PR comments."""
|
|
213
|
+
if result.error:
|
|
214
|
+
return (
|
|
215
|
+
f"### Generator drift check\n\n"
|
|
216
|
+
f"Artifact: `{result.artifact_path}` \n"
|
|
217
|
+
f"Status: error \n"
|
|
218
|
+
f"Detail: {result.error}\n"
|
|
219
|
+
)
|
|
220
|
+
if not result.drifted:
|
|
221
|
+
return (
|
|
222
|
+
f"### Generator drift check\n\n"
|
|
223
|
+
f"Artifact: `{result.artifact_path}` \n"
|
|
224
|
+
f"Status: clean (committed artifact matches generator output) \n"
|
|
225
|
+
f"Generator runtime: {result.runtime_seconds:.2f}s\n"
|
|
226
|
+
)
|
|
227
|
+
breaking = sum(1 for c in result.changes if c.is_breaking)
|
|
228
|
+
non_breaking = len(result.changes) - breaking
|
|
229
|
+
lines = [
|
|
230
|
+
"### Generator drift check",
|
|
231
|
+
"",
|
|
232
|
+
f"Artifact: `{result.artifact_path}` ",
|
|
233
|
+
f"Status: drifted ({len(result.changes)} change(s) — {breaking} breaking, {non_breaking} non-breaking) ",
|
|
234
|
+
f"Generator runtime: {result.runtime_seconds:.2f}s ",
|
|
235
|
+
"",
|
|
236
|
+
"The committed artifact does not match what the generator produces today. Re-run the generator and commit the result, or revert the source change.",
|
|
237
|
+
"",
|
|
238
|
+
]
|
|
239
|
+
for c in result.changes:
|
|
240
|
+
marker = "breaking" if c.is_breaking else "ok"
|
|
241
|
+
lines.append(f"- [{marker}] {c.type.value} at `{c.path}` — {c.message}")
|
|
242
|
+
return "\n".join(lines) + "\n"
|
|
@@ -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.
|
|
4
|
+
"version": "4.1.51",
|
|
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": [
|