@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.
Files changed (245) hide show
  1. package/README.md +34 -4
  2. package/node_modules/@playwright/test/LICENSE +202 -0
  3. package/node_modules/@playwright/test/NOTICE +5 -0
  4. package/node_modules/@playwright/test/README.md +318 -0
  5. package/node_modules/@playwright/test/cli.js +19 -0
  6. package/node_modules/@playwright/test/index.d.ts +18 -0
  7. package/node_modules/@playwright/test/index.js +17 -0
  8. package/node_modules/@playwright/test/index.mjs +18 -0
  9. package/node_modules/@playwright/test/package.json +35 -0
  10. package/node_modules/@playwright/test/reporter.d.ts +17 -0
  11. package/node_modules/@playwright/test/reporter.js +17 -0
  12. package/node_modules/@playwright/test/reporter.mjs +17 -0
  13. package/node_modules/playwright/LICENSE +202 -0
  14. package/node_modules/playwright/NOTICE +5 -0
  15. package/node_modules/playwright/README.md +318 -0
  16. package/node_modules/playwright/ThirdPartyNotices.txt +14 -0
  17. package/node_modules/playwright/cli.js +19 -0
  18. package/node_modules/playwright/index.d.ts +17 -0
  19. package/node_modules/playwright/index.js +17 -0
  20. package/node_modules/playwright/index.mjs +18 -0
  21. package/node_modules/playwright/jsx-runtime.js +42 -0
  22. package/node_modules/playwright/jsx-runtime.mjs +21 -0
  23. package/node_modules/playwright/lib/agents/agentParser.js +89 -0
  24. package/node_modules/playwright/lib/agents/copilot-setup-steps.yml +34 -0
  25. package/node_modules/playwright/lib/agents/generateAgents.js +395 -0
  26. package/node_modules/playwright/lib/agents/playwright-test-coverage.prompt.md +31 -0
  27. package/node_modules/playwright/lib/agents/playwright-test-generate.prompt.md +8 -0
  28. package/node_modules/playwright/lib/agents/playwright-test-generator.agent.md +88 -0
  29. package/node_modules/playwright/lib/agents/playwright-test-heal.prompt.md +6 -0
  30. package/node_modules/playwright/lib/agents/playwright-test-healer.agent.md +56 -0
  31. package/node_modules/playwright/lib/agents/playwright-test-plan.prompt.md +9 -0
  32. package/node_modules/playwright/lib/agents/playwright-test-planner.agent.md +74 -0
  33. package/node_modules/playwright/lib/cli/reportActions.js +78 -0
  34. package/node_modules/playwright/lib/cli/testActions.js +214 -0
  35. package/node_modules/playwright/lib/common/index.js +2923 -0
  36. package/node_modules/playwright/lib/common/index.js.txt +34 -0
  37. package/node_modules/playwright/lib/errorContext.js +129 -0
  38. package/node_modules/playwright/lib/globals.js +58 -0
  39. package/node_modules/playwright/lib/index.js +815 -0
  40. package/node_modules/playwright/lib/isomorphic.js +260 -0
  41. package/node_modules/playwright/lib/isomorphic.js.txt +9 -0
  42. package/node_modules/playwright/lib/loader/loaderProcessEntry.js +34 -0
  43. package/node_modules/playwright/lib/loader/loaderProcessEntry.js.txt +9 -0
  44. package/node_modules/playwright/lib/matchers/expect.js +13490 -0
  45. package/node_modules/playwright/lib/matchers/expect.js.LICENSE +640 -0
  46. package/node_modules/playwright/lib/matchers/expect.js.txt +67 -0
  47. package/node_modules/playwright/lib/mcp/test/browserBackend.js +135 -0
  48. package/node_modules/playwright/lib/mcp/test/generatorTools.js +122 -0
  49. package/node_modules/playwright/lib/mcp/test/plannerTools.js +150 -0
  50. package/node_modules/playwright/lib/mcp/test/seed.js +82 -0
  51. package/node_modules/playwright/lib/mcp/test/streams.js +44 -0
  52. package/node_modules/playwright/lib/mcp/test/testBackend.js +99 -0
  53. package/node_modules/playwright/lib/mcp/test/testContext.js +306 -0
  54. package/node_modules/playwright/lib/mcp/test/testTool.js +30 -0
  55. package/node_modules/playwright/lib/mcp/test/testTools.js +98 -0
  56. package/node_modules/playwright/lib/package.js +47 -0
  57. package/node_modules/playwright/lib/program.js +241 -0
  58. package/node_modules/playwright/lib/runner/index.js +8527 -0
  59. package/node_modules/playwright/lib/runner/index.js.txt +61 -0
  60. package/node_modules/playwright/lib/transform/babelBundle.js +72274 -0
  61. package/node_modules/playwright/lib/transform/babelBundle.js.LICENSE +2384 -0
  62. package/node_modules/playwright/lib/transform/babelBundle.js.txt +325 -0
  63. package/node_modules/playwright/lib/transform/esmLoader.js +8054 -0
  64. package/node_modules/playwright/lib/transform/esmLoader.js.LICENSE +355 -0
  65. package/node_modules/playwright/lib/transform/esmLoader.js.txt +60 -0
  66. package/node_modules/playwright/lib/util.js +373 -0
  67. package/node_modules/playwright/lib/worker/workerProcessEntry.js +1861 -0
  68. package/node_modules/playwright/lib/worker/workerProcessEntry.js.txt +19 -0
  69. package/node_modules/playwright/package.json +55 -0
  70. package/node_modules/playwright/test.d.ts +18 -0
  71. package/node_modules/playwright/test.js +24 -0
  72. package/node_modules/playwright/test.mjs +35 -0
  73. package/node_modules/playwright/types/test.d.ts +11074 -0
  74. package/node_modules/playwright/types/testReporter.d.ts +950 -0
  75. package/node_modules/playwright-core/LICENSE +202 -0
  76. package/node_modules/playwright-core/NOTICE +5 -0
  77. package/node_modules/playwright-core/README.md +3 -0
  78. package/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
  79. package/node_modules/playwright-core/bin/install_media_pack.ps1 +5 -0
  80. package/node_modules/playwright-core/bin/install_webkit_wsl.ps1 +33 -0
  81. package/node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh +42 -0
  82. package/node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh +13 -0
  83. package/node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1 +24 -0
  84. package/node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh +42 -0
  85. package/node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh +12 -0
  86. package/node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1 +24 -0
  87. package/node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh +48 -0
  88. package/node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh +11 -0
  89. package/node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1 +23 -0
  90. package/node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh +48 -0
  91. package/node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh +11 -0
  92. package/node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1 +23 -0
  93. package/node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh +48 -0
  94. package/node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh +11 -0
  95. package/node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1 +24 -0
  96. package/node_modules/playwright-core/browsers.json +52 -0
  97. package/node_modules/playwright-core/cli.js +21 -0
  98. package/node_modules/playwright-core/index.d.ts +17 -0
  99. package/node_modules/playwright-core/index.js +17 -0
  100. package/node_modules/playwright-core/index.mjs +28 -0
  101. package/node_modules/playwright-core/lib/bootstrap.js +88 -0
  102. package/node_modules/playwright-core/lib/coreBundle.js +75813 -0
  103. package/node_modules/playwright-core/lib/entry/cliDaemon.js +5 -0
  104. package/node_modules/playwright-core/lib/entry/dashboardApp.js +3 -0
  105. package/node_modules/playwright-core/lib/entry/mcp.js +10 -0
  106. package/node_modules/playwright-core/lib/entry/oopBrowserDownload.js +3 -0
  107. package/node_modules/playwright-core/lib/package.js +50 -0
  108. package/node_modules/playwright-core/lib/server/chromium/appIcon.png +0 -0
  109. package/node_modules/playwright-core/lib/server/electron/loader.js +116 -0
  110. package/node_modules/playwright-core/lib/serverRegistry.js +1939 -0
  111. package/node_modules/playwright-core/lib/serverRegistry.js.LICENSE +65 -0
  112. package/node_modules/playwright-core/lib/tools/cli-client/channelSessions.js +141 -0
  113. package/node_modules/playwright-core/lib/tools/cli-client/cli.js +6 -0
  114. package/node_modules/playwright-core/lib/tools/cli-client/help.json +721 -0
  115. package/node_modules/playwright-core/lib/tools/cli-client/minimist.js +128 -0
  116. package/node_modules/playwright-core/lib/tools/cli-client/output.js +351 -0
  117. package/node_modules/playwright-core/lib/tools/cli-client/program.js +418 -0
  118. package/node_modules/playwright-core/lib/tools/cli-client/registry.js +180 -0
  119. package/node_modules/playwright-core/lib/tools/cli-client/session.js +259 -0
  120. package/node_modules/playwright-core/lib/tools/dashboard/appIcon.png +0 -0
  121. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/SKILL.md +425 -0
  122. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/element-attributes.md +23 -0
  123. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/playwright-tests.md +39 -0
  124. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/request-mocking.md +87 -0
  125. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/running-code.md +241 -0
  126. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/session-management.md +225 -0
  127. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/storage-state.md +275 -0
  128. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/test-generation.md +433 -0
  129. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/tracing.md +139 -0
  130. package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/video-recording.md +143 -0
  131. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md +136 -0
  132. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md +144 -0
  133. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md +89 -0
  134. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md +69 -0
  135. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/typing.md +173 -0
  136. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md +77 -0
  137. package/node_modules/playwright-core/lib/tools/skills/playwright-trace/SKILL.md +174 -0
  138. package/node_modules/playwright-core/lib/tools/utils/extension.js +112 -0
  139. package/node_modules/playwright-core/lib/tools/utils/socketConnection.js +108 -0
  140. package/node_modules/playwright-core/lib/utilsBundle.js +85357 -0
  141. package/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +1890 -0
  142. package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule--QdMvsKi.css +1 -0
  143. package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule-CQ8RZGBm.js +32 -0
  144. package/node_modules/playwright-core/lib/vite/dashboard/assets/codicon-DCmgc-ay.ttf +0 -0
  145. package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-1bWoP6pv.svg +1 -0
  146. package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-beta-k3eOH_eK.svg +1 -0
  147. package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-nightly-Cp5nfeDT.svg +1 -0
  148. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-AwgwcYie.css +1 -0
  149. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-hRvO4yU2.js +12 -0
  150. package/node_modules/playwright-core/lib/vite/dashboard/assets/safari-na3_-uQk.svg +1 -0
  151. package/node_modules/playwright-core/lib/vite/dashboard/index.html +29 -0
  152. package/node_modules/playwright-core/lib/vite/dashboard/playwright-logo.svg +24 -0
  153. package/node_modules/playwright-core/lib/vite/htmlReport/index.html +16 -0
  154. package/node_modules/playwright-core/lib/vite/htmlReport/report.css +2 -0
  155. package/node_modules/playwright-core/lib/vite/htmlReport/report.js +32 -0
  156. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css +1 -0
  157. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CVQWJZAA.js +32 -0
  158. package/node_modules/playwright-core/lib/vite/recorder/assets/codicon-DCmgc-ay.ttf +0 -0
  159. package/node_modules/playwright-core/lib/vite/recorder/assets/index-BZpYJZ-P.css +1 -0
  160. package/node_modules/playwright-core/lib/vite/recorder/assets/index-hrlLqLtq.js +129 -0
  161. package/node_modules/playwright-core/lib/vite/recorder/index.html +29 -0
  162. package/node_modules/playwright-core/lib/vite/recorder/playwright-logo.svg +9 -0
  163. package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-BbkfBe3n.js +32 -0
  164. package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Ds6CBOo0.js +182 -0
  165. package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js +1 -0
  166. package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-DywYcAf8.js +7 -0
  167. package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css +1 -0
  168. package/node_modules/playwright-core/lib/vite/traceViewer/codicon.DCmgc-ay.ttf +0 -0
  169. package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.Bqk9acqE.css +1 -0
  170. package/node_modules/playwright-core/lib/vite/traceViewer/index.B4cLoZK3.js +1 -0
  171. package/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css +1 -0
  172. package/node_modules/playwright-core/lib/vite/traceViewer/index.html +44 -0
  173. package/node_modules/playwright-core/lib/vite/traceViewer/manifest.webmanifest +16 -0
  174. package/node_modules/playwright-core/lib/vite/traceViewer/playwright-logo.svg +9 -0
  175. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js +1 -0
  176. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html +10 -0
  177. package/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js +4 -0
  178. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CU5KtEkS.js +5 -0
  179. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CyMfwkXJ.css +1 -0
  180. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +18 -0
  181. package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css +1 -0
  182. package/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  183. package/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  184. package/node_modules/playwright-core/lib/xdg-open +1267 -0
  185. package/node_modules/playwright-core/package.json +34 -0
  186. package/node_modules/playwright-core/types/protocol.d.ts +24645 -0
  187. package/node_modules/playwright-core/types/structs.d.ts +52 -0
  188. package/node_modules/playwright-core/types/types.d.ts +26632 -0
  189. package/package.json +13 -2
  190. package/plugins/devflow-core/extensions/devflow-core.ts +432 -7
  191. package/plugins/devflow-core/runtime/hooks/hook_common.py +514 -0
  192. package/plugins/devflow-core/runtime/hooks/index_gate.py +47 -16
  193. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +488 -71
  194. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +51 -0
  195. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_pins.py +433 -0
  196. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_store.py +137 -0
  197. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +87 -0
  198. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py +53 -0
  199. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +318 -0
  200. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +134 -0
  201. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
  202. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_surface.py +138 -0
  203. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +515 -0
  204. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tc_hash.py +132 -0
  205. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +102 -8
  206. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +13 -0
  207. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +13 -34
  208. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -1
  209. package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +68 -140
  210. package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +1 -1
  211. package/plugins/devflow-implementation/skills/workflow-execute-phase/references/execute-phase.md +3 -1
  212. package/plugins/devflow-intel/extensions/devflow-intel.ts +1 -1
  213. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +23 -4
  214. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +872 -0
  215. package/plugins/devflow-quality/runtime/playwright/playwright.config.mjs +46 -0
  216. package/plugins/devflow-quality/runtime/playwright/reporter.mjs +311 -0
  217. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +1308 -0
  218. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +11 -0
  219. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -0
  220. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +71 -58
  221. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/api-mainflow-rules.md +20 -15
  222. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/codebase-discovery.md +3 -1
  223. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +115 -192
  224. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/mainflow-selection.md +27 -23
  225. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +78 -153
  226. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +36 -83
  227. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +497 -556
  228. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/export_legacy_testagent.py +647 -0
  229. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/normalize_case_payloads.py +10 -3
  230. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/validate_case_payloads.py +165 -275
  231. package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +63 -0
  232. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +4 -4
  233. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +17 -9
  234. package/src/approval-store.ts +340 -0
  235. package/src/cli.ts +10 -0
  236. package/src/constants.ts +1 -1
  237. package/src/doctor.ts +268 -2
  238. package/src/initialization-store.ts +48 -1
  239. package/src/mcp/auth.ts +103 -0
  240. package/src/mcp/core.ts +102 -0
  241. package/src/mcp/server.ts +193 -0
  242. package/src/mcp/types.ts +5 -0
  243. package/src/paths.ts +12 -1
  244. package/src/project-context.ts +24 -1
  245. 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 evidence_artifacts(root: Path, value: Any) -> tuple[list[dict[str, Any]], list[str]]:
