@guanlin4924/devflow-pi 0.1.94 → 0.1.95

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 (238) hide show
  1. package/README.md +4 -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 +12 -2
  190. package/plugins/devflow-core/extensions/devflow-core.ts +431 -7
  191. package/plugins/devflow-core/runtime/hooks/hook_common.py +269 -0
  192. package/plugins/devflow-core/runtime/hooks/index_gate.py +29 -16
  193. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +407 -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_v1.py +318 -0
  199. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +134 -0
  200. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
  201. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_surface.py +138 -0
  202. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +515 -0
  203. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tc_hash.py +132 -0
  204. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +102 -8
  205. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +13 -0
  206. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +13 -34
  207. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -1
  208. package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +68 -140
  209. package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +1 -1
  210. package/plugins/devflow-implementation/skills/workflow-execute-phase/references/execute-phase.md +3 -1
  211. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +23 -4
  212. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +872 -0
  213. package/plugins/devflow-quality/runtime/playwright/playwright.config.mjs +46 -0
  214. package/plugins/devflow-quality/runtime/playwright/reporter.mjs +311 -0
  215. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +1308 -0
  216. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +11 -0
  217. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -0
  218. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +71 -58
  219. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/api-mainflow-rules.md +20 -15
  220. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/codebase-discovery.md +3 -1
  221. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +115 -192
  222. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/mainflow-selection.md +27 -23
  223. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +78 -153
  224. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +36 -83
  225. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +497 -556
  226. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/export_legacy_testagent.py +647 -0
  227. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/normalize_case_payloads.py +10 -3
  228. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/validate_case_payloads.py +165 -275
  229. package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +61 -0
  230. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +4 -4
  231. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +15 -9
  232. package/src/approval-store.ts +340 -0
  233. package/src/constants.ts +1 -1
  234. package/src/doctor.ts +259 -2
  235. package/src/initialization-store.ts +48 -1
  236. package/src/paths.ts +12 -1
  237. package/src/project-context.ts +24 -1
  238. package/templates/omp/hook-config.json +6 -0
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ """Structural coverage of a frozen TC by its v1 DSL (方案 §6.1, first layer).
3
+
4
+ Every frozen precondition and step must be covered by at least one DSL step;
5
+ the oracle must be covered by at least one non-weak assertion. This only
6
+ detects omissions and obviously weak assertions; semantic adequacy remains the
7
+ job of the human-approved execution plan.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from dsl_v1 import UI_ASSERTIONS, UI_INTERACTIONS, all_steps
14
+
15
+
16
+ def is_assertion(step: dict[str, Any]) -> bool:
17
+ if step.get("driver") == "ui":
18
+ return step.get("action") in UI_ASSERTIONS
19
+ return step.get("driver") == "api" and (bool(step.get("assertions")) or "expect_status" in step)
20
+
21
+
22
+ def weak_reason(step: dict[str, Any], preceding: list[dict[str, Any]]) -> str | None:
23
+ """MVP weak assertions: assert_visible of a static entry element; 2xx-only API checks."""
24
+ if step.get("driver") == "api":
25
+ status = step.get("expect_status")
26
+ if not step.get("assertions") and (status is None or 200 <= status <= 299):
27
+ return "API assertion only checks a 2xx status without response body assertions"
28
+ return None
29
+ if step.get("action") != "assert_visible":
30
+ return None
31
+ interactions = [item for item in preceding if (item.get("driver") == "ui" and item.get("action") in UI_INTERACTIONS)
32
+ or (item.get("driver") == "api" and item.get("method") != "GET")]
33
+ if not interactions:
34
+ return "assert_visible before any interaction only proves a static entry element is present"
35
+ if any(item.get("target") == step.get("target") for item in interactions):
36
+ return "assert_visible on an element the flow itself interacted with does not verify an outcome"
37
+ return None
38
+
39
+
40
+ def _count(data: dict[str, Any], key: str) -> int:
41
+ value = data.get(key)
42
+ if isinstance(value, list):
43
+ return len(value)
44
+ return 1 if isinstance(value, str) and value.strip() else 0
45
+
46
+
47
+ def check(tc_data: dict[str, Any], dsl: dict[str, Any]) -> list[str]:
48
+ errors: list[str] = []
49
+ counts = {"preconditions": _count(tc_data, "preconditions"), "steps": _count(tc_data, "steps")}
50
+ covered: dict[str, set[int]] = {"preconditions": set(), "steps": set()}
51
+ oracle = tc_data.get("oracle")
52
+ has_oracle = (isinstance(oracle, str) and bool(oracle.strip())) or (isinstance(oracle, list) and bool(oracle))
53
+ oracle_assertions: list[tuple[str, dict[str, Any], str | None]] = []
54
+ main: list[dict[str, Any]] = []
55
+ for phase, path, step in all_steps(dsl):
56
+ covers = step.get("covers") if isinstance(step.get("covers"), dict) else {}
57
+ if phase == "cleanup" and covers:
58
+ errors.append(f"{path}.covers is not allowed on cleanup steps")
59
+ continue
60
+ for key in ("preconditions", "steps"):
61
+ for index in covers.get(key, []):
62
+ if not isinstance(index, int) or isinstance(index, bool) or not 1 <= index <= counts[key]:
63
+ errors.append(f"{path}.covers.{key} index {index} is outside the frozen TC {key} (1..{counts[key]})")
64
+ else:
65
+ covered[key].add(index)
66
+ if covers.get("oracle") is True:
67
+ if not is_assertion(step):
68
+ errors.append(f"{path} covers the oracle but {step.get('action')} is not an assertion")
69
+ elif phase != "main":
70
+ errors.append(f"{path} covers the oracle outside the main steps")
71
+ else:
72
+ oracle_assertions.append((path, step, weak_reason(step, main)))
73
+ if phase == "main":
74
+ main.append(step)
75
+ for key in ("preconditions", "steps"):
76
+ missing = sorted(set(range(1, counts[key] + 1)) - covered[key])
77
+ if missing:
78
+ errors.append(f"frozen TC {key} not covered by any DSL step: {', '.join(map(str, missing))}")
79
+ if has_oracle and not oracle_assertions:
80
+ errors.append("frozen TC oracle is not covered by any assertion step")
81
+ elif oracle_assertions and all(reason for _path, _step, reason in oracle_assertions):
82
+ errors.extend(f"{path}: weak oracle assertion ({reason})" for path, _step, reason in oracle_assertions)
83
+ errors.append("frozen TC oracle is covered only by weak assertions")
84
+ if tc_data.get("execution_surface") == "ui" or (tc_data.get("execution_surface") is None and tc_data.get("test_type") == "page"):
85
+ if oracle_assertions and not any(step.get("driver") == "ui" and not reason for _path, step, reason in oracle_assertions):
86
+ errors.append("UI TC oracle must be covered by a non-weak page assertion; API checks cannot replace it")
87
+ return errors
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ """Pure-data validator for devflow-test-dsl/v1 (standard library only).
3
+
4
+ Structural validation interprets the shipped JSON Schema
5
+ (RT/devflow-test-dsl-v1.schema.json) with a small 2020-12 subset evaluator, so
6
+ the Python gate and the Runtime judge the shared corpus from one source.
7
+ Semantic rules the schema cannot express (unique ids, variable scoping, retry
8
+ safety, secret headers) are checked afterwards. Nothing here imports or
9
+ executes code from the project under test.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ from functools import lru_cache
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from tc_hash import canonicalize
23
+
24
+
25
+ SCHEMA_NAME = "devflow-test-dsl-v1.schema.json"
26
+ SCHEMA_VERSION = "devflow-test-dsl/v1"
27
+ BUILTIN_VARIABLES = frozenset({"execution_id", "namespace"})
28
+ VARIABLE_REF_RE = re.compile(r"\$\{([^}]*)\}")
29
+ VARIABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
30
+ SECRET_HEADERS = frozenset({"authorization", "proxy-authorization", "cookie", "x-api-key", "x-auth-token"})
31
+ UI_ASSERTIONS = frozenset({"assert_visible", "assert_text", "assert_url"})
32
+ UI_INTERACTIONS = frozenset({"click", "fill", "select", "check", "uncheck"})
33
+ _IGNORED_KEYWORDS = {"$schema", "$id", "title", "description", "$defs", "default", "examples"}
34
+
35
+
36
+ def runtime_dir_candidates() -> list[Path]:
37
+ """Script-relative package first, then DEVFLOW_KIT_ROOT (…/plugins/devflow-core/skills)."""
38
+ candidates = [Path(__file__).resolve().parents[4] / "devflow-quality" / "runtime" / "playwright"]
39
+ configured = os.environ.get("DEVFLOW_KIT_ROOT")
40
+ if configured:
41
+ candidates.append(Path(configured).expanduser().resolve().parents[1] / "devflow-quality" / "runtime" / "playwright")
42
+ return candidates
43
+
44
+
45
+ def schema_path() -> Path | None:
46
+ return next((candidate / SCHEMA_NAME for candidate in runtime_dir_candidates() if (candidate / SCHEMA_NAME).is_file()), None)
47
+
48
+
49
+ @lru_cache(maxsize=4)
50
+ def _load_schema(path: str) -> dict[str, Any]:
51
+ return json.loads(Path(path).read_text(encoding="utf-8"))
52
+
53
+
54
+ def load_schema() -> dict[str, Any]:
55
+ path = schema_path()
56
+ if path is None:
57
+ raise FileNotFoundError(f"{SCHEMA_NAME} not found in the Devflow runtime directory")
58
+ return _load_schema(str(path))
59
+
60
+
61
+ def _json_equal(left: Any, right: Any) -> bool:
62
+ try:
63
+ return canonicalize(left) == canonicalize(right)
64
+ except ValueError:
65
+ return False
66
+
67
+
68
+ def _type_ok(value: Any, expected: str) -> bool:
69
+ if expected == "object":
70
+ return isinstance(value, dict)
71
+ if expected == "array":
72
+ return isinstance(value, list)
73
+ if expected == "string":
74
+ return isinstance(value, str)
75
+ if expected == "boolean":
76
+ return isinstance(value, bool)
77
+ if expected == "null":
78
+ return value is None
79
+ if expected == "integer":
80
+ return (isinstance(value, int) and not isinstance(value, bool)) or (isinstance(value, float) and value.is_integer())
81
+ if expected == "number":
82
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
83
+ raise ValueError(f"unsupported schema type: {expected}")
84
+
85
+
86
+ class _Validator:
87
+ def __init__(self, root: dict[str, Any]) -> None:
88
+ self.root = root
89
+
90
+ def resolve(self, schema: dict[str, Any]) -> dict[str, Any]:
91
+ while "$ref" in schema:
92
+ reference = schema["$ref"]
93
+ if not reference.startswith("#/"):
94
+ raise ValueError(f"unsupported $ref: {reference}")
95
+ target: Any = self.root
96
+ for token in reference[2:].split("/"):
97
+ target = target[token.replace("~1", "/").replace("~0", "~")]
98
+ schema = target
99
+ return schema
100
+
101
+ def errors(self, value: Any, schema: dict[str, Any] | bool, path: str) -> list[str]:
102
+ if schema is True:
103
+ return []
104
+ if schema is False:
105
+ return [f"{path} is not allowed"]
106
+ schema = self.resolve(schema)
107
+ unknown = set(schema) - _IGNORED_KEYWORDS - {
108
+ "type", "const", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "minItems", "maxItems",
109
+ "uniqueItems", "items", "properties", "required", "additionalProperties", "patternProperties",
110
+ "minProperties", "maxProperties", "oneOf"}
111
+ if unknown:
112
+ raise ValueError(f"unsupported schema keywords: {sorted(unknown)}")
113
+ if "oneOf" in schema:
114
+ return self.one_of(value, schema["oneOf"], path)
115
+ found: list[str] = []
116
+ if "type" in schema:
117
+ types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
118
+ if not any(_type_ok(value, name) for name in types):
119
+ return [f"{path} must be of type {' or '.join(types)}"]
120
+ if "const" in schema and not _json_equal(value, schema["const"]):
121
+ found.append(f"{path} must equal {json.dumps(schema['const'], ensure_ascii=False)}")
122
+ if "enum" in schema and not any(_json_equal(value, item) for item in schema["enum"]):
123
+ found.append(f"{path} must be one of {', '.join(json.dumps(item, ensure_ascii=False) for item in schema['enum'])}")
124
+ if isinstance(value, str):
125
+ if "minLength" in schema and len(value) < schema["minLength"]:
126
+ found.append(f"{path} must be at least {schema['minLength']} characters")
127
+ if "maxLength" in schema and len(value) > schema["maxLength"]:
128
+ found.append(f"{path} must be at most {schema['maxLength']} characters")
129
+ if "pattern" in schema and not re.search(schema["pattern"], value):
130
+ found.append(f"{path} does not match pattern {schema['pattern']}")
131
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
132
+ if "minimum" in schema and value < schema["minimum"]:
133
+ found.append(f"{path} must be >= {schema['minimum']}")
134
+ if "maximum" in schema and value > schema["maximum"]:
135
+ found.append(f"{path} must be <= {schema['maximum']}")
136
+ if isinstance(value, list):
137
+ if "minItems" in schema and len(value) < schema["minItems"]:
138
+ found.append(f"{path} must contain at least {schema['minItems']} items")
139
+ if "maxItems" in schema and len(value) > schema["maxItems"]:
140
+ found.append(f"{path} must contain at most {schema['maxItems']} items")
141
+ if schema.get("uniqueItems") and len({canonicalize(item) for item in value}) != len(value):
142
+ found.append(f"{path} items must be unique")
143
+ if "items" in schema:
144
+ for index, item in enumerate(value):
145
+ found.extend(self.errors(item, schema["items"], f"{path}[{index}]"))
146
+ if isinstance(value, dict):
147
+ if "minProperties" in schema and len(value) < schema["minProperties"]:
148
+ found.append(f"{path} must contain at least {schema['minProperties']} properties")
149
+ if "maxProperties" in schema and len(value) > schema["maxProperties"]:
150
+ found.append(f"{path} must contain at most {schema['maxProperties']} properties")
151
+ for key in schema.get("required", []):
152
+ if key not in value:
153
+ found.append(f"{path}.{key} is required")
154
+ properties = schema.get("properties", {})
155
+ patterns = schema.get("patternProperties", {})
156
+ for key, child in value.items():
157
+ child_path = f"{path}.{key}"
158
+ matched = False
159
+ if key in properties:
160
+ matched = True
161
+ found.extend(self.errors(child, properties[key], child_path))
162
+ for pattern, child_schema in patterns.items():
163
+ if re.search(pattern, key):
164
+ matched = True
165
+ found.extend(self.errors(child, child_schema, child_path))
166
+ if not matched and "additionalProperties" in schema:
167
+ additional = schema["additionalProperties"]
168
+ if additional is False:
169
+ found.append(f"{child_path} is not an allowed property")
170
+ else:
171
+ found.extend(self.errors(child, additional, child_path))
172
+ return found
173
+
174
+ def one_of(self, value: Any, branches: list[Any], path: str) -> list[str]:
175
+ results = [self.errors(value, branch, path) for branch in branches]
176
+ passing = [index for index, errors in enumerate(results) if not errors]
177
+ if len(passing) == 1:
178
+ return []
179
+ if len(passing) > 1:
180
+ return [f"{path} matches more than one allowed form"]
181
+ # Report the branch selected by const discriminators (e.g. driver + action).
182
+ applicable: list[int] = []
183
+ discriminators: set[str] = set()
184
+ for index, branch in enumerate(branches):
185
+ resolved = self.resolve(branch) if isinstance(branch, dict) else {}
186
+ consts = {key: child["const"] for key, child in resolved.get("properties", {}).items() if isinstance(child, dict) and "const" in child}
187
+ discriminators.update(consts)
188
+ if consts and isinstance(value, dict) and all(key in value and _json_equal(value[key], expected) for key, expected in consts.items()):
189
+ applicable.append(index)
190
+ if len(applicable) == 1:
191
+ return results[applicable[0]]
192
+ if discriminators and isinstance(value, dict):
193
+ shown = ", ".join(f"{key}={json.dumps(value.get(key), ensure_ascii=False)}" for key in sorted(discriminators))
194
+ return [f"{path} has an unsupported combination ({shown})"]
195
+ if isinstance(value, dict) and not value:
196
+ return [f"{path} must not be empty"]
197
+ return [f"{path} must match exactly one allowed form"]
198
+
199
+
200
+ def schema_errors(document: Any) -> list[str]:
201
+ schema = load_schema()
202
+ return _Validator(schema).errors(document, schema, "$")
203
+
204
+
205
+ def _strings(value: Any, path: str) -> list[tuple[str, str]]:
206
+ if isinstance(value, str):
207
+ return [(path, value)]
208
+ if isinstance(value, dict):
209
+ return [item for key, child in value.items() for item in _strings(child, f"{path}.{key}")]
210
+ if isinstance(value, list):
211
+ return [item for index, child in enumerate(value) for item in _strings(child, f"{path}[{index}]")]
212
+ return []
213
+
214
+
215
+ def _references(value: Any, path: str, known: set[str], errors: list[str]) -> set[str]:
216
+ used: set[str] = set()
217
+ for location, text in _strings(value, path):
218
+ for match in VARIABLE_REF_RE.finditer(text):
219
+ name = match.group(1)
220
+ if not VARIABLE_NAME_RE.fullmatch(name):
221
+ errors.append(f"{location} has an invalid variable reference ${{{name}}}")
222
+ elif name not in known:
223
+ errors.append(f"{location} references undefined variable ${{{name}}}")
224
+ used.add(name)
225
+ stripped = VARIABLE_REF_RE.sub("", text)
226
+ if "${" in stripped:
227
+ errors.append(f"{location} has an unterminated variable reference")
228
+ return used
229
+
230
+
231
+ def all_steps(dsl: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]:
232
+ """(phase, json-path, step) in execution order: setup, main, cleanup."""
233
+ data = dsl.get("data") if isinstance(dsl.get("data"), dict) else {}
234
+ out: list[tuple[str, str, dict[str, Any]]] = []
235
+ for phase, items, base in (("setup", data.get("setup"), "$.data.setup"), ("main", dsl.get("steps"), "$.steps"), ("cleanup", data.get("cleanup"), "$.data.cleanup")):
236
+ for index, step in enumerate(items if isinstance(items, list) else []):
237
+ if isinstance(step, dict):
238
+ out.append((phase, f"{base}[{index}]", step))
239
+ return out
240
+
241
+
242
+ def semantic_errors(dsl: dict[str, Any]) -> list[str]:
243
+ errors: list[str] = []
244
+ variables = dsl.get("variables", {})
245
+ sensitive: set[str] = set()
246
+ for name, spec in variables.items():
247
+ if name in BUILTIN_VARIABLES:
248
+ errors.append(f"$.variables.{name} redefines a built-in variable")
249
+ if "secret" in spec or spec.get("sensitive") is True:
250
+ sensitive.add(name)
251
+ known = set(variables) | {"execution_id"}
252
+ _references(dsl["data"]["namespace"], "$.data.namespace", known, errors)
253
+ known.add("namespace")
254
+ seen_ids: dict[str, str] = {}
255
+ for _phase, path, step in all_steps(dsl):
256
+ step_id = step["id"]
257
+ if step_id in seen_ids:
258
+ errors.append(f"{path}.id duplicates step id '{step_id}' ({seen_ids[step_id]})")
259
+ seen_ids.setdefault(step_id, path)
260
+ payload = {key: value for key, value in step.items() if key not in {"id", "driver", "action", "covers", "extract", "assertions", "retry"}}
261
+ _references(payload, path, known, errors)
262
+ if step["driver"] != "api":
263
+ continue
264
+ for index, assertion in enumerate(step.get("assertions", [])):
265
+ _references(assertion.get("value"), f"{path}.assertions[{index}].value", known, errors)
266
+ has_value = "value" in assertion
267
+ if assertion["op"] in {"exists", "not_exists"} and has_value:
268
+ errors.append(f"{path}.assertions[{index}] op {assertion['op']} must not have a value")
269
+ if assertion["op"] not in {"exists", "not_exists"} and not has_value:
270
+ errors.append(f"{path}.assertions[{index}] op {assertion['op']} requires a value")
271
+ if "retry" in step and step["method"] in {"POST", "PATCH"} and step.get("idempotent") is not True:
272
+ errors.append(f"{path}.retry on {step['method']} requires idempotent=true")
273
+ for header, value in step.get("headers", {}).items():
274
+ if header.lower() in SECRET_HEADERS:
275
+ refs = {match.group(1) for match in VARIABLE_REF_RE.finditer(value)}
276
+ if not refs or not refs <= sensitive:
277
+ errors.append(f"{path}.headers.{header} must be built only from secret or sensitive variables")
278
+ for name, spec in step.get("extract", {}).items():
279
+ if name in known:
280
+ errors.append(f"{path}.extract.{name} redefines an existing variable")
281
+ known.add(name)
282
+ if spec.get("sensitive") is True:
283
+ sensitive.add(name)
284
+ return errors
285
+
286
+
287
+ def validate(dsl: Any) -> list[str]:
288
+ try:
289
+ errors = schema_errors(dsl)
290
+ except (OSError, ValueError, KeyError) as error:
291
+ return [f"DSL v1 schema unavailable: {error}"]
292
+ return errors or semantic_errors(dsl)
293
+
294
+
295
+ def load_file(path: Path) -> tuple[Any, list[str]]:
296
+ try:
297
+ return json.loads(path.read_text(encoding="utf-8")), []
298
+ except FileNotFoundError:
299
+ return None, [f"DSL file is missing: {path.as_posix()}"]
300
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
301
+ return None, [f"DSL file is not valid JSON: {error}"]
302
+
303
+
304
+ def main() -> int:
305
+ parser = argparse.ArgumentParser(description="Validate devflow-test-dsl/v1 files.")
306
+ parser.add_argument("paths", nargs="+")
307
+ args = parser.parse_args()
308
+ report: dict[str, Any] = {}
309
+ for raw in args.paths:
310
+ document, errors = load_file(Path(raw))
311
+ report[raw] = errors or validate(document)
312
+ ok = not any(report.values())
313
+ print(json.dumps({"ok": ok, "errors": report}, ensure_ascii=False, indent=2))
314
+ return 0 if ok else 1
315
+
316
+
317
+ if __name__ == "__main__":
318
+ sys.exit(main())
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """Load and validate .devflow/planning/test-environments/<profile>.json.
3
+
4
+ The profile hash is the SHA-256 of the raw file bytes; only an
5
+ environment_profile approval pinned to that hash makes a profile usable.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urlsplit
16
+
17
+
18
+ PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
19
+ SECRET_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
20
+ ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$")
21
+ ORIGIN_RE = re.compile(r"^https?://(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?)(:[0-9]{1,5})?$")
22
+ RESERVED_ENV = frozenset({
23
+ "DEVFLOW_DSL_FILE", "DEVFLOW_DSL_SHA256", "DEVFLOW_TC_ID", "DEVFLOW_TC_SHA256", "DEVFLOW_EXECUTION_ID",
24
+ "DEVFLOW_EXECUTION_NONCE", "DEVFLOW_RESULT_FILE", "DEVFLOW_ARTIFACT_DIR", "DEVFLOW_ENVIRONMENT_FILE",
25
+ "PATH", "HOME", "TMPDIR", "LANG", "PLAYWRIGHT_BROWSERS_PATH", "SHELL", "USER", "PWD",
26
+ })
27
+ RESERVED_ENV_PREFIXES = ("NODE_", "LD_", "DYLD_", "PLAYWRIGHT_", "PYTHON", "BUN_", "NPM_", "DEVFLOW_HOME", "DEVFLOW_KIT", "DEVFLOW_SOFT")
28
+ SUBRESOURCE_POLICIES = ("same-site-plus-declared", "declared-only")
29
+ ARTIFACT_MODES = ("on_failure", "off")
30
+ TOP_LEVEL = {"profile", "base_url", "api_base_url", "navigation_origins", "api_origins", "subresources", "secret_env", "trace", "screenshots"}
31
+
32
+
33
+ def profile_path(root: Path, name: str) -> Path:
34
+ return root / ".devflow/planning" / "test-environments" / f"{name}.json"
35
+
36
+
37
+ def origin_of(url: str) -> str | None:
38
+ parts = urlsplit(url)
39
+ if parts.scheme not in {"http", "https"} or not parts.hostname or parts.username or parts.password:
40
+ return None
41
+ origin = f"{parts.scheme}://{parts.netloc}"
42
+ return origin if ORIGIN_RE.fullmatch(origin) else None
43
+
44
+
45
+ def _origins(value: Any, label: str, errors: list[str], required: bool) -> list[str]:
46
+ if not isinstance(value, list) or (required and not value):
47
+ errors.append(f"{label} must be a {'non-empty ' if required else ''}array of origins")
48
+ return []
49
+ origins: list[str] = []
50
+ for index, item in enumerate(value):
51
+ if not isinstance(item, str) or not ORIGIN_RE.fullmatch(item):
52
+ errors.append(f"{label}[{index}] must be an exact http(s) origin without path, wildcard, or credentials")
53
+ elif item in origins:
54
+ errors.append(f"{label}[{index}] duplicates {item}")
55
+ else:
56
+ origins.append(item)
57
+ return origins
58
+
59
+
60
+ def validate_profile(profile: Any, name: str) -> list[str]:
61
+ if not isinstance(profile, dict):
62
+ return ["environment profile root must be an object"]
63
+ errors = [f"environment profile field is not allowed: {key}" for key in sorted(set(profile) - TOP_LEVEL)]
64
+ if profile.get("profile") != name:
65
+ errors.append("environment profile 'profile' must equal the profile file name")
66
+ navigation = _origins(profile.get("navigation_origins"), "navigation_origins", errors, required=True)
67
+ api = _origins(profile.get("api_origins", []), "api_origins", errors, required=False)
68
+ for key, allowed in (("base_url", navigation), ("api_base_url", api)):
69
+ if key not in profile and key == "api_base_url":
70
+ continue
71
+ value = profile.get(key)
72
+ origin = origin_of(value) if isinstance(value, str) else None
73
+ if origin is None:
74
+ errors.append(f"{key} must be an absolute http(s) URL")
75
+ elif origin not in allowed:
76
+ errors.append(f"{key} origin {origin} must be listed in {'navigation_origins' if key == 'base_url' else 'api_origins'}")
77
+ subresources = profile.get("subresources", {"policy": "same-site-plus-declared", "allowed_origins": []})
78
+ if not isinstance(subresources, dict) or set(subresources) - {"policy", "allowed_origins"}:
79
+ errors.append("subresources must be an object with policy and allowed_origins")
80
+ else:
81
+ if subresources.get("policy") not in SUBRESOURCE_POLICIES:
82
+ errors.append(f"subresources.policy must be one of {', '.join(SUBRESOURCE_POLICIES)}")
83
+ _origins(subresources.get("allowed_origins", []), "subresources.allowed_origins", errors, required=False)
84
+ secret_env = profile.get("secret_env", {})
85
+ if not isinstance(secret_env, dict):
86
+ errors.append("secret_env must be an object mapping secret keys to environment variable names")
87
+ else:
88
+ for key, env_name in secret_env.items():
89
+ if not SECRET_KEY_RE.fullmatch(key):
90
+ errors.append(f"secret_env key is invalid: {key}")
91
+ if not isinstance(env_name, str) or not ENV_NAME_RE.fullmatch(env_name):
92
+ errors.append(f"secret_env.{key} must name an upper-case environment variable")
93
+ elif env_name in RESERVED_ENV or env_name.startswith(RESERVED_ENV_PREFIXES):
94
+ errors.append(f"secret_env.{key} uses a reserved environment variable: {env_name}")
95
+ for key in ("trace", "screenshots"):
96
+ if key in profile and profile[key] not in ARTIFACT_MODES:
97
+ errors.append(f"{key} must be one of {', '.join(ARTIFACT_MODES)}")
98
+ return errors
99
+
100
+
101
+ def load(root: Path, name: str) -> tuple[dict[str, Any] | None, str | None, list[str]]:
102
+ """Return (profile, sha256 of raw bytes, errors)."""
103
+ if not isinstance(name, str) or not PROFILE_NAME_RE.fullmatch(name):
104
+ return None, None, [f"invalid environment profile name: {name!r}"]
105
+ path = profile_path(root, name)
106
+ try:
107
+ if path.is_symlink():
108
+ return None, None, [f"environment profile must not be a symlink: {path.relative_to(root).as_posix()}"]
109
+ raw = path.read_bytes()
110
+ except FileNotFoundError:
111
+ return None, None, [f"environment profile is missing: {path.relative_to(root).as_posix()}"]
112
+ except OSError as error:
113
+ return None, None, [f"environment profile is unreadable: {error}"]
114
+ sha256 = hashlib.sha256(raw).hexdigest()
115
+ try:
116
+ profile = json.loads(raw.decode("utf-8"))
117
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
118
+ return None, sha256, [f"environment profile is not valid JSON: {error}"]
119
+ errors = validate_profile(profile, name)
120
+ return (profile if not errors else None), sha256, errors
121
+
122
+
123
+ def main() -> int:
124
+ parser = argparse.ArgumentParser()
125
+ parser.add_argument("name")
126
+ parser.add_argument("--json", action="store_true")
127
+ args = parser.parse_args()
128
+ _profile, sha256, errors = load(Path.cwd().resolve(), args.name)
129
+ print(json.dumps({"ok": not errors, "profile": args.name, "sha256": sha256, "errors": errors}, ensure_ascii=False, indent=2))
130
+ return 0
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main())
@@ -130,6 +130,7 @@ begin
130
130
  devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests devflow_mainflow
131
131
  devflow_requirement_graph devflow_static_runtime_context
132
132
  devflow_architecture_constraint devflow_architecture_feasibility devflow_architecture_brief
133
+ devflow_approval
133
134
  ],
134
135
  "tool_budget" => policy["budget"], "hard_gate_mode" => (workflow.dig("governance", "hard_gate_mode").to_s.empty? ? "chain" : workflow.dig("governance", "hard_gate_mode").to_s), "acceptance" => Array(graph["nodes"]).select { |node| node["kind"] == "acceptance" }.map { |node| node["id"] },
135
136
  "allowed_graph_delta" => Array(policy["allowed_graph_delta"]),