archapi 0.3.0__py3-none-any.whl

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.
archapi/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from archapi.core import ArchAPI
2
+
3
+ __all__ = ["ArchAPI"]
archapi/core.py ADDED
@@ -0,0 +1,295 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ from archapi.frameworks.detector import FrameworkDetector
7
+ from archapi.frameworks.registry import FrameworkRegistry
8
+ from archapi.indexing.cache import CacheManager
9
+ from archapi.security.secret_scanner import SecretScanner
10
+ from archapi.security.context_redactor import ContextRedactor
11
+ from archapi.security.policy_gate import PolicyGate
12
+ from archapi.validation.architecture_score import ArchitectureConsistencyScorer
13
+ from archapi.validation.command_validator import CommandValidator
14
+ from archapi.types import (
15
+ APIGenome,
16
+ APIPlan,
17
+ DetectionResult,
18
+ GenerationResult,
19
+ ScanResult,
20
+ )
21
+
22
+
23
+ class ArchAPI:
24
+ def __init__(self, project_path: Union[str, Path], framework: Optional[str] = None, config: Optional[Dict[str, Any]] = None):
25
+ self.project_path = Path(project_path).resolve()
26
+ if not self.project_path.exists():
27
+ raise FileNotFoundError(f"Project path does not exist: {self.project_path}")
28
+
29
+ self._framework_override = framework
30
+ self._config = config or {}
31
+ self._detector = FrameworkDetector()
32
+ self._registry = FrameworkRegistry()
33
+ self._cache = CacheManager(self.project_path)
34
+ self._secret_scanner = SecretScanner(self.project_path)
35
+ self._context_redactor = ContextRedactor()
36
+ self._policy_gate = PolicyGate()
37
+ self._architecture_scorer = ArchitectureConsistencyScorer()
38
+ self._command_validator = CommandValidator(self.project_path)
39
+
40
+ self._detection: Optional[DetectionResult] = None
41
+ self._scan: Optional[ScanResult] = None
42
+ self._maps: Optional[Dict[str, Any]] = None
43
+ self._genome: Optional[APIGenome] = None
44
+
45
+ def detect_framework(self) -> DetectionResult:
46
+ if self._framework_override:
47
+ self._detection = DetectionResult(
48
+ framework=self._framework_override,
49
+ confidence=1.0,
50
+ reasons=["Framework explicitly provided"],
51
+ )
52
+ return self._detection
53
+
54
+ self._detection = self._detector.detect(self.project_path)
55
+ return self._detection
56
+
57
+ def _adapter(self):
58
+ detection = self._detection or self.detect_framework()
59
+ return self._registry.get(detection.framework)
60
+
61
+ def _has_config_hints(self) -> bool:
62
+ hint_keys = {
63
+ "route_dir",
64
+ "controller_dir",
65
+ "service_dir",
66
+ "model_dir",
67
+ "schema_dir",
68
+ "middleware_dir",
69
+ "test_dir",
70
+ }
71
+ return any(key in self._config for key in hint_keys)
72
+
73
+ def scan(self) -> ScanResult:
74
+ detection = self._detection or self.detect_framework()
75
+
76
+ # Strict config mode:
77
+ # If user provides architecture hints, scan ONLY those hinted directories.
78
+ # This prevents accidental scanning of the library repo, sample projects,
79
+ # caches, or unrelated test files.
80
+ if self._has_config_hints():
81
+ self._scan = ScanResult(
82
+ framework=detection.framework,
83
+ project_path=self.project_path,
84
+ )
85
+ self._apply_config_hints_to_scan(self._scan)
86
+ return self._scan
87
+
88
+ adapter = self._adapter()
89
+ self._scan = adapter.scan(self.project_path)
90
+ self._scan.framework = detection.framework
91
+ return self._scan
92
+
93
+ def _apply_config_hints_to_scan(self, scan: ScanResult) -> None:
94
+ """
95
+ Applies user-provided architecture hints.
96
+
97
+ Supported config keys:
98
+ - route_dir
99
+ - controller_dir
100
+ - service_dir
101
+ - model_dir
102
+ - schema_dir
103
+ - middleware_dir
104
+ - test_dir
105
+ """
106
+
107
+ hint_map = {
108
+ "route_dir": scan.routes,
109
+ "controller_dir": scan.controllers,
110
+ "service_dir": scan.services,
111
+ "model_dir": scan.models,
112
+ "schema_dir": scan.schemas,
113
+ "middleware_dir": scan.middleware,
114
+ "test_dir": scan.tests,
115
+ }
116
+
117
+ ignored_parts = {
118
+ ".git",
119
+ ".venv",
120
+ "node_modules",
121
+ "dist",
122
+ "build",
123
+ "coverage",
124
+ "__pycache__",
125
+ ".archapi",
126
+ "archapi.egg-info",
127
+ }
128
+
129
+ for config_key, target_list in hint_map.items():
130
+ raw_dir = self._config.get(config_key)
131
+ if not raw_dir:
132
+ continue
133
+
134
+ hint_path = (self.project_path / raw_dir).resolve()
135
+
136
+ if not hint_path.exists() or not hint_path.is_dir():
137
+ continue
138
+
139
+ for file_path in hint_path.rglob("*"):
140
+ if not file_path.is_file():
141
+ continue
142
+
143
+ try:
144
+ rel_parts = file_path.relative_to(self.project_path).parts
145
+ except ValueError:
146
+ rel_parts = file_path.parts
147
+
148
+ if any(part in ignored_parts for part in rel_parts):
149
+ continue
150
+
151
+ if file_path not in target_list:
152
+ target_list.append(file_path)
153
+
154
+ def config(self) -> Dict[str, Any]:
155
+ return dict(self._config)
156
+
157
+ def build_maps(self) -> Dict[str, Any]:
158
+ scan = self._scan or self.scan()
159
+ adapter = self._adapter()
160
+ self._maps = adapter.build_maps(scan)
161
+ return self._maps
162
+
163
+ def extract_genome(self) -> APIGenome:
164
+ scan = self._scan or self.scan()
165
+ maps = self._maps or self.build_maps()
166
+ adapter = self._adapter()
167
+ self._genome = adapter.extract_genome(maps, scan)
168
+ self._genome.framework = (self._detection or self.detect_framework()).framework
169
+ return self._genome
170
+
171
+ def compute_confidence(self) -> Dict[str, Any]:
172
+ detection = self._detection or self.detect_framework()
173
+ genome = self._genome or self.extract_genome()
174
+
175
+ missing = []
176
+
177
+ if genome.route_style == "unknown":
178
+ missing.append("route style")
179
+ if genome.controller_style == "unknown":
180
+ missing.append("controller style")
181
+ if genome.service_style == "unknown":
182
+ missing.append("service style")
183
+ if genome.schema_style == "unknown":
184
+ missing.append("schema style")
185
+
186
+ # Overall confidence should consider BOTH:
187
+ # 1. framework detection confidence
188
+ # 2. API architecture/genome confidence
189
+ #
190
+ # This prevents generic/unknown projects from being treated as safe
191
+ # just because some folders accidentally look like routes/services.
192
+ overall = round(min(detection.confidence, genome.confidence), 2)
193
+
194
+ mode = "generate"
195
+
196
+ if detection.framework in {"generic", "node-unknown"}:
197
+ mode = "blocked"
198
+ elif overall < 0.30:
199
+ mode = "blocked"
200
+ elif overall < 0.55:
201
+ mode = "plan_only"
202
+ elif overall < 0.75:
203
+ mode = "generate_with_warnings"
204
+
205
+ return {
206
+ "overall": overall,
207
+ "detection_confidence": detection.confidence,
208
+ "genome_confidence": genome.confidence,
209
+ "mode": mode,
210
+ "missing": missing,
211
+ "framework": genome.framework,
212
+ }
213
+
214
+ def plan_api(self, request: str) -> APIPlan:
215
+ maps = self._maps or self.build_maps()
216
+ genome = self._genome or self.extract_genome()
217
+ adapter = self._adapter()
218
+
219
+ plan = adapter.plan_api(request, genome, maps)
220
+ confidence = self.compute_confidence()
221
+
222
+ if confidence["mode"] in {"blocked", "plan_only"}:
223
+ plan.generation_allowed = False
224
+
225
+ if confidence["framework"] in {"generic", "node-unknown"}:
226
+ plan.reason = (
227
+ "Framework could not be confidently detected. "
228
+ "Provide framework or config before generation."
229
+ )
230
+ else:
231
+ plan.reason = (
232
+ "Architecture confidence too low for code generation. "
233
+ f"Missing: {', '.join(confidence['missing']) or 'unknown'}"
234
+ )
235
+
236
+ return plan
237
+
238
+ def save_cache(self) -> Dict[str, Path]:
239
+ detection = self._detection or self.detect_framework()
240
+ scan = self._scan or self.scan()
241
+ maps = self._maps or self.build_maps()
242
+ genome = self._genome or self.extract_genome()
243
+
244
+ return self._cache.save_snapshot(
245
+ detection=detection,
246
+ scan=scan,
247
+ maps=maps,
248
+ genome=genome,
249
+ )
250
+
251
+ def changed_files(self) -> list:
252
+ return self._cache.changed_files()
253
+
254
+ def scan_secrets(self):
255
+ return self._secret_scanner.scan()
256
+
257
+ def redact_context(self, text: str) -> str:
258
+ return self._context_redactor.redact(text)
259
+
260
+ def validate_policy(self, result: GenerationResult):
261
+ return self._policy_gate.validate_result(result)
262
+
263
+ def score_architecture(self, result: GenerationResult):
264
+ genome = self._genome or self.extract_genome()
265
+ return self._architecture_scorer.score(result.files, genome)
266
+
267
+ def validate_project_commands(self):
268
+ detection = self._detection or self.detect_framework()
269
+
270
+ if detection.framework in {"express-typescript", "nestjs", "node-unknown"}:
271
+ return self._command_validator.validate_node_project()
272
+
273
+ return self._command_validator.validate_node_project()
274
+
275
+ def generate_api(self, request: str, dry_run: bool = True) -> GenerationResult:
276
+ maps = self._maps or self.build_maps()
277
+ genome = self._genome or self.extract_genome()
278
+ plan = self.plan_api(request)
279
+
280
+ adapter = self._adapter()
281
+ files = adapter.generate_code(plan, genome, maps)
282
+ report = adapter.validate_generated_code(files, plan, genome)
283
+
284
+ result = GenerationResult(
285
+ project_path=self.project_path,
286
+ plan=plan,
287
+ files=files,
288
+ validation_report=report,
289
+ warnings=report.warnings,
290
+ )
291
+
292
+ if not dry_run and report.success:
293
+ result.apply()
294
+
295
+ return result
File without changes
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List
6
+
7
+ from archapi.types import (
8
+ APIPlan,
9
+ APIGenome,
10
+ DetectionResult,
11
+ GeneratedFile,
12
+ ScanResult,
13
+ ValidationReport,
14
+ )
15
+
16
+
17
+ class FrameworkAdapter(ABC):
18
+ name: str = "unknown"
19
+
20
+ @abstractmethod
21
+ def detect(self, project_path: Path) -> DetectionResult:
22
+ raise NotImplementedError
23
+
24
+ @abstractmethod
25
+ def scan(self, project_path: Path) -> ScanResult:
26
+ raise NotImplementedError
27
+
28
+ @abstractmethod
29
+ def build_maps(self, scan_result: ScanResult) -> Dict[str, Any]:
30
+ raise NotImplementedError
31
+
32
+ @abstractmethod
33
+ def extract_genome(self, maps: Dict[str, Any], scan_result: ScanResult) -> APIGenome:
34
+ raise NotImplementedError
35
+
36
+ @abstractmethod
37
+ def plan_api(self, request: str, genome: APIGenome, maps: Dict[str, Any]) -> APIPlan:
38
+ raise NotImplementedError
39
+
40
+ @abstractmethod
41
+ def generate_code(
42
+ self,
43
+ plan: APIPlan,
44
+ genome: APIGenome,
45
+ maps: Dict[str, Any],
46
+ ) -> List[GeneratedFile]:
47
+ raise NotImplementedError
48
+
49
+ @abstractmethod
50
+ def validate_generated_code(
51
+ self,
52
+ files: List[GeneratedFile],
53
+ plan: APIPlan,
54
+ genome: APIGenome,
55
+ ) -> ValidationReport:
56
+ raise NotImplementedError
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ from archapi.types import DetectionResult
8
+
9
+
10
+ class FrameworkDetector:
11
+ def detect(self, project_path: Path) -> DetectionResult:
12
+ candidates: List[DetectionResult] = []
13
+
14
+ package_json = project_path / "package.json"
15
+ if package_json.exists():
16
+ try:
17
+ data = json.loads(package_json.read_text(encoding="utf-8"))
18
+ deps = {}
19
+ deps.update(data.get("dependencies", {}))
20
+ deps.update(data.get("devDependencies", {}))
21
+
22
+ if "@nestjs/core" in deps:
23
+ candidates.append(
24
+ DetectionResult("nestjs", 0.95, ["package.json contains @nestjs/core"])
25
+ )
26
+
27
+ if "express" in deps:
28
+ candidates.append(
29
+ DetectionResult("express-typescript", 0.85, ["package.json contains express"])
30
+ )
31
+ except Exception:
32
+ candidates.append(
33
+ DetectionResult("node-unknown", 0.30, ["package.json exists but could not be parsed"])
34
+ )
35
+
36
+ if (project_path / "manage.py").exists():
37
+ candidates.append(
38
+ DetectionResult("django-drf", 0.75, ["manage.py detected"])
39
+ )
40
+
41
+ py_text = ""
42
+ pyproject = project_path / "pyproject.toml"
43
+ requirements = project_path / "requirements.txt"
44
+
45
+ if pyproject.exists():
46
+ py_text += pyproject.read_text(encoding="utf-8", errors="ignore").lower()
47
+
48
+ if requirements.exists():
49
+ py_text += requirements.read_text(encoding="utf-8", errors="ignore").lower()
50
+
51
+ if "fastapi" in py_text:
52
+ candidates.append(
53
+ DetectionResult("fastapi", 0.90, ["fastapi dependency detected"])
54
+ )
55
+
56
+ if "flask" in py_text:
57
+ candidates.append(
58
+ DetectionResult("flask", 0.85, ["flask dependency detected"])
59
+ )
60
+
61
+ if (project_path / "pom.xml").exists() or (project_path / "build.gradle").exists():
62
+ candidates.append(
63
+ DetectionResult("spring-boot", 0.70, ["Java build file detected"])
64
+ )
65
+
66
+ if list(project_path.glob("*.csproj")):
67
+ candidates.append(
68
+ DetectionResult("dotnet-core", 0.75, [".csproj file detected"])
69
+ )
70
+
71
+ if (project_path / "composer.json").exists():
72
+ composer = (project_path / "composer.json").read_text(
73
+ encoding="utf-8",
74
+ errors="ignore",
75
+ ).lower()
76
+ if "laravel" in composer:
77
+ candidates.append(
78
+ DetectionResult("laravel", 0.90, ["composer.json contains laravel"])
79
+ )
80
+
81
+ if (project_path / "Gemfile").exists():
82
+ gemfile = (project_path / "Gemfile").read_text(
83
+ encoding="utf-8",
84
+ errors="ignore",
85
+ ).lower()
86
+ if "rails" in gemfile:
87
+ candidates.append(
88
+ DetectionResult("rails", 0.90, ["Gemfile contains rails"])
89
+ )
90
+
91
+ if (project_path / "go.mod").exists():
92
+ go_mod = (project_path / "go.mod").read_text(
93
+ encoding="utf-8",
94
+ errors="ignore",
95
+ ).lower()
96
+
97
+ if "gin-gonic/gin" in go_mod or "gofiber/fiber" in go_mod:
98
+ candidates.append(
99
+ DetectionResult("go-api", 0.85, ["go.mod contains Gin or Fiber"])
100
+ )
101
+ else:
102
+ candidates.append(
103
+ DetectionResult("go-api", 0.55, ["go.mod detected"])
104
+ )
105
+
106
+ if not candidates:
107
+ return DetectionResult(
108
+ framework="generic",
109
+ confidence=0.10,
110
+ reasons=["No known framework markers detected"],
111
+ )
112
+
113
+ return sorted(candidates, key=lambda item: item.confidence, reverse=True)[0]
File without changes