148
- """Hash causal evidence from bounded project-relative files only."""
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 [], ["causal verification requires non-empty evidence_files"]
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
- continue
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
- if mode != source.get("automation_mode"):
295
- errors.append(f"{label}.mode must match frozen TC automation_mode")
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
- elif adapter.get("kind") == "command":
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 adapter.get("kind") == "http":
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.append(f"{label}.adapter kind must be command or http")
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
- _artifacts, artifact_errors = evidence_artifacts(root, entry.get("evidence_files"))
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
- strategy = cases[tc_id].get("evidence_strategy")
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
- if adapter["kind"] == "command":
490
+ kind = adapter["kind"]
491
+ if kind == "command":
353
492
  argv = adapter["argv"]
354
- try:
355
- completed = subprocess.run(argv, cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180, check=False)
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
- else:
368
- passed, exit_code, output = run_http_case(cases[tc_id], adapter)
369
- result = {"tc_id": tc_id, "status": "passed" if passed else "failed", "mode": mode, "adapter_kind": "http",
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"] = "failed"
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
- results.append({"tc_id": tc_id, "status": "blocked", "mode": mode, "owner": entry["owner"], "reason": entry["reason"],
382
- "blocked_by": "manual execution has no Core-observed result"})
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
- results.append({"tc_id": tc_id, "status": "blocked", "mode": mode, "owner": entry["owner"], "blocked_by": entry["blocked_by"]})
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), "results": results, "passed": all_passed, "history": history[-20:]}
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 validate_results(root: Path, feature: str) -> dict[str, Any]:
416
- validation, suite, _ = validate(root, feature)
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
- return {"ok": not errors, "feature": feature, "errors": errors, "result_path": str(result_path.relative_to(root))}
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__":