delimit-cli 4.1.49 → 4.1.50

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.1.50] - 2026-04-09
4
+
5
+ ### Fixed (CRITICAL — CLAUDE.md in-prose marker clobber)
6
+ - **`upsertDelimitSection` regex was unanchored** — `bin/delimit-setup.js` used `/<!-- delimit:start[^>]*-->/` (no line anchors) to detect the managed-section markers. If a user *quoted* the markers inside backticks in a documentation bullet (e.g. `Use managed-section markers (\`<!-- delimit:start -->\` / \`<!-- delimit:end -->\`)`), the regex matched the prose mention. On the next `delimit setup` run the upsert sliced everything between the prose start and prose end markers and replaced it with a fresh stock template — the exact "never clobber user-customized files" failure mode 4.1.48 / 4.1.49 were written to prevent. Reproduced on `/root/CLAUDE.md` 2026-04-09.
7
+ - Both markers are now anchored to start-of-line with the multiline flag, allow optional leading horizontal whitespace (`/^[ \t]*<!-- delimit:start[^>]*-->[ \t]*$/m`), and the file is BOM-stripped before matching. Result: documentation prose that quotes the markers inside backticks, blockquotes (`> `), or bullets (`- `, `* `) is never matched, while genuine markers — flush-left, indented, or BOM-prefixed — still are. Same fix applied to the test mirror in `tests/setup-onboarding.test.js`.
8
+
9
+ ### Added
10
+ - **Regression tests** in `tests/setup-onboarding.test.js` covering five failure modes the v4.1.49 regex would have matched incorrectly:
11
+ - Markers quoted in a bullet via inline backticks (the exact /root/CLAUDE.md 2026-04-09 incident)
12
+ - Markers with a CRLF (`\r\n`) line ending
13
+ - File starting with a UTF-8 BOM
14
+ - Tab- and space-indented real markers (must still be recognized)
15
+ - Bullet- and blockquote-prefixed markers (must NOT be recognized)
16
+ Each test asserts both the first-run behavior (`appended` vs `updated`) and that user content survives a subsequent version-bump upgrade verbatim.
17
+
18
+ ### Scope
19
+ - Single-purpose patch: CLAUDE.md preservation only. Unrelated gateway fixes (e.g. `loop_engine` LED-814) deferred to 4.1.51 per multi-model deliberation, since 4.1.48 and 4.1.49 both shipped with the regression bug undetected and 4.1.50 must stay laser-focused.
20
+
21
+ ### Tests
22
+ - 134/134 npm CLI tests passing (was 129). New regression suite (`does not match markers quoted in prose`, CRLF, BOM, indented, bullet/blockquote-prefixed) covers every edge case the multi-model deliberation surfaced.
23
+
3
24
  ## [4.1.49] - 2026-04-09
4
25
 
5
26
  ### Fixed (full preservation audit follow-up to 4.1.48)
@@ -1362,24 +1362,38 @@ function upsertDelimitSection(filePath) {
1362
1362
  return { action: 'created' };
1363
1363
  }
1364
1364
 
1365
- const existing = fs.readFileSync(filePath, 'utf-8');
1366
-
1367
- // Check if managed markers already exist
1368
- const startMarkerRe = /<!-- delimit:start[^>]*-->/;
1369
- const endMarker = '<!-- delimit:end -->';
1370
- const hasStart = startMarkerRe.test(existing);
1371
- const hasEnd = existing.includes(endMarker);
1365
+ const rawExisting = fs.readFileSync(filePath, 'utf-8');
1366
+ // Strip a UTF-8 BOM if present so the start-of-line anchor still matches
1367
+ // the very first line of the file. We write back the stripped form to keep
1368
+ // serialization deterministic.
1369
+ const existing = rawExisting.replace(/^\uFEFF/, '');
1370
+
1371
+ // Check if managed markers already exist.
1372
+ // Markers MUST be on their own line — anchored with the multiline flag — so
1373
+ // that documentation prose that quotes the markers (e.g. inside backticks,
1374
+ // bullets, or blockquotes) does NOT get mistaken for a real managed section.
1375
+ // The v4.1.49 unanchored regex caused exactly this clobber on /root/CLAUDE.md.
1376
+ // We allow optional leading horizontal whitespace ([ \t]*) so genuinely
1377
+ // indented markers still match, but NOT a leading "- ", "> ", "`", "*", etc.
1378
+ const startMarkerRe = /^[ \t]*<!-- delimit:start[^>]*-->[ \t]*$/m;
1379
+ const endMarkerRe = /^[ \t]*<!-- delimit:end -->[ \t]*$/m;
1380
+ const startMatch = existing.match(startMarkerRe);
1381
+ const endMatch = existing.match(endMarkerRe);
1382
+ const hasStart = !!startMatch;
1383
+ const hasEnd = !!endMatch;
1372
1384
 
