norn-cli 0.7.1__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.
norf_harness/config.py ADDED
@@ -0,0 +1,398 @@
1
+ """Configuration loading, merging, and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from importlib.resources import files
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+
16
+ class ConfigError(ValueError):
17
+ """Raised when harness configuration is invalid."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class QualityGateConfig:
22
+ critical: int
23
+ high: int
24
+ medium: int
25
+ low: int
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class LimitsConfig:
30
+ max_total_rounds: int
31
+ max_planning_retries: int
32
+ max_implementation_rounds: int
33
+ max_final_replans: int
34
+ max_provider_retries: int
35
+ role_timeout_seconds: int
36
+ test_timeout_seconds: int
37
+ e2e_timeout_seconds: int
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ProviderConfig:
42
+ name: str
43
+ adapter: str
44
+ command: str
45
+ auth: str
46
+ args: tuple[str, ...] = ()
47
+ output: str = "json"
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ModelCandidate:
52
+ provider: str
53
+ model: str
54
+ effort: str
55
+ model_id: str = ""
56
+
57
+ @property
58
+ def resolved_model(self) -> str:
59
+ """Provider-facing model identifier, preserving a stable logical alias."""
60
+ return self.model_id or self.model
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ProfileConfig:
65
+ name: str
66
+ candidates: tuple[ModelCandidate, ...]
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class GateCommandConfig:
71
+ name: str
72
+ kind: str
73
+ command: tuple[str, ...]
74
+ timeout_seconds: int | None = None
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ProjectGatesConfig:
79
+ enabled: bool
80
+ auto_detect: bool
81
+ commands: tuple[GateCommandConfig, ...]
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class GitConfig:
86
+ worktree: bool
87
+ auto_commit: bool
88
+ publish: bool
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class HarnessConfig:
93
+ project_root: Path
94
+ quality_gate: QualityGateConfig
95
+ limits: LimitsConfig
96
+ runs_dir: Path
97
+ providers: Mapping[str, ProviderConfig]
98
+ profiles: Mapping[str, ProfileConfig]
99
+ roles: Mapping[str, str]
100
+ project_gates: ProjectGatesConfig
101
+ git: GitConfig
102
+ e2e_enabled: bool
103
+ e2e_required_for_ui_changes: bool
104
+ e2e_require_all_testers: bool
105
+ raw: Mapping[str, Any]
106
+ sources: tuple[Path, ...]
107
+
108
+ def to_dict(self) -> dict[str, Any]:
109
+ return copy.deepcopy(dict(self.raw))
110
+
111
+
112
+ def default_config_path() -> Path:
113
+ return Path(str(files("norf_harness").joinpath("resources/default.yaml")))
114
+
115
+
116
+ def user_config_path() -> Path:
117
+ overridden = os.environ.get("NORN_USER_CONFIG") or os.environ.get("NORF_HARNESS_USER_CONFIG")
118
+ if overridden:
119
+ return Path(overridden).expanduser()
120
+ canonical = Path.home() / ".config" / "norn" / "config.yaml"
121
+ legacy = Path.home() / ".config" / "norf-harness" / "config.yaml"
122
+ if not canonical.is_file() and legacy.is_file():
123
+ return legacy
124
+ return canonical
125
+
126
+
127
+ def project_config_path(project_root: Path) -> Path:
128
+ return project_root / ".norf" / "harness.yaml"
129
+
130
+
131
+ def load_config(
132
+ project_root: Path | str,
133
+ *,
134
+ config_path: Path | str | None = None,
135
+ overrides: Mapping[str, Any] | None = None,
136
+ ) -> HarnessConfig:
137
+ """Load defaults < user < project/explicit < CLI overrides."""
138
+
139
+ root = Path(project_root).expanduser().resolve()
140
+ sources: list[Path] = []
141
+
142
+ default_path = default_config_path()
143
+ raw = _load_yaml(default_path, required=True)
144
+ sources.append(default_path)
145
+
146
+ user_path = user_config_path()
147
+ if user_path.is_file():
148
+ raw = _deep_merge(raw, _load_yaml(user_path, required=True))
149
+ sources.append(user_path)
150
+
151
+ selected_project_path = (
152
+ Path(config_path).expanduser().resolve()
153
+ if config_path is not None
154
+ else project_config_path(root)
155
+ )
156
+ if selected_project_path.is_file():
157
+ raw = _deep_merge(raw, _load_yaml(selected_project_path, required=True))
158
+ sources.append(selected_project_path)
159
+ elif config_path is not None:
160
+ raise ConfigError(f"Config file does not exist: {selected_project_path}")
161
+
162
+ for dotted_key, value in (overrides or {}).items():
163
+ _set_dotted(raw, dotted_key, value)
164
+
165
+ return _validate(root, raw, tuple(sources))
166
+
167
+
168
+ def _load_yaml(path: Path, *, required: bool) -> dict[str, Any]:
169
+ if not path.is_file():
170
+ if required:
171
+ raise ConfigError(f"Config file does not exist: {path}")
172
+ return {}
173
+ try:
174
+ value = yaml.safe_load(path.read_text(encoding="utf-8"))
175
+ except (OSError, yaml.YAMLError) as exc:
176
+ raise ConfigError(f"Failed to load config {path}: {exc}") from exc
177
+ if value is None:
178
+ return {}
179
+ if not isinstance(value, dict):
180
+ raise ConfigError(f"Config root must be a mapping: {path}")
181
+ return value
182
+
183
+
184
+ def _deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
185
+ result = copy.deepcopy(dict(base))
186
+ for key, value in override.items():
187
+ if isinstance(result.get(key), dict) and isinstance(value, dict):
188
+ result[key] = _deep_merge(result[key], value)
189
+ else:
190
+ result[key] = copy.deepcopy(value)
191
+ return result
192
+
193
+
194
+ def _set_dotted(target: dict[str, Any], dotted_key: str, value: Any) -> None:
195
+ parts = dotted_key.split(".")
196
+ if not parts or any(not part for part in parts):
197
+ raise ConfigError(f"Invalid override key: {dotted_key!r}")
198
+ cursor = target
199
+ for part in parts[:-1]:
200
+ child = cursor.setdefault(part, {})
201
+ if not isinstance(child, dict):
202
+ raise ConfigError(f"Cannot override nested key below {part!r}")
203
+ cursor = child
204
+ cursor[parts[-1]] = value
205
+
206
+
207
+ def _mapping(raw: Mapping[str, Any], key: str) -> Mapping[str, Any]:
208
+ value = raw.get(key)
209
+ if not isinstance(value, dict):
210
+ raise ConfigError(f"{key} must be a mapping")
211
+ return value
212
+
213
+
214
+ def _integer(mapping: Mapping[str, Any], key: str, *, minimum: int = 0) -> int:
215
+ value = mapping.get(key)
216
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
217
+ raise ConfigError(f"{key} must be an integer >= {minimum}")
218
+ return value
219
+
220
+
221
+ def _positive(mapping: Mapping[str, Any], key: str) -> int:
222
+ return _integer(mapping, key, minimum=1)
223
+
224
+
225
+ def _boolean(mapping: Mapping[str, Any], key: str) -> bool:
226
+ value = mapping.get(key)
227
+ if not isinstance(value, bool):
228
+ raise ConfigError(f"{key} must be a boolean")
229
+ return value
230
+
231
+
232
+ def _validate(root: Path, raw: dict[str, Any], sources: tuple[Path, ...]) -> HarnessConfig:
233
+ if raw.get("version") != 1:
234
+ raise ConfigError("version must be 1")
235
+
236
+ gate_raw = _mapping(raw, "quality_gate")
237
+ gate = QualityGateConfig(
238
+ critical=_integer(gate_raw, "critical"),
239
+ high=_integer(gate_raw, "high"),
240
+ medium=_integer(gate_raw, "medium"),
241
+ low=_integer(gate_raw, "low", minimum=-1),
242
+ )
243
+ if gate.low < -1:
244
+ raise ConfigError("quality_gate.low must be -1 (unlimited) or >= 0")
245
+
246
+ limits_raw = _mapping(raw, "limits")
247
+ pipeline = _mapping(limits_raw, "pipeline")
248
+ planning = _mapping(limits_raw, "planning")
249
+ implementation = _mapping(limits_raw, "implementation")
250
+ final_gate = _mapping(limits_raw, "final_gate")
251
+ provider_limits = _mapping(limits_raw, "providers")
252
+ timeouts = _mapping(limits_raw, "timeouts")
253
+ limits = LimitsConfig(
254
+ max_total_rounds=_positive(pipeline, "max_total_rounds"),
255
+ max_planning_retries=_integer(planning, "max_retries"),
256
+ max_implementation_rounds=_positive(implementation, "max_rounds"),
257
+ max_final_replans=_integer(final_gate, "max_replans"),
258
+ max_provider_retries=_integer(provider_limits, "max_retries_per_call"),
259
+ role_timeout_seconds=_positive(timeouts, "role_seconds"),
260
+ test_timeout_seconds=_positive(timeouts, "test_seconds"),
261
+ e2e_timeout_seconds=_positive(timeouts, "e2e_seconds"),
262
+ )
263
+ if limits.max_implementation_rounds > limits.max_total_rounds:
264
+ raise ConfigError("implementation.max_rounds cannot exceed pipeline.max_total_rounds")
265
+
266
+ paths = _mapping(raw, "paths")
267
+ runs_dir_value = paths.get("runs_dir")
268
+ if not isinstance(runs_dir_value, str) or not runs_dir_value.strip():
269
+ raise ConfigError("paths.runs_dir must be a non-empty string")
270
+ runs_dir = Path(runs_dir_value).expanduser()
271
+ if not runs_dir.is_absolute():
272
+ runs_dir = root / runs_dir
273
+ runs_dir = runs_dir.resolve()
274
+
275
+ providers_raw = _mapping(raw, "providers")
276
+ providers: dict[str, ProviderConfig] = {}
277
+ for name, value in providers_raw.items():
278
+ if not isinstance(name, str) or not isinstance(value, dict):
279
+ raise ConfigError("providers entries must be named mappings")
280
+ adapter = value.get("adapter")
281
+ command = value.get("command")
282
+ auth = value.get("auth")
283
+ if not all(isinstance(item, str) and item for item in (adapter, command, auth)):
284
+ raise ConfigError(f"provider {name!r} requires adapter, command, and auth")
285
+ args = value.get("args", [])
286
+ output = value.get("output", "json")
287
+ if not isinstance(args, list) or not all(isinstance(item, str) for item in args):
288
+ raise ConfigError(f"provider {name!r} args must be a list of strings")
289
+ if output not in {"json", "jsonl", "text"}:
290
+ raise ConfigError(f"provider {name!r} output must be json, jsonl, or text")
291
+ providers[name] = ProviderConfig(name, adapter, command, auth, tuple(args), output)
292
+
293
+ profiles_raw = _mapping(raw, "profiles")
294
+ profiles: dict[str, ProfileConfig] = {}
295
+ for name, value in profiles_raw.items():
296
+ if not isinstance(name, str) or not isinstance(value, dict):
297
+ raise ConfigError("profiles entries must be named mappings")
298
+ candidate_values = value.get("candidates")
299
+ if not isinstance(candidate_values, list) or not candidate_values:
300
+ raise ConfigError(f"profile {name!r} requires at least one candidate")
301
+ candidates: list[ModelCandidate] = []
302
+ for candidate in candidate_values:
303
+ if not isinstance(candidate, dict):
304
+ raise ConfigError(f"profile {name!r} candidates must be mappings")
305
+ provider = candidate.get("provider")
306
+ model = candidate.get("model")
307
+ model_id = candidate.get("model_id", "")
308
+ effort = candidate.get("effort")
309
+ if provider not in providers:
310
+ raise ConfigError(f"profile {name!r} references unknown provider {provider!r}")
311
+ if not isinstance(model, str) or not model:
312
+ raise ConfigError(f"profile {name!r} candidate model must be non-empty")
313
+ if not isinstance(model_id, str):
314
+ raise ConfigError(f"profile {name!r} candidate model_id must be a string")
315
+ if effort not in {"low", "medium", "high", "xhigh", "max"}:
316
+ raise ConfigError(f"profile {name!r} has invalid effort {effort!r}")
317
+ candidates.append(ModelCandidate(provider, model, effort, model_id))
318
+ profiles[name] = ProfileConfig(name, tuple(candidates))
319
+
320
+ roles_raw = _mapping(raw, "roles")
321
+ roles: dict[str, str] = {}
322
+ required_roles = {
323
+ "orchestrator",
324
+ "planner",
325
+ "plan_reviewer",
326
+ "implementer",
327
+ "spec_tester",
328
+ "clean_tester",
329
+ "security_tester",
330
+ "chaos_tester",
331
+ "triage",
332
+ "final_gate",
333
+ }
334
+ for role, value in roles_raw.items():
335
+ if not isinstance(role, str) or not isinstance(value, dict):
336
+ raise ConfigError("roles entries must be named mappings")
337
+ profile = value.get("profile")
338
+ if profile not in profiles:
339
+ raise ConfigError(f"role {role!r} references unknown profile {profile!r}")
340
+ roles[role] = profile
341
+ missing_roles = sorted(required_roles - roles.keys())
342
+ if missing_roles:
343
+ raise ConfigError(f"missing required roles: {', '.join(missing_roles)}")
344
+
345
+ gates_raw = _mapping(raw, "gates")
346
+ gate_commands_raw = gates_raw.get("commands", [])
347
+ if not isinstance(gate_commands_raw, list):
348
+ raise ConfigError("gates.commands must be a list")
349
+ gate_commands: list[GateCommandConfig] = []
350
+ for index, value in enumerate(gate_commands_raw):
351
+ if not isinstance(value, dict):
352
+ raise ConfigError(f"gates.commands[{index}] must be a mapping")
353
+ name = value.get("name")
354
+ kind = value.get("kind")
355
+ command = value.get("command")
356
+ timeout = value.get("timeout_seconds")
357
+ if not isinstance(name, str) or not name.strip():
358
+ raise ConfigError(f"gates.commands[{index}].name must be non-empty")
359
+ if kind not in {"test", "lint"}:
360
+ raise ConfigError(f"gates.commands[{index}].kind must be test or lint")
361
+ if (
362
+ not isinstance(command, list)
363
+ or not command
364
+ or not all(isinstance(item, str) and item for item in command)
365
+ ):
366
+ raise ConfigError(f"gates.commands[{index}].command must be a non-empty argv list")
367
+ if timeout is not None and (
368
+ isinstance(timeout, bool) or not isinstance(timeout, int) or timeout < 1
369
+ ):
370
+ raise ConfigError(f"gates.commands[{index}].timeout_seconds must be >= 1")
371
+ gate_commands.append(GateCommandConfig(name, kind, tuple(command), timeout))
372
+
373
+ e2e = _mapping(raw, "e2e")
374
+ git = _mapping(raw, "git")
375
+ return HarnessConfig(
376
+ project_root=root,
377
+ quality_gate=gate,
378
+ limits=limits,
379
+ runs_dir=runs_dir,
380
+ providers=providers,
381
+ profiles=profiles,
382
+ roles=roles,
383
+ project_gates=ProjectGatesConfig(
384
+ enabled=_boolean(gates_raw, "enabled"),
385
+ auto_detect=_boolean(gates_raw, "auto_detect"),
386
+ commands=tuple(gate_commands),
387
+ ),
388
+ git=GitConfig(
389
+ worktree=_boolean(git, "worktree"),
390
+ auto_commit=_boolean(git, "auto_commit"),
391
+ publish=_boolean(git, "publish"),
392
+ ),
393
+ e2e_enabled=_boolean(e2e, "enabled"),
394
+ e2e_required_for_ui_changes=_boolean(e2e, "required_for_ui_changes"),
395
+ e2e_require_all_testers=_boolean(e2e, "require_all_testers"),
396
+ raw=copy.deepcopy(raw),
397
+ sources=sources,
398
+ )
norf_harness/doctor.py ADDED
@@ -0,0 +1,103 @@
1
+ """Read-only local environment diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+
11
+ from .config import HarnessConfig, project_config_path
12
+ from .providers import default_registry
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Check:
17
+ name: str
18
+ status: str
19
+ detail: str
20
+
21
+
22
+ def run_doctor(config: HarnessConfig) -> list[Check]:
23
+ checks: list[Check] = []
24
+ checks.append(
25
+ Check(
26
+ "python",
27
+ "PASS" if sys.version_info >= (3, 12) else "FAIL",
28
+ sys.version.split()[0],
29
+ )
30
+ )
31
+ checks.append(_git_check(config.project_root))
32
+ project_config = project_config_path(config.project_root)
33
+ checks.append(
34
+ Check(
35
+ "project-config",
36
+ "PASS" if project_config.is_file() else "WARN",
37
+ str(project_config) if project_config.is_file() else "using package/user defaults",
38
+ )
39
+ )
40
+ adapters = default_registry()
41
+ for name, provider in config.providers.items():
42
+ adapter = adapters.get(provider.adapter)
43
+ if adapter is None:
44
+ checks.append(
45
+ Check(
46
+ f"provider:{name}",
47
+ "FAIL",
48
+ f"adapter not registered: {provider.adapter}",
49
+ )
50
+ )
51
+ continue
52
+ probe = adapter.probe(provider)
53
+ status = "PASS" if probe.compatible else ("WARN" if not probe.available else "FAIL")
54
+ checks.append(
55
+ Check(
56
+ f"provider:{name}",
57
+ status,
58
+ f"{probe.command} — {probe.detail}",
59
+ )
60
+ )
61
+ for name, profile in config.profiles.items():
62
+ available = [
63
+ candidate
64
+ for candidate in profile.candidates
65
+ if shutil.which(config.providers[candidate.provider].command)
66
+ ]
67
+ checks.append(
68
+ Check(
69
+ f"profile:{name}",
70
+ "PASS" if available else "FAIL",
71
+ (
72
+ f"{available[0].provider}/{available[0].model}"
73
+ f"->{available[0].resolved_model}/{available[0].effort}"
74
+ if available
75
+ else "no locally available provider candidate"
76
+ ),
77
+ )
78
+ )
79
+ return checks
80
+
81
+
82
+ def checks_as_dict(checks: list[Check]) -> list[dict[str, str]]:
83
+ return [asdict(check) for check in checks]
84
+
85
+
86
+ def doctor_exit_code(checks: list[Check]) -> int:
87
+ return 1 if any(check.status == "FAIL" for check in checks) else 0
88
+
89
+
90
+ def _git_check(root: Path) -> Check:
91
+ try:
92
+ proc = subprocess.run(
93
+ ["git", "-C", str(root), "rev-parse", "--show-toplevel"],
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=5,
97
+ check=False,
98
+ )
99
+ except (OSError, subprocess.TimeoutExpired) as exc:
100
+ return Check("git-repository", "FAIL", str(exc))
101
+ if proc.returncode != 0:
102
+ return Check("git-repository", "FAIL", proc.stderr.strip() or "not a git repository")
103
+ return Check("git-repository", "PASS", proc.stdout.strip())
@@ -0,0 +1,163 @@
1
+ """Validate and archive tester-produced E2E evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ MAX_EVIDENCE_FILES = 20
11
+ MAX_EVIDENCE_BYTES = 64 * 1024 * 1024
12
+
13
+
14
+ class EvidenceError(RuntimeError):
15
+ """Raised when an isolated evidence directory cannot be prepared safely."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class EvidenceValidation:
20
+ errors: tuple[str, ...]
21
+ archived: tuple[str, ...]
22
+
23
+ @property
24
+ def valid(self) -> bool:
25
+ return not self.errors
26
+
27
+
28
+ def prepare_evidence_directory(
29
+ workspace_root: Path, run_id: str, round_number: int, role: str
30
+ ) -> Path:
31
+ workspace = workspace_root.resolve(strict=True)
32
+ parent = workspace
33
+ for part in (".norf", "e2e-evidence", run_id, f"round-{round_number}"):
34
+ parent = parent / part
35
+ if parent.is_symlink() or (parent.exists() and not parent.is_dir()):
36
+ raise EvidenceError(f"unsafe E2E evidence parent: {parent}")
37
+ parent.mkdir(mode=0o700, exist_ok=True)
38
+ destination = parent / role
39
+ if destination.is_symlink() or (destination.exists() and not destination.is_dir()):
40
+ raise EvidenceError(f"unsafe E2E evidence destination: {destination}")
41
+ if destination.is_dir():
42
+ shutil.rmtree(destination)
43
+ destination.mkdir(parents=True, mode=0o700)
44
+ resolved = destination.resolve(strict=True)
45
+ try:
46
+ resolved.relative_to(workspace)
47
+ except ValueError as exc:
48
+ raise EvidenceError(f"E2E evidence destination escaped workspace: {resolved}") from exc
49
+ return resolved
50
+
51
+
52
+ def validate_and_archive_e2e(
53
+ e2e: dict[str, Any],
54
+ *,
55
+ workspace_root: Path,
56
+ evidence_dir: Path,
57
+ archive_dir: Path,
58
+ ) -> EvidenceValidation:
59
+ errors = _validate_execution(e2e)
60
+ status = e2e.get("status")
61
+ if status == "unavailable":
62
+ return EvidenceValidation(tuple(errors), ())
63
+
64
+ artifact_values = e2e.get("artifacts")
65
+ if not isinstance(artifact_values, list) or not artifact_values:
66
+ errors.append("at least one E2E artifact is required")
67
+ return EvidenceValidation(tuple(errors), ())
68
+ if len(artifact_values) > MAX_EVIDENCE_FILES:
69
+ errors.append(f"too many E2E artifacts (maximum {MAX_EVIDENCE_FILES})")
70
+ return EvidenceValidation(tuple(errors), ())
71
+
72
+ evidence_root = evidence_dir.resolve()
73
+ workspace = workspace_root.resolve()
74
+ candidates: list[tuple[Path, Path]] = []
75
+ total_bytes = 0
76
+ for value in artifact_values:
77
+ if not isinstance(value, str) or not value.strip():
78
+ errors.append("E2E artifact paths must be non-empty strings")
79
+ continue
80
+ supplied = Path(value)
81
+ candidate = supplied if supplied.is_absolute() else workspace / supplied
82
+ if candidate.is_symlink():
83
+ errors.append(f"E2E artifact must not be a symlink: {value}")
84
+ continue
85
+ try:
86
+ resolved = candidate.resolve(strict=True)
87
+ except (OSError, RuntimeError):
88
+ errors.append(f"E2E artifact does not exist: {value}")
89
+ continue
90
+ try:
91
+ relative = resolved.relative_to(evidence_root)
92
+ except ValueError:
93
+ errors.append(f"E2E artifact is outside the assigned evidence directory: {value}")
94
+ continue
95
+ if not resolved.is_file():
96
+ errors.append(f"E2E artifact is not a file: {value}")
97
+ continue
98
+ stat = resolved.stat()
99
+ if stat.st_nlink != 1:
100
+ errors.append(f"E2E artifact must not be hard-linked: {value}")
101
+ continue
102
+ size = stat.st_size
103
+ if size <= 0:
104
+ errors.append(f"E2E artifact is empty: {value}")
105
+ continue
106
+ total_bytes += size
107
+ if total_bytes > MAX_EVIDENCE_BYTES:
108
+ errors.append(f"E2E artifacts exceed {MAX_EVIDENCE_BYTES // (1024 * 1024)} MiB total")
109
+ break
110
+ candidates.append((resolved, relative))
111
+
112
+ if errors:
113
+ return EvidenceValidation(tuple(errors), ())
114
+
115
+ archived: list[str] = []
116
+ try:
117
+ if archive_dir.exists():
118
+ shutil.rmtree(archive_dir)
119
+ for source, relative in candidates:
120
+ destination = archive_dir / relative
121
+ destination.parent.mkdir(parents=True, exist_ok=True)
122
+ shutil.copy2(source, destination)
123
+ archived.append(str(destination))
124
+ except OSError as exc:
125
+ shutil.rmtree(archive_dir, ignore_errors=True)
126
+ return EvidenceValidation((f"failed to archive E2E evidence: {exc}",), ())
127
+ return EvidenceValidation((), tuple(archived))
128
+
129
+
130
+ def _validate_execution(e2e: dict[str, Any]) -> list[str]:
131
+ errors: list[str] = []
132
+ if e2e.get("required") is not True:
133
+ errors.append("tester did not mark the required E2E as required")
134
+ status = e2e.get("status")
135
+ if status == "unavailable":
136
+ return errors
137
+ if status not in {"passed", "failed"}:
138
+ errors.append(f"required E2E has invalid status: {status}")
139
+
140
+ command = e2e.get("command")
141
+ if (
142
+ not isinstance(command, list)
143
+ or not command
144
+ or not all(isinstance(item, str) and item.strip() for item in command)
145
+ ):
146
+ errors.append("E2E command must be a non-empty argv list")
147
+
148
+ exit_code = e2e.get("exit_code")
149
+ if isinstance(exit_code, bool) or not isinstance(exit_code, int):
150
+ errors.append("E2E exit_code must be an integer")
151
+ elif status == "passed" and exit_code != 0:
152
+ errors.append("passed E2E must have exit_code 0")
153
+ elif status == "failed" and exit_code == 0:
154
+ errors.append("failed E2E must have a nonzero exit_code")
155
+
156
+ evidence = e2e.get("evidence")
157
+ if (
158
+ not isinstance(evidence, list)
159
+ or not evidence
160
+ or not all(isinstance(item, str) and item.strip() for item in evidence)
161
+ ):
162
+ errors.append("E2E evidence summary must contain at least one non-empty item")
163
+ return errors