gitlab-compliance 2.0.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.
- gitlab_compliance-2.0.0.dist-info/METADATA +92 -0
- gitlab_compliance-2.0.0.dist-info/RECORD +50 -0
- gitlab_compliance-2.0.0.dist-info/WHEEL +4 -0
- gitlab_compliance-2.0.0.dist-info/entry_points.txt +4 -0
- gitlab_compliance-2.0.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +1 -0
- src/compliance/__init__.py +1 -0
- src/compliance/api_config.py +94 -0
- src/compliance/api_enrichment.py +52 -0
- src/compliance/behave_support/environment.py +43 -0
- src/compliance/behave_support/steps/__init__.py +0 -0
- src/compliance/behave_support/steps/given_steps.py +154 -0
- src/compliance/behave_support/steps/then_steps.py +234 -0
- src/compliance/behave_support/steps/when_steps.py +168 -0
- src/compliance/console.py +95 -0
- src/compliance/container_fix.py +135 -0
- src/compliance/gitlab_api.py +94 -0
- src/compliance/image_versions.py +316 -0
- src/compliance/include_fix.py +112 -0
- src/compliance/include_versions.py +250 -0
- src/compliance/metadata.py +245 -0
- src/compliance/model.py +277 -0
- src/compliance/models.py +29 -0
- src/compliance/oci_registry.py +147 -0
- src/compliance/policy_doc.py +177 -0
- src/compliance/release_cache.py +51 -0
- src/compliance/render.py +360 -0
- src/compliance/runner.py +310 -0
- src/compliance/stash.py +203 -0
- src/compliance/supply_chain_fix.py +69 -0
- src/gitlab_compliance.py +636 -0
- src/gitlab_docs.py +40 -0
- src/modules/__init__.py +1 -0
- src/modules/command_reference.py +220 -0
- src/modules/common.py +144 -0
- src/modules/constants.py +22 -0
- src/modules/doc_controller.py +178 -0
- src/modules/logging.py +36 -0
- src/modules/pipeline_data.py +364 -0
- src/modules/release.py +343 -0
- src/modules/swagger_html.py +408 -0
- src/modules/yaml_lines.py +70 -0
- src/modules/yaml_md_table.py +29 -0
- src/properties/__init__.py +0 -0
- src/properties/extract_job_attribute.py +76 -0
- src/properties/includes.py +178 -0
- src/properties/inputs.py +86 -0
- src/properties/jobs.py +167 -0
- src/properties/variables.py +91 -0
- src/properties/workflows.py +54 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Include version validation and GitLab release metadata for compliance checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import semver
|
|
11
|
+
|
|
12
|
+
from src.compliance.release_cache import ReleaseMetadataCache
|
|
13
|
+
from src.modules.release import parse_gitlab_date
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_valid_semver_version(version: str) -> bool:
|
|
17
|
+
if not version or not str(version).strip():
|
|
18
|
+
return False
|
|
19
|
+
try:
|
|
20
|
+
return semver.Version.is_valid(str(version).strip())
|
|
21
|
+
except Exception:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_include_project_path(include_type: str, project: str) -> str | None:
|
|
26
|
+
if include_type not in {"project", "component"}:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
path = str(project or "").strip()
|
|
30
|
+
if not path:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
34
|
+
path = urlparse(path).path.lstrip("/")
|
|
35
|
+
elif "/" in path and "." in path.split("/", 1)[0]:
|
|
36
|
+
path = "/".join(path.split("/")[1:])
|
|
37
|
+
|
|
38
|
+
if path.endswith(".git"):
|
|
39
|
+
path = path[:-4]
|
|
40
|
+
|
|
41
|
+
return path or None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def days_between(start: datetime, end: datetime) -> int:
|
|
45
|
+
"""Whole days from start to end (end may be before start)."""
|
|
46
|
+
start_utc = start.astimezone(timezone.utc)
|
|
47
|
+
end_utc = end.astimezone(timezone.utc)
|
|
48
|
+
return (end_utc.date() - start_utc.date()).days
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compute_release_lag_days(
|
|
52
|
+
pinned_date: datetime | None, latest_date: datetime | None
|
|
53
|
+
) -> int | None:
|
|
54
|
+
if pinned_date is None or latest_date is None:
|
|
55
|
+
return None
|
|
56
|
+
return days_between(pinned_date, latest_date)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compute_latest_release_age_days(
|
|
60
|
+
latest_date: datetime | None, *, now: datetime | None = None
|
|
61
|
+
) -> int | None:
|
|
62
|
+
if latest_date is None:
|
|
63
|
+
return None
|
|
64
|
+
reference = now or datetime.now(timezone.utc)
|
|
65
|
+
return days_between(latest_date, reference)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tag_committed_date(tag: Any) -> datetime | None:
|
|
69
|
+
commit = getattr(tag, "commit", None) or {}
|
|
70
|
+
if isinstance(commit, dict):
|
|
71
|
+
committed_date = commit.get("committed_date")
|
|
72
|
+
else:
|
|
73
|
+
committed_date = getattr(commit, "committed_date", None)
|
|
74
|
+
if not committed_date:
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
return parse_gitlab_date(str(committed_date))
|
|
78
|
+
except ValueError:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fetch_semver_tag_dates(
|
|
83
|
+
gl: Any,
|
|
84
|
+
project_path: str,
|
|
85
|
+
*,
|
|
86
|
+
gitlab_url: str | None = None,
|
|
87
|
+
cache: ReleaseMetadataCache | None = None,
|
|
88
|
+
) -> dict[str, datetime]:
|
|
89
|
+
"""Map semver tag names to commit dates from a single tags.list call."""
|
|
90
|
+
if cache is not None and gitlab_url:
|
|
91
|
+
cached = cache.get_gitlab_tag_dates(gitlab_url, project_path)
|
|
92
|
+
if cached is not None:
|
|
93
|
+
return cached
|
|
94
|
+
|
|
95
|
+
project = gl.projects.get(project_path)
|
|
96
|
+
tag_dates: dict[str, datetime] = {}
|
|
97
|
+
for tag in project.tags.list(all=True):
|
|
98
|
+
if not is_valid_semver_version(tag.name):
|
|
99
|
+
continue
|
|
100
|
+
committed_date = _tag_committed_date(tag)
|
|
101
|
+
if committed_date is not None:
|
|
102
|
+
tag_dates[tag.name] = committed_date
|
|
103
|
+
|
|
104
|
+
if cache is not None and gitlab_url:
|
|
105
|
+
cache.set_gitlab_tag_dates(gitlab_url, project_path, tag_dates)
|
|
106
|
+
return tag_dates
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _semver_tags_descending(tag_names: list[str]) -> list[str]:
|
|
110
|
+
valid = [name for name in tag_names if is_valid_semver_version(name)]
|
|
111
|
+
return sorted(valid, key=lambda value: semver.Version.parse(value), reverse=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def compute_version_tag_rank(version: str, tags_desc: list[str]) -> int | None:
|
|
115
|
+
try:
|
|
116
|
+
return tags_desc.index(version) + 1
|
|
117
|
+
except ValueError:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def version_within_latest_tags(version: str, tags_desc: list[str], count: int) -> bool:
|
|
122
|
+
rank = compute_version_tag_rank(version, tags_desc)
|
|
123
|
+
if rank is None:
|
|
124
|
+
return False
|
|
125
|
+
return rank <= count
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _latest_semver_tag(tag_names: list[str]) -> str | None:
|
|
129
|
+
valid = [name for name in tag_names if is_valid_semver_version(name)]
|
|
130
|
+
if not valid:
|
|
131
|
+
return None
|
|
132
|
+
return max(valid, key=lambda value: semver.Version.parse(value))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def fetch_latest_semver_version(gl: Any, project_path: str) -> str | None:
|
|
136
|
+
tag_dates = fetch_semver_tag_dates(gl, project_path)
|
|
137
|
+
return _latest_semver_tag(list(tag_dates.keys()))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def include_update_available(current_version: str, latest_version: str) -> bool:
|
|
141
|
+
if not latest_version:
|
|
142
|
+
return False
|
|
143
|
+
current = str(current_version or "").strip()
|
|
144
|
+
if not is_valid_semver_version(current):
|
|
145
|
+
return is_valid_semver_version(latest_version)
|
|
146
|
+
if not is_valid_semver_version(latest_version):
|
|
147
|
+
return False
|
|
148
|
+
return semver.Version.parse(latest_version) > semver.Version.parse(current_version)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def enrich_include_release_metadata(
|
|
152
|
+
include: dict,
|
|
153
|
+
*,
|
|
154
|
+
gitlab_url: str | None,
|
|
155
|
+
token: str,
|
|
156
|
+
gl: Any | None = None,
|
|
157
|
+
cache: ReleaseMetadataCache | None = None,
|
|
158
|
+
) -> dict:
|
|
159
|
+
enriched = dict(include)
|
|
160
|
+
include_type = enriched.get("include_type", "")
|
|
161
|
+
version = str(enriched.get("version", "")).strip()
|
|
162
|
+
enriched["valid_version"] = is_valid_semver_version(version)
|
|
163
|
+
enriched["latest_version"] = ""
|
|
164
|
+
enriched["update_available"] = False
|
|
165
|
+
enriched["release_metadata_resolved"] = False
|
|
166
|
+
enriched["version_released_at"] = ""
|
|
167
|
+
enriched["latest_version_released_at"] = ""
|
|
168
|
+
enriched["latest_release_age_days"] = None
|
|
169
|
+
enriched["release_lag_days"] = None
|
|
170
|
+
enriched["version_tag_rank"] = None
|
|
171
|
+
enriched["semver_tag_count"] = 0
|
|
172
|
+
|
|
173
|
+
project_path = resolve_include_project_path(
|
|
174
|
+
include_type, enriched.get("project", "")
|
|
175
|
+
)
|
|
176
|
+
if not project_path:
|
|
177
|
+
return enriched
|
|
178
|
+
|
|
179
|
+
gitlab_url = (
|
|
180
|
+
gitlab_url
|
|
181
|
+
or os.getenv("CI_SERVER_URL")
|
|
182
|
+
or os.getenv("GITLAB_URL")
|
|
183
|
+
or "https://gitlab.com"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
if gl is None:
|
|
188
|
+
import gitlab
|
|
189
|
+
|
|
190
|
+
gl = gitlab.Gitlab(gitlab_url, private_token=token)
|
|
191
|
+
gl.auth()
|
|
192
|
+
tag_dates = fetch_semver_tag_dates(
|
|
193
|
+
gl, project_path, gitlab_url=gitlab_url, cache=cache
|
|
194
|
+
)
|
|
195
|
+
except Exception:
|
|
196
|
+
return enriched
|
|
197
|
+
|
|
198
|
+
latest_version = _latest_semver_tag(list(tag_dates.keys()))
|
|
199
|
+
if not latest_version:
|
|
200
|
+
return enriched
|
|
201
|
+
|
|
202
|
+
tags_desc = _semver_tags_descending(list(tag_dates.keys()))
|
|
203
|
+
pinned_date = tag_dates.get(version) if enriched["valid_version"] else None
|
|
204
|
+
latest_date = tag_dates.get(latest_version)
|
|
205
|
+
|
|
206
|
+
enriched["latest_version"] = latest_version
|
|
207
|
+
enriched["update_available"] = include_update_available(version, latest_version)
|
|
208
|
+
enriched["release_metadata_resolved"] = True
|
|
209
|
+
enriched["semver_tag_count"] = len(tags_desc)
|
|
210
|
+
if enriched["valid_version"]:
|
|
211
|
+
enriched["version_tag_rank"] = compute_version_tag_rank(version, tags_desc)
|
|
212
|
+
|
|
213
|
+
if pinned_date is not None:
|
|
214
|
+
enriched["version_released_at"] = pinned_date.isoformat()
|
|
215
|
+
if latest_date is not None:
|
|
216
|
+
enriched["latest_version_released_at"] = latest_date.isoformat()
|
|
217
|
+
enriched["latest_release_age_days"] = compute_latest_release_age_days(
|
|
218
|
+
latest_date
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
enriched["update_available"]
|
|
223
|
+
and pinned_date is not None
|
|
224
|
+
and latest_date is not None
|
|
225
|
+
):
|
|
226
|
+
enriched["release_lag_days"] = compute_release_lag_days(
|
|
227
|
+
pinned_date, latest_date
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return enriched
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def enrich_includes_with_releases(
|
|
234
|
+
includes: list[dict],
|
|
235
|
+
*,
|
|
236
|
+
gitlab_url: str | None,
|
|
237
|
+
token: str,
|
|
238
|
+
gl: Any | None = None,
|
|
239
|
+
cache: ReleaseMetadataCache | None = None,
|
|
240
|
+
) -> list[dict]:
|
|
241
|
+
return [
|
|
242
|
+
enrich_include_release_metadata(
|
|
243
|
+
include,
|
|
244
|
+
gitlab_url=gitlab_url,
|
|
245
|
+
token=token,
|
|
246
|
+
gl=gl,
|
|
247
|
+
cache=cache,
|
|
248
|
+
)
|
|
249
|
+
for include in includes
|
|
250
|
+
]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Parse Conftest-style # METADATA annotations from compliance policy files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
METADATA_MARKER = "# METADATA"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class PolicyAnnotation:
|
|
17
|
+
policy_id: str
|
|
18
|
+
title: str
|
|
19
|
+
description: str = ""
|
|
20
|
+
scope: str = ""
|
|
21
|
+
feature_file: str = ""
|
|
22
|
+
line: int = 0
|
|
23
|
+
feature_name: str = ""
|
|
24
|
+
scenario_name: str = ""
|
|
25
|
+
custom: dict = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FeaturePolicies:
|
|
30
|
+
feature_file: str
|
|
31
|
+
feature_name: str
|
|
32
|
+
annotation: PolicyAnnotation | None = None
|
|
33
|
+
scenarios: list[PolicyAnnotation] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PolicyCatalog:
|
|
38
|
+
features: list[FeaturePolicies] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def lookup_scenario(
|
|
41
|
+
self, feature_file: str, scenario_name: str
|
|
42
|
+
) -> PolicyAnnotation | None:
|
|
43
|
+
normalized = os.path.realpath(feature_file)
|
|
44
|
+
for feature in self.features:
|
|
45
|
+
if os.path.realpath(feature.feature_file) != normalized:
|
|
46
|
+
continue
|
|
47
|
+
for scenario in feature.scenarios:
|
|
48
|
+
if scenario.scenario_name == scenario_name:
|
|
49
|
+
return scenario
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _slug(value: str) -> str:
|
|
54
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").upper()
|
|
55
|
+
return slug or "POLICY"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _auto_feature_id(feature_file: str) -> str:
|
|
59
|
+
return f"GLCI-{_slug(Path(feature_file).stem)}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _auto_scenario_id(feature_id: str, index: int) -> str:
|
|
63
|
+
return f"{feature_id}-{index:03d}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _quote_yaml_scalar(value: str) -> str:
|
|
67
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
68
|
+
return f'"{escaped}"'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _prepare_metadata_yaml_line(line: str) -> str:
|
|
72
|
+
if line.startswith(" "):
|
|
73
|
+
return line
|
|
74
|
+
if ":" not in line:
|
|
75
|
+
return line
|
|
76
|
+
key, sep, rest = line.partition(":")
|
|
77
|
+
value = rest.strip()
|
|
78
|
+
if not value or value in ("|", ">"):
|
|
79
|
+
return line
|
|
80
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
81
|
+
value.startswith("'") and value.endswith("'")
|
|
82
|
+
):
|
|
83
|
+
return line
|
|
84
|
+
if ":" in value:
|
|
85
|
+
return f"{key}{sep} {_quote_yaml_scalar(value)}"
|
|
86
|
+
return line
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_metadata_yaml(yaml_lines: list[str]) -> dict:
|
|
90
|
+
if not yaml_lines:
|
|
91
|
+
return {}
|
|
92
|
+
prepared = [_prepare_metadata_yaml_line(line) for line in yaml_lines]
|
|
93
|
+
parsed = yaml.safe_load("\n".join(prepared))
|
|
94
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _collect_metadata_block(lines: list[str], start_index: int) -> tuple[dict, int]:
|
|
98
|
+
yaml_lines: list[str] = []
|
|
99
|
+
index = start_index + 1
|
|
100
|
+
while index < len(lines):
|
|
101
|
+
line = lines[index]
|
|
102
|
+
if line.startswith("# "):
|
|
103
|
+
yaml_lines.append(line[2:])
|
|
104
|
+
index += 1
|
|
105
|
+
continue
|
|
106
|
+
break
|
|
107
|
+
return _parse_metadata_yaml(yaml_lines), index
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _annotation_from_raw(
|
|
111
|
+
raw: dict,
|
|
112
|
+
*,
|
|
113
|
+
scope: str,
|
|
114
|
+
feature_file: str,
|
|
115
|
+
line: int,
|
|
116
|
+
feature_name: str,
|
|
117
|
+
scenario_name: str = "",
|
|
118
|
+
default_title: str,
|
|
119
|
+
default_id: str,
|
|
120
|
+
) -> PolicyAnnotation:
|
|
121
|
+
custom = raw.get("custom", {})
|
|
122
|
+
if not isinstance(custom, dict):
|
|
123
|
+
custom = {}
|
|
124
|
+
|
|
125
|
+
policy_id = str(custom.get("id") or raw.get("id") or default_id)
|
|
126
|
+
title = str(raw.get("title") or default_title)
|
|
127
|
+
description = str(raw.get("description") or "")
|
|
128
|
+
|
|
129
|
+
return PolicyAnnotation(
|
|
130
|
+
policy_id=policy_id,
|
|
131
|
+
title=title,
|
|
132
|
+
description=description,
|
|
133
|
+
scope=scope,
|
|
134
|
+
feature_file=feature_file,
|
|
135
|
+
line=line,
|
|
136
|
+
feature_name=feature_name,
|
|
137
|
+
scenario_name=scenario_name,
|
|
138
|
+
custom=custom,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_feature_policies(feature_file: str) -> FeaturePolicies:
|
|
143
|
+
feature_path = os.path.realpath(feature_file)
|
|
144
|
+
with open(feature_path, encoding="utf-8") as handle:
|
|
145
|
+
lines = handle.read().splitlines()
|
|
146
|
+
|
|
147
|
+
pending_metadata: dict = {}
|
|
148
|
+
feature_name = Path(feature_path).stem
|
|
149
|
+
feature_annotation: PolicyAnnotation | None = None
|
|
150
|
+
scenarios: list[PolicyAnnotation] = []
|
|
151
|
+
scenario_index = 0
|
|
152
|
+
|
|
153
|
+
index = 0
|
|
154
|
+
while index < len(lines):
|
|
155
|
+
line = lines[index]
|
|
156
|
+
if line.startswith(METADATA_MARKER):
|
|
157
|
+
pending_metadata, index = _collect_metadata_block(lines, index)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
if line.startswith("Feature:"):
|
|
161
|
+
feature_name = line.split(":", 1)[1].strip() or feature_name
|
|
162
|
+
feature_id = _auto_feature_id(feature_path)
|
|
163
|
+
feature_annotation = _annotation_from_raw(
|
|
164
|
+
pending_metadata,
|
|
165
|
+
scope="feature",
|
|
166
|
+
feature_file=feature_path,
|
|
167
|
+
line=index + 1,
|
|
168
|
+
feature_name=feature_name,
|
|
169
|
+
default_title=feature_name,
|
|
170
|
+
default_id=feature_id,
|
|
171
|
+
)
|
|
172
|
+
pending_metadata = {}
|
|
173
|
+
index += 1
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
stripped = line.strip()
|
|
177
|
+
if stripped.startswith("Scenario:"):
|
|
178
|
+
scenario_name = stripped.split(":", 1)[1].strip()
|
|
179
|
+
scenario_index += 1
|
|
180
|
+
feature_id = (
|
|
181
|
+
feature_annotation.policy_id
|
|
182
|
+
if feature_annotation
|
|
183
|
+
else _auto_feature_id(feature_path)
|
|
184
|
+
)
|
|
185
|
+
scenarios.append(
|
|
186
|
+
_annotation_from_raw(
|
|
187
|
+
pending_metadata,
|
|
188
|
+
scope="scenario",
|
|
189
|
+
feature_file=feature_path,
|
|
190
|
+
line=index + 1,
|
|
191
|
+
feature_name=feature_name,
|
|
192
|
+
scenario_name=scenario_name,
|
|
193
|
+
default_title=scenario_name,
|
|
194
|
+
default_id=_auto_scenario_id(feature_id, scenario_index),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
pending_metadata = {}
|
|
198
|
+
index += 1
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
index += 1
|
|
202
|
+
|
|
203
|
+
if feature_annotation is None:
|
|
204
|
+
feature_id = _auto_feature_id(feature_path)
|
|
205
|
+
feature_annotation = PolicyAnnotation(
|
|
206
|
+
policy_id=feature_id,
|
|
207
|
+
title=feature_name,
|
|
208
|
+
scope="feature",
|
|
209
|
+
feature_file=feature_path,
|
|
210
|
+
line=1,
|
|
211
|
+
feature_name=feature_name,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return FeaturePolicies(
|
|
215
|
+
feature_file=feature_path,
|
|
216
|
+
feature_name=feature_name,
|
|
217
|
+
annotation=feature_annotation,
|
|
218
|
+
scenarios=scenarios,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def build_policy_catalog(features_dir: str) -> PolicyCatalog:
|
|
223
|
+
catalog = PolicyCatalog()
|
|
224
|
+
if not os.path.isdir(features_dir):
|
|
225
|
+
raise FileNotFoundError(f"Features directory not found: {features_dir}")
|
|
226
|
+
|
|
227
|
+
feature_files: list[str] = []
|
|
228
|
+
for root, _dirs, files in os.walk(features_dir):
|
|
229
|
+
for filename in sorted(files):
|
|
230
|
+
if filename.endswith(".feature"):
|
|
231
|
+
feature_files.append(os.path.join(root, filename))
|
|
232
|
+
|
|
233
|
+
for feature_file in feature_files:
|
|
234
|
+
catalog.features.append(parse_feature_policies(feature_file))
|
|
235
|
+
|
|
236
|
+
return catalog
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def collect_policy_index(catalog: PolicyCatalog) -> list[PolicyAnnotation]:
|
|
240
|
+
policies: list[PolicyAnnotation] = []
|
|
241
|
+
for feature in catalog.features:
|
|
242
|
+
if feature.annotation:
|
|
243
|
+
policies.append(feature.annotation)
|
|
244
|
+
policies.extend(feature.scenarios)
|
|
245
|
+
return policies
|