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,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Surface Bridge for V12 Gateway Integration
|
|
3
|
+
Provides unified interface for CLI, MCP, and CI surfaces
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional, Any
|
|
10
|
+
|
|
11
|
+
# Add parent directory to path for imports
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
13
|
+
|
|
14
|
+
from core.gateway_v3 import delimit_run
|
|
15
|
+
from schemas.evidence import TaskEvidence
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SurfaceBridge:
|
|
19
|
+
"""
|
|
20
|
+
Bridge between different surfaces (CLI, MCP, CI) and V12 gateway.
|
|
21
|
+
Ensures all surfaces produce identical TaskEvidence outputs.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def execute_task(task: str, **kwargs) -> Dict[str, Any]:
|
|
26
|
+
"""
|
|
27
|
+
Execute a governance task through the V12 gateway.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
task: Task name (validate-api, check-policy, explain-diff)
|
|
31
|
+
**kwargs: Task-specific parameters
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
TaskEvidence as dictionary
|
|
35
|
+
"""
|
|
36
|
+
return delimit_run(task, **kwargs)
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def validate_api(old_spec: str, new_spec: str, version: Optional[str] = None) -> Dict[str, Any]:
|
|
40
|
+
"""
|
|
41
|
+
Validate API for breaking changes.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
old_spec: Path to old API specification
|
|
45
|
+
new_spec: Path to new API specification
|
|
46
|
+
version: Task version (default: latest)
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
APIChangeEvidence as dictionary
|
|
50
|
+
"""
|
|
51
|
+
return delimit_run(
|
|
52
|
+
"validate-api",
|
|
53
|
+
files=[old_spec, new_spec],
|
|
54
|
+
version=version
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def check_policy(spec_files: List[str],
|
|
59
|
+
policy_file: Optional[str] = None,
|
|
60
|
+
policy_inline: Optional[Dict] = None,
|
|
61
|
+
version: Optional[str] = None) -> Dict[str, Any]:
|
|
62
|
+
"""
|
|
63
|
+
Check API specifications against policy rules.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
spec_files: List of API specification files
|
|
67
|
+
policy_file: Optional path to policy file
|
|
68
|
+
policy_inline: Optional inline policy dict
|
|
69
|
+
version: Task version (default: latest)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
PolicyComplianceEvidence as dictionary
|
|
73
|
+
"""
|
|
74
|
+
return delimit_run(
|
|
75
|
+
"check-policy",
|
|
76
|
+
files=spec_files,
|
|
77
|
+
policy_file=policy_file,
|
|
78
|
+
policy_inline=policy_inline,
|
|
79
|
+
version=version
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def explain_diff(old_spec: str,
|
|
84
|
+
new_spec: str,
|
|
85
|
+
detail_level: str = "medium",
|
|
86
|
+
version: Optional[str] = None) -> Dict[str, Any]:
|
|
87
|
+
"""
|
|
88
|
+
Explain differences between two API specifications.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
old_spec: Path to old API specification
|
|
92
|
+
new_spec: Path to new API specification
|
|
93
|
+
detail_level: Level of detail (summary, medium, detailed)
|
|
94
|
+
version: Task version (default: latest)
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
DiffExplanationEvidence as dictionary
|
|
98
|
+
"""
|
|
99
|
+
return delimit_run(
|
|
100
|
+
"explain-diff",
|
|
101
|
+
files=[old_spec, new_spec],
|
|
102
|
+
detail_level=detail_level,
|
|
103
|
+
version=version
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def format_for_cli(evidence: Dict[str, Any]) -> str:
|
|
108
|
+
"""
|
|
109
|
+
Format TaskEvidence for CLI output.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
evidence: TaskEvidence dictionary
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Formatted string for terminal display
|
|
116
|
+
"""
|
|
117
|
+
output = []
|
|
118
|
+
|
|
119
|
+
# Header
|
|
120
|
+
decision = evidence.get("decision", "unknown")
|
|
121
|
+
task = evidence.get("task", "unknown")
|
|
122
|
+
|
|
123
|
+
# Color codes
|
|
124
|
+
colors = {
|
|
125
|
+
"pass": "\033[92m", # Green
|
|
126
|
+
"warn": "\033[93m", # Yellow
|
|
127
|
+
"fail": "\033[91m", # Red
|
|
128
|
+
"reset": "\033[0m",
|
|
129
|
+
"bold": "\033[1m"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Decision banner
|
|
133
|
+
color = colors.get(decision, colors["reset"])
|
|
134
|
+
output.append(f"{color}{colors['bold']}[{decision.upper()}]{colors['reset']} {task}")
|
|
135
|
+
output.append("")
|
|
136
|
+
|
|
137
|
+
# Summary
|
|
138
|
+
if evidence.get("summary"):
|
|
139
|
+
output.append(f"📊 {evidence['summary']}")
|
|
140
|
+
output.append("")
|
|
141
|
+
|
|
142
|
+
# Violations
|
|
143
|
+
violations = evidence.get("violations", [])
|
|
144
|
+
if violations:
|
|
145
|
+
output.append(f"⚠️ Violations ({len(violations)}):")
|
|
146
|
+
for v in violations[:5]: # Show first 5
|
|
147
|
+
severity = v.get("severity", "unknown")
|
|
148
|
+
message = v.get("message", "")
|
|
149
|
+
output.append(f" • [{severity}] {message}")
|
|
150
|
+
if len(violations) > 5:
|
|
151
|
+
output.append(f" ... and {len(violations) - 5} more")
|
|
152
|
+
output.append("")
|
|
153
|
+
|
|
154
|
+
# Remediation
|
|
155
|
+
if evidence.get("remediation"):
|
|
156
|
+
rem = evidence["remediation"]
|
|
157
|
+
output.append("💡 Remediation:")
|
|
158
|
+
output.append(f" {rem.get('summary', '')}")
|
|
159
|
+
for step in rem.get("steps", [])[:3]:
|
|
160
|
+
output.append(f" • {step}")
|
|
161
|
+
output.append("")
|
|
162
|
+
|
|
163
|
+
# Metrics
|
|
164
|
+
if evidence.get("metrics"):
|
|
165
|
+
output.append("📈 Metrics:")
|
|
166
|
+
for key, value in evidence["metrics"].items():
|
|
167
|
+
output.append(f" • {key}: {value}")
|
|
168
|
+
|
|
169
|
+
return "\n".join(output)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def format_for_mcp(evidence: Dict[str, Any]) -> Dict[str, Any]:
|
|
173
|
+
"""
|
|
174
|
+
Format TaskEvidence for MCP tool response.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
evidence: TaskEvidence dictionary
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
MCP-compatible response dictionary
|
|
181
|
+
"""
|
|
182
|
+
# MCP expects specific format
|
|
183
|
+
return {
|
|
184
|
+
"success": evidence.get("exit_code", 1) == 0,
|
|
185
|
+
"result": evidence,
|
|
186
|
+
"message": evidence.get("summary", "Task completed")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def format_for_ci(evidence: Dict[str, Any]) -> Dict[str, Any]:
|
|
191
|
+
"""
|
|
192
|
+
Format TaskEvidence for CI/CD systems (GitHub Actions, etc).
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
evidence: TaskEvidence dictionary
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
CI-compatible response dictionary
|
|
199
|
+
"""
|
|
200
|
+
# GitHub Actions format
|
|
201
|
+
annotations = []
|
|
202
|
+
for v in evidence.get("violations", []):
|
|
203
|
+
level = "error" if v.get("severity") == "high" else "warning"
|
|
204
|
+
annotations.append({
|
|
205
|
+
"level": level,
|
|
206
|
+
"message": v.get("message", ""),
|
|
207
|
+
"file": v.get("path", ""),
|
|
208
|
+
"title": v.get("rule", "")
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
"conclusion": evidence.get("decision", "fail"),
|
|
213
|
+
"exit_code": evidence.get("exit_code", 1),
|
|
214
|
+
"summary": evidence.get("summary", ""),
|
|
215
|
+
"annotations": annotations,
|
|
216
|
+
"evidence": evidence
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def parse_cli_args(args: List[str]) -> tuple[str, Dict[str, Any]]:
|
|
221
|
+
"""
|
|
222
|
+
Parse CLI arguments into task and parameters.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
args: Command line arguments
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Tuple of (task_name, parameters)
|
|
229
|
+
"""
|
|
230
|
+
if not args:
|
|
231
|
+
raise ValueError("No task specified")
|
|
232
|
+
|
|
233
|
+
task = args[0]
|
|
234
|
+
params = {}
|
|
235
|
+
|
|
236
|
+
# Parse remaining arguments
|
|
237
|
+
i = 1
|
|
238
|
+
while i < len(args):
|
|
239
|
+
arg = args[i]
|
|
240
|
+
if arg.startswith("--"):
|
|
241
|
+
key = arg[2:]
|
|
242
|
+
if i + 1 < len(args) and not args[i + 1].startswith("--"):
|
|
243
|
+
params[key] = args[i + 1]
|
|
244
|
+
i += 2
|
|
245
|
+
else:
|
|
246
|
+
params[key] = True
|
|
247
|
+
i += 1
|
|
248
|
+
else:
|
|
249
|
+
# Positional argument
|
|
250
|
+
if "files" not in params:
|
|
251
|
+
params["files"] = []
|
|
252
|
+
params["files"].append(arg)
|
|
253
|
+
i += 1
|
|
254
|
+
|
|
255
|
+
return task, params
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# Example usage functions for different surfaces
|
|
259
|
+
def cli_main(args: List[str]) -> int:
|
|
260
|
+
"""CLI entry point."""
|
|
261
|
+
bridge = SurfaceBridge()
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
task, params = bridge.parse_cli_args(args)
|
|
265
|
+
evidence = bridge.execute_task(task, **params)
|
|
266
|
+
print(bridge.format_for_cli(evidence))
|
|
267
|
+
return evidence.get("exit_code", 0)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
print(f"Error: {e}")
|
|
270
|
+
return 1
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def mcp_handler(task: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
274
|
+
"""MCP tool handler."""
|
|
275
|
+
bridge = SurfaceBridge()
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
evidence = bridge.execute_task(task, **params)
|
|
279
|
+
return bridge.format_for_mcp(evidence)
|
|
280
|
+
except Exception as e:
|
|
281
|
+
return {
|
|
282
|
+
"success": False,
|
|
283
|
+
"result": None,
|
|
284
|
+
"message": str(e)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def ci_handler(task: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
289
|
+
"""CI/CD handler."""
|
|
290
|
+
bridge = SurfaceBridge()
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
evidence = bridge.execute_task(task, **params)
|
|
294
|
+
return bridge.format_for_ci(evidence)
|
|
295
|
+
except Exception as e:
|
|
296
|
+
return {
|
|
297
|
+
"conclusion": "fail",
|
|
298
|
+
"exit_code": 1,
|
|
299
|
+
"summary": f"Error: {e}",
|
|
300
|
+
"annotations": [],
|
|
301
|
+
"evidence": None
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
# CLI mode
|
|
307
|
+
sys.exit(cli_main(sys.argv[1:]))
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Framework detector — identifies API frameworks in a project directory.
|
|
3
|
+
Scans dependency files and source code to determine which framework is used.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Framework(Enum):
|
|
15
|
+
FASTAPI = "fastapi"
|
|
16
|
+
EXPRESS = "express"
|
|
17
|
+
NESTJS = "nestjs"
|
|
18
|
+
UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AppLocation:
|
|
23
|
+
"""Where the framework app instance was found."""
|
|
24
|
+
file: str
|
|
25
|
+
variable: str
|
|
26
|
+
line: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class FrameworkInfo:
|
|
31
|
+
"""Result of framework detection."""
|
|
32
|
+
framework: Framework
|
|
33
|
+
confidence: float # 0.0 to 1.0
|
|
34
|
+
app_locations: List[AppLocation] = field(default_factory=list)
|
|
35
|
+
entry_point: Optional[str] = None
|
|
36
|
+
message: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_framework(project_dir: str = ".") -> FrameworkInfo:
|
|
40
|
+
"""
|
|
41
|
+
Detect which API framework a project uses.
|
|
42
|
+
|
|
43
|
+
Checks dependency files first (high confidence), then scans source
|
|
44
|
+
files for framework imports (medium confidence).
|
|
45
|
+
"""
|
|
46
|
+
root = Path(project_dir)
|
|
47
|
+
|
|
48
|
+
# Check Python dependency files for FastAPI
|
|
49
|
+
fastapi_confidence = _check_python_deps(root, "fastapi")
|
|
50
|
+
if fastapi_confidence > 0:
|
|
51
|
+
apps = _find_fastapi_apps(root)
|
|
52
|
+
entry = apps[0].file if apps else None
|
|
53
|
+
return FrameworkInfo(
|
|
54
|
+
framework=Framework.FASTAPI,
|
|
55
|
+
confidence=fastapi_confidence,
|
|
56
|
+
app_locations=apps,
|
|
57
|
+
entry_point=entry,
|
|
58
|
+
message=f"FastAPI detected{f' in {entry}' if entry else ''}",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Check Node dependency files for NestJS (before Express since NestJS uses Express internally)
|
|
62
|
+
nestjs_confidence = _check_node_deps(root, "@nestjs/core")
|
|
63
|
+
if nestjs_confidence > 0:
|
|
64
|
+
apps = _find_nestjs_apps(root)
|
|
65
|
+
entry = apps[0].file if apps else None
|
|
66
|
+
return FrameworkInfo(
|
|
67
|
+
framework=Framework.NESTJS,
|
|
68
|
+
confidence=nestjs_confidence,
|
|
69
|
+
app_locations=apps,
|
|
70
|
+
entry_point=entry,
|
|
71
|
+
message=f"NestJS detected{f' in {entry}' if entry else ''}",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Check Node dependency files for Express
|
|
75
|
+
express_confidence = _check_node_deps(root, "express")
|
|
76
|
+
if express_confidence > 0:
|
|
77
|
+
apps = _find_express_apps(root)
|
|
78
|
+
entry = apps[0].file if apps else None
|
|
79
|
+
return FrameworkInfo(
|
|
80
|
+
framework=Framework.EXPRESS,
|
|
81
|
+
confidence=express_confidence,
|
|
82
|
+
app_locations=apps,
|
|
83
|
+
entry_point=entry,
|
|
84
|
+
message=f"Express detected{f' in {entry}' if entry else ''}",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return FrameworkInfo(
|
|
88
|
+
framework=Framework.UNKNOWN,
|
|
89
|
+
confidence=0.0,
|
|
90
|
+
message="No supported API framework detected",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_python_deps(root: Path, package: str) -> float:
|
|
95
|
+
"""Check Python dependency files for a package. Returns confidence 0-1."""
|
|
96
|
+
# pyproject.toml — highest confidence
|
|
97
|
+
pyproject = root / "pyproject.toml"
|
|
98
|
+
if pyproject.exists():
|
|
99
|
+
text = pyproject.read_text()
|
|
100
|
+
if re.search(rf'["\']?{package}["\']?\s*[>=<~!]', text) or f'"{package}"' in text or f"'{package}'" in text:
|
|
101
|
+
return 0.95
|
|
102
|
+
|
|
103
|
+
# requirements.txt
|
|
104
|
+
for req_file in ["requirements.txt", "requirements/base.txt", "requirements/prod.txt"]:
|
|
105
|
+
req_path = root / req_file
|
|
106
|
+
if req_path.exists():
|
|
107
|
+
text = req_path.read_text()
|
|
108
|
+
for line in text.splitlines():
|
|
109
|
+
line = line.strip()
|
|
110
|
+
if line.startswith("#") or not line:
|
|
111
|
+
continue
|
|
112
|
+
if re.match(rf'^{package}\b', line):
|
|
113
|
+
return 0.9
|
|
114
|
+
|
|
115
|
+
# setup.py / setup.cfg
|
|
116
|
+
for setup_file in ["setup.py", "setup.cfg"]:
|
|
117
|
+
path = root / setup_file
|
|
118
|
+
if path.exists():
|
|
119
|
+
text = path.read_text()
|
|
120
|
+
if package in text:
|
|
121
|
+
return 0.8
|
|
122
|
+
|
|
123
|
+
# Fallback: scan .py files for import
|
|
124
|
+
for py_file in _iter_python_files(root, max_files=50):
|
|
125
|
+
try:
|
|
126
|
+
text = py_file.read_text()
|
|
127
|
+
if f"import {package}" in text or f"from {package}" in text:
|
|
128
|
+
return 0.7
|
|
129
|
+
except Exception:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
return 0.0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _check_node_deps(root: Path, package: str) -> float:
|
|
136
|
+
"""Check Node.js dependency files for a package. Returns confidence 0-1."""
|
|
137
|
+
pkg_json = root / "package.json"
|
|
138
|
+
if not pkg_json.exists():
|
|
139
|
+
return 0.0
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
import json
|
|
143
|
+
data = json.loads(pkg_json.read_text())
|
|
144
|
+
all_deps = {}
|
|
145
|
+
all_deps.update(data.get("dependencies", {}))
|
|
146
|
+
all_deps.update(data.get("devDependencies", {}))
|
|
147
|
+
if package in all_deps:
|
|
148
|
+
return 0.9
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
return 0.0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _find_express_apps(root: Path) -> List[AppLocation]:
|
|
156
|
+
"""Find Express app instances via regex scanning of JS/TS files."""
|
|
157
|
+
apps = []
|
|
158
|
+
|
|
159
|
+
# Check common entry points first
|
|
160
|
+
priority_files = [
|
|
161
|
+
"app.js", "src/app.js", "server.js", "src/server.js",
|
|
162
|
+
"index.js", "src/index.js", "app.ts", "src/app.ts",
|
|
163
|
+
"server.ts", "src/server.ts",
|
|
164
|
+
]
|
|
165
|
+
checked = set()
|
|
166
|
+
|
|
167
|
+
for rel in priority_files:
|
|
168
|
+
path = root / rel
|
|
169
|
+
if path.exists():
|
|
170
|
+
checked.add(path)
|
|
171
|
+
found = _scan_file_for_express(path, root)
|
|
172
|
+
apps.extend(found)
|
|
173
|
+
|
|
174
|
+
# If not found in priority files, scan .js files
|
|
175
|
+
if not apps:
|
|
176
|
+
skip_dirs = {
|
|
177
|
+
"node_modules", ".git", "dist", "build", "coverage",
|
|
178
|
+
".nyc_output", ".next", ".nuxt",
|
|
179
|
+
}
|
|
180
|
+
count = 0
|
|
181
|
+
for js_file in root.rglob("*.js"):
|
|
182
|
+
if any(part in skip_dirs for part in js_file.parts):
|
|
183
|
+
continue
|
|
184
|
+
if js_file in checked:
|
|
185
|
+
continue
|
|
186
|
+
found = _scan_file_for_express(js_file, root)
|
|
187
|
+
apps.extend(found)
|
|
188
|
+
if apps:
|
|
189
|
+
break
|
|
190
|
+
count += 1
|
|
191
|
+
if count >= 50:
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
return apps
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _scan_file_for_express(path: Path, root: Path) -> List[AppLocation]:
|
|
198
|
+
"""Scan a single JS/TS file for Express app instantiation."""
|
|
199
|
+
results = []
|
|
200
|
+
try:
|
|
201
|
+
source = path.read_text()
|
|
202
|
+
except Exception:
|
|
203
|
+
return results
|
|
204
|
+
|
|
205
|
+
# Must import express
|
|
206
|
+
if not re.search(r"require\s*\(\s*['\"]express['\"]\s*\)", source) and \
|
|
207
|
+
not re.search(r"from\s+['\"]express['\"]", source):
|
|
208
|
+
return results
|
|
209
|
+
|
|
210
|
+
# Find the express variable name
|
|
211
|
+
express_var = None
|
|
212
|
+
m_req = re.search(r"(?:const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*['\"]express['\"]\s*\)", source)
|
|
213
|
+
if m_req:
|
|
214
|
+
express_var = m_req.group(1)
|
|
215
|
+
|
|
216
|
+
if not express_var:
|
|
217
|
+
return results
|
|
218
|
+
|
|
219
|
+
# Find app = express()
|
|
220
|
+
m_app = re.search(
|
|
221
|
+
rf"(?:const|let|var)\s+(\w+)\s*=\s*{re.escape(express_var)}\s*\(\s*\)",
|
|
222
|
+
source,
|
|
223
|
+
)
|
|
224
|
+
if m_app:
|
|
225
|
+
var_name = m_app.group(1)
|
|
226
|
+
# Determine the line number
|
|
227
|
+
line_num = source[:m_app.start()].count("\n") + 1
|
|
228
|
+
rel_path = str(path.relative_to(root))
|
|
229
|
+
results.append(AppLocation(file=rel_path, variable=var_name, line=line_num))
|
|
230
|
+
|
|
231
|
+
return results
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _find_nestjs_apps(root: Path) -> List[AppLocation]:
|
|
235
|
+
"""Find NestJS AppModule files."""
|
|
236
|
+
apps = []
|
|
237
|
+
candidates = [
|
|
238
|
+
"src/app.module.ts",
|
|
239
|
+
"src/app.module.js",
|
|
240
|
+
"app/app.module.ts",
|
|
241
|
+
"app/app.module.js",
|
|
242
|
+
]
|
|
243
|
+
for rel in candidates:
|
|
244
|
+
path = root / rel
|
|
245
|
+
if path.exists():
|
|
246
|
+
apps.append(AppLocation(file=rel, variable="AppModule", line=1))
|
|
247
|
+
break
|
|
248
|
+
return apps
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _find_fastapi_apps(root: Path) -> List[AppLocation]:
|
|
252
|
+
"""Find FastAPI app instances via AST analysis."""
|
|
253
|
+
apps = []
|
|
254
|
+
|
|
255
|
+
# Check common entry points first
|
|
256
|
+
priority_files = ["main.py", "app.py", "app/main.py", "src/main.py", "src/app.py", "server.py"]
|
|
257
|
+
checked = set()
|
|
258
|
+
|
|
259
|
+
for rel in priority_files:
|
|
260
|
+
path = root / rel
|
|
261
|
+
if path.exists():
|
|
262
|
+
checked.add(path)
|
|
263
|
+
found = _scan_file_for_fastapi(path, root)
|
|
264
|
+
apps.extend(found)
|
|
265
|
+
|
|
266
|
+
# If not found in priority files, scan all .py files
|
|
267
|
+
if not apps:
|
|
268
|
+
for py_file in _iter_python_files(root, max_files=100):
|
|
269
|
+
if py_file in checked:
|
|
270
|
+
continue
|
|
271
|
+
found = _scan_file_for_fastapi(py_file, root)
|
|
272
|
+
apps.extend(found)
|
|
273
|
+
if apps:
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
return apps
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _scan_file_for_fastapi(path: Path, root: Path) -> List[AppLocation]:
|
|
280
|
+
"""Scan a single Python file for FastAPI() instantiation via AST."""
|
|
281
|
+
results = []
|
|
282
|
+
try:
|
|
283
|
+
source = path.read_text()
|
|
284
|
+
tree = ast.parse(source)
|
|
285
|
+
except Exception:
|
|
286
|
+
return results
|
|
287
|
+
|
|
288
|
+
# Check if FastAPI is imported
|
|
289
|
+
has_fastapi_import = False
|
|
290
|
+
fastapi_names = set()
|
|
291
|
+
|
|
292
|
+
for node in ast.walk(tree):
|
|
293
|
+
# from fastapi import FastAPI
|
|
294
|
+
if isinstance(node, ast.ImportFrom) and node.module and "fastapi" in node.module:
|
|
295
|
+
has_fastapi_import = True
|
|
296
|
+
for alias in node.names:
|
|
297
|
+
fastapi_names.add(alias.asname or alias.name)
|
|
298
|
+
|
|
299
|
+
# import fastapi
|
|
300
|
+
if isinstance(node, ast.Import):
|
|
301
|
+
for alias in node.names:
|
|
302
|
+
if "fastapi" in alias.name:
|
|
303
|
+
has_fastapi_import = True
|
|
304
|
+
fastapi_names.add(alias.asname or alias.name)
|
|
305
|
+
|
|
306
|
+
if not has_fastapi_import:
|
|
307
|
+
return results
|
|
308
|
+
|
|
309
|
+
# Find assignments like: app = FastAPI(...)
|
|
310
|
+
for node in ast.walk(tree):
|
|
311
|
+
if isinstance(node, ast.Assign):
|
|
312
|
+
if isinstance(node.value, ast.Call):
|
|
313
|
+
call = node.value
|
|
314
|
+
func_name = _get_call_name(call)
|
|
315
|
+
if func_name in fastapi_names or func_name == "FastAPI":
|
|
316
|
+
for target in node.targets:
|
|
317
|
+
if isinstance(target, ast.Name):
|
|
318
|
+
rel_path = str(path.relative_to(root))
|
|
319
|
+
results.append(AppLocation(
|
|
320
|
+
file=rel_path,
|
|
321
|
+
variable=target.id,
|
|
322
|
+
line=node.lineno,
|
|
323
|
+
))
|
|
324
|
+
|
|
325
|
+
return results
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _get_call_name(call: ast.Call) -> str:
|
|
329
|
+
"""Extract function name from an ast.Call node."""
|
|
330
|
+
if isinstance(call.func, ast.Name):
|
|
331
|
+
return call.func.id
|
|
332
|
+
if isinstance(call.func, ast.Attribute):
|
|
333
|
+
return call.func.attr
|
|
334
|
+
return ""
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _iter_python_files(root: Path, max_files: int = 100) -> List[Path]:
|
|
338
|
+
"""Iterate Python files, skipping venvs and hidden dirs."""
|
|
339
|
+
skip_dirs = {
|
|
340
|
+
"venv", ".venv", "env", ".env", "node_modules",
|
|
341
|
+
"__pycache__", ".git", ".tox", ".mypy_cache", ".pytest_cache",
|
|
342
|
+
"dist", "build", "egg-info",
|
|
343
|
+
}
|
|
344
|
+
count = 0
|
|
345
|
+
files = []
|
|
346
|
+
for path in root.rglob("*.py"):
|
|
347
|
+
if any(part in skip_dirs for part in path.parts):
|
|
348
|
+
continue
|
|
349
|
+
files.append(path)
|
|
350
|
+
count += 1
|
|
351
|
+
if count >= max_files:
|
|
352
|
+
break
|
|
353
|
+
return files
|