1373
1385
  if (hasStart && hasEnd) {
1374
- // Extract current version from the marker
1375
- const versionMatch = existing.match(/<!-- delimit:start v([^ ]+) -->/);
1386
+ // Extract current version from the marker (also anchored, allows indent)
1387
+ const versionMatch = existing.match(/^[ \t]*<!-- delimit:start v([^ ]+) -->[ \t]*$/m);
1376
1388
  const currentVersion = versionMatch ? versionMatch[1] : '';
1377
1389
  if (currentVersion === version) {
1378
1390
  return { action: 'unchanged' };
1379
1391
  }
1380
1392
  // Replace only the managed region — preserve content above/below
1381
- const before = existing.substring(0, existing.search(startMarkerRe));
1382
- const after = existing.substring(existing.indexOf(endMarker) + endMarker.length);
1393
+ const startIdx = startMatch.index;
1394
+ const endIdx = endMatch.index + endMatch[0].length;
1395
+ const before = existing.substring(0, startIdx);
1396
+ const after = existing.substring(endIdx);
1383
1397
  fs.writeFileSync(filePath, before + newSection + after);
1384
1398
  return { action: 'updated' };
1385
1399
  }
@@ -23,11 +23,10 @@ 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 API spec (OpenAPI or JSON Schema) from a file path.
26
+ """Load an OpenAPI spec 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.
31
30
  """
32
31
  import yaml
33
32
 
@@ -42,146 +41,15 @@ def _load_specs(spec_path: str) -> Dict[str, Any]:
42
41
  spec = json.loads(content)
43
42
 
44
43
  # 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.
47
44
  try:
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)
45
+ from core.openapi_version import assert_supported
46
+ assert_supported(spec, strict=False)
51
47
  except Exception as exc: # pragma: no cover -- defensive only
52
48
  logger.debug("openapi version check skipped: %s", exc)
53
49
 
54
50
  return spec
55
51
 
56
52
 
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
-
185
53
  def _read_jsonl(path: Path) -> List[Dict[str, Any]]:
186
54
  """Read JSONL entries from a file, skipping malformed lines."""
187
55
  items: List[Dict[str, Any]] = []
@@ -247,51 +115,29 @@ def run_lint(old_spec: str, new_spec: str, policy_file: Optional[str] = None) ->
247
115
  """Run the full lint pipeline: diff + policy evaluation.
248
116
 
249
117
  This is the Tier 1 primary tool — combines diff detection with
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.
118
+ policy enforcement into a single pass/fail decision.
253
119
  """
254
120
  from core.policy_engine import evaluate_with_policy
255
121
 
256
122
  old = _load_specs(old_spec)
257
123
  new = _load_specs(new_spec)
258
124
 
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
-
264
125
  return evaluate_with_policy(old, new, policy_file)
265
126
 
266
127
 
267
128
  def run_diff(old_spec: str, new_spec: str) -> Dict[str, Any]:
268
- """Run diff engine only — no policy evaluation.
129
+ """Run diff engine only — no policy evaluation."""
130
+ from core.diff_engine_v2 import OpenAPIDiffEngine
269
131
 
270
- Auto-detects OpenAPI vs JSON Schema and dispatches (LED-713).
271
- """
272
132
  old = _load_specs(old_spec)
273
133
  new = _load_specs(new_spec)
274
134
 
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
288
135
  engine = OpenAPIDiffEngine()
289
136
  changes = engine.compare(old, new)
290
137
 
291
138
  breaking = [c for c in changes if c.is_breaking]
292
139
 
