delimit-cli 2.4.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +7 -0
- package/.github/workflows/ci.yml +22 -0
- package/CODE_OF_CONDUCT.md +48 -0
- package/CONTRIBUTING.md +67 -0
- package/Dockerfile +9 -0
- package/LICENSE +21 -0
- package/README.md +18 -69
- package/SECURITY.md +42 -0
- package/adapters/gemini-forge.js +11 -0
- package/adapters/gemini-jamsons.js +152 -0
- package/bin/delimit-cli.js +8 -0
- package/bin/delimit-setup.js +258 -0
- package/gateway/ai/backends/__init__.py +0 -0
- package/gateway/ai/backends/async_utils.py +21 -0
- package/gateway/ai/backends/deploy_bridge.py +150 -0
- package/gateway/ai/backends/gateway_core.py +261 -0
- package/gateway/ai/backends/generate_bridge.py +38 -0
- package/gateway/ai/backends/governance_bridge.py +196 -0
- package/gateway/ai/backends/intel_bridge.py +59 -0
- package/gateway/ai/backends/memory_bridge.py +93 -0
- package/gateway/ai/backends/ops_bridge.py +137 -0
- package/gateway/ai/backends/os_bridge.py +82 -0
- package/gateway/ai/backends/repo_bridge.py +117 -0
- package/gateway/ai/backends/ui_bridge.py +118 -0
- package/gateway/ai/backends/vault_bridge.py +129 -0
- package/gateway/ai/server.py +1182 -0
- package/gateway/core/__init__.py +3 -0
- package/gateway/core/__pycache__/__init__.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/auto_baseline.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/ci_formatter.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/contract_ledger.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/dependency_graph.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/dependency_manifest.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/diff_engine_v2.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/event_backbone.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/event_schema.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/explainer.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/gateway.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/gateway_v2.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/gateway_v3.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/impact_analyzer.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/policy_engine.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/registry.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/registry_v2.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/registry_v3.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/semver_classifier.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/spec_detector.cpython-310.pyc +0 -0
- package/gateway/core/__pycache__/surface_bridge.cpython-310.pyc +0 -0
- package/gateway/core/auto_baseline.py +304 -0
- package/gateway/core/ci_formatter.py +283 -0
- package/gateway/core/complexity_analyzer.py +386 -0
- package/gateway/core/contract_ledger.py +345 -0
- package/gateway/core/dependency_graph.py +218 -0
- package/gateway/core/dependency_manifest.py +223 -0
- package/gateway/core/diff_engine_v2.py +477 -0
- package/gateway/core/diff_engine_v2.py.bak +426 -0
- package/gateway/core/event_backbone.py +268 -0
- package/gateway/core/event_schema.py +258 -0
- package/gateway/core/explainer.py +438 -0
- package/gateway/core/gateway.py +128 -0
- package/gateway/core/gateway_v2.py +154 -0
- package/gateway/core/gateway_v3.py +224 -0
- package/gateway/core/impact_analyzer.py +163 -0
- package/gateway/core/policies/default.yml +13 -0
- package/gateway/core/policies/relaxed.yml +48 -0
- package/gateway/core/policies/strict.yml +55 -0
- package/gateway/core/policy_engine.py +464 -0
- package/gateway/core/registry.py +52 -0
- package/gateway/core/registry_v2.py +132 -0
- package/gateway/core/registry_v3.py +134 -0
- package/gateway/core/semver_classifier.py +152 -0
- package/gateway/core/spec_detector.py +130 -0
- package/gateway/core/surface_bridge.py +307 -0
- package/gateway/core/zero_spec/__init__.py +4 -0
- package/gateway/core/zero_spec/__pycache__/__init__.cpython-310.pyc +0 -0
- package/gateway/core/zero_spec/__pycache__/detector.cpython-310.pyc +0 -0
- package/gateway/core/zero_spec/__pycache__/express_extractor.cpython-310.pyc +0 -0
- package/gateway/core/zero_spec/__pycache__/fastapi_extractor.cpython-310.pyc +0 -0
- package/gateway/core/zero_spec/__pycache__/nestjs_extractor.cpython-310.pyc +0 -0
- package/gateway/core/zero_spec/detector.py +353 -0
- package/gateway/core/zero_spec/express_extractor.py +483 -0
- package/gateway/core/zero_spec/fastapi_extractor.py +254 -0
- package/gateway/core/zero_spec/nestjs_extractor.py +369 -0
- package/gateway/tasks/__init__.py +1 -0
- package/gateway/tasks/__pycache__/__init__.cpython-310.pyc +0 -0
- package/gateway/tasks/__pycache__/check_policy.cpython-310.pyc +0 -0
- package/gateway/tasks/__pycache__/check_policy_v2.cpython-310.pyc +0 -0
- package/gateway/tasks/__pycache__/check_policy_v3.cpython-310.pyc +0 -0
- package/gateway/tasks/__pycache__/explain_diff.cpython-310.pyc +0 -0
- package/gateway/tasks/__pycache__/explain_diff_v2.cpython-310.pyc +0 -0
- package/gateway/tasks/__pycache__/validate_api.cpython-310.pyc +0 -0
- package/gateway/tasks/__pycache__/validate_api_v2.cpython-310.pyc +0 -0
- package/gateway/tasks/__pycache__/validate_api_v3.cpython-310.pyc +0 -0
- package/gateway/tasks/check_policy.py +177 -0
- package/gateway/tasks/check_policy_v2.py +255 -0
- package/gateway/tasks/check_policy_v3.py +255 -0
- package/gateway/tasks/explain_diff.py +305 -0
- package/gateway/tasks/explain_diff_v2.py +267 -0
- package/gateway/tasks/validate_api.py +131 -0
- package/gateway/tasks/validate_api_v2.py +208 -0
- package/gateway/tasks/validate_api_v3.py +163 -0
- package/package.json +2 -2
- package/adapters/codex-skill.js +0 -87
- package/adapters/cursor-extension.js +0 -190
- package/adapters/gemini-action.js +0 -93
- package/adapters/openai-function.js +0 -112
- package/adapters/xai-plugin.js +0 -151
- package/test-decision-engine.js +0 -181
- package/test-hook.js +0 -27
- package/tests/cli.test.js +0 -359
- package/tests/fixtures/openapi-changed.yaml +0 -56
- package/tests/fixtures/openapi.yaml +0 -87
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Delimit Policy Preset: relaxed
|
|
2
|
+
# For internal APIs, early-stage startups, and rapid iteration.
|
|
3
|
+
# Only blocks the most destructive changes. Everything else is a warning.
|
|
4
|
+
|
|
5
|
+
override_defaults: true
|
|
6
|
+
|
|
7
|
+
rules:
|
|
8
|
+
- id: no_endpoint_removal
|
|
9
|
+
name: Warn on Endpoint Removal
|
|
10
|
+
change_types: [endpoint_removed]
|
|
11
|
+
severity: warning
|
|
12
|
+
action: warn
|
|
13
|
+
message: "Endpoint {path} was removed. Make sure no consumers depend on it."
|
|
14
|
+
|
|
15
|
+
- id: no_method_removal
|
|
16
|
+
name: Warn on Method Removal
|
|
17
|
+
change_types: [method_removed]
|
|
18
|
+
severity: warning
|
|
19
|
+
action: warn
|
|
20
|
+
message: "HTTP method removed from {path}. Check downstream consumers."
|
|
21
|
+
|
|
22
|
+
- id: warn_required_param
|
|
23
|
+
name: Warn on Required Parameter Addition
|
|
24
|
+
change_types: [required_param_added]
|
|
25
|
+
severity: warning
|
|
26
|
+
action: warn
|
|
27
|
+
message: "New required parameter at {path}. Existing clients will need to update."
|
|
28
|
+
|
|
29
|
+
- id: warn_type_change
|
|
30
|
+
name: Warn on Type Changes
|
|
31
|
+
change_types: [type_changed]
|
|
32
|
+
severity: warning
|
|
33
|
+
action: warn
|
|
34
|
+
message: "Type changed at {path}. Verify client compatibility."
|
|
35
|
+
|
|
36
|
+
- id: allow_field_removal
|
|
37
|
+
name: Allow Field Removal (warn)
|
|
38
|
+
change_types: [field_removed]
|
|
39
|
+
severity: info
|
|
40
|
+
action: allow
|
|
41
|
+
message: "Field removed from {path}."
|
|
42
|
+
|
|
43
|
+
- id: allow_enum_changes
|
|
44
|
+
name: Allow Enum Changes
|
|
45
|
+
change_types: [enum_value_removed]
|
|
46
|
+
severity: info
|
|
47
|
+
action: allow
|
|
48
|
+
message: "Enum value removed at {path}."
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Delimit Policy Preset: strict
|
|
2
|
+
# For public APIs, payment systems, and regulated environments.
|
|
3
|
+
# Zero tolerance for breaking changes. All breaking changes are errors.
|
|
4
|
+
|
|
5
|
+
override_defaults: true
|
|
6
|
+
|
|
7
|
+
rules:
|
|
8
|
+
- id: no_endpoint_removal
|
|
9
|
+
name: Forbid Endpoint Removal
|
|
10
|
+
change_types: [endpoint_removed]
|
|
11
|
+
severity: error
|
|
12
|
+
action: forbid
|
|
13
|
+
message: "Endpoint {path} cannot be removed. Deprecate with Sunset header first."
|
|
14
|
+
|
|
15
|
+
- id: no_method_removal
|
|
16
|
+
name: Forbid Method Removal
|
|
17
|
+
change_types: [method_removed]
|
|
18
|
+
severity: error
|
|
19
|
+
action: forbid
|
|
20
|
+
message: "HTTP method removed from {path}. This breaks all clients using this method."
|
|
21
|
+
|
|
22
|
+
- id: no_required_param_addition
|
|
23
|
+
name: Forbid Required Parameter Addition
|
|
24
|
+
change_types: [required_param_added]
|
|
25
|
+
severity: error
|
|
26
|
+
action: forbid
|
|
27
|
+
message: "Cannot add required parameter to {path}. Make it optional with a default."
|
|
28
|
+
|
|
29
|
+
- id: no_field_removal
|
|
30
|
+
name: Forbid Response Field Removal
|
|
31
|
+
change_types: [field_removed]
|
|
32
|
+
severity: error
|
|
33
|
+
action: forbid
|
|
34
|
+
message: "Cannot remove field from {path}. Deprecate it first."
|
|
35
|
+
|
|
36
|
+
- id: no_type_change
|
|
37
|
+
name: Forbid Type Changes
|
|
38
|
+
change_types: [type_changed]
|
|
39
|
+
severity: error
|
|
40
|
+
action: forbid
|
|
41
|
+
message: "Type change at {path} breaks client deserialization."
|
|
42
|
+
|
|
43
|
+
- id: no_enum_removal
|
|
44
|
+
name: Forbid Enum Value Removal
|
|
45
|
+
change_types: [enum_value_removed]
|
|
46
|
+
severity: error
|
|
47
|
+
action: forbid
|
|
48
|
+
message: "Enum value removed at {path}. Clients may be sending this value."
|
|
49
|
+
|
|
50
|
+
- id: no_param_removal
|
|
51
|
+
name: Forbid Parameter Removal
|
|
52
|
+
change_types: [param_removed]
|
|
53
|
+
severity: error
|
|
54
|
+
action: forbid
|
|
55
|
+
message: "Parameter removed from {path}. Clients may depend on this parameter."
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delimit Policy Engine - Define and enforce custom governance rules.
|
|
3
|
+
Organizations can define policies to control API evolution.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from typing import Dict, List, Any, Optional
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from core.diff_engine_v2 import OpenAPIDiffEngine, Change, ChangeType
|
|
13
|
+
|
|
14
|
+
class RuleSeverity(Enum):
|
|
15
|
+
ERROR = "error" # Fails CI
|
|
16
|
+
WARNING = "warning" # Shows warning but passes
|
|
17
|
+
INFO = "info" # Informational only
|
|
18
|
+
|
|
19
|
+
class RuleAction(Enum):
|
|
20
|
+
FORBID = "forbid" # Forbids the change
|
|
21
|
+
ALLOW = "allow" # Explicitly allows
|
|
22
|
+
WARN = "warn" # Warns but allows
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PolicyRule:
|
|
26
|
+
"""A single governance rule."""
|
|
27
|
+
id: str
|
|
28
|
+
name: str
|
|
29
|
+
description: str
|
|
30
|
+
change_types: List[ChangeType]
|
|
31
|
+
severity: RuleSeverity
|
|
32
|
+
action: RuleAction
|
|
33
|
+
conditions: Optional[Dict] = None
|
|
34
|
+
message_template: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
def evaluate(self, change: Change) -> Optional['Violation']:
|
|
37
|
+
"""Evaluate if this rule applies to a change."""
|
|
38
|
+
if change.type not in self.change_types:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
# Check additional conditions if specified
|
|
42
|
+
if self.conditions:
|
|
43
|
+
if not self._check_conditions(change, self.conditions):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
# Create violation if rule matches
|
|
47
|
+
if self.action == RuleAction.FORBID:
|
|
48
|
+
return Violation(
|
|
49
|
+
rule_id=self.id,
|
|
50
|
+
rule_name=self.name,
|
|
51
|
+
severity=self.severity.value,
|
|
52
|
+
message=self._format_message(change),
|
|
53
|
+
change=change
|
|
54
|
+
)
|
|
55
|
+
elif self.action == RuleAction.WARN:
|
|
56
|
+
return Violation(
|
|
57
|
+
rule_id=self.id,
|
|
58
|
+
rule_name=self.name,
|
|
59
|
+
severity=RuleSeverity.WARNING.value,
|
|
60
|
+
message=self._format_message(change),
|
|
61
|
+
change=change
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def _check_conditions(self, change: Change, conditions: Dict) -> bool:
|
|
67
|
+
"""Check if change meets additional conditions."""
|
|
68
|
+
# Example conditions: path patterns, specific fields, etc.
|
|
69
|
+
if "path_pattern" in conditions:
|
|
70
|
+
import re
|
|
71
|
+
pattern = conditions["path_pattern"]
|
|
72
|
+
if not re.match(pattern, change.path):
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
if "exclude_paths" in conditions:
|
|
76
|
+
for excluded in conditions["exclude_paths"]:
|
|
77
|
+
if excluded in change.path:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def _format_message(self, change: Change) -> str:
|
|
83
|
+
"""Format violation message."""
|
|
84
|
+
if self.message_template:
|
|
85
|
+
return self.message_template.format(
|
|
86
|
+
path=change.path,
|
|
87
|
+
details=change.details,
|
|
88
|
+
message=change.message
|
|
89
|
+
)
|
|
90
|
+
return f"{self.name}: {change.message}"
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Violation:
|
|
94
|
+
"""A policy violation."""
|
|
95
|
+
rule_id: str
|
|
96
|
+
rule_name: str
|
|
97
|
+
severity: str
|
|
98
|
+
message: str
|
|
99
|
+
change: Change
|
|
100
|
+
|
|
101
|
+
# Available policy presets
|
|
102
|
+
POLICY_PRESETS = ("strict", "default", "relaxed")
|
|
103
|
+
_PRESETS_DIR = Path(__file__).resolve().parent / "policies"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PolicyEngine:
|
|
107
|
+
"""Main policy engine for evaluating governance rules."""
|
|
108
|
+
|
|
109
|
+
# Default rules that are always active unless overridden
|
|
110
|
+
DEFAULT_RULES = [
|
|
111
|
+
PolicyRule(
|
|
112
|
+
id="no_endpoint_removal",
|
|
113
|
+
name="Forbid Endpoint Removal",
|
|
114
|
+
description="Endpoints cannot be removed without deprecation",
|
|
115
|
+
change_types=[ChangeType.ENDPOINT_REMOVED],
|
|
116
|
+
severity=RuleSeverity.ERROR,
|
|
117
|
+
action=RuleAction.FORBID,
|
|
118
|
+
message_template="Endpoint {path} cannot be removed. Deprecate it first."
|
|
119
|
+
),
|
|
120
|
+
PolicyRule(
|
|
121
|
+
id="no_method_removal",
|
|
122
|
+
name="Forbid Method Removal",
|
|
123
|
+
description="HTTP methods cannot be removed from endpoints",
|
|
124
|
+
change_types=[ChangeType.METHOD_REMOVED],
|
|
125
|
+
severity=RuleSeverity.ERROR,
|
|
126
|
+
action=RuleAction.FORBID,
|
|
127
|
+
message_template="Method {details[method]} cannot be removed from {details[endpoint]}"
|
|
128
|
+
),
|
|
129
|
+
PolicyRule(
|
|
130
|
+
id="no_required_param_addition",
|
|
131
|
+
name="Forbid Required Parameter Addition",
|
|
132
|
+
description="New required parameters break existing clients",
|
|
133
|
+
change_types=[ChangeType.REQUIRED_PARAM_ADDED],
|
|
134
|
+
severity=RuleSeverity.ERROR,
|
|
135
|
+
action=RuleAction.FORBID,
|
|
136
|
+
message_template="Cannot add required parameter {details[parameter]} to {path}"
|
|
137
|
+
),
|
|
138
|
+
PolicyRule(
|
|
139
|
+
id="no_response_field_removal",
|
|
140
|
+
name="Forbid Response Field Removal",
|
|
141
|
+
description="Removing response fields breaks clients",
|
|
142
|
+
change_types=[ChangeType.FIELD_REMOVED],
|
|
143
|
+
severity=RuleSeverity.ERROR,
|
|
144
|
+
action=RuleAction.FORBID,
|
|
145
|
+
conditions={"path_pattern": ".*:2\\d\\d.*"}, # Only 2xx responses
|
|
146
|
+
message_template="Cannot remove field {details[field]} from response"
|
|
147
|
+
),
|
|
148
|
+
PolicyRule(
|
|
149
|
+
id="warn_type_change",
|
|
150
|
+
name="Warn on Type Changes",
|
|
151
|
+
description="Type changes may break clients",
|
|
152
|
+
change_types=[ChangeType.TYPE_CHANGED],
|
|
153
|
+
severity=RuleSeverity.WARNING,
|
|
154
|
+
action=RuleAction.WARN,
|
|
155
|
+
message_template="Type changed from {details[old_type]} to {details[new_type]} at {path}"
|
|
156
|
+
),
|
|
157
|
+
PolicyRule(
|
|
158
|
+
id="allow_enum_expansion",
|
|
159
|
+
name="Allow Enum Expansion",
|
|
160
|
+
description="Adding enum values is safe",
|
|
161
|
+
change_types=[ChangeType.ENUM_VALUE_ADDED],
|
|
162
|
+
severity=RuleSeverity.INFO,
|
|
163
|
+
action=RuleAction.ALLOW,
|
|
164
|
+
message_template="Enum value {details[value]} added (non-breaking)"
|
|
165
|
+
)
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
def __init__(self, policy_file: Optional[str] = None):
|
|
169
|
+
"""Initialize policy engine with optional custom policy file or preset name.
|
|
170
|
+
|
|
171
|
+
policy_file can be:
|
|
172
|
+
- A file path to a YAML policy file
|
|
173
|
+
- A preset name: "strict", "default", or "relaxed"
|
|
174
|
+
"""
|
|
175
|
+
self.rules: List[PolicyRule] = []
|
|
176
|
+
self.custom_rules: List[PolicyRule] = []
|
|
177
|
+
|
|
178
|
+
# Load default rules
|
|
179
|
+
self.rules.extend(self.DEFAULT_RULES)
|
|
180
|
+
|
|
181
|
+
# Load custom policy if provided
|
|
182
|
+
if policy_file:
|
|
183
|
+
# Check if it's a preset name
|
|
184
|
+
if policy_file in POLICY_PRESETS:
|
|
185
|
+
preset_path = _PRESETS_DIR / f"{policy_file}.yml"
|
|
186
|
+
if preset_path.exists():
|
|
187
|
+
self.load_policy(str(preset_path))
|
|
188
|
+
# "default" preset = built-in defaults, no-op
|
|
189
|
+
else:
|
|
190
|
+
self.load_policy(policy_file)
|
|
191
|
+
|
|
192
|
+
def load_policy(self, policy_file: str):
|
|
193
|
+
"""Load custom policy from YAML file."""
|
|
194
|
+
path = Path(policy_file)
|
|
195
|
+
|
|
196
|
+
# Check common locations if file not found
|
|
197
|
+
if not path.exists():
|
|
198
|
+
for location in [".delimit/policies.yml", ".delimit/policy.yaml", "delimit.yml"]:
|
|
199
|
+
test_path = Path(location)
|
|
200
|
+
if test_path.exists():
|
|
201
|
+
path = test_path
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
if not path.exists():
|
|
205
|
+
return # No custom policy, use defaults
|
|
206
|
+
|
|
207
|
+
with open(path, 'r') as f:
|
|
208
|
+
config = yaml.safe_load(f)
|
|
209
|
+
|
|
210
|
+
if not config:
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# Parse rules
|
|
214
|
+
for rule_config in config.get("rules", []):
|
|
215
|
+
rule = self._parse_rule(rule_config)
|
|
216
|
+
if rule:
|
|
217
|
+
self.custom_rules.append(rule)
|
|
218
|
+
|
|
219
|
+
# Override defaults if specified
|
|
220
|
+
if config.get("override_defaults", False):
|
|
221
|
+
self.rules = self.custom_rules
|
|
222
|
+
else:
|
|
223
|
+
# Merge custom rules with defaults
|
|
224
|
+
# Custom rules take precedence for same IDs
|
|
225
|
+
custom_ids = {r.id for r in self.custom_rules}
|
|
226
|
+
self.rules = self.custom_rules + [r for r in self.DEFAULT_RULES if r.id not in custom_ids]
|
|
227
|
+
|
|
228
|
+
def _parse_rule(self, config: Dict) -> Optional[PolicyRule]:
|
|
229
|
+
"""Parse a rule from configuration."""
|
|
230
|
+
try:
|
|
231
|
+
# Map string change types to enum
|
|
232
|
+
change_type_map = {
|
|
233
|
+
"endpoint_removed": ChangeType.ENDPOINT_REMOVED,
|
|
234
|
+
"method_removed": ChangeType.METHOD_REMOVED,
|
|
235
|
+
"required_param_added": ChangeType.REQUIRED_PARAM_ADDED,
|
|
236
|
+
"param_removed": ChangeType.PARAM_REMOVED,
|
|
237
|
+
"field_removed": ChangeType.FIELD_REMOVED,
|
|
238
|
+
"type_changed": ChangeType.TYPE_CHANGED,
|
|
239
|
+
"enum_value_removed": ChangeType.ENUM_VALUE_REMOVED,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
change_types = []
|
|
243
|
+
for ct in config.get("change_types", []):
|
|
244
|
+
if ct in change_type_map:
|
|
245
|
+
change_types.append(change_type_map[ct])
|
|
246
|
+
|
|
247
|
+
if not change_types:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
return PolicyRule(
|
|
251
|
+
id=config["id"],
|
|
252
|
+
name=config.get("name", config["id"]),
|
|
253
|
+
description=config.get("description", ""),
|
|
254
|
+
change_types=change_types,
|
|
255
|
+
severity=RuleSeverity(config.get("severity", "error")),
|
|
256
|
+
action=RuleAction(config.get("action", "forbid")),
|
|
257
|
+
conditions=config.get("conditions"),
|
|
258
|
+
message_template=config.get("message")
|
|
259
|
+
)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
print(f"Warning: Failed to parse rule {config.get('id', 'unknown')}: {e}")
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def evaluate(self, changes: List[Change]) -> List[Violation]:
|
|
265
|
+
"""Evaluate all changes against policy rules."""
|
|
266
|
+
violations = []
|
|
267
|
+
|
|
268
|
+
for change in changes:
|
|
269
|
+
for rule in self.rules:
|
|
270
|
+
violation = rule.evaluate(change)
|
|
271
|
+
if violation:
|
|
272
|
+
violations.append(violation)
|
|
273
|
+
|
|
274
|
+
return violations
|
|
275
|
+
|
|
276
|
+
def create_policy_template(self) -> str:
|
|
277
|
+
"""Generate a template policy file."""
|
|
278
|
+
template = """# Delimit Policy Configuration
|
|
279
|
+
# Define custom governance rules for your API
|
|
280
|
+
|
|
281
|
+
# Override default rules completely (default: false)
|
|
282
|
+
override_defaults: false
|
|
283
|
+
|
|
284
|
+
# Custom governance rules
|
|
285
|
+
rules:
|
|
286
|
+
# Forbid removing any endpoint
|
|
287
|
+
- id: no_endpoint_removal
|
|
288
|
+
name: Forbid Endpoint Removal
|
|
289
|
+
description: Endpoints cannot be removed without deprecation
|
|
290
|
+
change_types:
|
|
291
|
+
- endpoint_removed
|
|
292
|
+
severity: error # error | warning | info
|
|
293
|
+
action: forbid # forbid | allow | warn
|
|
294
|
+
message: "Endpoint {path} cannot be removed. Use deprecation headers instead."
|
|
295
|
+
|
|
296
|
+
# Forbid type changes in responses
|
|
297
|
+
- id: no_response_type_change
|
|
298
|
+
name: Forbid Response Type Changes
|
|
299
|
+
change_types:
|
|
300
|
+
- type_changed
|
|
301
|
+
severity: error
|
|
302
|
+
action: forbid
|
|
303
|
+
conditions:
|
|
304
|
+
path_pattern: ".*:2\\d\\d.*" # Only 2xx responses
|
|
305
|
+
message: "Type change not allowed in {path}"
|
|
306
|
+
|
|
307
|
+
# Allow adding optional fields
|
|
308
|
+
- id: allow_optional_fields
|
|
309
|
+
name: Allow Optional Field Addition
|
|
310
|
+
change_types:
|
|
311
|
+
- optional_field_added
|
|
312
|
+
severity: info
|
|
313
|
+
action: allow
|
|
314
|
+
message: "Optional field added (safe change)"
|
|
315
|
+
|
|
316
|
+
# Warn about enum changes
|
|
317
|
+
- id: warn_enum_removal
|
|
318
|
+
name: Warn on Enum Value Removal
|
|
319
|
+
change_types:
|
|
320
|
+
- enum_value_removed
|
|
321
|
+
severity: warning
|
|
322
|
+
action: warn
|
|
323
|
+
message: "Enum value removed - may break clients using this value"
|
|
324
|
+
|
|
325
|
+
# Custom rule for specific paths
|
|
326
|
+
- id: protect_v1_api
|
|
327
|
+
name: Protect V1 API
|
|
328
|
+
description: V1 endpoints are frozen
|
|
329
|
+
change_types:
|
|
330
|
+
- endpoint_removed
|
|
331
|
+
- method_removed
|
|
332
|
+
- field_removed
|
|
333
|
+
severity: error
|
|
334
|
+
action: forbid
|
|
335
|
+
conditions:
|
|
336
|
+
path_pattern: "^/v1/.*"
|
|
337
|
+
message: "V1 API is frozen. Changes must be made in V2."
|
|
338
|
+
|
|
339
|
+
# Organization-specific rules
|
|
340
|
+
organization:
|
|
341
|
+
# Require approval for high-risk changes
|
|
342
|
+
require_approval:
|
|
343
|
+
- endpoint_removed
|
|
344
|
+
- method_removed
|
|
345
|
+
- required_param_added
|
|
346
|
+
|
|
347
|
+
# Deprecation policy
|
|
348
|
+
deprecation:
|
|
349
|
+
min_notice_days: 30
|
|
350
|
+
require_sunset_header: true
|
|
351
|
+
"""
|
|
352
|
+
return template
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def evaluate_with_policy(
|
|
356
|
+
old_spec: Dict,
|
|
357
|
+
new_spec: Dict,
|
|
358
|
+
policy_file: Optional[str] = None,
|
|
359
|
+
include_semver: bool = False,
|
|
360
|
+
current_version: Optional[str] = None,
|
|
361
|
+
api_name: Optional[str] = None,
|
|
362
|
+
) -> Dict[str, Any]:
|
|
363
|
+
"""
|
|
364
|
+
Main entry point for policy evaluation.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
old_spec: Old OpenAPI spec dict.
|
|
368
|
+
new_spec: New OpenAPI spec dict.
|
|
369
|
+
policy_file: Optional custom policy file.
|
|
370
|
+
include_semver: Attach semver classification + migration guide.
|
|
371
|
+
current_version: Current version for next-version computation.
|
|
372
|
+
api_name: API name for explainer context.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Dictionary with violations, summary, decision, and optionally
|
|
376
|
+
semver/migration data for PR comment rendering.
|
|
377
|
+
"""
|
|
378
|
+
# Run diff engine
|
|
379
|
+
diff_engine = OpenAPIDiffEngine()
|
|
380
|
+
changes = diff_engine.compare(old_spec, new_spec)
|
|
381
|
+
|
|
382
|
+
# Run policy engine
|
|
383
|
+
policy_engine = PolicyEngine(policy_file)
|
|
384
|
+
violations = policy_engine.evaluate(changes)
|
|
385
|
+
|
|
386
|
+
# Determine decision
|
|
387
|
+
has_errors = any(v.severity == "error" for v in violations)
|
|
388
|
+
has_warnings = any(v.severity == "warning" for v in violations)
|
|
389
|
+
|
|
390
|
+
if has_errors:
|
|
391
|
+
decision = "fail"
|
|
392
|
+
exit_code = 1
|
|
393
|
+
elif has_warnings:
|
|
394
|
+
decision = "warn"
|
|
395
|
+
exit_code = 0
|
|
396
|
+
else:
|
|
397
|
+
decision = "pass"
|
|
398
|
+
exit_code = 0
|
|
399
|
+
|
|
400
|
+
result: Dict[str, Any] = {
|
|
401
|
+
"decision": decision,
|
|
402
|
+
"exit_code": exit_code,
|
|
403
|
+
"violations": [
|
|
404
|
+
{
|
|
405
|
+
"rule": v.rule_id,
|
|
406
|
+
"name": v.rule_name,
|
|
407
|
+
"severity": v.severity,
|
|
408
|
+
"message": v.message,
|
|
409
|
+
"path": v.change.path,
|
|
410
|
+
"details": v.change.details
|
|
411
|
+
}
|
|
412
|
+
for v in violations
|
|
413
|
+
],
|
|
414
|
+
"summary": {
|
|
415
|
+
"total_changes": len(changes),
|
|
416
|
+
"breaking_changes": len([c for c in changes if c.is_breaking]),
|
|
417
|
+
"violations": len(violations),
|
|
418
|
+
"errors": len([v for v in violations if v.severity == "error"]),
|
|
419
|
+
"warnings": len([v for v in violations if v.severity == "warning"])
|
|
420
|
+
},
|
|
421
|
+
"all_changes": [
|
|
422
|
+
{
|
|
423
|
+
"type": c.type.value,
|
|
424
|
+
"path": c.path,
|
|
425
|
+
"message": c.message,
|
|
426
|
+
"is_breaking": c.is_breaking
|
|
427
|
+
}
|
|
428
|
+
for c in changes
|
|
429
|
+
],
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
# Attach semver + migration for PR comment rendering
|
|
433
|
+
if include_semver and changes:
|
|
434
|
+
try:
|
|
435
|
+
from core.semver_classifier import classify_detailed, bump_version, classify
|
|
436
|
+
from core.explainer import explain
|
|
437
|
+
|
|
438
|
+
detail = classify_detailed(changes)
|
|
439
|
+
semver_data = {
|
|
440
|
+
"bump": detail["bump"],
|
|
441
|
+
"counts": detail["counts"],
|
|
442
|
+
}
|
|
443
|
+
if current_version:
|
|
444
|
+
semver_data["current_version"] = current_version
|
|
445
|
+
semver_data["next_version"] = bump_version(
|
|
446
|
+
current_version, classify(changes)
|
|
447
|
+
)
|
|
448
|
+
result["semver"] = semver_data
|
|
449
|
+
|
|
450
|
+
# Generate migration guide for breaking changes
|
|
451
|
+
if detail["counts"]["breaking"] > 0:
|
|
452
|
+
old_ver = current_version or old_spec.get("info", {}).get("version")
|
|
453
|
+
new_ver = new_spec.get("info", {}).get("version")
|
|
454
|
+
result["migration"] = explain(
|
|
455
|
+
changes,
|
|
456
|
+
template="migration",
|
|
457
|
+
old_version=old_ver,
|
|
458
|
+
new_version=new_ver,
|
|
459
|
+
api_name=api_name,
|
|
460
|
+
)
|
|
461
|
+
except Exception:
|
|
462
|
+
pass # semver/explainer not available — degrade gracefully
|
|
463
|
+
|
|
464
|
+
return result
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import Callable, Dict, Optional
|
|
2
|
+
from functools import wraps
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
class TaskRegistry:
|
|
8
|
+
"""Registry for task handlers following Gemini's recommendation"""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self._tasks: Dict[str, Callable] = {}
|
|
12
|
+
self._task_metadata: Dict[str, Dict] = {}
|
|
13
|
+
|
|
14
|
+
def register(self, task_name: str, version: str = "v1", **metadata):
|
|
15
|
+
"""Decorator to register a task handler"""
|
|
16
|
+
def decorator(func: Callable):
|
|
17
|
+
full_name = f"{task_name}:{version}" if version != "v1" else task_name
|
|
18
|
+
|
|
19
|
+
@wraps(func)
|
|
20
|
+
def wrapper(*args, **kwargs):
|
|
21
|
+
return func(*args, **kwargs)
|
|
22
|
+
|
|
23
|
+
self._tasks[full_name] = wrapper
|
|
24
|
+
self._tasks[task_name] = wrapper # Default version alias
|
|
25
|
+
self._task_metadata[full_name] = {
|
|
26
|
+
"name": task_name,
|
|
27
|
+
"version": version,
|
|
28
|
+
"handler": func.__name__,
|
|
29
|
+
**metadata
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
logger.info(f"Registered task: {full_name}")
|
|
33
|
+
return wrapper
|
|
34
|
+
return decorator
|
|
35
|
+
|
|
36
|
+
def get_handler(self, task_name: str, version: Optional[str] = None) -> Optional[Callable]:
|
|
37
|
+
"""Get a task handler by name and optional version"""
|
|
38
|
+
if version:
|
|
39
|
+
full_name = f"{task_name}:{version}"
|
|
40
|
+
return self._tasks.get(full_name)
|
|
41
|
+
return self._tasks.get(task_name)
|
|
42
|
+
|
|
43
|
+
def list_tasks(self) -> list:
|
|
44
|
+
"""List all registered tasks"""
|
|
45
|
+
return list(self._tasks.keys())
|
|
46
|
+
|
|
47
|
+
def has_task(self, task_name: str) -> bool:
|
|
48
|
+
"""Check if a task is registered"""
|
|
49
|
+
return task_name in self._tasks
|
|
50
|
+
|
|
51
|
+
# Global registry instance
|
|
52
|
+
task_registry = TaskRegistry()
|