@guanlin4924/devflow-pi 0.1.94 → 0.1.96
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 +34 -4
- package/node_modules/@playwright/test/LICENSE +202 -0
- package/node_modules/@playwright/test/NOTICE +5 -0
- package/node_modules/@playwright/test/README.md +318 -0
- package/node_modules/@playwright/test/cli.js +19 -0
- package/node_modules/@playwright/test/index.d.ts +18 -0
- package/node_modules/@playwright/test/index.js +17 -0
- package/node_modules/@playwright/test/index.mjs +18 -0
- package/node_modules/@playwright/test/package.json +35 -0
- package/node_modules/@playwright/test/reporter.d.ts +17 -0
- package/node_modules/@playwright/test/reporter.js +17 -0
- package/node_modules/@playwright/test/reporter.mjs +17 -0
- package/node_modules/playwright/LICENSE +202 -0
- package/node_modules/playwright/NOTICE +5 -0
- package/node_modules/playwright/README.md +318 -0
- package/node_modules/playwright/ThirdPartyNotices.txt +14 -0
- package/node_modules/playwright/cli.js +19 -0
- package/node_modules/playwright/index.d.ts +17 -0
- package/node_modules/playwright/index.js +17 -0
- package/node_modules/playwright/index.mjs +18 -0
- package/node_modules/playwright/jsx-runtime.js +42 -0
- package/node_modules/playwright/jsx-runtime.mjs +21 -0
- package/node_modules/playwright/lib/agents/agentParser.js +89 -0
- package/node_modules/playwright/lib/agents/copilot-setup-steps.yml +34 -0
- package/node_modules/playwright/lib/agents/generateAgents.js +395 -0
- package/node_modules/playwright/lib/agents/playwright-test-coverage.prompt.md +31 -0
- package/node_modules/playwright/lib/agents/playwright-test-generate.prompt.md +8 -0
- package/node_modules/playwright/lib/agents/playwright-test-generator.agent.md +88 -0
- package/node_modules/playwright/lib/agents/playwright-test-heal.prompt.md +6 -0
- package/node_modules/playwright/lib/agents/playwright-test-healer.agent.md +56 -0
- package/node_modules/playwright/lib/agents/playwright-test-plan.prompt.md +9 -0
- package/node_modules/playwright/lib/agents/playwright-test-planner.agent.md +74 -0
- package/node_modules/playwright/lib/cli/reportActions.js +78 -0
- package/node_modules/playwright/lib/cli/testActions.js +214 -0
- package/node_modules/playwright/lib/common/index.js +2923 -0
- package/node_modules/playwright/lib/common/index.js.txt +34 -0
- package/node_modules/playwright/lib/errorContext.js +129 -0
- package/node_modules/playwright/lib/globals.js +58 -0
- package/node_modules/playwright/lib/index.js +815 -0
- package/node_modules/playwright/lib/isomorphic.js +260 -0
- package/node_modules/playwright/lib/isomorphic.js.txt +9 -0
- package/node_modules/playwright/lib/loader/loaderProcessEntry.js +34 -0
- package/node_modules/playwright/lib/loader/loaderProcessEntry.js.txt +9 -0
- package/node_modules/playwright/lib/matchers/expect.js +13490 -0
- package/node_modules/playwright/lib/matchers/expect.js.LICENSE +640 -0
- package/node_modules/playwright/lib/matchers/expect.js.txt +67 -0
- package/node_modules/playwright/lib/mcp/test/browserBackend.js +135 -0
- package/node_modules/playwright/lib/mcp/test/generatorTools.js +122 -0
- package/node_modules/playwright/lib/mcp/test/plannerTools.js +150 -0
- package/node_modules/playwright/lib/mcp/test/seed.js +82 -0
- package/node_modules/playwright/lib/mcp/test/streams.js +44 -0
- package/node_modules/playwright/lib/mcp/test/testBackend.js +99 -0
- package/node_modules/playwright/lib/mcp/test/testContext.js +306 -0
- package/node_modules/playwright/lib/mcp/test/testTool.js +30 -0
- package/node_modules/playwright/lib/mcp/test/testTools.js +98 -0
- package/node_modules/playwright/lib/package.js +47 -0
- package/node_modules/playwright/lib/program.js +241 -0
- package/node_modules/playwright/lib/runner/index.js +8527 -0
- package/node_modules/playwright/lib/runner/index.js.txt +61 -0
- package/node_modules/playwright/lib/transform/babelBundle.js +72274 -0
- package/node_modules/playwright/lib/transform/babelBundle.js.LICENSE +2384 -0
- package/node_modules/playwright/lib/transform/babelBundle.js.txt +325 -0
- package/node_modules/playwright/lib/transform/esmLoader.js +8054 -0
- package/node_modules/playwright/lib/transform/esmLoader.js.LICENSE +355 -0
- package/node_modules/playwright/lib/transform/esmLoader.js.txt +60 -0
- package/node_modules/playwright/lib/util.js +373 -0
- package/node_modules/playwright/lib/worker/workerProcessEntry.js +1861 -0
- package/node_modules/playwright/lib/worker/workerProcessEntry.js.txt +19 -0
- package/node_modules/playwright/package.json +55 -0
- package/node_modules/playwright/test.d.ts +18 -0
- package/node_modules/playwright/test.js +24 -0
- package/node_modules/playwright/test.mjs +35 -0
- package/node_modules/playwright/types/test.d.ts +11074 -0
- package/node_modules/playwright/types/testReporter.d.ts +950 -0
- package/node_modules/playwright-core/LICENSE +202 -0
- package/node_modules/playwright-core/NOTICE +5 -0
- package/node_modules/playwright-core/README.md +3 -0
- package/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
- package/node_modules/playwright-core/bin/install_media_pack.ps1 +5 -0
- package/node_modules/playwright-core/bin/install_webkit_wsl.ps1 +33 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh +42 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh +13 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1 +24 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh +42 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh +12 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1 +24 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1 +23 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1 +23 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1 +24 -0
- package/node_modules/playwright-core/browsers.json +52 -0
- package/node_modules/playwright-core/cli.js +21 -0
- package/node_modules/playwright-core/index.d.ts +17 -0
- package/node_modules/playwright-core/index.js +17 -0
- package/node_modules/playwright-core/index.mjs +28 -0
- package/node_modules/playwright-core/lib/bootstrap.js +88 -0
- package/node_modules/playwright-core/lib/coreBundle.js +75813 -0
- package/node_modules/playwright-core/lib/entry/cliDaemon.js +5 -0
- package/node_modules/playwright-core/lib/entry/dashboardApp.js +3 -0
- package/node_modules/playwright-core/lib/entry/mcp.js +10 -0
- package/node_modules/playwright-core/lib/entry/oopBrowserDownload.js +3 -0
- package/node_modules/playwright-core/lib/package.js +50 -0
- package/node_modules/playwright-core/lib/server/chromium/appIcon.png +0 -0
- package/node_modules/playwright-core/lib/server/electron/loader.js +116 -0
- package/node_modules/playwright-core/lib/serverRegistry.js +1939 -0
- package/node_modules/playwright-core/lib/serverRegistry.js.LICENSE +65 -0
- package/node_modules/playwright-core/lib/tools/cli-client/channelSessions.js +141 -0
- package/node_modules/playwright-core/lib/tools/cli-client/cli.js +6 -0
- package/node_modules/playwright-core/lib/tools/cli-client/help.json +721 -0
- package/node_modules/playwright-core/lib/tools/cli-client/minimist.js +128 -0
- package/node_modules/playwright-core/lib/tools/cli-client/output.js +351 -0
- package/node_modules/playwright-core/lib/tools/cli-client/program.js +418 -0
- package/node_modules/playwright-core/lib/tools/cli-client/registry.js +180 -0
- package/node_modules/playwright-core/lib/tools/cli-client/session.js +259 -0
- package/node_modules/playwright-core/lib/tools/dashboard/appIcon.png +0 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/SKILL.md +425 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/element-attributes.md +23 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/playwright-tests.md +39 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/request-mocking.md +87 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/running-code.md +241 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/session-management.md +225 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/storage-state.md +275 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/test-generation.md +433 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/tracing.md +139 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/video-recording.md +143 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md +136 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md +144 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md +89 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md +69 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/typing.md +173 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md +77 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-trace/SKILL.md +174 -0
- package/node_modules/playwright-core/lib/tools/utils/extension.js +112 -0
- package/node_modules/playwright-core/lib/tools/utils/socketConnection.js +108 -0
- package/node_modules/playwright-core/lib/utilsBundle.js +85357 -0
- package/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +1890 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule--QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule-CQ8RZGBm.js +32 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codicon-DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-1bWoP6pv.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-beta-k3eOH_eK.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-nightly-Cp5nfeDT.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/index-AwgwcYie.css +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/index-hRvO4yU2.js +12 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/safari-na3_-uQk.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/index.html +29 -0
- package/node_modules/playwright-core/lib/vite/dashboard/playwright-logo.svg +24 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/index.html +16 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/report.css +2 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/report.js +32 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CVQWJZAA.js +32 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codicon-DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/index-BZpYJZ-P.css +1 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/index-hrlLqLtq.js +129 -0
- package/node_modules/playwright-core/lib/vite/recorder/index.html +29 -0
- package/node_modules/playwright-core/lib/vite/recorder/playwright-logo.svg +9 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-BbkfBe3n.js +32 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Ds6CBOo0.js +182 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-DywYcAf8.js +7 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/codicon.DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.Bqk9acqE.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.B4cLoZK3.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.html +44 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/manifest.webmanifest +16 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/playwright-logo.svg +9 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html +10 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js +4 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CU5KtEkS.js +5 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CyMfwkXJ.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +18 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css +1 -0
- package/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
- package/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
- package/node_modules/playwright-core/lib/xdg-open +1267 -0
- package/node_modules/playwright-core/package.json +34 -0
- package/node_modules/playwright-core/types/protocol.d.ts +24645 -0
- package/node_modules/playwright-core/types/structs.d.ts +52 -0
- package/node_modules/playwright-core/types/types.d.ts +26632 -0
- package/package.json +13 -2
- package/plugins/devflow-core/extensions/devflow-core.ts +432 -7
- package/plugins/devflow-core/runtime/hooks/hook_common.py +514 -0
- package/plugins/devflow-core/runtime/hooks/index_gate.py +47 -16
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +488 -71
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_pins.py +433 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_store.py +137 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +87 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py +53 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +318 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +134 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_surface.py +138 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +515 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tc_hash.py +132 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +102 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +13 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +13 -34
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -1
- package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +68 -140
- package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +1 -1
- package/plugins/devflow-implementation/skills/workflow-execute-phase/references/execute-phase.md +3 -1
- package/plugins/devflow-intel/extensions/devflow-intel.ts +1 -1
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +23 -4
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +872 -0
- package/plugins/devflow-quality/runtime/playwright/playwright.config.mjs +46 -0
- package/plugins/devflow-quality/runtime/playwright/reporter.mjs +311 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +1308 -0
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +11 -0
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +71 -58
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/api-mainflow-rules.md +20 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/codebase-discovery.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +115 -192
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/mainflow-selection.md +27 -23
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +78 -153
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +36 -83
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +497 -556
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/export_legacy_testagent.py +647 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/normalize_case_payloads.py +10 -3
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/validate_case_payloads.py +165 -275
- package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +63 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +4 -4
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +17 -9
- package/src/approval-store.ts +340 -0
- package/src/cli.ts +10 -0
- package/src/constants.ts +1 -1
- package/src/doctor.ts +268 -2
- package/src/initialization-store.ts +48 -1
- package/src/mcp/auth.ts +103 -0
- package/src/mcp/core.ts +102 -0
- package/src/mcp/server.ts +193 -0
- package/src/mcp/types.ts +5 -0
- package/src/paths.ts +12 -1
- package/src/project-context.ts +24 -1
- package/templates/omp/hook-config.json +6 -0
|
@@ -3,18 +3,28 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import fcntl
|
|
6
8
|
import hashlib
|
|
7
9
|
import json
|
|
8
10
|
import os
|
|
9
11
|
import re
|
|
12
|
+
import shutil
|
|
10
13
|
import subprocess
|
|
11
|
-
from datetime import datetime, timezone
|
|
14
|
+
from datetime import date, datetime, timedelta, timezone
|
|
12
15
|
from pathlib import Path
|
|
13
16
|
from typing import Any
|
|
14
17
|
from urllib.error import HTTPError, URLError
|
|
15
18
|
from urllib.parse import urlencode, urljoin
|
|
16
19
|
from urllib.request import Request, urlopen
|
|
17
20
|
|
|
21
|
+
import playwright_adapter
|
|
22
|
+
from approval_store import REQUIRES_DATABASE, valid_approval
|
|
23
|
+
from dsl_coverage import check as dsl_coverage_check
|
|
24
|
+
from dsl_v1 import validate as validate_dsl
|
|
25
|
+
from environment_profile import load as load_environment_profile
|
|
26
|
+
from execution_surface import ADAPTERS_BY_SURFACE, consistency_errors, derive as derive_surface, http_execution_error
|
|
27
|
+
from tc_hash import tc_sha256
|
|
18
28
|
from test_command_policy import validate_test_command
|
|
19
29
|
|
|
20
30
|
|
|
@@ -22,6 +32,13 @@ FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
|
|
|
22
32
|
AUTOMATION_MODES = {"automated", "manual", "blocked"}
|
|
23
33
|
EVIDENCE_STRATEGIES = {"replay", "causal", "observation"}
|
|
24
34
|
MAX_EVIDENCE_FILE_BYTES = 25 * 1024 * 1024
|
|
35
|
+
# 方案 §11: automation_mode may only be downgraded by a binding, never upgraded.
|
|
36
|
+
ALLOWED_BINDING_MODES = {"automated": ("automated", "manual", "blocked"), "manual": ("manual", "blocked"), "blocked": ("blocked",)}
|
|
37
|
+
RESULT_ORIGINS = {"command": "project_test_command", "http": "core_http_adapter", playwright_adapter.ADAPTER_KIND: playwright_adapter.RESULT_ORIGIN}
|
|
38
|
+
DEFAULT_RUN_RETENTION_DAYS = 30
|
|
39
|
+
DEFAULT_UNREFERENCED_RUN_TTL_DAYS = 7
|
|
40
|
+
MAX_REPORT_ERRORS = 50
|
|
41
|
+
MAX_REPORT_ERROR_CHARS = 500
|
|
25
42
|
|
|
26
43
|
|
|
27
44
|
def utc_now() -> str:
|
|
@@ -56,6 +73,49 @@ def write_json(path: Path, value: dict[str, Any]) -> None:
|
|
|
56
73
|
temporary.replace(path)
|
|
57
74
|
|
|
58
75
|
|
|
76
|
+
def human_results_path(root: Path, feature: str) -> Path:
|
|
77
|
+
"""Agent-maintained draft/evidence list only; never a result source (方案 §11)."""
|
|
78
|
+
return root / ".devflow/planning" / "test-runs" / feature / "human-acceptance-results.json"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def manual_case_ids(root: Path, feature: str, suite: dict[str, Any], bindings: dict[str, Any] | None) -> list[str]:
|
|
82
|
+
"""Manual TCs are determined by the binding mode; before bindings exist, by the frozen mode."""
|
|
83
|
+
cases = frozen_cases(suite, [])
|
|
84
|
+
if bindings is None:
|
|
85
|
+
_, binding_path, _ = paths(root, feature)
|
|
86
|
+
bindings = read_json(binding_path, [], "acceptance-test bindings") if binding_path.is_file() else None
|
|
87
|
+
if isinstance(bindings, dict) and isinstance(bindings.get("cases"), list):
|
|
88
|
+
return sorted({entry["tc_id"] for entry in bindings["cases"] if isinstance(entry, dict) and entry.get("mode") == "manual" and entry.get("tc_id") in cases})
|
|
89
|
+
return sorted(tc_id for tc_id, data in cases.items() if data.get("automation_mode") == "manual")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_human_results(root: Path, feature: str, suite: dict[str, Any], bindings: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
93
|
+
"""Manual results come only from valid Core human_result approvals (UI-confirmed, pins re-derived)."""
|
|
94
|
+
errors: list[str] = []
|
|
95
|
+
manual = manual_case_ids(root, feature, suite, bindings)
|
|
96
|
+
by_id: dict[str, dict[str, Any]] = {}
|
|
97
|
+
for tc_id in manual:
|
|
98
|
+
found = valid_approval(root, feature, "human_result", tc_id)
|
|
99
|
+
if not found["ok"]:
|
|
100
|
+
code = REQUIRES_DATABASE if found["reason"] == REQUIRES_DATABASE else "human_result_unapproved"
|
|
101
|
+
errors.append(f"{code}: manual TC {tc_id} has no valid human_result approval ({found['message']})")
|
|
102
|
+
continue
|
|
103
|
+
approval = found["approval"]
|
|
104
|
+
decision = approval["decision"]
|
|
105
|
+
status = decision.get("status")
|
|
106
|
+
actual = decision.get("actual_result")
|
|
107
|
+
evidence = approval["pins"].get("evidence") if isinstance(approval["pins"].get("evidence"), list) else []
|
|
108
|
+
if status not in {"passed", "failed", "blocked"} or not isinstance(actual, str) or not actual.strip():
|
|
109
|
+
errors.append(f"approval_invalid: human_result approval for {tc_id} must record status and actual_result")
|
|
110
|
+
continue
|
|
111
|
+
if status == "passed" and not evidence:
|
|
112
|
+
errors.append(f"approval_invalid: passed human_result for {tc_id} requires at least one pinned evidence file")
|
|
113
|
+
continue
|
|
114
|
+
by_id[tc_id] = {"status": status, "actual_result": actual, "approval_id": approval["id"], "approved_at": approval["approved_at"],
|
|
115
|
+
"evidence_artifacts": [{"path": item.get("path"), "sha256": item.get("sha256")} for item in evidence if isinstance(item, dict)]}
|
|
116
|
+
return {"ok": not errors, "feature": feature, "tc_ids": manual, "errors": errors}, by_id
|
|
117
|
+
|
|
118
|
+
|
|
59
119
|
def valid_argv(value: Any) -> bool:
|
|
60
120
|
return isinstance(value, list) and bool(value) and all(isinstance(token, str) and token and "\x00" not in token for token in value)
|
|
61
121
|
|
|
@@ -90,30 +150,6 @@ def pointer_value(document: Any, pointer: str) -> Any:
|
|
|
90
150
|
return current
|
|
91
151
|
|
|
92
152
|
|
|
93
|
-
def http_execution_error(source: dict[str, Any]) -> str | None:
|
|
94
|
-
execution = source.get("execution")
|
|
95
|
-
if not isinstance(execution, dict) or execution.get("protocol") != "http":
|
|
96
|
-
return "frozen TC has no HTTP execution definition"
|
|
97
|
-
request = execution.get("request")
|
|
98
|
-
expect = execution.get("expect")
|
|
99
|
-
if not isinstance(request, dict) or not isinstance(expect, dict):
|
|
100
|
-
return "frozen TC HTTP execution requires request and expect"
|
|
101
|
-
if not isinstance(request.get("method"), str) or not re.fullmatch(r"[A-Z]+", request["method"]):
|
|
102
|
-
return "frozen TC HTTP request method is invalid"
|
|
103
|
-
if not isinstance(request.get("path"), str) or not request["path"].startswith("/"):
|
|
104
|
-
return "frozen TC HTTP request path is invalid"
|
|
105
|
-
if not isinstance(expect.get("http_status"), int) or not 100 <= expect["http_status"] <= 599:
|
|
106
|
-
return "frozen TC HTTP expectation http_status is invalid"
|
|
107
|
-
for key in ("path_params", "query", "headers", "body"):
|
|
108
|
-
if key in request and not isinstance(request[key], dict):
|
|
109
|
-
return f"frozen TC HTTP request {key} must be an object"
|
|
110
|
-
if "error_code" in expect and (not isinstance(expect["error_code"], str) or not expect["error_code"].strip()):
|
|
111
|
-
return "frozen TC HTTP expectation error_code is invalid"
|
|
112
|
-
if "error_code_pointer" in expect and (not isinstance(expect["error_code_pointer"], str) or not expect["error_code_pointer"].startswith("/")):
|
|
113
|
-
return "frozen TC HTTP expectation error_code_pointer is invalid"
|
|
114
|
-
return None
|
|
115
|
-
|
|
116
|
-
|
|
117
153
|
def frozen_cases(suite: dict[str, Any], errors: list[str]) -> dict[str, dict[str, Any]]:
|
|
118
154
|
cases: dict[str, dict[str, Any]] = {}
|
|
119
155
|
for node in suite.get("nodes", []):
|
|
@@ -144,27 +180,33 @@ def file_fingerprint(path: Path) -> str | None:
|
|
|
144
180
|
return digest(path.read_bytes()) if path.is_file() else None
|
|
145
181
|
|
|
146
182
|
|
|
147
|
-
def
|
|
148
|
-
"""
|
|
183
|
+
def evidence_declaration_errors(root: Path, value: Any) -> list[str]:
|
|
184
|
+
"""Before a run only the declaration and path boundary are checked; files may be produced by the run."""
|
|
149
185
|
if not isinstance(value, list) or not value:
|
|
150
|
-
return [
|
|
186
|
+
return ["causal verification requires non-empty evidence_files"]
|
|
151
187
|
root_real = root.resolve()
|
|
152
|
-
artifacts: list[dict[str, Any]] = []
|
|
153
188
|
errors: list[str] = []
|
|
154
189
|
for raw in value:
|
|
155
190
|
if not isinstance(raw, str) or not raw.strip() or "\x00" in raw:
|
|
156
191
|
errors.append("evidence_files entries must be non-empty relative paths")
|
|
157
192
|
continue
|
|
158
193
|
candidate = Path(raw)
|
|
159
|
-
if candidate.is_absolute() or ".." in candidate.parts:
|
|
160
|
-
errors.append(f"evidence file escapes project root: {raw}")
|
|
161
|
-
continue
|
|
162
|
-
resolved = (root_real / candidate).resolve()
|
|
163
|
-
try:
|
|
164
|
-
relative = resolved.relative_to(root_real)
|
|
165
|
-
except ValueError:
|
|
194
|
+
if candidate.is_absolute() or ".." in candidate.parts or not (root_real / candidate).resolve().is_relative_to(root_real):
|
|
166
195
|
errors.append(f"evidence file escapes project root: {raw}")
|
|
167
|
-
|
|
196
|
+
return errors
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def evidence_artifacts(root: Path, value: Any) -> tuple[list[dict[str, Any]], list[str]]:
|
|
200
|
+
"""After a run: hash causal evidence from bounded project-relative files only."""
|
|
201
|
+
declaration_errors = evidence_declaration_errors(root, value)
|
|
202
|
+
if declaration_errors:
|
|
203
|
+
return [], declaration_errors
|
|
204
|
+
root_real = root.resolve()
|
|
205
|
+
artifacts: list[dict[str, Any]] = []
|
|
206
|
+
errors: list[str] = []
|
|
207
|
+
for raw in value:
|
|
208
|
+
resolved = (root_real / Path(raw)).resolve()
|
|
209
|
+
relative = resolved.relative_to(root_real)
|
|
168
210
|
if not resolved.is_file():
|
|
169
211
|
errors.append(f"causal evidence file is missing: {raw}")
|
|
170
212
|
continue
|
|
@@ -237,6 +279,37 @@ def run_http_case(source: dict[str, Any], adapter: dict[str, Any]) -> tuple[bool
|
|
|
237
279
|
return True, 0, f"{setup_output}\nHTTP {http_status} matched expectation"
|
|
238
280
|
|
|
239
281
|
|
|
282
|
+
def playwright_binding_errors(root: Path, feature: str, tc_id: str, source: dict[str, Any], adapter: dict[str, Any]) -> list[str]:
|
|
283
|
+
"""Binding shape, DSL schema/semantics, TC identity, structural coverage and profile validity."""
|
|
284
|
+
errors = playwright_adapter.adapter_errors(root, feature, adapter)
|
|
285
|
+
if errors:
|
|
286
|
+
return errors
|
|
287
|
+
path, _ = playwright_adapter.dsl_path(root, feature, adapter["dsl_file"])
|
|
288
|
+
assert path is not None
|
|
289
|
+
_raw, dsl, dsl_error = playwright_adapter.read_dsl(path)
|
|
290
|
+
if dsl_error:
|
|
291
|
+
return [dsl_error]
|
|
292
|
+
dsl_errors = validate_dsl(dsl)
|
|
293
|
+
if dsl_errors:
|
|
294
|
+
return [f"DSL is invalid: {error}" for error in dsl_errors]
|
|
295
|
+
if dsl["tc_id"] != tc_id:
|
|
296
|
+
errors.append(f"DSL tc_id {dsl['tc_id']} does not match binding {tc_id}")
|
|
297
|
+
if dsl["tc_sha256"] != tc_sha256(source):
|
|
298
|
+
errors.append("DSL tc_sha256 does not match the current frozen TC (JCS SHA-256)")
|
|
299
|
+
errors.extend(f"DSL coverage: {error}" for error in dsl_coverage_check(source, dsl))
|
|
300
|
+
_profile, _sha, profile_errors = load_environment_profile(root, adapter["environment_profile"])
|
|
301
|
+
errors.extend(f"environment profile: {error}" for error in profile_errors)
|
|
302
|
+
return errors
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def mode_override_errors(root: Path, feature: str, tc_id: str) -> list[str]:
|
|
306
|
+
found = valid_approval(root, feature, "mode_override", tc_id)
|
|
307
|
+
if found["ok"]:
|
|
308
|
+
return []
|
|
309
|
+
code = REQUIRES_DATABASE if found["reason"] == REQUIRES_DATABASE else "approval_invalid"
|
|
310
|
+
return [f"{code}: automated -> manual downgrade of {tc_id} requires a valid UI-confirmed mode_override approval ({found['message']})"]
|
|
311
|
+
|
|
312
|
+
|
|
240
313
|
def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] | None, dict[str, Any] | None]:
|
|
241
314
|
errors: list[str] = []
|
|
242
315
|
suite_path, binding_path, _ = paths(root, feature)
|
|
@@ -282,6 +355,8 @@ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] |
|
|
|
282
355
|
if source is None:
|
|
283
356
|
errors.append(f"unknown TC reference: {tc_id}")
|
|
284
357
|
continue
|
|
358
|
+
surface, _legacy = derive_surface(source)
|
|
359
|
+
errors.extend(f"execution_surface_mismatch: {label} ({tc_id}) {error}" for error in consistency_errors(source))
|
|
285
360
|
strategy = source.get("evidence_strategy")
|
|
286
361
|
if strategy is not None and strategy not in EVIDENCE_STRATEGIES:
|
|
287
362
|
errors.append(f"{label}.source evidence_strategy is invalid")
|
|
@@ -291,20 +366,30 @@ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] |
|
|
|
291
366
|
if mode not in AUTOMATION_MODES:
|
|
292
367
|
errors.append(f"{label}.mode is invalid")
|
|
293
368
|
continue
|
|
294
|
-
|
|
295
|
-
|
|
369
|
+
frozen_mode = source.get("automation_mode")
|
|
370
|
+
if mode not in ALLOWED_BINDING_MODES.get(frozen_mode, ()):
|
|
371
|
+
errors.append(f"{label}.mode {mode} is not allowed for frozen automation_mode {frozen_mode} (bindings may only downgrade)")
|
|
372
|
+
continue
|
|
296
373
|
if mode == "automated":
|
|
297
374
|
adapter = entry.get("adapter")
|
|
298
375
|
if not isinstance(adapter, dict):
|
|
299
376
|
errors.append(f"{label}.adapter is required")
|
|
300
|
-
|
|
377
|
+
continue
|
|
378
|
+
kind = adapter.get("kind")
|
|
379
|
+
if kind not in RESULT_ORIGINS:
|
|
380
|
+
errors.append(f"{label}.adapter kind must be command, http, or devflow_playwright")
|
|
381
|
+
continue
|
|
382
|
+
if surface is not None and kind not in ADAPTERS_BY_SURFACE[surface]:
|
|
383
|
+
errors.append(f"execution_surface_mismatch: {label} ({tc_id}) execution_surface={surface} cannot be bound to adapter kind {kind}")
|
|
384
|
+
continue
|
|
385
|
+
if kind == "command":
|
|
301
386
|
if not valid_argv(adapter.get("argv")):
|
|
302
387
|
errors.append(f"{label}.adapter command requires non-empty argv")
|
|
303
388
|
continue
|
|
304
389
|
command_error = validate_test_command(root, adapter["argv"])
|
|
305
390
|
if command_error:
|
|
306
391
|
errors.append(f"{label}.adapter: {command_error}")
|
|
307
|
-
elif
|
|
392
|
+
elif kind == "http":
|
|
308
393
|
if not valid_http_adapter(adapter):
|
|
309
394
|
errors.append(f"{label}.adapter HTTP requires base_url_env, valid optional headers_env, and optional setup.argv")
|
|
310
395
|
else:
|
|
@@ -316,27 +401,74 @@ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] |
|
|
|
316
401
|
if setup_error:
|
|
317
402
|
errors.append(f"{label}.adapter.setup: {setup_error}")
|
|
318
403
|
else:
|
|
319
|
-
errors.
|
|
404
|
+
errors.extend(f"{label}.adapter: {error}" for error in playwright_binding_errors(root, feature, tc_id, source, adapter))
|
|
320
405
|
if strategy == "causal":
|
|
321
|
-
|
|
322
|
-
errors.extend(f"{label}.{error}" for error in artifact_errors)
|
|
406
|
+
errors.extend(f"{label}.{error}" for error in evidence_declaration_errors(root, entry.get("evidence_files")))
|
|
323
407
|
elif mode == "manual":
|
|
324
408
|
if not isinstance(entry.get("owner"), str) or not entry["owner"].strip() or not isinstance(entry.get("reason"), str) or not entry["reason"].strip():
|
|
325
409
|
errors.append(f"{label}.manual requires owner and reason")
|
|
410
|
+
if frozen_mode == "automated":
|
|
411
|
+
errors.extend(f"{error} [{label}]" for error in mode_override_errors(root, feature, tc_id))
|
|
326
412
|
elif mode == "blocked":
|
|
327
413
|
if not isinstance(entry.get("blocked_by"), str) or not entry["blocked_by"].strip() or not isinstance(entry.get("owner"), str) or not entry["owner"].strip():
|
|
328
414
|
errors.append(f"{label}.blocked requires blocked_by and owner")
|
|
415
|
+
if frozen_mode != "blocked" and (not isinstance(entry.get("reason"), str) or not entry["reason"].strip()):
|
|
416
|
+
errors.append(f"{label}.blocked downgrade requires reason")
|
|
329
417
|
for tc_id in sorted(set(cases) - seen):
|
|
330
418
|
errors.append(f"unbound frozen TC: {tc_id}")
|
|
419
|
+
human_validation, _ = validate_human_results(root, feature, suite, bindings)
|
|
420
|
+
errors.extend(human_validation["errors"])
|
|
331
421
|
return {"ok": not errors, "feature": feature, "tc_ids": sorted(cases), "errors": errors}, suite, bindings
|
|
332
422
|
|
|
333
423
|
|
|
424
|
+
def run_command_case(root: Path, argv: list[str]) -> tuple[int, str]:
|
|
425
|
+
try:
|
|
426
|
+
completed = subprocess.run(argv, cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180, check=False)
|
|
427
|
+
return completed.returncode, f"{completed.stdout}\n{completed.stderr}"
|
|
428
|
+
except subprocess.TimeoutExpired as error:
|
|
429
|
+
return 124, f"command timed out: {error}"
|
|
430
|
+
except OSError as error:
|
|
431
|
+
return 127, f"command could not start: {error}"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@contextlib.contextmanager
|
|
435
|
+
def acceptance_run_lock(root: Path, feature: str):
|
|
436
|
+
"""Hold the feature write lock for the complete run, including result writes."""
|
|
437
|
+
lock_path = root / ".devflow/planning/test-runs" / feature / ".run.lock"
|
|
438
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
handle = lock_path.open("a+")
|
|
440
|
+
try:
|
|
441
|
+
try:
|
|
442
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
443
|
+
except BlockingIOError:
|
|
444
|
+
yield False
|
|
445
|
+
return
|
|
446
|
+
yield True
|
|
447
|
+
finally:
|
|
448
|
+
try:
|
|
449
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
450
|
+
finally:
|
|
451
|
+
handle.close()
|
|
452
|
+
|
|
453
|
+
|
|
334
454
|
def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
455
|
+
if write:
|
|
456
|
+
with acceptance_run_lock(root, feature) as acquired:
|
|
457
|
+
if not acquired:
|
|
458
|
+
return {"ok": False, "feature": feature, "reason": "acceptance_run_in_progress", "errors": []}
|
|
459
|
+
return _run_locked(root, feature, write)
|
|
460
|
+
return _run_locked(root, feature, write)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
335
464
|
validation, suite, bindings = validate(root, feature)
|
|
336
465
|
if not validation["ok"]:
|
|
337
466
|
return validation
|
|
338
467
|
assert suite is not None and bindings is not None
|
|
339
468
|
cases = frozen_cases(suite, [])
|
|
469
|
+
human_validation, human_results = validate_human_results(root, feature, suite, bindings)
|
|
470
|
+
if not human_validation["ok"]:
|
|
471
|
+
return human_validation
|
|
340
472
|
_, _, result_path = paths(root, feature)
|
|
341
473
|
previous = read_json(result_path, [], "previous acceptance-test results")
|
|
342
474
|
task_bindings_path = root / ".devflow/planning" / "features" / feature / "test-bindings.json"
|
|
@@ -345,43 +477,54 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
345
477
|
for entry in bindings["cases"]:
|
|
346
478
|
tc_id = entry["tc_id"]
|
|
347
479
|
mode = entry["mode"]
|
|
348
|
-
|
|
480
|
+
source = cases[tc_id]
|
|
481
|
+
strategy = source.get("evidence_strategy")
|
|
482
|
+
surface, legacy = derive_surface(source)
|
|
483
|
+
common = {"tc_id": tc_id, "mode": mode, "execution_surface": surface, "legacy_unclassified": legacy, "tc_sha256": tc_sha256(source)}
|
|
484
|
+
frozen_mode = source.get("automation_mode")
|
|
485
|
+
if mode != frozen_mode:
|
|
486
|
+
common["downgrade"] = {"from": frozen_mode, "to": mode}
|
|
349
487
|
if mode == "automated":
|
|
350
488
|
started_at = utc_now()
|
|
351
489
|
adapter = entry["adapter"]
|
|
352
|
-
|
|
490
|
+
kind = adapter["kind"]
|
|
491
|
+
if kind == "command":
|
|
353
492
|
argv = adapter["argv"]
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
exit_code = completed.returncode
|
|
357
|
-
output = f"{completed.stdout}\n{completed.stderr}"
|
|
358
|
-
except subprocess.TimeoutExpired as error:
|
|
359
|
-
exit_code = 124
|
|
360
|
-
output = f"command timed out: {error}"
|
|
361
|
-
except OSError as error:
|
|
362
|
-
exit_code = 127
|
|
363
|
-
output = f"command could not start: {error}"
|
|
364
|
-
result = {"tc_id": tc_id, "status": "passed" if exit_code == 0 else "failed", "mode": mode, "adapter_kind": "command", "argv": argv,
|
|
493
|
+
exit_code, output = run_command_case(root, argv)
|
|
494
|
+
result = {**common, "status": "passed" if exit_code == 0 else "failed", "adapter_kind": "command", "origin": RESULT_ORIGINS[kind], "argv": argv,
|
|
365
495
|
"command_hash": digest(json.dumps(argv, ensure_ascii=False, separators=(",", ":"))), "started_at": started_at,
|
|
366
496
|
"finished_at": utc_now(), "exit_code": exit_code, "output_sha256": digest(output)}
|
|
367
|
-
|
|
368
|
-
passed, exit_code, output = run_http_case(
|
|
369
|
-
result = {
|
|
497
|
+
elif kind == "http":
|
|
498
|
+
passed, exit_code, output = run_http_case(source, adapter)
|
|
499
|
+
result = {**common, "status": "passed" if passed else "failed", "adapter_kind": "http", "origin": RESULT_ORIGINS[kind],
|
|
370
500
|
"adapter_fingerprint": digest(json.dumps(adapter, ensure_ascii=False, separators=(",", ":"))), "started_at": started_at,
|
|
371
501
|
"finished_at": utc_now(), "exit_code": exit_code, "output_sha256": digest(output)}
|
|
502
|
+
else:
|
|
503
|
+
outcome = playwright_adapter.execute(root, feature, tc_id, source, suite, adapter)
|
|
504
|
+
result = {**common, "adapter_kind": kind, "origin": RESULT_ORIGINS[kind], "started_at": started_at, **outcome}
|
|
505
|
+
result.setdefault("finished_at", utc_now())
|
|
506
|
+
result.setdefault("dsl_approval", "none")
|
|
372
507
|
if strategy == "causal":
|
|
373
508
|
artifacts, artifact_errors = evidence_artifacts(root, entry.get("evidence_files"))
|
|
374
509
|
if artifact_errors:
|
|
375
|
-
result["status"]
|
|
510
|
+
if result["status"] == "passed":
|
|
511
|
+
result["status"] = "failed"
|
|
376
512
|
result["evidence_error"] = "; ".join(artifact_errors)
|
|
377
513
|
else:
|
|
378
514
|
result["evidence_artifacts"] = artifacts
|
|
379
515
|
results.append(result)
|
|
380
516
|
elif mode == "manual":
|
|
381
|
-
|
|
382
|
-
|
|
517
|
+
human = human_results[tc_id]
|
|
518
|
+
if "downgrade" in common:
|
|
519
|
+
override = valid_approval(root, feature, "mode_override", tc_id)
|
|
520
|
+
common["downgrade"]["approval_id"] = override["approval"]["id"] if override["ok"] else None
|
|
521
|
+
results.append({**common, "status": human["status"], "origin": "human_result_approval", "approval_id": human["approval_id"],
|
|
522
|
+
"approved_at": human["approved_at"], "actual_result": human["actual_result"], "evidence_artifacts": human["evidence_artifacts"]})
|
|
383
523
|
else:
|
|
384
|
-
|
|
524
|
+
blocked_result = {**common, "status": "blocked", "owner": entry["owner"], "blocked_by": entry["blocked_by"]}
|
|
525
|
+
if isinstance(entry.get("reason"), str):
|
|
526
|
+
blocked_result["reason"] = entry["reason"]
|
|
527
|
+
results.append(blocked_result)
|
|
385
528
|
all_passed = all(item["status"] == "passed" for item in results)
|
|
386
529
|
history = previous.get("history", []) if isinstance(previous, dict) and isinstance(previous.get("history"), list) else []
|
|
387
530
|
if isinstance(previous, dict) and isinstance(previous.get("results"), list):
|
|
@@ -390,7 +533,9 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
390
533
|
"results": previous["results"]}]
|
|
391
534
|
payload = {"schema_version": 1, "origin": "core_acceptance_test_runner", "feature": feature, "executed_at": utc_now(),
|
|
392
535
|
"test_suite": bindings["test_suite"], "implementation_task_bindings": {"path": str(task_bindings_path.relative_to(root)), "fingerprint": file_fingerprint(task_bindings_path)},
|
|
393
|
-
"execution_binding_fingerprint": file_fingerprint(execution_bindings_path),
|
|
536
|
+
"execution_binding_fingerprint": file_fingerprint(execution_bindings_path),
|
|
537
|
+
"human_acceptance_fingerprint": file_fingerprint(human_results_path(root, feature)),
|
|
538
|
+
"results": results, "passed": all_passed, "history": history[-20:]}
|
|
394
539
|
if write:
|
|
395
540
|
write_json(result_path, payload)
|
|
396
541
|
meta_path = result_path.with_name("acceptance-results.meta.json")
|
|
@@ -403,6 +548,9 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
403
548
|
"acceptance_tc_passed": passed,
|
|
404
549
|
"acceptance_tc_failed": failed,
|
|
405
550
|
"acceptance_tc_blocked": blocked,
|
|
551
|
+
"acceptance_tc_legacy_unclassified": sum(1 for item in results if item.get("legacy_unclassified")),
|
|
552
|
+
"acceptance_tc_downgraded": sum(1 for item in results if item.get("downgrade")),
|
|
553
|
+
"acceptance_tc_dsl_unapproved": sum(1 for item in results if item.get("dsl_approval") == "none"),
|
|
406
554
|
"acceptance_suite_graph_revision": payload["test_suite"].get("graph_revision"),
|
|
407
555
|
"acceptance_results_fingerprint": file_fingerprint(result_path),
|
|
408
556
|
"built_by": "core_acceptance_test_runner",
|
|
@@ -412,13 +560,190 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
412
560
|
"errors": [] if all_passed else ["one or more frozen TC cases failed or are blocked"]}
|
|
413
561
|
|
|
414
562
|
|
|
415
|
-
def
|
|
416
|
-
|
|
563
|
+
def _report_error(value: Any) -> dict[str, Any]:
|
|
564
|
+
text = str(value)
|
|
565
|
+
# Reports are safe summaries: do not expose project or run locations from gate diagnostics.
|
|
566
|
+
text = re.sub(r"(?:[A-Za-z]:)?[^\s,;()]+(?:/|\\)[^\s,;()]+", "<path>", text)
|
|
567
|
+
text = re.sub(r"(?:^|\s)(?:/|~/?|\.\.?/)[^\s,;()]+", " <path>", text)
|
|
568
|
+
truncated = len(text) > MAX_REPORT_ERROR_CHARS
|
|
569
|
+
return {"message": text[:MAX_REPORT_ERROR_CHARS], "truncated": True} if truncated else {"message": text, "truncated": False}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _result_summary(entry: dict[str, Any]) -> dict[str, Any]:
|
|
573
|
+
approval = entry.get("dsl_approval")
|
|
574
|
+
if isinstance(approval, dict):
|
|
575
|
+
approval = "approved" if approval.get("status") == "approved" else "none"
|
|
576
|
+
elif approval not in {"approved", "none"}:
|
|
577
|
+
approval = None
|
|
578
|
+
return {key: entry.get(key) for key in (
|
|
579
|
+
"tc_id", "status", "mode", "adapter_kind", "execution_surface", "error_code"
|
|
580
|
+
)} | {"dsl_approval": approval, "legacy_unclassified": bool(entry.get("legacy_unclassified")),
|
|
581
|
+
"downgrade": entry.get("downgrade")}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def report(root: Path, feature: str) -> dict[str, Any]:
|
|
585
|
+
_, _, result_path = paths(root, feature)
|
|
586
|
+
if not result_path.is_file():
|
|
587
|
+
return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
|
|
588
|
+
try:
|
|
589
|
+
payload = json.loads(result_path.read_text(encoding="utf-8"))
|
|
590
|
+
except (OSError, json.JSONDecodeError):
|
|
591
|
+
return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
|
|
592
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
|
|
593
|
+
return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
|
|
594
|
+
entries = [entry for entry in payload["results"] if isinstance(entry, dict)]
|
|
595
|
+
statuses = {status: sum(1 for entry in entries if entry.get("status") == status)
|
|
596
|
+
for status in ("passed", "failed", "blocked")}
|
|
597
|
+
counts = {"passed": statuses["passed"], "failed": statuses["failed"], "blocked": statuses["blocked"],
|
|
598
|
+
"legacy_unclassified": sum(bool(entry.get("legacy_unclassified")) for entry in entries),
|
|
599
|
+
"downgraded": sum(bool(entry.get("downgrade")) for entry in entries)}
|
|
600
|
+
gate = validate_results(root, feature)
|
|
601
|
+
raw_errors = gate.get("errors", []) if isinstance(gate, dict) else []
|
|
602
|
+
return {"ok": True, "feature": feature,
|
|
603
|
+
"gate": {"ok": bool(gate.get("ok")), "reason": gate.get("reason"),
|
|
604
|
+
"errors": [_report_error(error) for error in raw_errors[:MAX_REPORT_ERRORS]]},
|
|
605
|
+
"executed_at": payload.get("executed_at"), "test_suite": payload.get("test_suite"),
|
|
606
|
+
"counts": counts, "results": [_result_summary(entry) for entry in entries]}
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def run_evidence_expired(root: Path, feature: str, today: date | None = None) -> bool:
|
|
610
|
+
"""True only once release-closeout completed and the post-closeout retention window has elapsed.
|
|
611
|
+
|
|
612
|
+
Decision: a missing .devflow/runs/<feature>/<execution_id> directory is an error while the feature is
|
|
613
|
+
open or within DEFAULT_RUN_RETENTION_DAYS after closeout (gc_runs never removes referenced runs
|
|
614
|
+
earlier, so a missing directory then means deletion/tampering). After that window gc-runs may have
|
|
615
|
+
removed it legitimately, so only the file-level evidence checks are skipped; every other recorded
|
|
616
|
+
field is still re-verified against current state.
|
|
617
|
+
"""
|
|
618
|
+
closed = closeout_completed_on(root, feature)
|
|
619
|
+
return closed is not None and (today or datetime.now(timezone.utc).date()) >= closed + timedelta(days=DEFAULT_RUN_RETENTION_DAYS)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _recorded_str(entry: dict[str, Any], key: str) -> str | None:
|
|
623
|
+
value = entry.get(key)
|
|
624
|
+
return value if isinstance(value, str) and value else None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def playwright_entry_errors(root: Path, feature: str, tc_id: str, source: dict[str, Any], suite: dict[str, Any], adapter: dict[str, Any],
|
|
628
|
+
entry: dict[str, Any], run_dir_expired: bool) -> list[str]:
|
|
629
|
+
"""A devflow_playwright result must still be backed by the CURRENT execution_plan and environment_profile approvals."""
|
|
630
|
+
errors: list[str] = []
|
|
631
|
+
if entry.get("origin") != playwright_adapter.RESULT_ORIGIN:
|
|
632
|
+
errors.append(f"origin must be {playwright_adapter.RESULT_ORIGIN}")
|
|
633
|
+
runtime = entry.get("runtime")
|
|
634
|
+
if not isinstance(runtime, dict) or not all(isinstance(runtime.get(key), str) and runtime[key] for key in ("version", "manifest_sha256")):
|
|
635
|
+
errors.append("runtime info (version, manifest_sha256) is missing")
|
|
636
|
+
for key in ("execution_id", "run_dir"):
|
|
637
|
+
if _recorded_str(entry, key) is None:
|
|
638
|
+
errors.append(f"{key} is missing")
|
|
639
|
+
if not (isinstance(entry.get("result_sha256"), str) and playwright_adapter.SHA256_RE.fullmatch(entry["result_sha256"])):
|
|
640
|
+
errors.append("result_sha256 is missing or malformed")
|
|
641
|
+
approval = entry.get("dsl_approval")
|
|
642
|
+
if approval == "none" or not isinstance(approval, dict) or approval.get("status") != "approved" or not _recorded_str(approval, "approval_id"):
|
|
643
|
+
errors.append(f"dsl_approval is {approval!r}, not an approved execution_plan approval")
|
|
644
|
+
if playwright_adapter.adapter_errors(root, feature, adapter):
|
|
645
|
+
return errors # the binding itself is invalid; validate() already reported why
|
|
646
|
+
trust = playwright_adapter.resolve_trust(root, feature, tc_id, source, suite, adapter)
|
|
647
|
+
current = trust["base"]
|
|
648
|
+
if trust["failure"] is not None:
|
|
649
|
+
failure = trust["failure"]
|
|
650
|
+
return errors + [f"{failure.get('error_code')}: {failure.get('error')}"]
|
|
651
|
+
if entry.get("dsl_sha256") != current["dsl_sha256"]:
|
|
652
|
+
errors.append("recorded dsl_sha256 differs from the current DSL file (DSL edited after the run)")
|
|
653
|
+
if isinstance(approval, dict) and approval.get("approval_id") != current["dsl_approval"]["approval_id"]:
|
|
654
|
+
errors.append(f"recorded execution_plan approval {approval.get('approval_id')!r} is not the currently valid approval {current['dsl_approval']['approval_id']!r} (superseded or re-approved after the run)")
|
|
655
|
+
recorded_profile = entry.get("environment_profile")
|
|
656
|
+
current_profile = current["environment_profile"]
|
|
657
|
+
if not isinstance(recorded_profile, dict):
|
|
658
|
+
errors.append("environment_profile approval record is missing")
|
|
659
|
+
else:
|
|
660
|
+
if recorded_profile.get("name") != current_profile["name"] or recorded_profile.get("sha256") != current_profile["sha256"]:
|
|
661
|
+
errors.append("recorded environment profile hash differs from the current profile file (profile edited after the run)")
|
|
662
|
+
if recorded_profile.get("approval_id") != current_profile["approval_id"]:
|
|
663
|
+
errors.append(f"recorded environment_profile approval {recorded_profile.get('approval_id')!r} is not the currently valid approval {current_profile['approval_id']!r}")
|
|
664
|
+
errors.extend(playwright_adapter.recorded_run_errors(root, feature, tc_id, entry, run_dir_expired))
|
|
665
|
+
return errors
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def manual_entry_errors(root: Path, feature: str, tc_id: str, source: dict[str, Any], entry: dict[str, Any], human: dict[str, Any] | None) -> list[str]:
|
|
669
|
+
errors: list[str] = []
|
|
670
|
+
if entry.get("origin") != "human_result_approval":
|
|
671
|
+
errors.append("origin must be human_result_approval")
|
|
672
|
+
if human is None:
|
|
673
|
+
return errors + ["no currently valid human_result approval backs this manual result"]
|
|
674
|
+
for key in ("status", "actual_result", "approval_id", "evidence_artifacts"):
|
|
675
|
+
if entry.get(key) != human[key]:
|
|
676
|
+
errors.append(f"recorded {key} does not match the current human_result approval (superseded or evidence changed)")
|
|
677
|
+
downgrade = entry.get("downgrade")
|
|
678
|
+
if source.get("automation_mode") == "automated":
|
|
679
|
+
override = valid_approval(root, feature, "mode_override", tc_id)
|
|
680
|
+
if not isinstance(downgrade, dict) or (downgrade.get("from"), downgrade.get("to")) != ("automated", "manual"):
|
|
681
|
+
errors.append("automated -> manual downgrade is not recorded")
|
|
682
|
+
elif not override["ok"] or downgrade.get("approval_id") != override["approval"]["id"]:
|
|
683
|
+
errors.append("automated -> manual downgrade has no currently valid mode_override approval matching the recorded approval_id")
|
|
684
|
+
return errors
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def result_entry_errors(root: Path, feature: str, tc_id: str, source: dict[str, Any], suite: dict[str, Any], binding: dict[str, Any],
|
|
688
|
+
entry: dict[str, Any], human: dict[str, Any] | None, run_dir_expired: bool) -> list[str]:
|
|
689
|
+
"""Re-verify one recorded result against the CURRENT binding, frozen TC, approvals and evidence files."""
|
|
690
|
+
errors: list[str] = []
|
|
691
|
+
surface, _legacy = derive_surface(source)
|
|
692
|
+
mode = binding.get("mode")
|
|
693
|
+
if entry.get("mode") != mode:
|
|
694
|
+
errors.append(f"recorded mode {entry.get('mode')!r} does not match the current binding mode {mode!r}")
|
|
695
|
+
if entry.get("execution_surface") != surface:
|
|
696
|
+
errors.append(f"recorded execution_surface {entry.get('execution_surface')!r} does not match the frozen TC surface {surface!r}")
|
|
697
|
+
frozen_mode = source.get("automation_mode")
|
|
698
|
+
recorded_downgrade = entry.get("downgrade")
|
|
699
|
+
if mode != frozen_mode and (not isinstance(recorded_downgrade, dict) or (recorded_downgrade.get("from"), recorded_downgrade.get("to")) != (frozen_mode, mode)):
|
|
700
|
+
errors.append(f"downgrade {frozen_mode} -> {mode} is not recorded")
|
|
701
|
+
if mode == frozen_mode and recorded_downgrade is not None:
|
|
702
|
+
errors.append("a downgrade is recorded but the current binding does not downgrade this TC")
|
|
703
|
+
passed = entry.get("status") == "passed"
|
|
704
|
+
if passed and entry.get("tc_sha256") != tc_sha256(source):
|
|
705
|
+
errors.append("recorded tc_sha256 differs from the current frozen TC")
|
|
706
|
+
if mode == "manual":
|
|
707
|
+
return errors + manual_entry_errors(root, feature, tc_id, source, entry, human)
|
|
708
|
+
if mode != "automated":
|
|
709
|
+
return errors # blocked entries never pass
|
|
710
|
+
adapter = binding.get("adapter") if isinstance(binding.get("adapter"), dict) else {}
|
|
711
|
+
kind = adapter.get("kind")
|
|
712
|
+
if entry.get("adapter_kind") != kind:
|
|
713
|
+
errors.append(f"recorded adapter_kind {entry.get('adapter_kind')!r} does not match the current binding adapter {kind!r}")
|
|
714
|
+
return errors
|
|
715
|
+
if surface is not None and kind not in ADAPTERS_BY_SURFACE[surface]:
|
|
716
|
+
errors.append(f"execution_surface_mismatch: execution_surface={surface} cannot be bound to adapter kind {kind}")
|
|
717
|
+
if kind in RESULT_ORIGINS and entry.get("origin") != RESULT_ORIGINS[kind]:
|
|
718
|
+
errors.append(f"origin {entry.get('origin')!r} does not match adapter kind {kind} (expected {RESULT_ORIGINS[kind]})")
|
|
719
|
+
if kind == "command":
|
|
720
|
+
if entry.get("argv") != adapter.get("argv") or (valid_argv(adapter.get("argv")) and entry.get("command_hash") != digest(json.dumps(adapter["argv"], ensure_ascii=False, separators=(",", ":")))):
|
|
721
|
+
errors.append("recorded command argv/command_hash differs from the current binding")
|
|
722
|
+
elif kind == "http":
|
|
723
|
+
if entry.get("adapter_fingerprint") != digest(json.dumps(adapter, ensure_ascii=False, separators=(",", ":"))):
|
|
724
|
+
errors.append("recorded HTTP adapter fingerprint differs from the current binding")
|
|
725
|
+
elif kind == playwright_adapter.ADAPTER_KIND and passed:
|
|
726
|
+
errors.extend(playwright_entry_errors(root, feature, tc_id, source, suite, adapter, entry, run_dir_expired))
|
|
727
|
+
if passed and source.get("evidence_strategy") == "causal":
|
|
728
|
+
recorded = entry.get("evidence_artifacts")
|
|
729
|
+
current, current_errors = evidence_artifacts(root, binding.get("evidence_files"))
|
|
730
|
+
if not isinstance(recorded, list) or not recorded:
|
|
731
|
+
errors.append("causal evidence_artifacts are not recorded")
|
|
732
|
+
elif current_errors:
|
|
733
|
+
errors.extend(current_errors)
|
|
734
|
+
elif [{"path": item.get("path"), "sha256": item.get("sha256")} for item in recorded if isinstance(item, dict)] != [{"path": item["path"], "sha256": item["sha256"]} for item in current]:
|
|
735
|
+
errors.append("causal evidence files differ from the recorded hashes (evidence changed)")
|
|
736
|
+
return errors
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def validate_results(root: Path, feature: str, today: date | None = None) -> dict[str, Any]:
|
|
740
|
+
"""Hard-gate check: every recorded result must still hold against the CURRENT frozen TCs, bindings, approvals and evidence."""
|
|
741
|
+
validation, suite, bindings = validate(root, feature)
|
|
417
742
|
errors = list(validation["errors"])
|
|
418
743
|
_, _, result_path = paths(root, feature)
|
|
419
744
|
results = read_json(result_path, errors, "acceptance-test results")
|
|
420
745
|
if suite is None or results is None:
|
|
421
|
-
return {"ok": False, "feature": feature, "errors": errors}
|
|
746
|
+
return {"ok": False, "feature": feature, "reason": "acceptance_results_not_passed", "errors": errors}
|
|
422
747
|
if results.get("origin") != "core_acceptance_test_runner" or results.get("feature") != feature:
|
|
423
748
|
errors.append("acceptance-test results identity is invalid")
|
|
424
749
|
meta = suite.get("view_meta") if isinstance(suite.get("view_meta"), dict) else {}
|
|
@@ -432,14 +757,97 @@ def validate_results(root: Path, feature: str) -> dict[str, Any]:
|
|
|
432
757
|
failed = sorted(item.get("tc_id", "<unknown>") for item in result_entries if not isinstance(item, dict) or item.get("status") != "passed")
|
|
433
758
|
if failed:
|
|
434
759
|
errors.append(f"frozen TC cases not passed: {', '.join(failed)}")
|
|
435
|
-
|
|
760
|
+
if suite is not None and isinstance(bindings, dict):
|
|
761
|
+
cases = frozen_cases(suite, [])
|
|
762
|
+
binding_by_id = {item["tc_id"]: item for item in bindings.get("cases", []) if isinstance(item, dict) and isinstance(item.get("tc_id"), str)}
|
|
763
|
+
_human_validation, human_results = validate_human_results(root, feature, suite, bindings)
|
|
764
|
+
expired = run_evidence_expired(root, feature, today)
|
|
765
|
+
seen: set[str] = set()
|
|
766
|
+
for entry in result_entries:
|
|
767
|
+
tc_id = entry.get("tc_id") if isinstance(entry, dict) else None
|
|
768
|
+
if not isinstance(tc_id, str) or tc_id not in cases or tc_id not in binding_by_id:
|
|
769
|
+
continue # coverage mismatch is already reported above
|
|
770
|
+
if tc_id in seen:
|
|
771
|
+
errors.append(f"{tc_id}: duplicate result entry")
|
|
772
|
+
continue
|
|
773
|
+
seen.add(tc_id)
|
|
774
|
+
errors.extend(f"{tc_id}: {error}" for error in result_entry_errors(root, feature, tc_id, cases[tc_id], suite, binding_by_id[tc_id], entry, human_results.get(tc_id), expired))
|
|
775
|
+
result = {"ok": not errors, "feature": feature, "errors": errors, "result_path": str(result_path.relative_to(root))}
|
|
776
|
+
if errors:
|
|
777
|
+
result["reason"] = "acceptance_results_not_passed"
|
|
778
|
+
return result
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def closeout_completed_on(root: Path, feature: str) -> date | None:
|
|
782
|
+
"""Date release-closeout completed for this feature according to STATE.md, if it did."""
|
|
783
|
+
try:
|
|
784
|
+
text = (root / ".devflow/planning" / "STATE.md").read_text(encoding="utf-8")
|
|
785
|
+
except OSError:
|
|
786
|
+
return None
|
|
787
|
+
match = re.match(r"\A---\s*\n(.*?)\n---", text, re.S)
|
|
788
|
+
if not match:
|
|
789
|
+
return None
|
|
790
|
+
front = match.group(1)
|
|
791
|
+
field = lambda name: (re.search(rf"^{name}:\s*['\"]?([^'\"\n]*)['\"]?\s*$", front, re.M) or [None, None])[1]
|
|
792
|
+
completed = re.search(r"^completed_steps:\s*\n((?:\s*-\s*.*\n?)*)", front, re.M)
|
|
793
|
+
steps = {line.strip()[1:].strip().strip("'\"") for line in completed.group(1).splitlines()} if completed else set()
|
|
794
|
+
if field("current_feature") != feature or "release-closeout" not in steps:
|
|
795
|
+
return None
|
|
796
|
+
try:
|
|
797
|
+
return date.fromisoformat(str(field("state_updated_at")))
|
|
798
|
+
except ValueError:
|
|
799
|
+
return None
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def referenced_execution_ids(result_path: Path) -> set[str]:
|
|
803
|
+
payload = read_json(result_path, [], "acceptance-test results") if result_path.is_file() else None
|
|
804
|
+
if not isinstance(payload, dict):
|
|
805
|
+
return set()
|
|
806
|
+
batches = [payload.get("results")] + [item.get("results") for item in payload.get("history", []) if isinstance(item, dict)]
|
|
807
|
+
return {entry["execution_id"] for batch in batches if isinstance(batch, list) for entry in batch
|
|
808
|
+
if isinstance(entry, dict) and isinstance(entry.get("execution_id"), str)}
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def gc_runs(root: Path, feature: str, write: bool, retention_days: int = DEFAULT_RUN_RETENTION_DAYS,
|
|
812
|
+
unreferenced_ttl_days: int = DEFAULT_UNREFERENCED_RUN_TTL_DAYS, today: date | None = None) -> dict[str, Any]:
|
|
813
|
+
"""方案 §9.1: keep referenced runs until release-closeout + retention; expire unreferenced runs after a short TTL."""
|
|
814
|
+
today = today or datetime.now(timezone.utc).date()
|
|
815
|
+
directory = playwright_adapter.runs_dir(root, feature)
|
|
816
|
+
_, _, result_path = paths(root, feature)
|
|
817
|
+
referenced = referenced_execution_ids(result_path)
|
|
818
|
+
closed = closeout_completed_on(root, feature)
|
|
819
|
+
kept: list[dict[str, str]] = []
|
|
820
|
+
removed: list[str] = []
|
|
821
|
+
if directory.is_dir() and not directory.is_symlink():
|
|
822
|
+
for entry in sorted(directory.iterdir()):
|
|
823
|
+
name = entry.name
|
|
824
|
+
if entry.is_symlink() or not entry.is_dir() or not playwright_adapter.EXECUTION_ID_RE.fullmatch(name):
|
|
825
|
+
kept.append({"execution_id": name, "reason": "not a Core run directory"})
|
|
826
|
+
continue
|
|
827
|
+
started = datetime.strptime(name[:16], "%Y%m%dT%H%M%SZ").date()
|
|
828
|
+
if name in referenced:
|
|
829
|
+
if closed is None:
|
|
830
|
+
kept.append({"execution_id": name, "reason": "referenced by acceptance-results.json before release-closeout"})
|
|
831
|
+
continue
|
|
832
|
+
if today < closed + timedelta(days=retention_days):
|
|
833
|
+
kept.append({"execution_id": name, "reason": f"referenced; retained {retention_days} days after release-closeout"})
|
|
834
|
+
continue
|
|
835
|
+
elif today < started + timedelta(days=unreferenced_ttl_days):
|
|
836
|
+
kept.append({"execution_id": name, "reason": f"unreferenced run younger than {unreferenced_ttl_days} days"})
|
|
837
|
+
continue
|
|
838
|
+
removed.append(name)
|
|
839
|
+
if write:
|
|
840
|
+
shutil.rmtree(entry)
|
|
841
|
+
return {"ok": True, "feature": feature, "written": write, "removed": removed, "kept": kept}
|
|
436
842
|
|
|
437
843
|
|
|
438
844
|
def main() -> int:
|
|
439
845
|
parser = argparse.ArgumentParser()
|
|
440
|
-
parser.add_argument("action", choices=("validate", "run", "validate-results"))
|
|
846
|
+
parser.add_argument("action", choices=("validate", "validate-human", "run", "validate-results", "report", "gc-runs"))
|
|
441
847
|
parser.add_argument("--feature", required=True)
|
|
442
848
|
parser.add_argument("--write", action="store_true")
|
|
849
|
+
parser.add_argument("--retention-days", type=int, default=DEFAULT_RUN_RETENTION_DAYS)
|
|
850
|
+
parser.add_argument("--unreferenced-ttl-days", type=int, default=DEFAULT_UNREFERENCED_RUN_TTL_DAYS)
|
|
443
851
|
parser.add_argument("--json", action="store_true")
|
|
444
852
|
args = parser.parse_args()
|
|
445
853
|
root = Path.cwd().resolve()
|
|
@@ -447,12 +855,21 @@ def main() -> int:
|
|
|
447
855
|
result = {"ok": False, "errors": ["invalid feature"]}
|
|
448
856
|
elif args.action == "validate":
|
|
449
857
|
result, _, _ = validate(root, args.feature)
|
|
858
|
+
elif args.action == "validate-human":
|
|
859
|
+
suite_path, _, _ = paths(root, args.feature)
|
|
860
|
+
errors: list[str] = []
|
|
861
|
+
suite = read_json(suite_path, errors, "TC suite")
|
|
862
|
+
result = {"ok": False, "feature": args.feature, "errors": errors} if suite is None else validate_human_results(root, args.feature, suite)[0]
|
|
450
863
|
elif args.action == "run":
|
|
451
864
|
result = run(root, args.feature, args.write) if args.write else {"ok": False, "errors": ["--write is required for run"]}
|
|
865
|
+
elif args.action == "gc-runs":
|
|
866
|
+
result = gc_runs(root, args.feature, args.write, max(args.retention_days, 0), max(args.unreferenced_ttl_days, 0))
|
|
867
|
+
elif args.action == "report":
|
|
868
|
+
result = report(root, args.feature)
|
|
452
869
|
else:
|
|
453
870
|
result = validate_results(root, args.feature)
|
|
454
871
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
455
|
-
return 0
|
|
872
|
+
return 0 if result.get("ok") is True else 1
|
|
456
873
|
|
|
457
874
|
|
|
458
875
|
if __name__ == "__main__":
|