apisec-code-bolt 0.1.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.
- apisec_code_bolt/__init__.py +42 -0
- apisec_code_bolt/__main__.py +11 -0
- apisec_code_bolt/analysis/__init__.py +96 -0
- apisec_code_bolt/analysis/analyzer.py +2309 -0
- apisec_code_bolt/analysis/binding_tracker.py +341 -0
- apisec_code_bolt/analysis/call_graph.py +1197 -0
- apisec_code_bolt/analysis/call_graph_types.py +332 -0
- apisec_code_bolt/analysis/call_resolver.py +988 -0
- apisec_code_bolt/analysis/capability_tagger.py +322 -0
- apisec_code_bolt/analysis/config_scanner.py +197 -0
- apisec_code_bolt/analysis/data_flow.py +1883 -0
- apisec_code_bolt/analysis/dependency_extractor.py +959 -0
- apisec_code_bolt/analysis/flow_analysis.py +1406 -0
- apisec_code_bolt/analysis/hof_catalog.py +61 -0
- apisec_code_bolt/analysis/integration_detector.py +1399 -0
- apisec_code_bolt/analysis/literal_scanner.py +300 -0
- apisec_code_bolt/analysis/path_normalizer.py +55 -0
- apisec_code_bolt/analysis/read_site_detector.py +310 -0
- apisec_code_bolt/analysis/request_patterns.py +162 -0
- apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
- apisec_code_bolt/analysis/sink_evidence.py +333 -0
- apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
- apisec_code_bolt/cli/__init__.py +5 -0
- apisec_code_bolt/cli/exit_codes.py +17 -0
- apisec_code_bolt/cli/main.py +1069 -0
- apisec_code_bolt/cloud/__init__.py +1 -0
- apisec_code_bolt/cloud/apisec_client.py +118 -0
- apisec_code_bolt/cloud/client.py +255 -0
- apisec_code_bolt/core/__init__.py +75 -0
- apisec_code_bolt/core/config.py +528 -0
- apisec_code_bolt/core/credentials.py +65 -0
- apisec_code_bolt/core/discovery.py +433 -0
- apisec_code_bolt/core/log_format.py +115 -0
- apisec_code_bolt/core/manifest.py +1009 -0
- apisec_code_bolt/core/repo.py +280 -0
- apisec_code_bolt/core/state.py +59 -0
- apisec_code_bolt/core/telemetry.py +451 -0
- apisec_code_bolt/core/types.py +587 -0
- apisec_code_bolt/fingerprinting/__init__.py +1 -0
- apisec_code_bolt/frameworks/__init__.py +29 -0
- apisec_code_bolt/frameworks/_jwt_common.py +50 -0
- apisec_code_bolt/frameworks/auth_helpers.py +437 -0
- apisec_code_bolt/frameworks/base.py +608 -0
- apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
- apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
- apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
- apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
- apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
- apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
- apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
- apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
- apisec_code_bolt/frameworks/java/__init__.py +6 -0
- apisec_code_bolt/frameworks/java/_annotations.py +167 -0
- apisec_code_bolt/frameworks/java/_constraints.py +128 -0
- apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
- apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
- apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
- apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
- apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
- apisec_code_bolt/frameworks/js/__init__.py +8 -0
- apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
- apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
- apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
- apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
- apisec_code_bolt/frameworks/python/__init__.py +19 -0
- apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
- apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
- apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
- apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
- apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
- apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
- apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
- apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
- apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
- apisec_code_bolt/parsing/__init__.py +62 -0
- apisec_code_bolt/parsing/base.py +554 -0
- apisec_code_bolt/parsing/csharp/__init__.py +5 -0
- apisec_code_bolt/parsing/csharp/language_services.py +203 -0
- apisec_code_bolt/parsing/csharp/literals.py +72 -0
- apisec_code_bolt/parsing/csharp/parser.py +1158 -0
- apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
- apisec_code_bolt/parsing/js/__init__.py +5 -0
- apisec_code_bolt/parsing/js/language_services.py +118 -0
- apisec_code_bolt/parsing/js/parser.py +622 -0
- apisec_code_bolt/parsing/jvm/__init__.py +7 -0
- apisec_code_bolt/parsing/jvm/language_services.py +270 -0
- apisec_code_bolt/parsing/jvm/parser.py +774 -0
- apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
- apisec_code_bolt/parsing/python/__init__.py +150 -0
- apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
- apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
- apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
- apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
- apisec_code_bolt/parsing/python/expression_utils.py +221 -0
- apisec_code_bolt/parsing/python/extraction_types.py +271 -0
- apisec_code_bolt/parsing/python/language_services.py +487 -0
- apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
- apisec_code_bolt/parsing/python/parser.py +719 -0
- apisec_code_bolt/parsing/python/path_resolver.py +576 -0
- apisec_code_bolt/parsing/python/router_registry.py +806 -0
- apisec_code_bolt/parsing/python/type_resolver.py +730 -0
- apisec_code_bolt/parsing/python/visitors.py +1544 -0
- apisec_code_bolt/parsing/services.py +544 -0
- apisec_code_bolt/query/__init__.py +1 -0
- apisec_code_bolt/query/ast_cache.py +182 -0
- apisec_code_bolt/query/executor.py +283 -0
- apisec_code_bolt/query/handlers.py +832 -0
- apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
- apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
- apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
- apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Git repository info detection for registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import unquote, urlparse
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run_git(project_root: Path, *args: str) -> str | None:
|
|
17
|
+
"""Run a git command and return stdout, or None on failure."""
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["git", *args],
|
|
21
|
+
cwd=project_root,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
timeout=5,
|
|
25
|
+
)
|
|
26
|
+
if result.returncode == 0 and result.stdout:
|
|
27
|
+
return result.stdout.strip()
|
|
28
|
+
return None
|
|
29
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _infer_scm_provider(repo_url: str) -> str:
|
|
34
|
+
"""Infer SCM provider from URL."""
|
|
35
|
+
url_lower = repo_url.lower()
|
|
36
|
+
if "github.com" in url_lower:
|
|
37
|
+
return "github"
|
|
38
|
+
if "gitlab.com" in url_lower or "gitlab." in url_lower:
|
|
39
|
+
return "gitlab"
|
|
40
|
+
if "bitbucket.org" in url_lower or "bitbucket." in url_lower:
|
|
41
|
+
return "bitbucket"
|
|
42
|
+
if "azure" in url_lower or "dev.azure.com" in url_lower or "visualstudio.com" in url_lower:
|
|
43
|
+
return "azure"
|
|
44
|
+
return "git"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sha256_hex(data: bytes) -> str:
|
|
48
|
+
return hashlib.sha256(data).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_GIT_AT_HOST_PATH = re.compile(
|
|
52
|
+
r"^git@(?P<host>[^:/]+)[:/](?P<path>.+)$",
|
|
53
|
+
re.IGNORECASE,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _strip_git_suffix(segment: str) -> str:
|
|
58
|
+
s = segment.strip().rstrip("/")
|
|
59
|
+
if s.lower().endswith(".git"):
|
|
60
|
+
return s[:-4]
|
|
61
|
+
return s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_github_host_path(host: str, path: str) -> str | None:
|
|
65
|
+
if "github.com" not in host.lower():
|
|
66
|
+
return None
|
|
67
|
+
segments = [p for p in path.split("/") if p]
|
|
68
|
+
if len(segments) < 2:
|
|
69
|
+
return None
|
|
70
|
+
org, repo = segments[0], _strip_git_suffix(segments[1])
|
|
71
|
+
if not org or not repo:
|
|
72
|
+
return None
|
|
73
|
+
return f"github:{org.lower()}/{repo.lower()}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_gitlab_host_path(host: str, path: str) -> str | None:
|
|
77
|
+
if "gitlab" not in host.lower():
|
|
78
|
+
return None
|
|
79
|
+
segments = [p for p in path.split("/") if p]
|
|
80
|
+
if len(segments) < 2:
|
|
81
|
+
return None
|
|
82
|
+
slug = "/".join(_strip_git_suffix(s).lower() for s in segments)
|
|
83
|
+
return f"gitlab:{slug}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_bitbucket_host_path(host: str, path: str) -> str | None:
|
|
87
|
+
if "bitbucket" not in host.lower():
|
|
88
|
+
return None
|
|
89
|
+
segments = [p for p in path.split("/") if p]
|
|
90
|
+
if len(segments) < 2:
|
|
91
|
+
return None
|
|
92
|
+
ws, repo = segments[0], _strip_git_suffix(segments[1])
|
|
93
|
+
if not ws or not repo:
|
|
94
|
+
return None
|
|
95
|
+
return f"bitbucket:{ws.lower()}/{repo.lower()}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_azure_https(host: str, path: str) -> str | None:
|
|
99
|
+
path = path.strip("/")
|
|
100
|
+
if not path:
|
|
101
|
+
return None
|
|
102
|
+
parts = [p for p in path.split("/") if p]
|
|
103
|
+
hl = host.lower()
|
|
104
|
+
if "dev.azure.com" in hl:
|
|
105
|
+
# dev.azure.com/Org/Project/_git/Repo
|
|
106
|
+
try:
|
|
107
|
+
idx = parts.index("_git")
|
|
108
|
+
except ValueError:
|
|
109
|
+
return None
|
|
110
|
+
if idx < 2 or idx + 1 >= len(parts):
|
|
111
|
+
return None
|
|
112
|
+
org, project = parts[0], parts[1]
|
|
113
|
+
repo = _strip_git_suffix(parts[idx + 1])
|
|
114
|
+
return f"azure:{org.lower()}/{project.lower()}/{repo.lower()}"
|
|
115
|
+
if "visualstudio.com" in hl:
|
|
116
|
+
try:
|
|
117
|
+
idx = parts.index("_git")
|
|
118
|
+
except ValueError:
|
|
119
|
+
return None
|
|
120
|
+
if idx < 1 or idx + 1 >= len(parts):
|
|
121
|
+
return None
|
|
122
|
+
namespace = "/".join(parts[:idx]).lower()
|
|
123
|
+
repo = _strip_git_suffix(parts[idx + 1])
|
|
124
|
+
return f"azure:{namespace}/{repo.lower()}"
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_azure_ssh(remote: str) -> str | None:
|
|
129
|
+
m = re.match(
|
|
130
|
+
r"^git@ssh\.dev\.azure\.com:v3/(?P<org>[^/]+)/(?P<project>[^/]+)/(?P<repo>.+)$",
|
|
131
|
+
remote.strip(),
|
|
132
|
+
re.IGNORECASE,
|
|
133
|
+
)
|
|
134
|
+
if not m:
|
|
135
|
+
return None
|
|
136
|
+
org, project, repo = m.group("org"), m.group("project"), _strip_git_suffix(m.group("repo"))
|
|
137
|
+
return f"azure:{org.lower()}/{project.lower()}/{repo.lower()}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_scp_style(remote: str) -> str | None:
|
|
141
|
+
m = _GIT_AT_HOST_PATH.match(remote.strip())
|
|
142
|
+
if not m:
|
|
143
|
+
return None
|
|
144
|
+
host = m.group("host").lower()
|
|
145
|
+
raw_path = m.group("path").strip()
|
|
146
|
+
path = raw_path.replace("\\", "/")
|
|
147
|
+
if path.startswith("/"):
|
|
148
|
+
path = path.lstrip("/")
|
|
149
|
+
path = unquote(path)
|
|
150
|
+
segments = [p for p in path.split("/") if p]
|
|
151
|
+
if not segments:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
if "github.com" in host:
|
|
155
|
+
if len(segments) < 2:
|
|
156
|
+
return None
|
|
157
|
+
org, repo = segments[0], _strip_git_suffix(segments[1])
|
|
158
|
+
return f"github:{org.lower()}/{repo.lower()}"
|
|
159
|
+
|
|
160
|
+
if "gitlab" in host:
|
|
161
|
+
slug = "/".join(_strip_git_suffix(s).lower() for s in segments)
|
|
162
|
+
return f"gitlab:{slug}"
|
|
163
|
+
|
|
164
|
+
if "bitbucket" in host:
|
|
165
|
+
if len(segments) < 2:
|
|
166
|
+
return None
|
|
167
|
+
ws, repo = segments[0], _strip_git_suffix(segments[1])
|
|
168
|
+
return f"bitbucket:{ws.lower()}/{repo.lower()}"
|
|
169
|
+
|
|
170
|
+
if (
|
|
171
|
+
("ssh.dev.azure.com" in host or "vs-ssh.visualstudio.com" in host)
|
|
172
|
+
and len(segments) >= 4
|
|
173
|
+
and segments[0].lower() == "v3"
|
|
174
|
+
):
|
|
175
|
+
org, project, repo = segments[1], segments[2], _strip_git_suffix(segments[3])
|
|
176
|
+
return f"azure:{org.lower()}/{project.lower()}/{repo.lower()}"
|
|
177
|
+
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_remote_to_canonical(remote_url: str) -> str | None:
|
|
182
|
+
u = remote_url.strip()
|
|
183
|
+
if not u:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
azure_ssh = _parse_azure_ssh(u)
|
|
187
|
+
if azure_ssh:
|
|
188
|
+
return azure_ssh
|
|
189
|
+
|
|
190
|
+
scp = _parse_scp_style(u)
|
|
191
|
+
if scp:
|
|
192
|
+
return scp
|
|
193
|
+
|
|
194
|
+
parsed = urlparse(u)
|
|
195
|
+
scheme = (parsed.scheme or "").lower()
|
|
196
|
+
if scheme not in {"http", "https", "ssh"}:
|
|
197
|
+
return None
|
|
198
|
+
host = (parsed.hostname or "").lower()
|
|
199
|
+
path = unquote((parsed.path or "").strip("/"))
|
|
200
|
+
if not host or not path:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
for fn in (
|
|
204
|
+
_parse_github_host_path,
|
|
205
|
+
_parse_gitlab_host_path,
|
|
206
|
+
_parse_bitbucket_host_path,
|
|
207
|
+
_parse_azure_https,
|
|
208
|
+
):
|
|
209
|
+
hit = fn(host, path)
|
|
210
|
+
if hit:
|
|
211
|
+
return hit
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def derive_canonical_repo_id(remote_url: str, project_root: Path) -> str:
|
|
216
|
+
"""
|
|
217
|
+
Stable repository id for applicationsservice surface idempotency.
|
|
218
|
+
|
|
219
|
+
Preferred: ``{provider}:{namespace/repo}`` (lowercase, no ``.git`` suffix), e.g. ``github:apisec/api``.
|
|
220
|
+
If the remote cannot be parsed but ``remote_url`` is non-empty: ``git:url-<sha256(normalized_url)>``.
|
|
221
|
+
If there is no remote URL: ``local:<sha256(absolute_project_root)>``.
|
|
222
|
+
"""
|
|
223
|
+
root = project_root.resolve()
|
|
224
|
+
ru = (remote_url or "").strip()
|
|
225
|
+
parsed = _parse_remote_to_canonical(ru) if ru else None
|
|
226
|
+
if parsed:
|
|
227
|
+
return parsed
|
|
228
|
+
if ru:
|
|
229
|
+
norm = ru.lower().rstrip("/")
|
|
230
|
+
if norm.endswith(".git"):
|
|
231
|
+
norm = norm[:-4]
|
|
232
|
+
digest = _sha256_hex(norm.encode("utf-8"))
|
|
233
|
+
return f"git:url-{digest}"
|
|
234
|
+
return f"local:{_sha256_hex(str(root).encode('utf-8'))}"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def detect_repo_info(project_root: Path) -> dict[str, Any]:
|
|
238
|
+
"""
|
|
239
|
+
Detect git repository info from the project root.
|
|
240
|
+
|
|
241
|
+
Falls back to sensible defaults when git is not available or not a repo.
|
|
242
|
+
Returns a dict with keys: repo_name, repo_url, branch, commit_sha, scm_provider, canonical_repo_id.
|
|
243
|
+
"""
|
|
244
|
+
root = project_root.resolve()
|
|
245
|
+
repo_name = root.name
|
|
246
|
+
repo_url = f"local://{root}"
|
|
247
|
+
branch = "main"
|
|
248
|
+
commit_sha = _sha256_hex(str(root).encode("utf-8"))[:12]
|
|
249
|
+
scm_provider = "local"
|
|
250
|
+
|
|
251
|
+
remote_url = _run_git(root, "config", "--get", "remote.origin.url")
|
|
252
|
+
if remote_url:
|
|
253
|
+
repo_url = remote_url
|
|
254
|
+
# Extract repo name from URL (e.g., git@github.com:org/repo.git -> repo)
|
|
255
|
+
if "/" in repo_url:
|
|
256
|
+
name_part = repo_url.rstrip("/").rsplit("/", 1)[-1]
|
|
257
|
+
if name_part.endswith(".git"):
|
|
258
|
+
name_part = name_part[:-4]
|
|
259
|
+
if name_part:
|
|
260
|
+
repo_name = name_part
|
|
261
|
+
scm_provider = _infer_scm_provider(repo_url)
|
|
262
|
+
|
|
263
|
+
branch_out = _run_git(root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
264
|
+
if branch_out:
|
|
265
|
+
branch = branch_out
|
|
266
|
+
|
|
267
|
+
sha_out = _run_git(root, "rev-parse", "HEAD")
|
|
268
|
+
if sha_out:
|
|
269
|
+
commit_sha = sha_out
|
|
270
|
+
|
|
271
|
+
canonical_repo_id = derive_canonical_repo_id(repo_url, root)
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
"repo_name": repo_name,
|
|
275
|
+
"repo_url": repo_url,
|
|
276
|
+
"branch": branch,
|
|
277
|
+
"commit_sha": commit_sha,
|
|
278
|
+
"scm_provider": scm_provider,
|
|
279
|
+
"canonical_repo_id": canonical_repo_id,
|
|
280
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Repository state file management for Nth-run routing.
|
|
2
|
+
|
|
3
|
+
Manages the `apisec-code-bolt/state.yaml` file in the repository root.
|
|
4
|
+
This file contains only the applicationId — written once after first registration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
STATE_DIR_NAME = "apisec-code-bolt"
|
|
19
|
+
STATE_FILE_NAME = "state.yaml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RepoState:
|
|
24
|
+
"""Minimal state persisted in the repository."""
|
|
25
|
+
|
|
26
|
+
application_id: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def state_file_path(project_root: Path) -> Path:
|
|
30
|
+
"""Return the path to the state file for the given project root."""
|
|
31
|
+
return project_root / STATE_DIR_NAME / STATE_FILE_NAME
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_state(project_root: Path) -> RepoState | None:
|
|
35
|
+
"""Read state from the repository. Returns None if no state file exists."""
|
|
36
|
+
path = state_file_path(project_root)
|
|
37
|
+
if not path.exists():
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
with open(path) as f:
|
|
41
|
+
data: dict[str, Any] = yaml.safe_load(f) or {}
|
|
42
|
+
app_id = data.get("applicationId", "")
|
|
43
|
+
if not app_id:
|
|
44
|
+
return None
|
|
45
|
+
return RepoState(application_id=app_id)
|
|
46
|
+
except Exception:
|
|
47
|
+
logger.warning("Failed to read state file at %s", path, exc_info=True)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_state(project_root: Path, state: RepoState) -> Path:
|
|
52
|
+
"""Write state to the repository. Creates directory if needed."""
|
|
53
|
+
path = state_file_path(project_root)
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
data = {"applicationId": state.application_id}
|
|
56
|
+
with open(path, "w") as f:
|
|
57
|
+
yaml.safe_dump(data, f, default_flow_style=False)
|
|
58
|
+
logger.info("State file written: %s", path)
|
|
59
|
+
return path
|