@michaelxwb/code-flow 0.8.1 → 1.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.
- package/README.md +73 -152
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +156 -1417
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +18 -21
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +12 -12
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +17 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +12 -12
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/acceptance-contract.md +0 -217
- package/docs/ci/acceptance-evidence.md +0 -77
- package/docs/migrations/runtime-0.7.0.md +0 -41
- package/docs/migrations/runtime-0.8.0.md +0 -154
- package/docs/migrations/runtime-0.9.0.md +0 -40
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/docs/spec-injection-hardening.md +0 -54
- package/docs/spec-injection-performance.json +0 -73
- package/docs/spec-optimization.md +0 -12
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -262
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -87
- package/src/adapters/claude/commands/cf-stats.md +0 -96
- package/src/adapters/claude/commands/cf-sync.md +0 -39
- package/src/adapters/claude/commands/cf-task/align.md +0 -331
- package/src/adapters/claude/commands/cf-task/archive.md +0 -155
- package/src/adapters/claude/commands/cf-task/block.md +0 -49
- package/src/adapters/claude/commands/cf-task/graph.md +0 -92
- package/src/adapters/claude/commands/cf-task/note.md +0 -248
- package/src/adapters/claude/commands/cf-task/plan.md +0 -477
- package/src/adapters/claude/commands/cf-task/prd.md +0 -173
- package/src/adapters/claude/commands/cf-task/start.md +0 -317
- package/src/adapters/claude/commands/cf-task/status.md +0 -71
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/claude/commands/cf-validate.md +0 -100
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -54
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -88
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -97
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -45
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -332
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -156
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -50
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -93
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -245
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -478
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -174
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -318
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -72
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -145
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -102
- package/src/adapters/commands.json +0 -115
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -262
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -87
- package/src/adapters/costrict/commands/cf-stats.md +0 -96
- package/src/adapters/costrict/commands/cf-sync.md +0 -39
- package/src/adapters/costrict/commands/cf-task/align.md +0 -331
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -155
- package/src/adapters/costrict/commands/cf-task/block.md +0 -49
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -92
- package/src/adapters/costrict/commands/cf-task/note.md +0 -248
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -477
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -173
- package/src/adapters/costrict/commands/cf-task/start.md +0 -317
- package/src/adapters/costrict/commands/cf-task/status.md +0 -71
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/costrict/commands/cf-validate.md +0 -100
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/install-plan.json +0 -100
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -91
- package/src/adapters/opencode/commands/cf-stats.md +0 -100
- package/src/adapters/opencode/commands/cf-sync.md +0 -44
- package/src/adapters/opencode/commands/cf-task/align.md +0 -331
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -160
- package/src/adapters/opencode/commands/cf-task/block.md +0 -54
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -97
- package/src/adapters/opencode/commands/cf-task/note.md +0 -253
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -477
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -178
- package/src/adapters/opencode/commands/cf-task/start.md +0 -317
- package/src/adapters/opencode/commands/cf-task/status.md +0 -76
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/opencode/commands/cf-validate.md +0 -105
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -162
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/adapters/platform-bindings.json +0 -370
- package/src/cli.js +0 -1076
- package/src/core/code-flow/config.yml +0 -161
- package/src/core/code-flow/runtime-commands.json +0 -259
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -10
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -10
- package/src/core/code-flow/scripts/cf_audit.py +0 -10
- package/src/core/code-flow/scripts/cf_codex_hook.py +0 -10
- package/src/core/code-flow/scripts/cf_design_lint.py +0 -10
- package/src/core/code-flow/scripts/cf_evidence_import.py +0 -10
- package/src/core/code-flow/scripts/cf_feedback.py +0 -10
- package/src/core/code-flow/scripts/cf_opencode_event.py +0 -10
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_scenarios.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -10
- package/src/core/code-flow/scripts/cf_stats.py +0 -10
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_sync.py +0 -10
- package/src/core/code-flow/scripts/cf_task_index.py +0 -10
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -10
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -10
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_validation.py +0 -10
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +0 -347
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +0 -660
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +0 -202
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +0 -60
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +0 -73
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +0 -282
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +0 -266
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +0 -172
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +0 -326
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_checks.py +0 -333
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +0 -197
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +0 -174
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +0 -300
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +0 -502
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +0 -347
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +0 -1490
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +0 -533
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +0 -172
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +0 -607
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +0 -53
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +0 -58
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +0 -1175
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +0 -60
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +0 -46
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +0 -540
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +0 -144
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +0 -177
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +0 -1142
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +0 -742
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +0 -31
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +0 -67
- package/src/core/code-flow/scripts/codeflow/core/cf_core.py +0 -609
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +0 -325
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +0 -57
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +0 -92
- package/src/core/code-flow/scripts/codeflow/core/cf_log.py +0 -160
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +0 -77
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +0 -26
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +0 -33
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +0 -32
- package/src/core/code-flow/scripts/codeflow/core/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +0 -53
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +0 -9
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +0 -408
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +0 -253
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +0 -527
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +0 -95
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +0 -98
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +0 -30
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +0 -41
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +0 -292
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +0 -161
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +0 -412
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +0 -301
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +0 -149
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +0 -111
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +0 -114
- package/src/core/code-flow/scripts/codeflow/spec/cf_scan.py +0 -367
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +0 -55
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +0 -477
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +0 -119
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_metadata.py +0 -387
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_migrate.py +0 -529
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +0 -193
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +0 -187
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_resolver.py +0 -369
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_router.py +0 -304
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_verify.py +0 -687
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +0 -330
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +0 -83
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +0 -755
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_transaction.py +0 -110
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +0 -867
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +0 -178
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +0 -635
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +0 -251
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +0 -570
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +0 -73
- package/src/core/code-flow/scripts/runtime-resources.json +0 -29
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/specs/shared/scenarios-template.md +0 -55
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -59
- package/src/migrate/runtime.js +0 -213
- package/src/migrate/spec-workflow.js +0 -253
- package/src/runtime.js +0 -219
|
@@ -1,1490 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Execute manifest scenarios with deterministic ordering, checkpoints and resume.
|
|
3
|
-
|
|
4
|
-
Execution is scheduled per check: a check result is persisted as soon as it
|
|
5
|
-
completes, so an interrupted run resumes from the first unfinished/failed check
|
|
6
|
-
instead of starting over. Independent parallel-safe checks may overlap;
|
|
7
|
-
`depends_on` edges serialize them. A global deadline or a failure budget stops
|
|
8
|
-
new work and reports unrun checks as `incomplete`, never as failed/verified.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import argparse
|
|
14
|
-
import hashlib
|
|
15
|
-
import json
|
|
16
|
-
import os
|
|
17
|
-
import re
|
|
18
|
-
import subprocess
|
|
19
|
-
import time
|
|
20
|
-
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
21
|
-
from dataclasses import dataclass
|
|
22
|
-
from datetime import datetime, timezone
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
import sys
|
|
25
|
-
from typing import Callable, IO, Mapping, Optional, Sequence
|
|
26
|
-
|
|
27
|
-
from codeflow.core import cf_fs
|
|
28
|
-
from codeflow.core.cf_exec_base import (
|
|
29
|
-
execution_environment_hash,
|
|
30
|
-
execution_key,
|
|
31
|
-
invalidate_executions,
|
|
32
|
-
record_report,
|
|
33
|
-
redact_sensitive_payload,
|
|
34
|
-
redact_sensitive_text,
|
|
35
|
-
remaining_seconds,
|
|
36
|
-
run_command,
|
|
37
|
-
worktree_fingerprint,
|
|
38
|
-
)
|
|
39
|
-
from codeflow.acceptance.cf_check_inputs import check_input_hashes, match_path
|
|
40
|
-
from codeflow.acceptance.cf_acceptance_selection import (
|
|
41
|
-
select_owned as _owned, select_profile as _select_profile, select_scenarios as _select_scenarios,
|
|
42
|
-
)
|
|
43
|
-
from codeflow.acceptance.cf_acceptance_risk import accepted_risk as _accepted_risk
|
|
44
|
-
from codeflow.acceptance.cf_acceptance_manifest_core import validate_execution_baseline
|
|
45
|
-
from codeflow.acceptance.cf_acceptance_schema import (check_outcome, load_manifest, recorded_check_hash,
|
|
46
|
-
scenario_check_ids)
|
|
47
|
-
from codeflow.acceptance.cf_acceptance_evidence import persist_results
|
|
48
|
-
from codeflow.evidence.cf_evidence_store import EvidenceStore
|
|
49
|
-
from codeflow.core.cf_worktree_env import (
|
|
50
|
-
SharedEnvLockTimeout,
|
|
51
|
-
check_environment,
|
|
52
|
-
effective_parallel_safe,
|
|
53
|
-
env_scope,
|
|
54
|
-
is_isolated,
|
|
55
|
-
preflight_commands,
|
|
56
|
-
resolve_run_id,
|
|
57
|
-
shared_env_lock,
|
|
58
|
-
worktree_namespace,
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
DEFAULT_JOBS = 4
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class AcceptanceInterrupted(KeyboardInterrupt):
|
|
65
|
-
"""A checkpointed run was interrupted; carries the partial, truthful payload."""
|
|
66
|
-
|
|
67
|
-
def __init__(self, payload: Mapping[str, object]) -> None:
|
|
68
|
-
super().__init__("acceptance run interrupted")
|
|
69
|
-
self.payload = dict(payload)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def _scenario_cwd(root: Path, item: Mapping[str, object]) -> str:
|
|
73
|
-
cwd = item.get("cwd", ".") if isinstance(item.get("cwd", "."), str) else "."
|
|
74
|
-
return str(root / cwd)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _scenario_timeout(item: Mapping[str, object]) -> float:
|
|
78
|
-
timeout = item.get("timeout", 60)
|
|
79
|
-
return float(timeout) if isinstance(timeout, (int, float)) else 60.0
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _included(item: Mapping[str, object], include_e2e: bool, only_e2e: bool) -> bool:
|
|
83
|
-
"""Whether the scenario would actually execute under the current flags."""
|
|
84
|
-
kind = str(item.get("kind", "functional"))
|
|
85
|
-
mode = str(item.get("exec", ""))
|
|
86
|
-
if kind == "manual" or mode in ("manual", "aspirational"):
|
|
87
|
-
return False
|
|
88
|
-
deferred = kind == "e2e" or mode == "deferred"
|
|
89
|
-
if only_e2e:
|
|
90
|
-
return deferred
|
|
91
|
-
if deferred:
|
|
92
|
-
return include_e2e
|
|
93
|
-
return True
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _reusable(row: Optional[Mapping[str, object]], item: Mapping[str, object], fingerprint: str) -> bool:
|
|
97
|
-
"""Reuse a passed check whose evidence matches current identity and content.
|
|
98
|
-
|
|
99
|
-
Reuse is content-addressed (like the validation cache): same command/cwd/
|
|
100
|
-
timeout AND unchanged non-ignored worktree. Scenarios with `depends_on`
|
|
101
|
-
never reuse (dependency side effects are not captured by the fingerprint),
|
|
102
|
-
failures are never stored, and `--no-cache` disables reuse entirely.
|
|
103
|
-
"""
|
|
104
|
-
if not fingerprint or not isinstance(row, Mapping):
|
|
105
|
-
return False
|
|
106
|
-
if item.get("depends_on"):
|
|
107
|
-
return False
|
|
108
|
-
if not item.get("scenario_ref") and row.get("status") != "verified":
|
|
109
|
-
return False
|
|
110
|
-
if not item.get("scenario_ref"):
|
|
111
|
-
if row.get("check") != item.get("check"):
|
|
112
|
-
return False
|
|
113
|
-
if row.get("check_config") and item.get("check_config") and row.get("check_config") != item.get("check_config"):
|
|
114
|
-
return False
|
|
115
|
-
evidence = row.get("evidence")
|
|
116
|
-
if not isinstance(evidence, Mapping):
|
|
117
|
-
return False
|
|
118
|
-
if item.get("scenario_ref"):
|
|
119
|
-
outcome = check_outcome(evidence, str(item.get("check")))
|
|
120
|
-
if row.get("status") != "verified" and outcome is evidence:
|
|
121
|
-
return False # Legacy aggregate failures cannot prove a passing member.
|
|
122
|
-
evidence = outcome
|
|
123
|
-
if evidence.get("status") != "passed" or evidence.get("exit_code") != 0:
|
|
124
|
-
return False
|
|
125
|
-
recorded = evidence.get("fingerprint")
|
|
126
|
-
if item.get("scenario_ref"):
|
|
127
|
-
recorded = recorded_check_hash(row, str(item.get("check")))
|
|
128
|
-
if recorded != fingerprint:
|
|
129
|
-
return False
|
|
130
|
-
if evidence.get("command") != item.get("command"):
|
|
131
|
-
return False
|
|
132
|
-
if str(evidence.get("cwd", ".")) != str(item.get("cwd", ".")):
|
|
133
|
-
return False
|
|
134
|
-
return float(evidence.get("timeout", 60)) == _scenario_timeout(item)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _command_outcome(
|
|
138
|
-
item: Mapping[str, object], root: Path, deadline: Optional[float],
|
|
139
|
-
env: Optional[Mapping[str, str]] = None,
|
|
140
|
-
) -> dict[str, object]:
|
|
141
|
-
"""Execute one command identity and normalize its execution status."""
|
|
142
|
-
command = item.get("command")
|
|
143
|
-
if not isinstance(command, list) or not command or not all(isinstance(value, str) for value in command):
|
|
144
|
-
return {"status": "not_configured"}
|
|
145
|
-
timeout_value = _scenario_timeout(item)
|
|
146
|
-
cwd = item.get("cwd", ".") if isinstance(item.get("cwd", "."), str) else "."
|
|
147
|
-
identity = {key: item[key] for key in ("suite", "test_ids", "markers", "collect", "level", "environment_revision") if key in item}
|
|
148
|
-
outcome = run_command(command, str(root / cwd), timeout_value, deadline, env=env, identity_context=identity)
|
|
149
|
-
result = _normalize_command_outcome(outcome)
|
|
150
|
-
result["execution_ms"] = float(outcome.get("execution_ms", 0))
|
|
151
|
-
return result
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _normalize_command_outcome(outcome: Mapping[str, object]) -> dict[str, object]:
|
|
155
|
-
if outcome["status"] == "deadline_exceeded":
|
|
156
|
-
return {"status": "incomplete", "error": "deadline_exceeded"}
|
|
157
|
-
if outcome["status"] == "timeout":
|
|
158
|
-
return {"status": "failed", "error": "timeout",
|
|
159
|
-
"output": redact_sensitive_text(str(outcome.get("stdout", "")))}
|
|
160
|
-
if outcome["status"] == "spawn_error":
|
|
161
|
-
return {"status": "failed", "error": redact_sensitive_text(str(outcome.get("stderr", "")))}
|
|
162
|
-
if outcome["returncode"] == 0:
|
|
163
|
-
return {"status": "passed", "exit_code": 0}
|
|
164
|
-
output = (str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()[-4000:]
|
|
165
|
-
return {"status": "failed", "exit_code": outcome["returncode"], "output": redact_sensitive_text(output)}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def _run(
|
|
169
|
-
item: Mapping[str, object], root: Path, include_e2e: bool = False, deadline: Optional[float] = None,
|
|
170
|
-
only_e2e: bool = False, env: Optional[Mapping[str, str]] = None,
|
|
171
|
-
) -> dict[str, object]:
|
|
172
|
-
scenario_id = str(item.get("id", "unknown"))
|
|
173
|
-
kind = str(item.get("kind", "functional"))
|
|
174
|
-
mode = str(item.get("exec", ""))
|
|
175
|
-
if mode == "aspirational":
|
|
176
|
-
return {"id": scenario_id, "kind": kind, "status": "not_included", "reason": "aspirational"}
|
|
177
|
-
if kind == "manual" or mode == "manual":
|
|
178
|
-
return {"id": scenario_id, "kind": kind, "status": "manual_pending"}
|
|
179
|
-
deferred = kind == "e2e" or mode == "deferred"
|
|
180
|
-
if only_e2e and not deferred:
|
|
181
|
-
return {"id": scenario_id, "kind": kind, "status": "not_included"}
|
|
182
|
-
if deferred and not include_e2e and not only_e2e:
|
|
183
|
-
return {"id": scenario_id, "kind": kind, "status": "e2e_deferred"}
|
|
184
|
-
outcome = _command_outcome(item, root, deadline, env)
|
|
185
|
-
return {"id": scenario_id, "kind": kind, **outcome}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def _preflight_failures(
|
|
189
|
-
check: Mapping[str, object], root: Path, env: Optional[Mapping[str, str]], deadline: Optional[float]
|
|
190
|
-
) -> list[dict[str, object]]:
|
|
191
|
-
"""Probe declared preflight commands; each failure is a missing dependency."""
|
|
192
|
-
cwd = check.get("cwd", ".") if isinstance(check.get("cwd", "."), str) else "."
|
|
193
|
-
timeout = _scenario_timeout(check)
|
|
194
|
-
failures: list[dict[str, object]] = []
|
|
195
|
-
for argv in preflight_commands(check):
|
|
196
|
-
outcome = run_command(argv, str(root / cwd), timeout, deadline, env=env)
|
|
197
|
-
status = outcome["status"]
|
|
198
|
-
item: dict[str, object] = {
|
|
199
|
-
"command": list(argv),
|
|
200
|
-
"cwd": cwd,
|
|
201
|
-
"timeout": timeout,
|
|
202
|
-
"reason": str(status),
|
|
203
|
-
}
|
|
204
|
-
if status == "deadline_exceeded":
|
|
205
|
-
item["exit_code"] = None
|
|
206
|
-
failures.append(item)
|
|
207
|
-
break
|
|
208
|
-
if status == "timeout":
|
|
209
|
-
item["exit_code"] = None
|
|
210
|
-
item["output"] = redact_sensitive_text(str(outcome.get("stdout", ""))[-2000:])
|
|
211
|
-
failures.append(item)
|
|
212
|
-
continue
|
|
213
|
-
if status == "spawn_error":
|
|
214
|
-
item["exit_code"] = None
|
|
215
|
-
item["output"] = redact_sensitive_text(str(outcome.get("stderr", ""))[-2000:])
|
|
216
|
-
failures.append(item)
|
|
217
|
-
continue
|
|
218
|
-
if outcome["returncode"] != 0:
|
|
219
|
-
item["exit_code"] = outcome["returncode"]
|
|
220
|
-
output = (str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()
|
|
221
|
-
item["output"] = redact_sensitive_text(output[-2000:])
|
|
222
|
-
item["reason"] = "exit_code"
|
|
223
|
-
failures.append(item)
|
|
224
|
-
return failures
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def _env_blocked_result(
|
|
228
|
-
item: Mapping[str, object], check_id: str, missing: Sequence[Mapping[str, object]]
|
|
229
|
-
) -> dict[str, object]:
|
|
230
|
-
return {
|
|
231
|
-
"id": str(item.get("id", "unknown")),
|
|
232
|
-
"kind": str(item.get("kind", "functional")),
|
|
233
|
-
"status": "env_blocked",
|
|
234
|
-
"check_id": check_id,
|
|
235
|
-
"owner": str(item.get("owner", "") or ""),
|
|
236
|
-
"missing": [dict(entry) for entry in missing],
|
|
237
|
-
"error": "env_preflight_failed",
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def _individual_results(results: Sequence[Mapping[str, object]]) -> list[Mapping[str, object]]:
|
|
242
|
-
"""Expose each member's diagnostics without attributing them to the scenario."""
|
|
243
|
-
individual: list[Mapping[str, object]] = []
|
|
244
|
-
for result in results:
|
|
245
|
-
outcomes = result.get("check_results")
|
|
246
|
-
if not isinstance(outcomes, Mapping):
|
|
247
|
-
individual.append(result)
|
|
248
|
-
continue
|
|
249
|
-
for check_id in outcomes:
|
|
250
|
-
individual.append({**check_outcome(result, str(check_id)), "check_id": str(check_id)})
|
|
251
|
-
return individual
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def _env_blocked_details(
|
|
255
|
-
checks: Mapping[str, Mapping[str, object]], results: Sequence[Mapping[str, object]]
|
|
256
|
-
) -> list[dict[str, object]]:
|
|
257
|
-
details: list[dict[str, object]] = []
|
|
258
|
-
seen: set[str] = set()
|
|
259
|
-
for item in _individual_results(results):
|
|
260
|
-
if item.get("status") != "env_blocked":
|
|
261
|
-
continue
|
|
262
|
-
check_id = str(item.get("check_id") or item.get("id"))
|
|
263
|
-
if check_id in seen:
|
|
264
|
-
continue
|
|
265
|
-
seen.add(check_id)
|
|
266
|
-
check = checks.get(check_id, {})
|
|
267
|
-
details.append({
|
|
268
|
-
"check_id": check_id,
|
|
269
|
-
"owner": str(item.get("owner", "") or ""),
|
|
270
|
-
"command": check.get("command", item.get("command")),
|
|
271
|
-
"missing": [dict(entry) for entry in item.get("missing", [])],
|
|
272
|
-
})
|
|
273
|
-
return details
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def _ordered(items: list[Mapping[str, object]]) -> list[Mapping[str, object]]:
|
|
277
|
-
by_id = {str(item.get("id")): item for item in items}
|
|
278
|
-
pending = set(by_id)
|
|
279
|
-
ordered: list[Mapping[str, object]] = []
|
|
280
|
-
while pending:
|
|
281
|
-
ready: list[str] = []
|
|
282
|
-
for item_id in by_id:
|
|
283
|
-
if item_id not in pending:
|
|
284
|
-
continue
|
|
285
|
-
deps = by_id[item_id].get("depends_on", [])
|
|
286
|
-
dep_ids = [str(dep) for dep in deps] if isinstance(deps, list) else []
|
|
287
|
-
unknown = [dep for dep in dep_ids if dep not in by_id]
|
|
288
|
-
if unknown:
|
|
289
|
-
raise ValueError(f"acceptance scenario dependency missing: {', '.join(unknown)}")
|
|
290
|
-
if all(dep not in pending for dep in dep_ids):
|
|
291
|
-
ready.append(item_id)
|
|
292
|
-
if not ready:
|
|
293
|
-
raise ValueError("acceptance scenario dependency cycle")
|
|
294
|
-
ordered.extend(by_id[item_id] for item_id in ready)
|
|
295
|
-
pending.difference_update(ready)
|
|
296
|
-
return ordered
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def _executable(items: list[Mapping[str, object]]) -> bool:
|
|
300
|
-
"""Draft completeness vs execution gate: at least one scenario must be
|
|
301
|
-
executable — a functional scenario with a registered command, or a
|
|
302
|
-
manual / e2e scenario with its own confirmation/deferral path. Zero
|
|
303
|
-
executable scenarios must block execution acceptance, never pass it."""
|
|
304
|
-
for item in items:
|
|
305
|
-
command = item.get("command")
|
|
306
|
-
if isinstance(command, list) and command and all(isinstance(value, str) for value in command):
|
|
307
|
-
return True
|
|
308
|
-
checks = item.get("checks")
|
|
309
|
-
if isinstance(checks, list) and checks and all(isinstance(value, str) for value in checks):
|
|
310
|
-
return True
|
|
311
|
-
if str(item.get("kind", "")) in ("manual", "e2e"):
|
|
312
|
-
return True
|
|
313
|
-
return False
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _identity_key(
|
|
317
|
-
item: Mapping[str, object], root: Path, include_e2e: bool, only_e2e: bool
|
|
318
|
-
) -> Optional[str]:
|
|
319
|
-
"""Execution identity for within-run dedup, or None when not configured."""
|
|
320
|
-
command = item.get("command")
|
|
321
|
-
if not isinstance(command, list) or not command or not all(isinstance(value, str) for value in command):
|
|
322
|
-
return None
|
|
323
|
-
key = execution_key(command, str(root / str(item.get("cwd", "."))), float(item.get("timeout", 60)))
|
|
324
|
-
return key + json.dumps([item.get("kind", "functional"), include_e2e, only_e2e])
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
def _group_key(
|
|
328
|
-
item: Mapping[str, object], root: Path, include_e2e: bool, only_e2e: bool
|
|
329
|
-
) -> Optional[str]:
|
|
330
|
-
"""Schema2 compatibility: identical independent commands share one run."""
|
|
331
|
-
if item.get("depends_on") or not _included(item, include_e2e, only_e2e):
|
|
332
|
-
return None
|
|
333
|
-
return _identity_key(item, root, include_e2e, only_e2e)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def _unit_dependencies(
|
|
337
|
-
units: list[dict[str, object]], scenario_unit: Mapping[str, str],
|
|
338
|
-
by_id: Mapping[str, dict[str, object]],
|
|
339
|
-
) -> None:
|
|
340
|
-
"""Serialize a check when a scenario or the check itself declares depends_on."""
|
|
341
|
-
aggregate_units: dict[str, list[str]] = {}
|
|
342
|
-
for unit in units:
|
|
343
|
-
for item in unit["scenarios"]:
|
|
344
|
-
ref = item.get("scenario_ref") if isinstance(item, Mapping) else None
|
|
345
|
-
if isinstance(ref, str) and ref:
|
|
346
|
-
aggregate_units.setdefault(ref, []).append(str(unit["id"]))
|
|
347
|
-
for unit in units:
|
|
348
|
-
wanted: list[str] = []
|
|
349
|
-
check = unit.get("check")
|
|
350
|
-
raw = check.get("depends_on", []) if isinstance(check, Mapping) else []
|
|
351
|
-
if isinstance(raw, list):
|
|
352
|
-
wanted.extend(str(dep) for dep in raw)
|
|
353
|
-
for item in unit["scenarios"]:
|
|
354
|
-
deps = item.get("depends_on", [])
|
|
355
|
-
if isinstance(deps, list):
|
|
356
|
-
wanted.extend(str(dep) for dep in deps)
|
|
357
|
-
resolved: list[str] = []
|
|
358
|
-
for dep in wanted:
|
|
359
|
-
target = by_id.get(dep)
|
|
360
|
-
if target is not None:
|
|
361
|
-
targets = [str(target["id"])]
|
|
362
|
-
elif dep in scenario_unit:
|
|
363
|
-
targets = [scenario_unit[dep]]
|
|
364
|
-
elif dep in aggregate_units:
|
|
365
|
-
targets = aggregate_units[dep]
|
|
366
|
-
else:
|
|
367
|
-
targets = []
|
|
368
|
-
for target_id in targets:
|
|
369
|
-
if target_id != unit["id"] and target_id not in resolved:
|
|
370
|
-
resolved.append(target_id)
|
|
371
|
-
unit["deps"] = resolved
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def _build_units(
|
|
375
|
-
items: Sequence[Mapping[str, object]], root: Path, check_map: Mapping[str, Mapping[str, object]],
|
|
376
|
-
include_e2e: bool, only_e2e: bool,
|
|
377
|
-
) -> tuple[list[dict[str, object]], dict[str, str]]:
|
|
378
|
-
"""Group scenarios into check units (v3) or command-identity units (schema2)."""
|
|
379
|
-
units: list[dict[str, object]] = []
|
|
380
|
-
by_id: dict[str, dict[str, object]] = {}
|
|
381
|
-
command_units: dict[str, dict[str, object]] = {}
|
|
382
|
-
scenario_unit: dict[str, str] = {}
|
|
383
|
-
for item in items:
|
|
384
|
-
scenario_id = str(item.get("id"))
|
|
385
|
-
check_id = item.get("check")
|
|
386
|
-
if isinstance(check_id, str) and check_id in check_map:
|
|
387
|
-
unit = by_id.get(check_id)
|
|
388
|
-
if unit is None:
|
|
389
|
-
unit = {"id": check_id, "check": check_map[check_id], "scenarios": [], "deps": []}
|
|
390
|
-
by_id[check_id] = unit
|
|
391
|
-
units.append(unit)
|
|
392
|
-
unit["scenarios"].append(item)
|
|
393
|
-
scenario_unit[scenario_id] = check_id
|
|
394
|
-
continue
|
|
395
|
-
key = _group_key(item, root, include_e2e, only_e2e)
|
|
396
|
-
unit = command_units.get(key) if key is not None else None
|
|
397
|
-
if unit is None:
|
|
398
|
-
identity = (
|
|
399
|
-
"command:" + hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
|
|
400
|
-
if key is not None else f"scenario:{scenario_id}"
|
|
401
|
-
)
|
|
402
|
-
unit = {"id": identity, "check": None, "scenarios": [], "deps": []}
|
|
403
|
-
units.append(unit)
|
|
404
|
-
by_id[identity] = unit
|
|
405
|
-
if key is not None:
|
|
406
|
-
command_units[key] = unit
|
|
407
|
-
unit["scenarios"].append(item)
|
|
408
|
-
scenario_unit[scenario_id] = str(unit["id"])
|
|
409
|
-
_unit_dependencies(units, scenario_unit, by_id)
|
|
410
|
-
return units, scenario_unit
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
def _unit_layers(units: Sequence[Mapping[str, object]]) -> list[list[Mapping[str, object]]]:
|
|
414
|
-
"""Stable dependency levels: every layer can run concurrently."""
|
|
415
|
-
remaining = list(units)
|
|
416
|
-
done: set[str] = set()
|
|
417
|
-
layers: list[list[Mapping[str, object]]] = []
|
|
418
|
-
while remaining:
|
|
419
|
-
ready = [
|
|
420
|
-
unit for unit in remaining
|
|
421
|
-
if all(str(dep) in done for dep in unit.get("deps", []))
|
|
422
|
-
]
|
|
423
|
-
if not ready:
|
|
424
|
-
raise ValueError("acceptance check dependency cycle")
|
|
425
|
-
layers.append(ready)
|
|
426
|
-
done.update(str(unit["id"]) for unit in ready)
|
|
427
|
-
remaining = [unit for unit in remaining if str(unit["id"]) not in done]
|
|
428
|
-
return layers
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
def _unit_active(unit: Mapping[str, object], include_e2e: bool, only_e2e: bool) -> list[Mapping[str, object]]:
|
|
432
|
-
return [
|
|
433
|
-
item for item in unit["scenarios"]
|
|
434
|
-
if _included(item, include_e2e, only_e2e)
|
|
435
|
-
]
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
def _evidence_within_ttl(check: Mapping[str, object], record: Mapping[str, object]) -> bool:
|
|
439
|
-
ttl = check.get("evidence_ttl_seconds")
|
|
440
|
-
if ttl is None:
|
|
441
|
-
return True
|
|
442
|
-
try:
|
|
443
|
-
timestamp = datetime.fromisoformat(str(record.get("executed_at", "")).replace("Z", "+00:00"))
|
|
444
|
-
if timestamp.tzinfo is None:
|
|
445
|
-
return False
|
|
446
|
-
age = (datetime.now(timezone.utc) - timestamp).total_seconds()
|
|
447
|
-
return 0 <= age <= float(ttl)
|
|
448
|
-
except (TypeError, ValueError, OverflowError):
|
|
449
|
-
return False
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
def _checkpoint_not_superseded(entry: Mapping[str, object], active: Sequence[Mapping[str, object]],
|
|
453
|
-
rows: Mapping[str, Mapping[str, object]]) -> bool:
|
|
454
|
-
for item in active:
|
|
455
|
-
row = rows.get(str(item.get("scenario_ref") or item.get("id")), {})
|
|
456
|
-
evidence = row.get("evidence")
|
|
457
|
-
if not isinstance(evidence, Mapping):
|
|
458
|
-
if row.get("runs"):
|
|
459
|
-
return False
|
|
460
|
-
continue
|
|
461
|
-
latest = check_outcome(evidence, str(item.get("check"))) if item.get("scenario_ref") else evidence
|
|
462
|
-
if latest.get("status") == "passed" and latest.get("exit_code") == 0:
|
|
463
|
-
continue
|
|
464
|
-
try:
|
|
465
|
-
checkpoint_at = datetime.fromisoformat(str(entry.get("executed_at", "")).replace("Z", "+00:00"))
|
|
466
|
-
evidence_at = datetime.fromisoformat(str(evidence.get("executed_at", "")).replace("Z", "+00:00"))
|
|
467
|
-
if checkpoint_at.tzinfo is None or evidence_at.tzinfo is None or evidence_at >= checkpoint_at:
|
|
468
|
-
return False
|
|
469
|
-
except (TypeError, ValueError):
|
|
470
|
-
return False
|
|
471
|
-
return True
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
def _unit_reusable(
|
|
475
|
-
unit: Mapping[str, object], rows: Mapping[str, Mapping[str, object]], fingerprint: str,
|
|
476
|
-
use_cache: bool, include_e2e: bool, only_e2e: bool, checkpoint: _Checkpoint,
|
|
477
|
-
environment_hash: str,
|
|
478
|
-
) -> bool:
|
|
479
|
-
"""A check is reusable only when every active scenario is still fresh."""
|
|
480
|
-
if not use_cache or not fingerprint or unit.get("deps"):
|
|
481
|
-
return False
|
|
482
|
-
check = unit.get("check") if isinstance(unit.get("check"), Mapping) else {}
|
|
483
|
-
if str(check.get("env", "none")).lower() in ("external", "staging", "production") and not check.get("environment_revision"):
|
|
484
|
-
return False
|
|
485
|
-
active = _unit_active(unit, include_e2e, only_e2e)
|
|
486
|
-
if not active:
|
|
487
|
-
return False
|
|
488
|
-
entry = checkpoint.entries.get(str(unit.get("id")), {})
|
|
489
|
-
if (check.get("evidence_ttl_seconds") is None and checkpoint.reusable(unit)
|
|
490
|
-
and entry.get("environment_hash") == environment_hash
|
|
491
|
-
and _evidence_within_ttl(check, entry)
|
|
492
|
-
and _checkpoint_not_superseded(entry, active, rows)):
|
|
493
|
-
return True
|
|
494
|
-
return all(
|
|
495
|
-
_reusable(rows.get(str(item.get("scenario_ref") or item.get("id"))), item, fingerprint)
|
|
496
|
-
and _evidence_within_ttl(check, (rows.get(str(item.get("scenario_ref") or item.get("id"))) or {}).get("evidence", {}))
|
|
497
|
-
and _item_environment_matches(rows.get(str(item.get("scenario_ref") or item.get("id"))), item, environment_hash)
|
|
498
|
-
for item in active
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
def _item_environment_matches(row: Optional[Mapping[str, object]], item: Mapping[str, object], expected: str) -> bool:
|
|
503
|
-
evidence = row.get("evidence") if isinstance(row, Mapping) else None
|
|
504
|
-
if not isinstance(evidence, Mapping):
|
|
505
|
-
return False
|
|
506
|
-
outcome = check_outcome(evidence, str(item.get("check"))) if item.get("scenario_ref") else evidence
|
|
507
|
-
return outcome.get("environment_hash") == expected
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
def _reuse_results(
|
|
511
|
-
unit: Mapping[str, object], root: Path, include_e2e: bool, only_e2e: bool, deadline: Optional[float],
|
|
512
|
-
checkpoint: _Checkpoint, rows: Mapping[str, Mapping[str, object]], environment_hash: str,
|
|
513
|
-
) -> list[dict[str, object]]:
|
|
514
|
-
check = unit.get("check") if isinstance(unit.get("check"), Mapping) else {}
|
|
515
|
-
entry = checkpoint.entries.get(str(unit.get("id")), {})
|
|
516
|
-
checkpoint_reused = (check.get("evidence_ttl_seconds") is None and checkpoint.reusable(unit)
|
|
517
|
-
and entry.get("environment_hash") == environment_hash
|
|
518
|
-
and _checkpoint_not_superseded(entry, _unit_active(unit, include_e2e, only_e2e), rows))
|
|
519
|
-
results: list[dict[str, object]] = []
|
|
520
|
-
for item in unit["scenarios"]:
|
|
521
|
-
if not _included(item, include_e2e, only_e2e):
|
|
522
|
-
results.append(_run(item, root, include_e2e, deadline, only_e2e))
|
|
523
|
-
continue
|
|
524
|
-
results.append({
|
|
525
|
-
"id": str(item.get("id", "unknown")),
|
|
526
|
-
"kind": str(item.get("kind", "functional")),
|
|
527
|
-
"status": "passed",
|
|
528
|
-
"exit_code": 0,
|
|
529
|
-
"cache_reused": True,
|
|
530
|
-
"checkpoint_reused": checkpoint_reused,
|
|
531
|
-
})
|
|
532
|
-
return results
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
def _apply_outcome(
|
|
536
|
-
unit: Mapping[str, object], outcome: Mapping[str, object], root: Path,
|
|
537
|
-
include_e2e: bool, only_e2e: bool, deadline: Optional[float],
|
|
538
|
-
) -> list[dict[str, object]]:
|
|
539
|
-
"""Project one check outcome onto every scenario that declares it."""
|
|
540
|
-
check_id = str(unit["id"]) if unit.get("check") is not None else ""
|
|
541
|
-
status = str(outcome.get("status"))
|
|
542
|
-
results: list[dict[str, object]] = []
|
|
543
|
-
for item in unit["scenarios"]:
|
|
544
|
-
if not _included(item, include_e2e, only_e2e):
|
|
545
|
-
results.append(_run(item, root, include_e2e, deadline, only_e2e))
|
|
546
|
-
continue
|
|
547
|
-
if status == "env_blocked":
|
|
548
|
-
results.append(_env_blocked_result(item, check_id, outcome.get("missing", [])))
|
|
549
|
-
continue
|
|
550
|
-
result = {"id": str(item.get("id", "unknown")), "kind": str(item.get("kind", "functional"))}
|
|
551
|
-
result.update({key: value for key, value in outcome.items() if key not in ("id", "kind")})
|
|
552
|
-
if status == "failed":
|
|
553
|
-
risk = _accepted_risk(unit.get("check"), item)
|
|
554
|
-
if risk is not None:
|
|
555
|
-
result["status"] = "accepted_risk"
|
|
556
|
-
result["risk"] = risk
|
|
557
|
-
if check_id:
|
|
558
|
-
result["check_id"] = check_id
|
|
559
|
-
results.append(result)
|
|
560
|
-
return results
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
def _unit_preflight(
|
|
564
|
-
check: Mapping[str, object], root: Path, env: Optional[Mapping[str, str]], deadline: Optional[float]
|
|
565
|
-
) -> Optional[dict[str, object]]:
|
|
566
|
-
missing = _preflight_failures(check, root, env, deadline)
|
|
567
|
-
if not missing:
|
|
568
|
-
return None
|
|
569
|
-
if any(str(entry.get("reason")) == "deadline_exceeded" for entry in missing):
|
|
570
|
-
return {"status": "incomplete", "error": "deadline_exceeded"}
|
|
571
|
-
return {"status": "env_blocked", "missing": missing}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
def _execute_check(
|
|
575
|
-
check: Mapping[str, object], root: Path, env: Mapping[str, str], deadline: Optional[float]
|
|
576
|
-
) -> dict[str, object]:
|
|
577
|
-
gated = _unit_preflight(check, root, env, deadline)
|
|
578
|
-
if gated is not None:
|
|
579
|
-
return gated
|
|
580
|
-
return _command_outcome(check, root, deadline, env)
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
def _execute_unit(
|
|
584
|
-
unit: Mapping[str, object], root: Path, include_e2e: bool, only_e2e: bool,
|
|
585
|
-
deadline: Optional[float], namespace: Optional[Mapping[str, str]],
|
|
586
|
-
) -> dict[str, object]:
|
|
587
|
-
check = unit.get("check")
|
|
588
|
-
if check is None:
|
|
589
|
-
return _command_outcome(unit["scenarios"][0], root, deadline, None)
|
|
590
|
-
if not _unit_active(unit, include_e2e, only_e2e):
|
|
591
|
-
return {"status": "not_included"}
|
|
592
|
-
env = check_environment(check, str(root), namespace)
|
|
593
|
-
if is_isolated(check):
|
|
594
|
-
return _execute_check(check, root, env, deadline)
|
|
595
|
-
# Shared services cannot be partitioned: every worktree serializes on the
|
|
596
|
-
# same lock, with a stale timeout so a crashed owner cannot stall a batch.
|
|
597
|
-
remaining = remaining_seconds(deadline)
|
|
598
|
-
if remaining is not None and remaining <= 0:
|
|
599
|
-
return {"status": "incomplete", "error": "deadline_exceeded"}
|
|
600
|
-
try:
|
|
601
|
-
with shared_env_lock(str(root), wait=remaining):
|
|
602
|
-
return _execute_check(check, root, env, deadline)
|
|
603
|
-
except SharedEnvLockTimeout as exc:
|
|
604
|
-
return {"status": "incomplete", "error": "shared_env_lock_timeout", "detail": str(exc)}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
def _parallel_safe(unit: Mapping[str, object]) -> bool:
|
|
608
|
-
check = unit.get("check")
|
|
609
|
-
return isinstance(check, Mapping) and effective_parallel_safe(check)
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
def _unit_command(unit: Mapping[str, object]) -> tuple[list[str], str, float]:
|
|
613
|
-
"""Command identity used to validate a checkpoint entry on resume."""
|
|
614
|
-
check = unit.get("check")
|
|
615
|
-
source = check if isinstance(check, Mapping) else unit["scenarios"][0]
|
|
616
|
-
command = source.get("command")
|
|
617
|
-
argv = [str(value) for value in command] if isinstance(command, list) else []
|
|
618
|
-
cwd = source.get("cwd", ".")
|
|
619
|
-
return argv, cwd if isinstance(cwd, str) else ".", _scenario_timeout(source)
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def _unit_execution_identity(unit: Mapping[str, object], root: Path, fingerprint: str,
|
|
623
|
-
namespace: Optional[Mapping[str, str]]) -> str:
|
|
624
|
-
command, cwd, timeout = _unit_command(unit)
|
|
625
|
-
check = unit.get("check")
|
|
626
|
-
extra = check_environment(check, str(root), namespace) if isinstance(check, Mapping) else {}
|
|
627
|
-
payload = {"command": command, "cwd": str((root / cwd).resolve()), "timeout": timeout,
|
|
628
|
-
"environment": {**os.environ, **extra}, "inputs": fingerprint,
|
|
629
|
-
"registration": check if isinstance(check, Mapping) else unit.get("scenarios", [])}
|
|
630
|
-
return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
def _atomic_checkpoint(path: Path, payload: Mapping[str, object]) -> None:
|
|
634
|
-
"""Checkpoint files are replaced atomically, like every other evidence file."""
|
|
635
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
636
|
-
cf_fs.atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
def _checkpoint_path(manifest_file: str, root: str) -> Path:
|
|
640
|
-
from codeflow.evidence.cf_evidence_store import requirement_key
|
|
641
|
-
digest = hashlib.sha256(str(Path(manifest_file).resolve()).encode("utf-8")).hexdigest()[:16]
|
|
642
|
-
requirement = requirement_key(Path(manifest_file).resolve().parent)
|
|
643
|
-
return EvidenceStore(root, requirement).directory.parent / "_checkpoints" / f"acceptance-{digest}.json"
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
class _Checkpoint:
|
|
647
|
-
"""Per-check completion sidecar, separate from the user's manifest.
|
|
648
|
-
|
|
649
|
-
The manifest is only rewritten when a run finishes, so an interrupt cannot
|
|
650
|
-
leave partially-verified rows. Resume reads passed checks whose command
|
|
651
|
-
identity and worktree freshness still match and reuses them.
|
|
652
|
-
"""
|
|
653
|
-
|
|
654
|
-
def __init__(self, path: Path, fingerprint: str, enabled: bool, resume: bool,
|
|
655
|
-
fingerprints: Optional[Mapping[str, str]] = None) -> None:
|
|
656
|
-
self.path = path
|
|
657
|
-
self.fingerprint = fingerprint
|
|
658
|
-
self.fingerprints = dict(fingerprints or {})
|
|
659
|
-
self.enabled = enabled
|
|
660
|
-
self.entries: dict[str, Mapping[str, object]] = {}
|
|
661
|
-
if not enabled:
|
|
662
|
-
return
|
|
663
|
-
if resume:
|
|
664
|
-
self.entries = self._read()
|
|
665
|
-
else:
|
|
666
|
-
self._write() # a fresh run must not inherit an older run's progress
|
|
667
|
-
|
|
668
|
-
def _read(self) -> dict[str, Mapping[str, object]]:
|
|
669
|
-
try:
|
|
670
|
-
text = cf_fs.read_text(self.path)
|
|
671
|
-
if text is None:
|
|
672
|
-
return {}
|
|
673
|
-
data = json.loads(text)
|
|
674
|
-
except (OSError, ValueError):
|
|
675
|
-
return {}
|
|
676
|
-
checks = data.get("checks") if isinstance(data, Mapping) else None
|
|
677
|
-
if not isinstance(checks, Mapping):
|
|
678
|
-
return {}
|
|
679
|
-
return {
|
|
680
|
-
str(key): value for key, value in checks.items() if isinstance(value, Mapping)
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
def _write(self) -> None:
|
|
684
|
-
if not self.enabled:
|
|
685
|
-
return
|
|
686
|
-
payload = {
|
|
687
|
-
"schema": 1,
|
|
688
|
-
"fingerprint": self.fingerprint,
|
|
689
|
-
"fingerprints": self.fingerprints,
|
|
690
|
-
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
691
|
-
"checks": self.entries,
|
|
692
|
-
}
|
|
693
|
-
_atomic_checkpoint(self.path, payload)
|
|
694
|
-
|
|
695
|
-
def fingerprint_for(self, unit: Mapping[str, object]) -> str:
|
|
696
|
-
if unit.get("check") is not None:
|
|
697
|
-
return self.fingerprints.get(str(unit.get("id")), self.fingerprint)
|
|
698
|
-
return self.fingerprint
|
|
699
|
-
|
|
700
|
-
def reusable(self, unit: Mapping[str, object]) -> bool:
|
|
701
|
-
if not self.enabled:
|
|
702
|
-
return False
|
|
703
|
-
entry = self.entries.get(str(unit.get("id")))
|
|
704
|
-
if not isinstance(entry, Mapping):
|
|
705
|
-
return False
|
|
706
|
-
command, cwd, timeout = _unit_command(unit)
|
|
707
|
-
try:
|
|
708
|
-
recorded_timeout = float(entry.get("timeout", 60))
|
|
709
|
-
except (TypeError, ValueError):
|
|
710
|
-
return False
|
|
711
|
-
return (
|
|
712
|
-
entry.get("status") == "passed"
|
|
713
|
-
and entry.get("fingerprint") == self.fingerprint_for(unit)
|
|
714
|
-
and entry.get("command") == command
|
|
715
|
-
and str(entry.get("cwd", ".")) == cwd
|
|
716
|
-
and recorded_timeout == timeout
|
|
717
|
-
)
|
|
718
|
-
|
|
719
|
-
def record(self, unit: Mapping[str, object], outcome: Mapping[str, object], environment_hash: str) -> None:
|
|
720
|
-
if not self.enabled:
|
|
721
|
-
return
|
|
722
|
-
if outcome.get("status") != "passed":
|
|
723
|
-
self.forget(unit)
|
|
724
|
-
return
|
|
725
|
-
command, cwd, timeout = _unit_command(unit)
|
|
726
|
-
self.entries[str(unit["id"])] = {
|
|
727
|
-
"status": "passed",
|
|
728
|
-
"fingerprint": self.fingerprint_for(unit),
|
|
729
|
-
"command": command,
|
|
730
|
-
"cwd": cwd,
|
|
731
|
-
"timeout": timeout,
|
|
732
|
-
"executed_at": datetime.now(timezone.utc).isoformat(),
|
|
733
|
-
"environment_hash": environment_hash,
|
|
734
|
-
}
|
|
735
|
-
self._write()
|
|
736
|
-
|
|
737
|
-
def forget(self, unit: Mapping[str, object]) -> None:
|
|
738
|
-
if not self.enabled:
|
|
739
|
-
return
|
|
740
|
-
if self.entries.pop(str(unit["id"]), None) is not None:
|
|
741
|
-
self._write()
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
def _failure(status: str) -> bool:
|
|
745
|
-
return status in ("failed", "not_configured")
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
class _UnitEngine:
|
|
749
|
-
"""Schedule check units, checkpointing each completion for resume."""
|
|
750
|
-
|
|
751
|
-
def __init__(
|
|
752
|
-
self, units: Sequence[Mapping[str, object]], root: Path, include_e2e: bool, only_e2e: bool,
|
|
753
|
-
deadline: Optional[float], namespace: Optional[Mapping[str, str]],
|
|
754
|
-
rows: Mapping[str, Mapping[str, object]], fingerprint: str, use_cache: bool,
|
|
755
|
-
max_failures: int, checkpoint: _Checkpoint, jobs: int,
|
|
756
|
-
fingerprints: Optional[Mapping[str, str]] = None,
|
|
757
|
-
) -> None:
|
|
758
|
-
self.units = list(units)
|
|
759
|
-
self.root = root
|
|
760
|
-
self.include_e2e = include_e2e
|
|
761
|
-
self.only_e2e = only_e2e
|
|
762
|
-
self.deadline = deadline
|
|
763
|
-
self.namespace = namespace
|
|
764
|
-
self.rows = rows
|
|
765
|
-
self.fingerprint = fingerprint
|
|
766
|
-
self.fingerprints = dict(fingerprints or {})
|
|
767
|
-
self.use_cache = use_cache
|
|
768
|
-
self.max_failures = max_failures
|
|
769
|
-
self.checkpoint = checkpoint
|
|
770
|
-
self.jobs = max(1, jobs or DEFAULT_JOBS)
|
|
771
|
-
self.results_by_id: dict[str, dict[str, object]] = {}
|
|
772
|
-
self.completed: set[str] = set()
|
|
773
|
-
self.failures = 0
|
|
774
|
-
self.executed = 0
|
|
775
|
-
self.reused = 0
|
|
776
|
-
self.execution_ms = 0.0
|
|
777
|
-
self.execution_identities: list[str] = []
|
|
778
|
-
self.stop_reason = ""
|
|
779
|
-
self.interrupted = False
|
|
780
|
-
|
|
781
|
-
def run(self) -> tuple[dict[str, dict[str, object]], dict[str, object]]:
|
|
782
|
-
try:
|
|
783
|
-
for layer in _unit_layers(self.units):
|
|
784
|
-
if self.stop_reason:
|
|
785
|
-
break
|
|
786
|
-
self._run_layer(layer)
|
|
787
|
-
except KeyboardInterrupt:
|
|
788
|
-
self.interrupted = True
|
|
789
|
-
self.stop_reason = "interrupted"
|
|
790
|
-
self._fill_unfinished()
|
|
791
|
-
state = {
|
|
792
|
-
"executed": self.executed,
|
|
793
|
-
"reused": self.reused,
|
|
794
|
-
"execution_ms": self.execution_ms,
|
|
795
|
-
"execution_identities": self.execution_identities,
|
|
796
|
-
"interrupted": self.interrupted,
|
|
797
|
-
"stopped_reason": self.stop_reason,
|
|
798
|
-
}
|
|
799
|
-
return self.results_by_id, state
|
|
800
|
-
|
|
801
|
-
def _run_layer(self, layer: Sequence[Mapping[str, object]]) -> None:
|
|
802
|
-
placeholders = [unit for unit in layer if not self._active(unit)]
|
|
803
|
-
for unit in placeholders:
|
|
804
|
-
self._record(unit, self._placeholder_results(unit))
|
|
805
|
-
parallel = [unit for unit in layer if self._active(unit) and _parallel_safe(unit)]
|
|
806
|
-
serial = [unit for unit in layer if self._active(unit) and not _parallel_safe(unit)]
|
|
807
|
-
self._run_parallel(parallel)
|
|
808
|
-
for unit in serial:
|
|
809
|
-
self._run_one(unit)
|
|
810
|
-
|
|
811
|
-
def _active(self, unit: Mapping[str, object]) -> bool:
|
|
812
|
-
return bool(_unit_active(unit, self.include_e2e, self.only_e2e))
|
|
813
|
-
|
|
814
|
-
def _can_start(self) -> bool:
|
|
815
|
-
if self.stop_reason:
|
|
816
|
-
return False
|
|
817
|
-
if self.max_failures and self.failures >= self.max_failures:
|
|
818
|
-
self._stop("max_failures_exceeded")
|
|
819
|
-
return False
|
|
820
|
-
if self._deadline_expired():
|
|
821
|
-
self._stop("deadline_exceeded")
|
|
822
|
-
return False
|
|
823
|
-
return True
|
|
824
|
-
|
|
825
|
-
def _stop(self, reason: str) -> None:
|
|
826
|
-
if not self.stop_reason:
|
|
827
|
-
self.stop_reason = reason
|
|
828
|
-
|
|
829
|
-
def _deadline_expired(self) -> bool:
|
|
830
|
-
remaining = remaining_seconds(self.deadline)
|
|
831
|
-
return remaining is not None and remaining <= 0
|
|
832
|
-
|
|
833
|
-
def _run_one(self, unit: Mapping[str, object]) -> None:
|
|
834
|
-
if not self._can_start():
|
|
835
|
-
return
|
|
836
|
-
self._run_unit(unit)
|
|
837
|
-
|
|
838
|
-
def _run_unit(self, unit: Mapping[str, object]) -> None:
|
|
839
|
-
fingerprint = self.checkpoint.fingerprint_for(unit)
|
|
840
|
-
check = unit.get("check") if isinstance(unit.get("check"), Mapping) else {}
|
|
841
|
-
environment_hash = execution_environment_hash(check_environment(check, str(self.root), self.namespace))
|
|
842
|
-
if _unit_reusable(unit, self.rows, fingerprint, self.use_cache,
|
|
843
|
-
self.include_e2e, self.only_e2e, self.checkpoint, environment_hash):
|
|
844
|
-
results = _reuse_results(unit, self.root, self.include_e2e, self.only_e2e,
|
|
845
|
-
self.deadline, self.checkpoint, self.rows, environment_hash)
|
|
846
|
-
self.reused += len(_unit_active(unit, self.include_e2e, self.only_e2e))
|
|
847
|
-
self._record(unit, self._with_input_hash(unit, results))
|
|
848
|
-
return
|
|
849
|
-
if unit.get("deps"):
|
|
850
|
-
invalidate_executions()
|
|
851
|
-
outcome = _execute_unit(unit, self.root, self.include_e2e, self.only_e2e,
|
|
852
|
-
self.deadline, self.namespace)
|
|
853
|
-
self.executed += 1
|
|
854
|
-
self._complete(unit, outcome)
|
|
855
|
-
|
|
856
|
-
def _with_input_hash(self, unit: Mapping[str, object],
|
|
857
|
-
results: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
|
|
858
|
-
value = self.checkpoint.fingerprint_for(unit)
|
|
859
|
-
check_id = str(unit.get("id")) if unit.get("check") is not None else ""
|
|
860
|
-
check_config = unit.get("check") if isinstance(unit.get("check"), Mapping) else None
|
|
861
|
-
environment_hash = execution_environment_hash(check_environment(check_config or {}, str(self.root), self.namespace))
|
|
862
|
-
return [
|
|
863
|
-
{
|
|
864
|
-
**dict(result), "input_hash": value, "environment_hash": environment_hash,
|
|
865
|
-
**({"check_id": check_id} if check_id else {}),
|
|
866
|
-
**({"check_config": dict(check_config)} if check_config else {}),
|
|
867
|
-
}
|
|
868
|
-
for result in results
|
|
869
|
-
]
|
|
870
|
-
|
|
871
|
-
def _complete(self, unit: Mapping[str, object], outcome: Mapping[str, object]) -> None:
|
|
872
|
-
self.execution_ms += float(outcome.get("execution_ms", 0))
|
|
873
|
-
if float(outcome.get("execution_ms", 0)) > 0:
|
|
874
|
-
self.execution_identities.append(_unit_execution_identity(
|
|
875
|
-
unit, self.root, self.checkpoint.fingerprint_for(unit), self.namespace))
|
|
876
|
-
results = _apply_outcome(unit, outcome, self.root, self.include_e2e,
|
|
877
|
-
self.only_e2e, self.deadline)
|
|
878
|
-
self._record(unit, self._with_input_hash(unit, results))
|
|
879
|
-
check = unit.get("check") if isinstance(unit.get("check"), Mapping) else {}
|
|
880
|
-
environment_hash = execution_environment_hash(check_environment(check, str(self.root), self.namespace))
|
|
881
|
-
self.checkpoint.record(unit, outcome, environment_hash)
|
|
882
|
-
status = str(outcome.get("status"))
|
|
883
|
-
if _failure(status):
|
|
884
|
-
self.failures += 1
|
|
885
|
-
if self.max_failures and self.failures >= self.max_failures:
|
|
886
|
-
self._stop("max_failures_exceeded")
|
|
887
|
-
if status == "incomplete" and outcome.get("error") == "deadline_exceeded":
|
|
888
|
-
self._stop("deadline_exceeded")
|
|
889
|
-
|
|
890
|
-
def _run_parallel(self, units: Sequence[Mapping[str, object]]) -> None:
|
|
891
|
-
runnable = []
|
|
892
|
-
for unit in units:
|
|
893
|
-
if not self._can_start():
|
|
894
|
-
break
|
|
895
|
-
runnable.append(unit)
|
|
896
|
-
if not runnable:
|
|
897
|
-
return
|
|
898
|
-
if len(runnable) == 1:
|
|
899
|
-
self._run_unit(runnable[0])
|
|
900
|
-
return
|
|
901
|
-
workers = min(len(runnable), self.jobs)
|
|
902
|
-
pool = ThreadPoolExecutor(max_workers=workers)
|
|
903
|
-
pending: dict[Future, Mapping[str, object]] = {}
|
|
904
|
-
cursor = 0
|
|
905
|
-
interrupted = False
|
|
906
|
-
try:
|
|
907
|
-
while cursor < len(runnable) and len(pending) < workers:
|
|
908
|
-
pending[pool.submit(self._execute, runnable[cursor])] = runnable[cursor]
|
|
909
|
-
cursor += 1
|
|
910
|
-
while pending:
|
|
911
|
-
done, _ = wait(tuple(pending), return_when=FIRST_COMPLETED)
|
|
912
|
-
for future in done:
|
|
913
|
-
unit = pending.pop(future)
|
|
914
|
-
self.executed += 1
|
|
915
|
-
self._complete(unit, future.result())
|
|
916
|
-
while (not self.stop_reason and cursor < len(runnable)
|
|
917
|
-
and len(pending) < workers and self._can_start()):
|
|
918
|
-
unit = runnable[cursor]
|
|
919
|
-
cursor += 1
|
|
920
|
-
pending[pool.submit(self._execute, unit)] = unit
|
|
921
|
-
except KeyboardInterrupt:
|
|
922
|
-
self._drain_done(pending)
|
|
923
|
-
interrupted = True
|
|
924
|
-
raise
|
|
925
|
-
finally:
|
|
926
|
-
pool.shutdown(wait=not interrupted, cancel_futures=interrupted)
|
|
927
|
-
|
|
928
|
-
def _drain_done(self, pending: dict[Future, Mapping[str, object]]) -> None:
|
|
929
|
-
"""A signal can arrive after a check finished but before it was recorded."""
|
|
930
|
-
for future in tuple(pending):
|
|
931
|
-
if not future.done() or future.cancelled():
|
|
932
|
-
continue
|
|
933
|
-
if future.exception() is not None:
|
|
934
|
-
continue
|
|
935
|
-
unit = pending.pop(future)
|
|
936
|
-
self.executed += 1
|
|
937
|
-
self._complete(unit, future.result())
|
|
938
|
-
|
|
939
|
-
def _execute(self, unit: Mapping[str, object]) -> dict[str, object]:
|
|
940
|
-
return _execute_unit(unit, self.root, self.include_e2e, self.only_e2e,
|
|
941
|
-
self.deadline, self.namespace)
|
|
942
|
-
|
|
943
|
-
def _placeholder_results(self, unit: Mapping[str, object]) -> list[dict[str, object]]:
|
|
944
|
-
return [
|
|
945
|
-
_run(item, self.root, self.include_e2e, self.deadline, self.only_e2e)
|
|
946
|
-
for item in unit["scenarios"]
|
|
947
|
-
]
|
|
948
|
-
|
|
949
|
-
def _record(self, unit: Mapping[str, object], results: Sequence[Mapping[str, object]]) -> None:
|
|
950
|
-
for result in results:
|
|
951
|
-
self.results_by_id[str(result.get("id"))] = dict(result)
|
|
952
|
-
self.completed.add(str(unit["id"]))
|
|
953
|
-
|
|
954
|
-
def _fill_unfinished(self) -> None:
|
|
955
|
-
reason = self.stop_reason or "incomplete"
|
|
956
|
-
outcome = {"status": "incomplete", "error": reason}
|
|
957
|
-
for unit in self.units:
|
|
958
|
-
if str(unit["id"]) in self.completed:
|
|
959
|
-
continue
|
|
960
|
-
results = _apply_outcome(unit, outcome, self.root, self.include_e2e,
|
|
961
|
-
self.only_e2e, self.deadline)
|
|
962
|
-
for result in results:
|
|
963
|
-
self.results_by_id.setdefault(str(result.get("id")), dict(result))
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
def _ordered_results(
|
|
967
|
-
items: Sequence[Mapping[str, object]], results_by_id: Mapping[str, Mapping[str, object]]
|
|
968
|
-
) -> list[dict[str, object]]:
|
|
969
|
-
"""Stable output order independent of parallel completion order."""
|
|
970
|
-
results: list[dict[str, object]] = []
|
|
971
|
-
for item in items:
|
|
972
|
-
scenario_id = str(item.get("id", "unknown"))
|
|
973
|
-
result = results_by_id.get(scenario_id)
|
|
974
|
-
if result is None:
|
|
975
|
-
result = {"id": scenario_id, "kind": str(item.get("kind", "functional")),
|
|
976
|
-
"status": "incomplete", "error": "not_scheduled"}
|
|
977
|
-
results.append(dict(result))
|
|
978
|
-
return results
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
def _check_summary(checks: Sequence[Mapping[str, object]], scenarios: Sequence[Mapping[str, object]],
|
|
982
|
-
results: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
|
|
983
|
-
"""Roll scenario results up to their executable checks (v3 manifests)."""
|
|
984
|
-
if not checks:
|
|
985
|
-
return []
|
|
986
|
-
by_id = {str(item.get("id")): item for item in results}
|
|
987
|
-
grouped: dict[str, list[str]] = {}
|
|
988
|
-
for scenario in scenarios:
|
|
989
|
-
scenario_id = str(scenario.get("id"))
|
|
990
|
-
for check_id in scenario_check_ids(scenario):
|
|
991
|
-
grouped.setdefault(check_id, []).append(scenario_id)
|
|
992
|
-
summary: list[dict[str, object]] = []
|
|
993
|
-
for check in checks:
|
|
994
|
-
check_id = str(check.get("id", ""))
|
|
995
|
-
scenario_ids = grouped.get(check_id, [])
|
|
996
|
-
statuses = [str(check_outcome(by_id.get(scenario_id, {}), check_id).get("status", "not_included"))
|
|
997
|
-
for scenario_id in scenario_ids]
|
|
998
|
-
if not statuses:
|
|
999
|
-
status = "not_included"
|
|
1000
|
-
elif all(value == "passed" for value in statuses):
|
|
1001
|
-
status = "passed"
|
|
1002
|
-
elif any(value in ("failed", "not_configured") for value in statuses):
|
|
1003
|
-
status = "failed"
|
|
1004
|
-
elif any(value == "incomplete" for value in statuses):
|
|
1005
|
-
status = "incomplete"
|
|
1006
|
-
elif any(value == "env_blocked" for value in statuses):
|
|
1007
|
-
status = "env_blocked"
|
|
1008
|
-
elif any(value == "accepted_risk" for value in statuses):
|
|
1009
|
-
status = "accepted_risk"
|
|
1010
|
-
elif any(value == "e2e_deferred" for value in statuses):
|
|
1011
|
-
status = "deferred"
|
|
1012
|
-
else:
|
|
1013
|
-
status = "included"
|
|
1014
|
-
summary.append({
|
|
1015
|
-
"id": check_id,
|
|
1016
|
-
"status": status,
|
|
1017
|
-
"scenarios": scenario_ids,
|
|
1018
|
-
"env_scope": env_scope(check),
|
|
1019
|
-
"parallel_safe": effective_parallel_safe(check),
|
|
1020
|
-
})
|
|
1021
|
-
return summary
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
def _check_plan(data: Mapping[str, object]) -> tuple[list[Mapping[str, object]], dict[str, Mapping[str, object]]]:
|
|
1025
|
-
"""Manifest v3 checks (schema2 manifests have none)."""
|
|
1026
|
-
value = data.get("checks")
|
|
1027
|
-
checks = value if isinstance(value, list) else []
|
|
1028
|
-
mapping = {
|
|
1029
|
-
str(check["id"]): check
|
|
1030
|
-
for check in checks
|
|
1031
|
-
if isinstance(check, Mapping) and "id" in check
|
|
1032
|
-
}
|
|
1033
|
-
return checks, mapping
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
def _check_input_fingerprints(
|
|
1037
|
-
root: str, check_map: Mapping[str, Mapping[str, object]], fallback: str
|
|
1038
|
-
) -> dict[str, dict[str, str]]:
|
|
1039
|
-
"""Per-check content identity; checks without inputs inherit repo scope."""
|
|
1040
|
-
outcomes = check_input_hashes(root, check_map, fallback)
|
|
1041
|
-
result: dict[str, dict[str, str]] = {}
|
|
1042
|
-
for check_id, check in check_map.items():
|
|
1043
|
-
outcome = outcomes.get(check_id, {})
|
|
1044
|
-
result[check_id] = {
|
|
1045
|
-
"input_hash": str(outcome.get("input_hash") or fallback),
|
|
1046
|
-
"scope": str(outcome.get("scope") or ("repo" if not check.get("inputs") else "empty")),
|
|
1047
|
-
}
|
|
1048
|
-
return result
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
def _scenario_fingerprints(
|
|
1052
|
-
data: Mapping[str, object], fingerprints: Mapping[str, str], fallback: str
|
|
1053
|
-
) -> dict[str, str]:
|
|
1054
|
-
result: dict[str, str] = {}
|
|
1055
|
-
for scenario in data.get("scenarios", []) if isinstance(data.get("scenarios"), list) else []:
|
|
1056
|
-
if not isinstance(scenario, Mapping):
|
|
1057
|
-
continue
|
|
1058
|
-
scenario_id = scenario.get("id")
|
|
1059
|
-
if not isinstance(scenario_id, str):
|
|
1060
|
-
continue
|
|
1061
|
-
raw = scenario.get("checks")
|
|
1062
|
-
check_ids = [str(value) for value in raw] if isinstance(raw, list) else []
|
|
1063
|
-
if not check_ids and isinstance(scenario.get("check"), str):
|
|
1064
|
-
check_ids = [str(scenario.get("check"))]
|
|
1065
|
-
values = [(check_id, fingerprints.get(check_id, fallback)) for check_id in check_ids]
|
|
1066
|
-
if not values:
|
|
1067
|
-
result[scenario_id] = fallback
|
|
1068
|
-
elif len(values) == 1:
|
|
1069
|
-
result[scenario_id] = values[0][1] or fallback
|
|
1070
|
-
else:
|
|
1071
|
-
payload = "|".join(f"{key}={value}" for key, value in sorted(values))
|
|
1072
|
-
result[scenario_id] = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
1073
|
-
return result
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
def _scenario_check_ids(scenario: Mapping[str, object]) -> list[str]:
|
|
1077
|
-
return scenario_check_ids(scenario)
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
def _run_namespace(
|
|
1081
|
-
root: str, check_map: Mapping[str, Mapping[str, object]], owner: str,
|
|
1082
|
-
items: Sequence[Mapping[str, object]], run_key: str,
|
|
1083
|
-
) -> Optional[dict[str, str]]:
|
|
1084
|
-
"""Namespace is only materialized when at least one check is isolated."""
|
|
1085
|
-
if not any(is_isolated(check) for check in check_map.values()):
|
|
1086
|
-
return None
|
|
1087
|
-
return worktree_namespace(str(root), task_id=_task_identity(owner, items), run_id=run_key)
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
def _task_identity(owner: str, items: Sequence[Mapping[str, object]]) -> str:
|
|
1091
|
-
"""Owner wins; a single-owner manifest still exposes CF_TASK_ID."""
|
|
1092
|
-
if owner:
|
|
1093
|
-
return owner
|
|
1094
|
-
owners = {
|
|
1095
|
-
str(item.get("owner")) for item in items
|
|
1096
|
-
if isinstance(item.get("owner"), str) and item.get("owner")
|
|
1097
|
-
}
|
|
1098
|
-
return owners.pop() if len(owners) == 1 else ""
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
def _decision(results: Sequence[Mapping[str, object]]) -> tuple[str, str]:
|
|
1102
|
-
allowed = ("passed", "manual_pending", "e2e_deferred", "not_included", "accepted_risk")
|
|
1103
|
-
if all(item["status"] in allowed for item in results):
|
|
1104
|
-
return "pass", ""
|
|
1105
|
-
pending = sorted({str(item["id"]) for item in results if item["status"] == "incomplete"})
|
|
1106
|
-
env_blocked = sorted({
|
|
1107
|
-
str(item.get("check_id") or item["id"]) for item in results if item["status"] == "env_blocked"
|
|
1108
|
-
})
|
|
1109
|
-
failed = sorted({
|
|
1110
|
-
str(item["id"]) for item in results
|
|
1111
|
-
if item["status"] not in (*allowed, "incomplete", "env_blocked")
|
|
1112
|
-
})
|
|
1113
|
-
segments = (
|
|
1114
|
-
["incomplete:" + ",".join(pending)] if pending else []
|
|
1115
|
-
) + (
|
|
1116
|
-
["env_blocked:" + ",".join(env_blocked)] if env_blocked else []
|
|
1117
|
-
) + (
|
|
1118
|
-
["failed:" + ",".join(failed)] if failed else []
|
|
1119
|
-
)
|
|
1120
|
-
return "block", ",".join(segments) or "acceptance scenario failed"
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
def _run_counts(results: Sequence[Mapping[str, object]]) -> tuple[int, list[str]]:
|
|
1124
|
-
incomplete = sum(1 for item in results if item["status"] == "incomplete")
|
|
1125
|
-
failed = sorted(str(item["id"]) for item in results if item["status"] in ("failed", "not_configured"))
|
|
1126
|
-
return incomplete, failed
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
def _payload(
|
|
1130
|
-
items: Sequence[Mapping[str, object]], check_list: Sequence[Mapping[str, object]],
|
|
1131
|
-
check_map: Mapping[str, Mapping[str, object]], results: Sequence[Mapping[str, object]],
|
|
1132
|
-
state: Mapping[str, object],
|
|
1133
|
-
) -> dict[str, object]:
|
|
1134
|
-
decision, error = _decision(results)
|
|
1135
|
-
if state.get("interrupted") and decision == "pass":
|
|
1136
|
-
decision, error = "block", "interrupted"
|
|
1137
|
-
incomplete, failed = _run_counts(results)
|
|
1138
|
-
outcome: dict[str, object] = {
|
|
1139
|
-
"decision": decision,
|
|
1140
|
-
"results": list(results),
|
|
1141
|
-
"executed": int(state.get("executed", 0)),
|
|
1142
|
-
"reused": int(state.get("reused", 0)),
|
|
1143
|
-
"incomplete": incomplete,
|
|
1144
|
-
"failed": failed,
|
|
1145
|
-
}
|
|
1146
|
-
if state.get("interrupted"):
|
|
1147
|
-
outcome["interrupted"] = True
|
|
1148
|
-
if state.get("stopped_reason"):
|
|
1149
|
-
outcome["stopped_reason"] = str(state["stopped_reason"])
|
|
1150
|
-
if check_list:
|
|
1151
|
-
outcome["checks"] = _check_summary(check_list, items, results)
|
|
1152
|
-
env_blocked = sum(1 for item in results if item["status"] == "env_blocked")
|
|
1153
|
-
outcome["env_blocked"] = env_blocked
|
|
1154
|
-
accepted = [item for item in results if item["status"] == "accepted_risk"]
|
|
1155
|
-
if accepted:
|
|
1156
|
-
outcome["accepted_risk"] = len(accepted)
|
|
1157
|
-
outcome["accepted_risk_details"] = [
|
|
1158
|
-
{
|
|
1159
|
-
"scenario": item.get("id"),
|
|
1160
|
-
"check_id": item.get("check_id") or item.get("id"),
|
|
1161
|
-
"risk": item.get("risk"),
|
|
1162
|
-
}
|
|
1163
|
-
for item in accepted
|
|
1164
|
-
]
|
|
1165
|
-
if env_blocked:
|
|
1166
|
-
outcome["env_blocked_details"] = _env_blocked_details(check_map, results)
|
|
1167
|
-
if error:
|
|
1168
|
-
outcome["error"] = error
|
|
1169
|
-
return outcome
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
def _scenario_rows(scenarios: Sequence[Mapping[str, object]]) -> dict[str, Mapping[str, object]]:
|
|
1173
|
-
return {
|
|
1174
|
-
str(row.get("id")): row
|
|
1175
|
-
for row in scenarios
|
|
1176
|
-
if isinstance(row, Mapping) and isinstance(row.get("id"), str)
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
def _make_checkpoint(
|
|
1181
|
-
manifest_file: str, root: str, fingerprint: str, use_cache: bool, resume: bool,
|
|
1182
|
-
fingerprints: Optional[Mapping[str, str]] = None,
|
|
1183
|
-
) -> _Checkpoint:
|
|
1184
|
-
return _Checkpoint(
|
|
1185
|
-
_checkpoint_path(manifest_file, root), fingerprint,
|
|
1186
|
-
enabled=use_cache and bool(fingerprint), resume=resume,
|
|
1187
|
-
fingerprints=fingerprints,
|
|
1188
|
-
)
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
def _expand_multi_checks(items: Sequence[Mapping[str, object]]) -> tuple[list[dict[str, object]], dict[str, list[str]]]:
|
|
1192
|
-
"""Expand scenarios with a checks list into one pseudo-scenario per check."""
|
|
1193
|
-
expanded: list[dict[str, object]] = []
|
|
1194
|
-
aggregates: dict[str, list[str]] = {}
|
|
1195
|
-
for item in items:
|
|
1196
|
-
checks = item.get("checks")
|
|
1197
|
-
if not isinstance(checks, list) or not checks:
|
|
1198
|
-
expanded.append(dict(item))
|
|
1199
|
-
continue
|
|
1200
|
-
scenario_id = str(item.get("id"))
|
|
1201
|
-
ids: list[str] = []
|
|
1202
|
-
for check_id in checks:
|
|
1203
|
-
pseudo = dict(item)
|
|
1204
|
-
pseudo["id"] = f"{scenario_id}::{check_id}"
|
|
1205
|
-
pseudo["scenario_ref"] = scenario_id
|
|
1206
|
-
pseudo["check"] = str(check_id)
|
|
1207
|
-
pseudo.pop("checks", None)
|
|
1208
|
-
expanded.append(pseudo)
|
|
1209
|
-
ids.append(pseudo["id"])
|
|
1210
|
-
aggregates[scenario_id] = ids
|
|
1211
|
-
return expanded, aggregates
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
def _multi_status(states: Sequence[str]) -> str:
|
|
1215
|
-
if states and all(state in ("passed", "verified") for state in states):
|
|
1216
|
-
return "passed"
|
|
1217
|
-
if any(state in ("failed", "not_configured") for state in states):
|
|
1218
|
-
return "failed"
|
|
1219
|
-
return next((state for state in ("env_blocked", "incomplete", "accepted_risk", "e2e_deferred")
|
|
1220
|
-
if state in states), "not_included")
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
def _multi_check_metadata(members: Sequence[Mapping[str, object]]) -> dict[str, object]:
|
|
1224
|
-
checks: list[str] = []
|
|
1225
|
-
hashes: dict[str, str] = {}
|
|
1226
|
-
reused: dict[str, bool] = {}
|
|
1227
|
-
checkpointed: dict[str, bool] = {}
|
|
1228
|
-
configs: dict[str, object] = {}
|
|
1229
|
-
outcomes: dict[str, object] = {}
|
|
1230
|
-
for member in members:
|
|
1231
|
-
check_id = str(member.get("check_id") or "")
|
|
1232
|
-
if not check_id and "::" in str(member.get("id")):
|
|
1233
|
-
check_id = str(member.get("id")).split("::", 1)[1]
|
|
1234
|
-
if not check_id:
|
|
1235
|
-
continue
|
|
1236
|
-
checks.append(check_id)
|
|
1237
|
-
reused[check_id] = member.get("cache_reused") is True
|
|
1238
|
-
checkpointed[check_id] = member.get("checkpoint_reused") is True
|
|
1239
|
-
outcomes[check_id] = {key: member[key] for key in (
|
|
1240
|
-
"status", "exit_code", "risk", "error", "output", "missing", "owner", "detail", "environment_hash",
|
|
1241
|
-
) if key in member}
|
|
1242
|
-
if isinstance(member.get("check_config"), Mapping):
|
|
1243
|
-
configs[check_id] = dict(member["check_config"])
|
|
1244
|
-
value = member.get("input_hash")
|
|
1245
|
-
if isinstance(value, str) and value:
|
|
1246
|
-
hashes[check_id] = value
|
|
1247
|
-
record: dict[str, object] = {}
|
|
1248
|
-
if checks:
|
|
1249
|
-
record.update(check_ids=checks, check_reused=reused, check_results=outcomes,
|
|
1250
|
-
checkpoint_reused_checks=checkpointed)
|
|
1251
|
-
if configs:
|
|
1252
|
-
record["check_configs"] = configs
|
|
1253
|
-
if hashes:
|
|
1254
|
-
record["check_hashes"] = hashes
|
|
1255
|
-
payload = "|".join(f"{key}={hashes[key]}" for key in sorted(hashes))
|
|
1256
|
-
record["input_hash"] = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
1257
|
-
return record
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
def _collapse_multi_result(scenario_id: str, members: Sequence[Mapping[str, object]],
|
|
1261
|
-
expected: int) -> dict[str, object]:
|
|
1262
|
-
states = [str(member.get("status")) for member in members]
|
|
1263
|
-
status = _multi_status(states) if len(members) == expected else "failed"
|
|
1264
|
-
record = {"id": scenario_id, "status": status, **_multi_check_metadata(members)}
|
|
1265
|
-
record["cache_reused"] = all(member.get("cache_reused") is True for member in members)
|
|
1266
|
-
record["checkpoint_reused"] = all(member.get("checkpoint_reused") is True for member in members)
|
|
1267
|
-
if status == "accepted_risk":
|
|
1268
|
-
risky = next((member for member in members if isinstance(member.get("risk"), Mapping)), None)
|
|
1269
|
-
if risky is not None:
|
|
1270
|
-
record["risk"] = dict(risky["risk"])
|
|
1271
|
-
if risky.get("check_id"):
|
|
1272
|
-
record["check_id"] = str(risky["check_id"])
|
|
1273
|
-
if status == "passed":
|
|
1274
|
-
record["exit_code"] = 0
|
|
1275
|
-
else:
|
|
1276
|
-
record["error"] = "checks_missing" if len(members) != expected else ",".join(states)
|
|
1277
|
-
return record
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
def _aggregate_multi_results(results: Sequence[Mapping[str, object]],
|
|
1281
|
-
aggregates: Mapping[str, Sequence[str]]) -> list[dict[str, object]]:
|
|
1282
|
-
"""Keep individual outcomes while deriving the scenario's AND verdict."""
|
|
1283
|
-
if not aggregates:
|
|
1284
|
-
return [dict(item) for item in results]
|
|
1285
|
-
by_id = {str(item.get("id")): item for item in results}
|
|
1286
|
-
pseudo_to_scenario = {
|
|
1287
|
-
pseudo: scenario_id for scenario_id, pseudos in aggregates.items() for pseudo in pseudos
|
|
1288
|
-
}
|
|
1289
|
-
collapsed: list[dict[str, object]] = []
|
|
1290
|
-
emitted: set[str] = set()
|
|
1291
|
-
for result in results:
|
|
1292
|
-
result_id = str(result.get("id"))
|
|
1293
|
-
scenario_id = pseudo_to_scenario.get(result_id)
|
|
1294
|
-
if scenario_id is None:
|
|
1295
|
-
collapsed.append(dict(result))
|
|
1296
|
-
continue
|
|
1297
|
-
if scenario_id in emitted:
|
|
1298
|
-
continue
|
|
1299
|
-
emitted.add(scenario_id)
|
|
1300
|
-
members = [by_id[pseudo] for pseudo in aggregates[scenario_id] if pseudo in by_id]
|
|
1301
|
-
if members:
|
|
1302
|
-
collapsed.append(_collapse_multi_result(scenario_id, members, len(aggregates[scenario_id])))
|
|
1303
|
-
else:
|
|
1304
|
-
collapsed.append({"id": scenario_id, "status": "failed", "error": "checks_missing"})
|
|
1305
|
-
for scenario_id in aggregates:
|
|
1306
|
-
if scenario_id not in emitted:
|
|
1307
|
-
collapsed.append({"id": scenario_id, "status": "failed", "error": "checks_missing"})
|
|
1308
|
-
return collapsed
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
def _prepare_manifest(manifest_file: str, root: str, profile: str, force_ids: Sequence[str], owner: str,
|
|
1312
|
-
only_scenarios: Optional[Sequence[str]] = None
|
|
1313
|
-
) -> tuple[dict[str, object], dict[str, object], list[Mapping[str, object]], Optional[dict[str, object]]]:
|
|
1314
|
-
if profile not in ("smoke", "changed", "full"):
|
|
1315
|
-
raise ValueError(f"invalid verify profile: {profile}")
|
|
1316
|
-
full_data = load_manifest(Path(manifest_file))
|
|
1317
|
-
data = _select_profile(full_data, root, profile, force_ids)
|
|
1318
|
-
validate_execution_baseline(Path(manifest_file), full_data)
|
|
1319
|
-
_ordered(data["scenarios"]) # Validate the complete DAG before owner filtering.
|
|
1320
|
-
if only_scenarios is not None:
|
|
1321
|
-
allowed = set(only_scenarios)
|
|
1322
|
-
data["scenarios"] = [item for item in data["scenarios"] if item.get("id") in allowed]
|
|
1323
|
-
items = _owned(data["scenarios"], owner)
|
|
1324
|
-
noop = None
|
|
1325
|
-
if not items:
|
|
1326
|
-
if owner:
|
|
1327
|
-
noop = {"decision": "pass", "results": [], "reason": "no_owned_scenarios"}
|
|
1328
|
-
elif profile != "full" and full_data.get("scenarios"):
|
|
1329
|
-
noop = {"decision": "pass", "results": [], "reason": "no_affected_scenarios",
|
|
1330
|
-
"profile": profile}
|
|
1331
|
-
return full_data, data, items, noop
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
def _previous_check_hashes(data: Mapping[str, object]) -> dict[str, set[str]]:
|
|
1335
|
-
result: dict[str, set[str]] = {}
|
|
1336
|
-
for row in data.get("scenarios", []):
|
|
1337
|
-
if not isinstance(row, Mapping):
|
|
1338
|
-
continue
|
|
1339
|
-
for check_id in scenario_check_ids(row):
|
|
1340
|
-
value = recorded_check_hash(row, check_id)
|
|
1341
|
-
if value:
|
|
1342
|
-
result.setdefault(check_id, set()).add(value)
|
|
1343
|
-
return result
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
def _input_change_categories(checks: Sequence[str], fingerprints: Mapping[str, Mapping[str, str]],
|
|
1347
|
-
previous: Mapping[str, set[str]]) -> dict[str, str]:
|
|
1348
|
-
result = {}
|
|
1349
|
-
for check_id in checks:
|
|
1350
|
-
current = fingerprints.get(check_id, {}).get("input_hash", "")
|
|
1351
|
-
old = previous.get(check_id, set())
|
|
1352
|
-
result[check_id] = ("unproven" if not current else "no_baseline" if not old else
|
|
1353
|
-
"unchanged" if old == {current} else "inputs_changed")
|
|
1354
|
-
return result
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
def _add_run_reporting(outcome: dict[str, object], state: Mapping[str, object],
|
|
1358
|
-
fingerprints: Mapping[str, Mapping[str, str]], data: Mapping[str, object],
|
|
1359
|
-
started: float, planning_ms: float, preflight_ms: float,
|
|
1360
|
-
evidence_ms: float, profile: str, owner: str,
|
|
1361
|
-
previous: Mapping[str, set[str]]) -> None:
|
|
1362
|
-
scope = {"profile": profile, "owner": owner}
|
|
1363
|
-
if data.get("missing_input_patterns"):
|
|
1364
|
-
scope["missing_input_patterns"] = data["missing_input_patterns"]
|
|
1365
|
-
outcome.update({"selection_reason": data.get("selection_reason", "profile_and_owner"), "scope": scope,
|
|
1366
|
-
"reuse_reason": "matching_evidence" if state.get("reused") else "no_reused_results",
|
|
1367
|
-
"summary": f"acceptance: executed {int(state.get('executed', 0))}, reused {int(state.get('reused', 0))}",
|
|
1368
|
-
"execution_identities": list(state.get("execution_identities", [])),
|
|
1369
|
-
"selected_check_ids": sorted(str(check["id"]) for check in outcome.get("checks", [])
|
|
1370
|
-
if check.get("status") != "not_included"),
|
|
1371
|
-
"timings": {"planning_ms": planning_ms, "preflight_ms": preflight_ms,
|
|
1372
|
-
"execution_ms": float(state.get("execution_ms", 0)),
|
|
1373
|
-
"evidence_ms": evidence_ms, "elapsed_ms": (time.monotonic() - started) * 1000}})
|
|
1374
|
-
outcome["input_change_category"] = _input_change_categories(outcome["selected_check_ids"], fingerprints, previous)
|
|
1375
|
-
for check in outcome.get("checks", []):
|
|
1376
|
-
identity = fingerprints.get(str(check.get("id")))
|
|
1377
|
-
if identity:
|
|
1378
|
-
check["input_hash"] = identity["input_hash"]
|
|
1379
|
-
check["input_scope"] = identity["scope"]
|
|
1380
|
-
if data.get("schema") == 2:
|
|
1381
|
-
outcome["schema2_compat"] = True
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
def _finalize_manifest(outcome: dict[str, object], state: Mapping[str, object],
|
|
1385
|
-
items: Sequence[Mapping[str, object]], results: list[dict[str, object]]) -> dict[str, object]:
|
|
1386
|
-
if state.get("interrupted"):
|
|
1387
|
-
raise AcceptanceInterrupted(outcome)
|
|
1388
|
-
if not _executable(items):
|
|
1389
|
-
return {"decision": "block", "results": results, "error": "no_executable_scenarios"}
|
|
1390
|
-
record_report({"kind": "acceptance", **{key: outcome[key] for key in
|
|
1391
|
-
("selection_reason", "scope", "reuse_reason", "timings", "selected_check_ids", "execution_identities", "input_change_category",
|
|
1392
|
-
"executed", "reused") if key in outcome}})
|
|
1393
|
-
return outcome
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
def run_manifest(manifest_file: str, root: str, write_evidence: bool = False,
|
|
1397
|
-
include_e2e: bool = False, owner: str = "", deadline: Optional[float] = None,
|
|
1398
|
-
only_e2e: bool = False, use_cache: bool = True, run_id: str = "", resume: bool = False,
|
|
1399
|
-
max_failures: int = 0, fail_fast: bool = False, jobs: int = 0,
|
|
1400
|
-
profile: str = "full", force_ids: Sequence[str] = (),
|
|
1401
|
-
only_scenarios: Optional[Sequence[str]] = None) -> dict[str, object]:
|
|
1402
|
-
started = time.monotonic()
|
|
1403
|
-
full_data, data, items, noop = _prepare_manifest(manifest_file, root, profile, force_ids, owner, only_scenarios)
|
|
1404
|
-
if noop is not None:
|
|
1405
|
-
return noop
|
|
1406
|
-
ordered = _ordered(items)
|
|
1407
|
-
expanded, aggregates = _expand_multi_checks(ordered)
|
|
1408
|
-
rows = _scenario_rows(data["scenarios"])
|
|
1409
|
-
previous = _previous_check_hashes(data)
|
|
1410
|
-
check_list, check_map = _check_plan(data)
|
|
1411
|
-
from codeflow.acceptance.cf_acceptance_manifest_core import kind
|
|
1412
|
-
for item in expanded:
|
|
1413
|
-
check = check_map.get(str(item.get("check", "")), {})
|
|
1414
|
-
level = check.get("level")
|
|
1415
|
-
if isinstance(level, str):
|
|
1416
|
-
item["level"], item["kind"] = level, kind(level)
|
|
1417
|
-
for item in items:
|
|
1418
|
-
if isinstance(item, dict):
|
|
1419
|
-
item["check_config"] = check_map.get(str(item.get("check", "")), {})
|
|
1420
|
-
run_key = resolve_run_id(run_id)
|
|
1421
|
-
namespace = _run_namespace(str(root), check_map, owner, items, run_key)
|
|
1422
|
-
preflight_started = time.monotonic()
|
|
1423
|
-
planning_ms = (preflight_started - started) * 1000
|
|
1424
|
-
fingerprint = worktree_fingerprint(str(root)) if use_cache else ""
|
|
1425
|
-
check_fingerprints = _check_input_fingerprints(str(root), check_map, fingerprint) if use_cache else {}
|
|
1426
|
-
fingerprints = {check_id: value["input_hash"] for check_id, value in check_fingerprints.items()}
|
|
1427
|
-
scenario_fingerprints = _scenario_fingerprints(data, fingerprints, fingerprint)
|
|
1428
|
-
engine_fingerprints = {**fingerprints, **scenario_fingerprints}
|
|
1429
|
-
limit = 1 if fail_fast else max(0, int(max_failures))
|
|
1430
|
-
checkpoint = _make_checkpoint(manifest_file, root, fingerprint, use_cache, resume, fingerprints)
|
|
1431
|
-
units, _ = _build_units(expanded, Path(root), check_map, include_e2e or only_e2e, only_e2e)
|
|
1432
|
-
engine = _UnitEngine(units, Path(root), include_e2e or only_e2e, only_e2e, deadline,
|
|
1433
|
-
namespace, rows, fingerprint, use_cache, limit, checkpoint, jobs,
|
|
1434
|
-
fingerprints=engine_fingerprints)
|
|
1435
|
-
preflight_ms = (time.monotonic() - preflight_started) * 1000
|
|
1436
|
-
results_by_id, state = engine.run()
|
|
1437
|
-
results = _ordered_results(expanded if aggregates else ordered, results_by_id)
|
|
1438
|
-
results = _aggregate_multi_results(results, aggregates)
|
|
1439
|
-
for result, item in zip(results, ordered):
|
|
1440
|
-
if isinstance(result, dict) and isinstance(item, dict) and item.get("check_config"):
|
|
1441
|
-
result["check_config"] = item.get("check_config")
|
|
1442
|
-
evidence_started = time.monotonic()
|
|
1443
|
-
if not state.get("interrupted") and (write_evidence or resume):
|
|
1444
|
-
persist_results(Path(manifest_file), full_data, results, fingerprint=fingerprint, run_id=run_key)
|
|
1445
|
-
evidence_ms = (time.monotonic() - evidence_started) * 1000
|
|
1446
|
-
outcome = _payload(items, check_list, check_map, results, state)
|
|
1447
|
-
_add_run_reporting(outcome, state, check_fingerprints, data,
|
|
1448
|
-
started, planning_ms, preflight_ms, evidence_ms, profile, owner, previous)
|
|
1449
|
-
return _finalize_manifest(outcome, state, items, results)
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
1453
|
-
parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND") or None)
|
|
1454
|
-
parser.add_argument("--manifest", required=True)
|
|
1455
|
-
parser.add_argument("--root", required=True)
|
|
1456
|
-
parser.add_argument("--write-evidence", action="store_true")
|
|
1457
|
-
parser.add_argument("--include-e2e", action="store_true", help="Execute E2E scenarios (deferred by default)")
|
|
1458
|
-
parser.add_argument("--only-e2e", action="store_true", help="Execute only E2E scenarios; functional/manual are reported not_included")
|
|
1459
|
-
parser.add_argument("--owner", default="", help="Only execute scenarios owned by this TASK (plus ownerless legacy ones)")
|
|
1460
|
-
parser.add_argument("--deadline", type=float, default=0.0, help="Absolute monotonic deadline propagated from the entry gate (0 = unbounded)")
|
|
1461
|
-
parser.add_argument("--no-cache", action="store_true", help="Force execution; skip content-fingerprint reuse of already-verified scenarios")
|
|
1462
|
-
parser.add_argument("--resume", action="store_true", help="Continue from the first unfinished/failed check using persisted checkpoints")
|
|
1463
|
-
parser.add_argument("--max-failures", type=int, default=0, help="Stop scheduling after N failed checks (0 = run all)")
|
|
1464
|
-
failure_mode = parser.add_mutually_exclusive_group()
|
|
1465
|
-
failure_mode.add_argument("--fail-fast", dest="fail_fast", action="store_true", help="Stop after the first failed check")
|
|
1466
|
-
failure_mode.add_argument("--continue", dest="fail_fast", action="store_false", help="Keep running after failures (default)")
|
|
1467
|
-
parser.set_defaults(fail_fast=False)
|
|
1468
|
-
parser.add_argument("--jobs", type=int, default=0, help="Maximum concurrent parallel-safe checks (0 = auto)")
|
|
1469
|
-
args = parser.parse_args(argv)
|
|
1470
|
-
try:
|
|
1471
|
-
result = run_manifest(
|
|
1472
|
-
args.manifest, args.root, args.write_evidence, args.include_e2e, args.owner,
|
|
1473
|
-
args.deadline or None, args.only_e2e, use_cache=not args.no_cache,
|
|
1474
|
-
resume=args.resume, max_failures=args.max_failures, fail_fast=args.fail_fast,
|
|
1475
|
-
jobs=args.jobs,
|
|
1476
|
-
)
|
|
1477
|
-
stdout.write(json.dumps(redact_sensitive_payload(result), ensure_ascii=False))
|
|
1478
|
-
if result.get("interrupted"):
|
|
1479
|
-
return 130
|
|
1480
|
-
return 0 if result["decision"] == "pass" else 3
|
|
1481
|
-
except AcceptanceInterrupted as exc:
|
|
1482
|
-
stdout.write(json.dumps(redact_sensitive_payload(exc.payload), ensure_ascii=False))
|
|
1483
|
-
return 130
|
|
1484
|
-
except KeyboardInterrupt:
|
|
1485
|
-
stdout.write(json.dumps({"decision": "block", "error": "interrupted", "interrupted": True},
|
|
1486
|
-
ensure_ascii=False))
|
|
1487
|
-
return 130
|
|
1488
|
-
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1489
|
-
stdout.write(json.dumps(redact_sensitive_payload({"decision": "block", "error": str(exc)}), ensure_ascii=False))
|
|
1490
|
-
return 2
|