delimit-cli 2.3.2 → 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/CHANGELOG.md +33 -0
- package/CODE_OF_CONDUCT.md +48 -0
- package/CONTRIBUTING.md +67 -0
- package/Dockerfile +9 -0
- package/LICENSE +21 -0
- package/README.md +51 -130
- package/SECURITY.md +42 -0
- package/adapters/codex-forge.js +107 -0
- package/adapters/codex-jamsons.js +142 -0
- package/adapters/codex-security.js +94 -0
- package/adapters/gemini-forge.js +120 -0
- package/adapters/gemini-jamsons.js +152 -0
- package/bin/delimit-cli.js +52 -2
- 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 +3 -3
- 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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fixed Task Registry with proper versioning support
|
|
3
|
+
V12 Core Hardening
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Callable, Dict, Optional, List, Tuple
|
|
7
|
+
from functools import wraps
|
|
8
|
+
import logging
|
|
9
|
+
from packaging import version
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskRegistry:
|
|
15
|
+
"""
|
|
16
|
+
Registry for task handlers with proper versioning
|
|
17
|
+
Key format: task_name:version (e.g., "validate-api:1.0")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self._tasks: Dict[str, Callable] = {}
|
|
22
|
+
self._task_metadata: Dict[str, Dict] = {}
|
|
23
|
+
self._latest_versions: Dict[str, str] = {} # task_name -> latest_version
|
|
24
|
+
|
|
25
|
+
def register(self, task_name: str, version: str, **metadata):
|
|
26
|
+
"""
|
|
27
|
+
Decorator to register a task handler with explicit version
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
task_name: Name of the task (e.g., "validate-api")
|
|
31
|
+
version: Version string (e.g., "1.0", "2.0")
|
|
32
|
+
**metadata: Additional metadata (description, etc.)
|
|
33
|
+
"""
|
|
34
|
+
def decorator(func: Callable):
|
|
35
|
+
# Create versioned key
|
|
36
|
+
task_key = f"{task_name}:{version}"
|
|
37
|
+
|
|
38
|
+
@wraps(func)
|
|
39
|
+
def wrapper(*args, **kwargs):
|
|
40
|
+
return func(*args, **kwargs)
|
|
41
|
+
|
|
42
|
+
# Store handler with versioned key
|
|
43
|
+
self._tasks[task_key] = wrapper
|
|
44
|
+
|
|
45
|
+
# Store metadata
|
|
46
|
+
self._task_metadata[task_key] = {
|
|
47
|
+
"name": task_name,
|
|
48
|
+
"version": version,
|
|
49
|
+
"handler": func.__name__,
|
|
50
|
+
**metadata
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Update latest version tracking
|
|
54
|
+
if task_name not in self._latest_versions:
|
|
55
|
+
self._latest_versions[task_name] = version
|
|
56
|
+
else:
|
|
57
|
+
# Compare versions properly
|
|
58
|
+
try:
|
|
59
|
+
current = version.parse(self._latest_versions[task_name])
|
|
60
|
+
new = version.parse(version)
|
|
61
|
+
if new > current:
|
|
62
|
+
self._latest_versions[task_name] = version
|
|
63
|
+
except:
|
|
64
|
+
# Fallback to string comparison if not semantic versioning
|
|
65
|
+
if version > self._latest_versions[task_name]:
|
|
66
|
+
self._latest_versions[task_name] = version
|
|
67
|
+
|
|
68
|
+
logger.info(f"Registered task: {task_key}")
|
|
69
|
+
return wrapper
|
|
70
|
+
return decorator
|
|
71
|
+
|
|
72
|
+
def get_handler(self, task_name: str, version: Optional[str] = None) -> Optional[Callable]:
|
|
73
|
+
"""
|
|
74
|
+
Get a task handler by name and optional version
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
task_name: Task name (e.g., "validate-api")
|
|
78
|
+
version: Optional version (e.g., "1.0"). If None, returns latest.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Task handler callable or None if not found
|
|
82
|
+
"""
|
|
83
|
+
if version:
|
|
84
|
+
# Explicit version requested
|
|
85
|
+
task_key = f"{task_name}:{version}"
|
|
86
|
+
return self._tasks.get(task_key)
|
|
87
|
+
else:
|
|
88
|
+
# No version specified, return latest
|
|
89
|
+
if task_name in self._latest_versions:
|
|
90
|
+
latest_version = self._latest_versions[task_name]
|
|
91
|
+
task_key = f"{task_name}:{latest_version}"
|
|
92
|
+
return self._tasks.get(task_key)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def list_tasks(self) -> List[str]:
|
|
96
|
+
"""List all registered task keys (with versions)"""
|
|
97
|
+
return sorted(self._tasks.keys())
|
|
98
|
+
|
|
99
|
+
def list_task_names(self) -> List[str]:
|
|
100
|
+
"""List unique task names (without versions)"""
|
|
101
|
+
return sorted(set(self._latest_versions.keys()))
|
|
102
|
+
|
|
103
|
+
def get_task_versions(self, task_name: str) -> List[str]:
|
|
104
|
+
"""Get all registered versions for a task"""
|
|
105
|
+
versions = []
|
|
106
|
+
for key in self._tasks.keys():
|
|
107
|
+
if key.startswith(f"{task_name}:"):
|
|
108
|
+
version = key.split(":", 1)[1]
|
|
109
|
+
versions.append(version)
|
|
110
|
+
return sorted(versions)
|
|
111
|
+
|
|
112
|
+
def has_task(self, task_name: str, version: Optional[str] = None) -> bool:
|
|
113
|
+
"""Check if a task is registered"""
|
|
114
|
+
if version:
|
|
115
|
+
task_key = f"{task_name}:{version}"
|
|
116
|
+
return task_key in self._tasks
|
|
117
|
+
else:
|
|
118
|
+
return task_name in self._latest_versions
|
|
119
|
+
|
|
120
|
+
def get_metadata(self, task_name: str, version: Optional[str] = None) -> Optional[Dict]:
|
|
121
|
+
"""Get metadata for a task"""
|
|
122
|
+
if version:
|
|
123
|
+
task_key = f"{task_name}:{version}"
|
|
124
|
+
elif task_name in self._latest_versions:
|
|
125
|
+
task_key = f"{task_name}:{self._latest_versions[task_name]}"
|
|
126
|
+
else:
|
|
127
|
+
return None
|
|
128
|
+
return self._task_metadata.get(task_key)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Global registry instance
|
|
132
|
+
task_registry = TaskRegistry()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fixed Task Registry with proper versioning support - No shadowing
|
|
3
|
+
V12 Core Hardening Final
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Callable, Dict, Optional, List
|
|
7
|
+
from functools import wraps
|
|
8
|
+
import logging
|
|
9
|
+
from packaging import version as pkg_version
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskRegistry:
|
|
15
|
+
"""
|
|
16
|
+
Registry for task handlers with proper versioning
|
|
17
|
+
Key format: task_name:version (e.g., "validate-api:1.0")
|
|
18
|
+
Fixed version shadowing issue
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self._tasks: Dict[str, Callable] = {}
|
|
23
|
+
self._task_metadata: Dict[str, Dict] = {}
|
|
24
|
+
self._latest_versions: Dict[str, str] = {} # task_name -> latest_version
|
|
25
|
+
|
|
26
|
+
def register(self, task_name: str, task_version: str, **metadata):
|
|
27
|
+
"""
|
|
28
|
+
Decorator to register a task handler with explicit version
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
task_name: Name of the task (e.g., "validate-api")
|
|
32
|
+
task_version: Version string (e.g., "1.0", "2.0")
|
|
33
|
+
**metadata: Additional metadata (description, etc.)
|
|
34
|
+
"""
|
|
35
|
+
def decorator(func: Callable):
|
|
36
|
+
# Create versioned key
|
|
37
|
+
task_key = f"{task_name}:{task_version}"
|
|
38
|
+
|
|
39
|
+
@wraps(func)
|
|
40
|
+
def wrapper(*args, **kwargs):
|
|
41
|
+
return func(*args, **kwargs)
|
|
42
|
+
|
|
43
|
+
# Store handler with versioned key
|
|
44
|
+
self._tasks[task_key] = wrapper
|
|
45
|
+
|
|
46
|
+
# Store metadata
|
|
47
|
+
self._task_metadata[task_key] = {
|
|
48
|
+
"name": task_name,
|
|
49
|
+
"version": task_version,
|
|
50
|
+
"handler": func.__name__,
|
|
51
|
+
**metadata
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Update latest version tracking
|
|
55
|
+
if task_name not in self._latest_versions:
|
|
56
|
+
self._latest_versions[task_name] = task_version
|
|
57
|
+
else:
|
|
58
|
+
# Compare versions properly using packaging.version
|
|
59
|
+
try:
|
|
60
|
+
current = pkg_version.parse(self._latest_versions[task_name])
|
|
61
|
+
new = pkg_version.parse(task_version)
|
|
62
|
+
if new > current:
|
|
63
|
+
self._latest_versions[task_name] = task_version
|
|
64
|
+
except Exception as e:
|
|
65
|
+
# Fallback to string comparison if not semantic versioning
|
|
66
|
+
logger.warning(f"Version comparison failed, using string comparison: {e}")
|
|
67
|
+
if task_version > self._latest_versions[task_name]:
|
|
68
|
+
self._latest_versions[task_name] = task_version
|
|
69
|
+
|
|
70
|
+
logger.info(f"Registered task: {task_key}")
|
|
71
|
+
return wrapper
|
|
72
|
+
return decorator
|
|
73
|
+
|
|
74
|
+
def get_handler(self, task_name: str, version: Optional[str] = None) -> Optional[Callable]:
|
|
75
|
+
"""
|
|
76
|
+
Get a task handler by name and optional version
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
task_name: Task name (e.g., "validate-api")
|
|
80
|
+
version: Optional version (e.g., "1.0"). If None, returns latest.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Task handler callable or None if not found
|
|
84
|
+
"""
|
|
85
|
+
if version:
|
|
86
|
+
# Explicit version requested
|
|
87
|
+
task_key = f"{task_name}:{version}"
|
|
88
|
+
return self._tasks.get(task_key)
|
|
89
|
+
else:
|
|
90
|
+
# No version specified, return latest
|
|
91
|
+
if task_name in self._latest_versions:
|
|
92
|
+
latest_version = self._latest_versions[task_name]
|
|
93
|
+
task_key = f"{task_name}:{latest_version}"
|
|
94
|
+
return self._tasks.get(task_key)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def list_tasks(self) -> List[str]:
|
|
98
|
+
"""List all registered task keys (with versions)"""
|
|
99
|
+
return sorted(self._tasks.keys())
|
|
100
|
+
|
|
101
|
+
def list_task_names(self) -> List[str]:
|
|
102
|
+
"""List unique task names (without versions)"""
|
|
103
|
+
return sorted(set(self._latest_versions.keys()))
|
|
104
|
+
|
|
105
|
+
def get_task_versions(self, task_name: str) -> List[str]:
|
|
106
|
+
"""Get all registered versions for a task"""
|
|
107
|
+
versions = []
|
|
108
|
+
for key in self._tasks.keys():
|
|
109
|
+
if key.startswith(f"{task_name}:"):
|
|
110
|
+
version = key.split(":", 1)[1]
|
|
111
|
+
versions.append(version)
|
|
112
|
+
return sorted(versions)
|
|
113
|
+
|
|
114
|
+
def has_task(self, task_name: str, version: Optional[str] = None) -> bool:
|
|
115
|
+
"""Check if a task is registered"""
|
|
116
|
+
if version:
|
|
117
|
+
task_key = f"{task_name}:{version}"
|
|
118
|
+
return task_key in self._tasks
|
|
119
|
+
else:
|
|
120
|
+
return task_name in self._latest_versions
|
|
121
|
+
|
|
122
|
+
def get_metadata(self, task_name: str, version: Optional[str] = None) -> Optional[Dict]:
|
|
123
|
+
"""Get metadata for a task"""
|
|
124
|
+
if version:
|
|
125
|
+
task_key = f"{task_name}:{version}"
|
|
126
|
+
elif task_name in self._latest_versions:
|
|
127
|
+
task_key = f"{task_name}:{self._latest_versions[task_name]}"
|
|
128
|
+
else:
|
|
129
|
+
return None
|
|
130
|
+
return self._task_metadata.get(task_key)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# Global registry instance
|
|
134
|
+
task_registry = TaskRegistry()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delimit Semver Classifier
|
|
3
|
+
|
|
4
|
+
Deterministic semver bump classification from diff engine output.
|
|
5
|
+
Takes a list of Change objects and returns the recommended version bump.
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- Any breaking change → MAJOR
|
|
9
|
+
- Any additive change (no breaking) → MINOR
|
|
10
|
+
- Non-functional changes only → PATCH
|
|
11
|
+
- No changes → NONE
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Dict, List
|
|
16
|
+
|
|
17
|
+
from .diff_engine_v2 import Change, ChangeType
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SemverBump(Enum):
|
|
21
|
+
NONE = "none"
|
|
22
|
+
PATCH = "patch"
|
|
23
|
+
MINOR = "minor"
|
|
24
|
+
MAJOR = "major"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Change-type buckets ──────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
BREAKING_TYPES = frozenset({
|
|
30
|
+
ChangeType.ENDPOINT_REMOVED,
|
|
31
|
+
ChangeType.METHOD_REMOVED,
|
|
32
|
+
ChangeType.REQUIRED_PARAM_ADDED,
|
|
33
|
+
ChangeType.PARAM_REMOVED,
|
|
34
|
+
ChangeType.RESPONSE_REMOVED,
|
|
35
|
+
ChangeType.REQUIRED_FIELD_ADDED,
|
|
36
|
+
ChangeType.FIELD_REMOVED,
|
|
37
|
+
ChangeType.TYPE_CHANGED,
|
|
38
|
+
ChangeType.FORMAT_CHANGED,
|
|
39
|
+
ChangeType.ENUM_VALUE_REMOVED,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
ADDITIVE_TYPES = frozenset({
|
|
43
|
+
ChangeType.ENDPOINT_ADDED,
|
|
44
|
+
ChangeType.METHOD_ADDED,
|
|
45
|
+
ChangeType.OPTIONAL_PARAM_ADDED,
|
|
46
|
+
ChangeType.RESPONSE_ADDED,
|
|
47
|
+
ChangeType.OPTIONAL_FIELD_ADDED,
|
|
48
|
+
ChangeType.ENUM_VALUE_ADDED,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
PATCH_TYPES = frozenset({
|
|
52
|
+
ChangeType.DESCRIPTION_CHANGED,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def classify(changes: List[Change]) -> SemverBump:
|
|
57
|
+
"""Classify a list of changes into a semver bump level.
|
|
58
|
+
|
|
59
|
+
Deterministic: same input always produces same output.
|
|
60
|
+
"""
|
|
61
|
+
if not changes:
|
|
62
|
+
return SemverBump.NONE
|
|
63
|
+
|
|
64
|
+
has_breaking = False
|
|
65
|
+
has_additive = False
|
|
66
|
+
|
|
67
|
+
for change in changes:
|
|
68
|
+
if change.type in BREAKING_TYPES:
|
|
69
|
+
has_breaking = True
|
|
70
|
+
break # short-circuit — can't go higher than MAJOR
|
|
71
|
+
if change.type in ADDITIVE_TYPES:
|
|
72
|
+
has_additive = True
|
|
73
|
+
|
|
74
|
+
if has_breaking:
|
|
75
|
+
return SemverBump.MAJOR
|
|
76
|
+
if has_additive:
|
|
77
|
+
return SemverBump.MINOR
|
|
78
|
+
return SemverBump.PATCH
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def classify_detailed(changes: List[Change]) -> Dict[str, Any]:
|
|
82
|
+
"""Return a detailed classification with per-category breakdowns.
|
|
83
|
+
|
|
84
|
+
Used by CLI explain and PR comment generation.
|
|
85
|
+
"""
|
|
86
|
+
bump = classify(changes)
|
|
87
|
+
|
|
88
|
+
breaking = [c for c in changes if c.type in BREAKING_TYPES]
|
|
89
|
+
additive = [c for c in changes if c.type in ADDITIVE_TYPES]
|
|
90
|
+
patch = [c for c in changes if c.type in PATCH_TYPES]
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"bump": bump.value,
|
|
94
|
+
"is_breaking": bump == SemverBump.MAJOR,
|
|
95
|
+
"counts": {
|
|
96
|
+
"total": len(changes),
|
|
97
|
+
"breaking": len(breaking),
|
|
98
|
+
"additive": len(additive),
|
|
99
|
+
"patch": len(patch),
|
|
100
|
+
},
|
|
101
|
+
"breaking_changes": [
|
|
102
|
+
{"type": c.type.value, "path": c.path, "message": c.message}
|
|
103
|
+
for c in breaking
|
|
104
|
+
],
|
|
105
|
+
"additive_changes": [
|
|
106
|
+
{"type": c.type.value, "path": c.path, "message": c.message}
|
|
107
|
+
for c in additive
|
|
108
|
+
],
|
|
109
|
+
"patch_changes": [
|
|
110
|
+
{"type": c.type.value, "path": c.path, "message": c.message}
|
|
111
|
+
for c in patch
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def bump_version(current: str, bump: SemverBump) -> str:
|
|
117
|
+
"""Apply a semver bump to a version string.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
current: Version string like "1.2.3" or "v1.2.3".
|
|
121
|
+
bump: The bump level to apply.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
New version string (preserves 'v' prefix if present).
|
|
125
|
+
"""
|
|
126
|
+
prefix = ""
|
|
127
|
+
ver = current
|
|
128
|
+
if ver.startswith("v"):
|
|
129
|
+
prefix = "v"
|
|
130
|
+
ver = ver[1:]
|
|
131
|
+
|
|
132
|
+
parts = ver.split(".")
|
|
133
|
+
if len(parts) != 3:
|
|
134
|
+
return current # can't parse — return unchanged
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
|
|
138
|
+
except ValueError:
|
|
139
|
+
return current
|
|
140
|
+
|
|
141
|
+
if bump == SemverBump.MAJOR:
|
|
142
|
+
major += 1
|
|
143
|
+
minor = 0
|
|
144
|
+
patch = 0
|
|
145
|
+
elif bump == SemverBump.MINOR:
|
|
146
|
+
minor += 1
|
|
147
|
+
patch = 0
|
|
148
|
+
elif bump == SemverBump.PATCH:
|
|
149
|
+
patch += 1
|
|
150
|
+
# NONE: no change
|
|
151
|
+
|
|
152
|
+
return f"{prefix}{major}.{minor}.{patch}"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Automatic OpenAPI specification detector for zero-config installation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import List, Optional, Tuple
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
class SpecDetector:
|
|
11
|
+
"""Auto-detect OpenAPI specifications in common locations."""
|
|
12
|
+
|
|
13
|
+
COMMON_SPEC_PATTERNS = [
|
|
14
|
+
"openapi.yaml",
|
|
15
|
+
"openapi.yml",
|
|
16
|
+
"swagger.yaml",
|
|
17
|
+
"swagger.yml",
|
|
18
|
+
"api/openapi.yaml",
|
|
19
|
+
"api/openapi.yml",
|
|
20
|
+
"api/swagger.yaml",
|
|
21
|
+
"api/swagger.yml",
|
|
22
|
+
"spec/openapi.yaml",
|
|
23
|
+
"spec/openapi.yml",
|
|
24
|
+
"docs/openapi.yaml",
|
|
25
|
+
"docs/api.yaml",
|
|
26
|
+
"api-spec.yaml",
|
|
27
|
+
"api-spec.yml",
|
|
28
|
+
"**/openapi.yaml", # Recursive search
|
|
29
|
+
"**/swagger.yaml",
|
|
30
|
+
"services/*/api/openapi.yaml", # Monorepo pattern
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
def __init__(self, root_path: str = "."):
|
|
34
|
+
self.root = Path(root_path)
|
|
35
|
+
|
|
36
|
+
def detect_specs(self) -> Tuple[List[str], Optional[str]]:
|
|
37
|
+
"""
|
|
38
|
+
Detect OpenAPI specifications.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
(spec_files, message): List of found specs and optional message
|
|
42
|
+
"""
|
|
43
|
+
found_specs = []
|
|
44
|
+
|
|
45
|
+
# Check each common pattern
|
|
46
|
+
for pattern in self.COMMON_SPEC_PATTERNS:
|
|
47
|
+
if "**" in pattern:
|
|
48
|
+
# Recursive glob
|
|
49
|
+
for spec_file in self.root.glob(pattern):
|
|
50
|
+
if self._is_valid_openapi(spec_file):
|
|
51
|
+
found_specs.append(str(spec_file.relative_to(self.root)))
|
|
52
|
+
elif "*" in pattern:
|
|
53
|
+
# Simple glob
|
|
54
|
+
for spec_file in self.root.glob(pattern):
|
|
55
|
+
if self._is_valid_openapi(spec_file):
|
|
56
|
+
found_specs.append(str(spec_file.relative_to(self.root)))
|
|
57
|
+
else:
|
|
58
|
+
# Direct path
|
|
59
|
+
spec_file = self.root / pattern
|
|
60
|
+
if spec_file.exists() and self._is_valid_openapi(spec_file):
|
|
61
|
+
found_specs.append(pattern)
|
|
62
|
+
|
|
63
|
+
# Remove duplicates while preserving order
|
|
64
|
+
found_specs = list(dict.fromkeys(found_specs))
|
|
65
|
+
|
|
66
|
+
# Generate appropriate message
|
|
67
|
+
if len(found_specs) == 0:
|
|
68
|
+
message = "No OpenAPI specifications found. Please specify 'files' or create openapi.yaml"
|
|
69
|
+
elif len(found_specs) == 1:
|
|
70
|
+
message = f"Auto-detected spec: {found_specs[0]}"
|
|
71
|
+
else:
|
|
72
|
+
message = f"Multiple specs found: {', '.join(found_specs[:3])}. Please specify 'files' parameter."
|
|
73
|
+
|
|
74
|
+
return found_specs, message
|
|
75
|
+
|
|
76
|
+
def _is_valid_openapi(self, file_path: Path) -> bool:
|
|
77
|
+
"""Check if file is a valid OpenAPI specification."""
|
|
78
|
+
if not file_path.is_file():
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
with open(file_path, 'r') as f:
|
|
83
|
+
data = yaml.safe_load(f)
|
|
84
|
+
# Check for OpenAPI/Swagger markers
|
|
85
|
+
if isinstance(data, dict):
|
|
86
|
+
return 'openapi' in data or 'swagger' in data
|
|
87
|
+
except:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def get_default_specs(self) -> Tuple[Optional[str], Optional[str]]:
|
|
93
|
+
"""
|
|
94
|
+
Get default old_spec and new_spec for auto-detection.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
(old_spec, new_spec): Paths or None if not found
|
|
98
|
+
"""
|
|
99
|
+
specs, _ = self.detect_specs()
|
|
100
|
+
|
|
101
|
+
if len(specs) == 0:
|
|
102
|
+
return None, None
|
|
103
|
+
|
|
104
|
+
# Use the first found spec as both old and new (baseline mode)
|
|
105
|
+
default_spec = specs[0]
|
|
106
|
+
return default_spec, default_spec
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def auto_detect_specs(root_path: str = ".") -> dict:
|
|
110
|
+
"""
|
|
111
|
+
Main entry point for spec auto-detection.
|
|
112
|
+
|
|
113
|
+
Returns dict with:
|
|
114
|
+
- detected: List of detected spec files
|
|
115
|
+
- old_spec: Suggested old spec path
|
|
116
|
+
- new_spec: Suggested new spec path
|
|
117
|
+
- message: User-friendly message
|
|
118
|
+
- requires_input: Whether user must specify paths
|
|
119
|
+
"""
|
|
120
|
+
detector = SpecDetector(root_path)
|
|
121
|
+
specs, message = detector.detect_specs()
|
|
122
|
+
old_spec, new_spec = detector.get_default_specs()
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"detected": specs,
|
|
126
|
+
"old_spec": old_spec,
|
|
127
|
+
"new_spec": new_spec,
|
|
128
|
+
"message": message,
|
|
129
|
+
"requires_input": len(specs) != 1
|
|
130
|
+
}
|