293
140
  return {
294
- "spec_type": "openapi",
295
141
  "total_changes": len(changes),
296
142
  "breaking_changes": len(breaking),
297
143
  "changes": [
@@ -318,20 +164,13 @@ def run_changelog(
318
164
  Uses the diff engine to detect changes, then formats them into
319
165
  a human-readable changelog grouped by category.
320
166
  """
167
+ from core.diff_engine_v2 import OpenAPIDiffEngine
321
168
  from datetime import datetime, timezone
322
169
 
323
170
  old = _load_specs(old_spec)
324
171
  new = _load_specs(new_spec)
325
172
 
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
-
173
+ engine = OpenAPIDiffEngine()
335
174
  changes = engine.compare(old, new)
336
175
 
337
176
  # Categorize changes
@@ -969,26 +808,14 @@ def run_semver(
969
808
  """Classify the semver bump for a spec change.
970
809
 
971
810
  Returns detailed breakdown: bump level, per-category counts,
972
- and optionally the bumped version string. Auto-detects OpenAPI vs
973
- JSON Schema (LED-713).
811
+ and optionally the bumped version string.
974
812
  """
975
- old = _load_specs(old_spec)
976
- new = _load_specs(new_spec)
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
813
  from core.diff_engine_v2 import OpenAPIDiffEngine
990
814
  from core.semver_classifier import classify_detailed, bump_version, classify
991
815
 
816
+ old = _load_specs(old_spec)
817
+ new = _load_specs(new_spec)
818
+
992
819
  engine = OpenAPIDiffEngine()
993
820
  changes = engine.compare(old, new)
994
821
  result = classify_detailed(changes)
@@ -1119,6 +946,7 @@ def run_diff_report(
1119
946
  """
1120
947
  from datetime import datetime, timezone
1121
948
 
949
+ from core.diff_engine_v2 import OpenAPIDiffEngine
1122
950
  from core.policy_engine import PolicyEngine
1123
951
  from core.semver_classifier import classify_detailed, classify
1124
952
  from core.spec_health import score_spec
@@ -1127,43 +955,6 @@ def run_diff_report(
1127
955
  old = _load_specs(old_spec)
1128
956
  new = _load_specs(new_spec)
1129
957
 
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
-
1167
958
  # -- Diff --
1168
959
  engine = OpenAPIDiffEngine()
1169
960
  changes = engine.compare(old, new)
@@ -158,80 +158,21 @@ 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
- 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"
161
+ """Collect project evidence: git log, test files, configs, governance data."""
162
+ import subprocess, time as _time
163
+ root = Path(target).resolve()
164
+ evidence: Dict[str, Any] = {"collected_at": _time.time(), "target": str(root)}
165
+ # Git log
166
+ try:
167
+ r = subprocess.run(["git", "-C", str(root), "log", "--oneline", "-10"], capture_output=True, text=True, timeout=10)
168
+ evidence["git_log"] = r.stdout.strip().splitlines() if r.returncode == 0 else []
169
+ except Exception:
189
170
  evidence["git_log"] = []
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
-
171
+ # Test files
172
+ test_dirs = [d for d in ["tests", "test", "__tests__", "spec"] if (root / d).exists()]
173
+ evidence["test_directories"] = test_dirs
174
+ # Configs
175
+ evidence["configs"] = [f.name for f in root.iterdir() if f.is_file() and (f.suffix in [".json", ".yaml", ".yml", ".toml"] or f.name.startswith("."))]
235
176
  # Save bundle
236
177
  ev_dir = Path(os.environ.get("DELIMIT_HOME", str(Path.home() / ".delimit"))) / "evidence"
237
178
  ev_dir.mkdir(parents=True, exist_ok=True)
@@ -239,13 +180,8 @@ def evidence_collect(target: str = ".", options: Optional[Dict] = None) -> Dict[
239
180
  bundle_path = ev_dir / f"{bundle_id}.json"
240
181
  evidence["bundle_id"] = bundle_id
241
182
  bundle_path.write_text(json.dumps(evidence, indent=2))
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
- }
183
+ return {"tool": "evidence.collect", "status": "ok", "bundle_id": bundle_id,
184
+ "bundle_path": str(bundle_path), "summary": {k: len(v) if isinstance(v, list) else v for k, v in evidence.items()}}
249
185
 
250
186
 
251
187
  def evidence_verify(bundle_id: Optional[str] = None, bundle_path: Optional[str] = None, options: Optional[Dict] = None) -> Dict[str, Any]:
@@ -3,7 +3,7 @@ Automatic OpenAPI specification detector for zero-config installation.
3
3
  """
4
4
 
5
5
  import os
6
- from typing import Any, List, Optional, Tuple
6
+ from typing import 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,66 +86,26 @@ 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
-
149
109
  def auto_detect_specs(root_path: str = ".") -> dict:
150
110
  """
151
111
  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.49",
4
+ "version": "4.1.50",
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": [
@@ -1,242 +0,0 @@
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"
@@ -1,375 +0,0 @@
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