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.
Files changed (112) hide show
  1. package/.dockerignore +7 -0
  2. package/.github/workflows/ci.yml +22 -0
  3. package/CODE_OF_CONDUCT.md +48 -0
  4. package/CONTRIBUTING.md +67 -0
  5. package/Dockerfile +9 -0
  6. package/LICENSE +21 -0
  7. package/README.md +18 -69
  8. package/SECURITY.md +42 -0
  9. package/adapters/gemini-forge.js +11 -0
  10. package/adapters/gemini-jamsons.js +152 -0
  11. package/bin/delimit-cli.js +8 -0
  12. package/bin/delimit-setup.js +258 -0
  13. package/gateway/ai/backends/__init__.py +0 -0
  14. package/gateway/ai/backends/async_utils.py +21 -0
  15. package/gateway/ai/backends/deploy_bridge.py +150 -0
  16. package/gateway/ai/backends/gateway_core.py +261 -0
  17. package/gateway/ai/backends/generate_bridge.py +38 -0
  18. package/gateway/ai/backends/governance_bridge.py +196 -0
  19. package/gateway/ai/backends/intel_bridge.py +59 -0
  20. package/gateway/ai/backends/memory_bridge.py +93 -0
  21. package/gateway/ai/backends/ops_bridge.py +137 -0
  22. package/gateway/ai/backends/os_bridge.py +82 -0
  23. package/gateway/ai/backends/repo_bridge.py +117 -0
  24. package/gateway/ai/backends/ui_bridge.py +118 -0
  25. package/gateway/ai/backends/vault_bridge.py +129 -0
  26. package/gateway/ai/server.py +1182 -0
  27. package/gateway/core/__init__.py +3 -0
  28. package/gateway/core/__pycache__/__init__.cpython-310.pyc +0 -0
  29. package/gateway/core/__pycache__/auto_baseline.cpython-310.pyc +0 -0
  30. package/gateway/core/__pycache__/ci_formatter.cpython-310.pyc +0 -0
  31. package/gateway/core/__pycache__/contract_ledger.cpython-310.pyc +0 -0
  32. package/gateway/core/__pycache__/dependency_graph.cpython-310.pyc +0 -0
  33. package/gateway/core/__pycache__/dependency_manifest.cpython-310.pyc +0 -0
  34. package/gateway/core/__pycache__/diff_engine_v2.cpython-310.pyc +0 -0
  35. package/gateway/core/__pycache__/event_backbone.cpython-310.pyc +0 -0
  36. package/gateway/core/__pycache__/event_schema.cpython-310.pyc +0 -0
  37. package/gateway/core/__pycache__/explainer.cpython-310.pyc +0 -0
  38. package/gateway/core/__pycache__/gateway.cpython-310.pyc +0 -0
  39. package/gateway/core/__pycache__/gateway_v2.cpython-310.pyc +0 -0
  40. package/gateway/core/__pycache__/gateway_v3.cpython-310.pyc +0 -0
  41. package/gateway/core/__pycache__/impact_analyzer.cpython-310.pyc +0 -0
  42. package/gateway/core/__pycache__/policy_engine.cpython-310.pyc +0 -0
  43. package/gateway/core/__pycache__/registry.cpython-310.pyc +0 -0
  44. package/gateway/core/__pycache__/registry_v2.cpython-310.pyc +0 -0
  45. package/gateway/core/__pycache__/registry_v3.cpython-310.pyc +0 -0
  46. package/gateway/core/__pycache__/semver_classifier.cpython-310.pyc +0 -0
  47. package/gateway/core/__pycache__/spec_detector.cpython-310.pyc +0 -0
  48. package/gateway/core/__pycache__/surface_bridge.cpython-310.pyc +0 -0
  49. package/gateway/core/auto_baseline.py +304 -0
  50. package/gateway/core/ci_formatter.py +283 -0
  51. package/gateway/core/complexity_analyzer.py +386 -0
  52. package/gateway/core/contract_ledger.py +345 -0
  53. package/gateway/core/dependency_graph.py +218 -0
  54. package/gateway/core/dependency_manifest.py +223 -0
  55. package/gateway/core/diff_engine_v2.py +477 -0
  56. package/gateway/core/diff_engine_v2.py.bak +426 -0
  57. package/gateway/core/event_backbone.py +268 -0
  58. package/gateway/core/event_schema.py +258 -0
  59. package/gateway/core/explainer.py +438 -0
  60. package/gateway/core/gateway.py +128 -0
  61. package/gateway/core/gateway_v2.py +154 -0
  62. package/gateway/core/gateway_v3.py +224 -0
  63. package/gateway/core/impact_analyzer.py +163 -0
  64. package/gateway/core/policies/default.yml +13 -0
  65. package/gateway/core/policies/relaxed.yml +48 -0
  66. package/gateway/core/policies/strict.yml +55 -0
  67. package/gateway/core/policy_engine.py +464 -0
  68. package/gateway/core/registry.py +52 -0
  69. package/gateway/core/registry_v2.py +132 -0
  70. package/gateway/core/registry_v3.py +134 -0
  71. package/gateway/core/semver_classifier.py +152 -0
  72. package/gateway/core/spec_detector.py +130 -0
  73. package/gateway/core/surface_bridge.py +307 -0
  74. package/gateway/core/zero_spec/__init__.py +4 -0
  75. package/gateway/core/zero_spec/__pycache__/__init__.cpython-310.pyc +0 -0
  76. package/gateway/core/zero_spec/__pycache__/detector.cpython-310.pyc +0 -0
  77. package/gateway/core/zero_spec/__pycache__/express_extractor.cpython-310.pyc +0 -0
  78. package/gateway/core/zero_spec/__pycache__/fastapi_extractor.cpython-310.pyc +0 -0
  79. package/gateway/core/zero_spec/__pycache__/nestjs_extractor.cpython-310.pyc +0 -0
  80. package/gateway/core/zero_spec/detector.py +353 -0
  81. package/gateway/core/zero_spec/express_extractor.py +483 -0
  82. package/gateway/core/zero_spec/fastapi_extractor.py +254 -0
  83. package/gateway/core/zero_spec/nestjs_extractor.py +369 -0
  84. package/gateway/tasks/__init__.py +1 -0
  85. package/gateway/tasks/__pycache__/__init__.cpython-310.pyc +0 -0
  86. package/gateway/tasks/__pycache__/check_policy.cpython-310.pyc +0 -0
  87. package/gateway/tasks/__pycache__/check_policy_v2.cpython-310.pyc +0 -0
  88. package/gateway/tasks/__pycache__/check_policy_v3.cpython-310.pyc +0 -0
  89. package/gateway/tasks/__pycache__/explain_diff.cpython-310.pyc +0 -0
  90. package/gateway/tasks/__pycache__/explain_diff_v2.cpython-310.pyc +0 -0
  91. package/gateway/tasks/__pycache__/validate_api.cpython-310.pyc +0 -0
  92. package/gateway/tasks/__pycache__/validate_api_v2.cpython-310.pyc +0 -0
  93. package/gateway/tasks/__pycache__/validate_api_v3.cpython-310.pyc +0 -0
  94. package/gateway/tasks/check_policy.py +177 -0
  95. package/gateway/tasks/check_policy_v2.py +255 -0
  96. package/gateway/tasks/check_policy_v3.py +255 -0
  97. package/gateway/tasks/explain_diff.py +305 -0
  98. package/gateway/tasks/explain_diff_v2.py +267 -0
  99. package/gateway/tasks/validate_api.py +131 -0
  100. package/gateway/tasks/validate_api_v2.py +208 -0
  101. package/gateway/tasks/validate_api_v3.py +163 -0
  102. package/package.json +2 -2
  103. package/adapters/codex-skill.js +0 -87
  104. package/adapters/cursor-extension.js +0 -190
  105. package/adapters/gemini-action.js +0 -93
  106. package/adapters/openai-function.js +0 -112
  107. package/adapters/xai-plugin.js +0 -151
  108. package/test-decision-engine.js +0 -181
  109. package/test-hook.js +0 -27
  110. package/tests/cli.test.js +0 -359
  111. package/tests/fixtures/openapi-changed.yaml +0 -56
  112. package/tests/fixtures/openapi.yaml +0 -87
@@ -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:]))
@@ -0,0 +1,4 @@
1
+ from .detector import detect_framework, FrameworkInfo
2
+ from .express_extractor import extract_express_spec
3
+ from .fastapi_extractor import extract_fastapi_spec
4
+ from .nestjs_extractor import extract_nestjs_spec
@@ -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