@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
@@ -558,6 +558,13 @@ HARD_FAILURE_REASONS = %w[
558
558
  cross_track_consistency_failed
559
559
  business_cross_model_check_failed
560
560
  gate_boundary_rejected
561
+ acceptance_results_not_passed
562
+ execution_surface_mismatch
563
+ approval_invalid
564
+ human_result_unapproved
565
+ dsl_not_approved
566
+ environment_profile_not_approved
567
+ runtime_integrity_error
561
568
  ].freeze
562
569
  $devflow_advisories = []
563
570
 
@@ -588,6 +595,32 @@ rescue JSON::ParserError => error
588
595
  { "ok" => false, "reason" => "test_task_binding_#{stage}_check_failed", "errors" => ["checker returned invalid JSON: #{error.message}"] }
589
596
  end
590
597
 
598
+ # Acceptance results are a hard gate even in the default soft mode: Core itself
599
+ # re-validates the frozen-suite results instead of trusting a checker summary.
600
+ ACCEPTANCE_RESULT_GATED_STEPS = %w[run-acceptance-tests release-closeout].freeze
601
+
602
+ def acceptance_results_check(root, feature)
603
+ script = File.join(__dir__, "acceptance_test_runner.py")
604
+ stdout, stderr, status = Open3.capture3("python3", script, "validate-results", "--feature", feature, "--json", chdir: root)
605
+ payload = JSON.parse(stdout)
606
+ return payload if status.success? && payload.is_a?(Hash) && payload["ok"] == true
607
+
608
+ { "ok" => false, "errors" => payload.is_a?(Hash) && payload["errors"].is_a?(Array) ? payload["errors"] : [stderr.strip.empty? ? "acceptance results checker returned invalid output" : stderr.strip] }
609
+ rescue JSON::ParserError => error
610
+ { "ok" => false, "errors" => ["acceptance results checker returned invalid JSON: #{error.message}"] }
611
+ end
612
+
613
+ def execution_surface_check(root, feature)
614
+ script = File.join(__dir__, "execution_surface.py")
615
+ stdout, stderr, status = Open3.capture3("python3", script, "check-suite", "--feature", feature, "--json", chdir: root)
616
+ payload = JSON.parse(stdout)
617
+ return payload if status.success? && payload.is_a?(Hash) && payload["ok"] == true
618
+
619
+ { "ok" => false, "errors" => payload.is_a?(Hash) && payload["errors"].is_a?(Array) ? payload["errors"] : [stderr.strip.empty? ? "execution surface checker returned invalid output" : stderr.strip] }
620
+ rescue JSON::ParserError => error
621
+ { "ok" => false, "errors" => ["execution surface checker returned invalid JSON: #{error.message}"] }
622
+ end
623
+
591
624
  def api_test_matrix_check(root, feature, graph)
592
625
  return { "ok" => true, "skipped" => "no_api_nodes" } unless Array(graph["nodes"]).any? { |node| node.is_a?(Hash) && node["kind"] == "api" }
593
626
 
@@ -976,6 +1009,18 @@ result =
976
1009
  )
977
1010
  end
978
1011
 
1012
+ acceptance_gate_step = if options[:complete_step]
1013
+ options[:complete_step].to_s
1014
+ elsif options[:confirm_checkpoint] && state["status"] == "waiting_checkpoint"
1015
+ state["current_step"].to_s
1016
+ end
1017
+ if result.nil? && ACCEPTANCE_RESULT_GATED_STEPS.include?(acceptance_gate_step) && step_by_id.key?(acceptance_gate_step)
1018
+ acceptance_check = acceptance_results_check(root, state.fetch("current_feature"))
1019
+ unless acceptance_check["ok"]
1020
+ result = fail_result("acceptance_results_not_passed", "step" => acceptance_gate_step, "acceptance_results" => acceptance_check)
1021
+ end
1022
+ end
1023
+
979
1024
  if result.nil? && options[:complete_step]
980
1025
  step_id = options[:complete_step]
981
1026
  step = step_by_id[step_id]
@@ -1106,9 +1151,15 @@ result =
1106
1151
  end
1107
1152
  else
1108
1153
  matrix_check = api_test_matrix_check(root, feature, graph)
1154
+ # A new freeze requires every TC to declare a consistent
1155
+ # execution_surface; this is a hard failure even in soft mode.
1156
+ surface_check = matrix_check["ok"] ? execution_surface_check(root, feature) : nil
1109
1157
  unless matrix_check["ok"]
1110
1158
  result = fail_result("api_test_matrix_incomplete", "api_test_matrix" => matrix_check)
1111
1159
  else
1160
+ result = fail_result("execution_surface_mismatch", "execution_surface" => surface_check) unless surface_check["ok"]
1161
+ end
1162
+ if matrix_check["ok"] && surface_check["ok"]
1112
1163
  candidate_check = test_task_binding_check(root, feature, "candidate")
1113
1164
  unless candidate_check["ok"]
1114
1165
  result = fail_result("acceptance_test_suite_candidate_binding_failed", "test_suite" => candidate_check)
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env python3
2
+ """Compute the current pins for a Core human-approval subject.
3
+
4
+ This module is the single pin implementation shared by the runtime extension
5
+ (propose + confirm CAS) and the Python approval reader. It only reads project
6
+ files; it never writes control.db. There is intentionally no confirm entry
7
+ point here: confirmation exists only in the runtime UI callback.
8
+
9
+ Pins per kind (change list §1.8):
10
+
11
+ - execution_plan {suite_graph_revision, suite_fingerprint, tc_sha256, dsl_sha256}
12
+ - environment_profile {profile_sha256}
13
+ - mode_override {suite_fingerprint, tc_sha256, binding_entry_sha256, from, to}
14
+ - human_result {suite_fingerprint, tc_sha256, evidence: [{path, sha256}]}
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import hashlib
21
+ import json
22
+ import re
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ SCRIPT_DIR = Path(__file__).resolve().parent
28
+ if str(SCRIPT_DIR) not in sys.path:
29
+ sys.path.insert(0, str(SCRIPT_DIR))
30
+
31
+ KINDS = ("execution_plan", "environment_profile", "mode_override", "human_result")
32
+ FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
33
+ TC_ID_RE = re.compile(r"^TC-[A-Za-z0-9_-]+$")
34
+ PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
35
+ MAX_EVIDENCE_FILE_BYTES = 25 * 1024 * 1024
36
+ MAX_DSL_FILE_BYTES = 2 * 1024 * 1024
37
+ MAX_EVIDENCE_FILES = 50
38
+ SUMMARY_LIMIT = 300
39
+
40
+
41
+ class PinError(Exception):
42
+ pass
43
+
44
+
45
+ def _import_tc_hash():
46
+ try:
47
+ import tc_hash # type: ignore
48
+ except ImportError as error: # pragma: no cover - depends on concurrent Core work
49
+ raise PinError(f"tc_hash module is unavailable: {error}") from error
50
+ return tc_hash
51
+
52
+
53
+ def jcs_sha256(value: Any) -> str:
54
+ """sha256(JCS(value)) using the single Core JCS implementation."""
55
+ tc_hash = _import_tc_hash()
56
+ return hashlib.sha256(tc_hash.canonicalize(value)).hexdigest()
57
+
58
+
59
+ def tc_sha256(data: dict[str, Any]) -> str:
60
+ return str(_import_tc_hash().tc_sha256(data))
61
+
62
+
63
+ def _read_json(path: Path, label: str) -> Any:
64
+ try:
65
+ return json.loads(path.read_text(encoding="utf-8"))
66
+ except FileNotFoundError as error:
67
+ raise PinError(f"missing {label}: {path.as_posix()}") from error
68
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
69
+ raise PinError(f"invalid {label}: {error}") from error
70
+
71
+
72
+ def workflow_state(root: Path) -> dict[str, Any]:
73
+ """Read revision and current_feature from STATE.md front matter."""
74
+ path = root / ".devflow/planning/STATE.md"
75
+ try:
76
+ text = path.read_text(encoding="utf-8")
77
+ except OSError as error:
78
+ raise PinError(f"workflow state is unavailable: {path.as_posix()}") from error
79
+ match = re.match(r"\A---\s*\n(.*?)\n---\s*(?:\n|\Z)", text, re.S)
80
+ if not match:
81
+ raise PinError("workflow state has no front matter")
82
+ fields: dict[str, str] = {}
83
+ for line in match.group(1).splitlines():
84
+ found = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$", line)
85
+ if found:
86
+ fields[found.group(1)] = found.group(2)
87
+ raw_revision = fields.get("revision", "0").strip("'\"") or "0"
88
+ if not re.fullmatch(r"\d+", raw_revision):
89
+ raise PinError(f"workflow state revision is not an integer: {raw_revision}")
90
+ feature = fields.get("current_feature", "").strip()
91
+ if len(feature) >= 2 and feature[0] == feature[-1] and feature[0] in "'\"":
92
+ feature = feature[1:-1]
93
+ return {"revision": int(raw_revision), "current_feature": feature}
94
+
95
+
96
+ def load_suite(root: Path, feature: str) -> dict[str, Any]:
97
+ suite = _read_json(root / ".devflow/planning/requirements" / feature / "tests.json", "TC suite")
98
+ if not isinstance(suite, dict):
99
+ raise PinError("TC suite root must be an object")
100
+ return suite
101
+
102
+
103
+ def suite_pins(suite: dict[str, Any]) -> dict[str, Any]:
104
+ meta = suite.get("view_meta") if isinstance(suite.get("view_meta"), dict) else {}
105
+ fingerprint = meta.get("fingerprint")
106
+ if not isinstance(fingerprint, str) or not fingerprint:
107
+ raise PinError("TC suite view_meta.fingerprint is missing")
108
+ revision = suite.get("graph_revision")
109
+ if revision is None or isinstance(revision, bool):
110
+ raise PinError("TC suite graph_revision is missing")
111
+ return {"suite_fingerprint": fingerprint, "suite_graph_revision": revision}
112
+
113
+
114
+ def frozen_case(suite: dict[str, Any], tc_id: str) -> dict[str, Any]:
115
+ for node in suite.get("nodes", []):
116
+ if isinstance(node, dict) and node.get("kind") == "test_case" and node.get("id") == tc_id:
117
+ if node.get("status") != "confirmed" or node.get("stale") is True:
118
+ raise PinError(f"TC is not frozen and fresh: {tc_id}")
119
+ data = node.get("data")
120
+ if not isinstance(data, dict):
121
+ raise PinError(f"TC has no data object: {tc_id}")
122
+ return data
123
+ raise PinError(f"TC is not in the frozen suite: {tc_id}")
124
+
125
+
126
+ def binding_entry(root: Path, feature: str, tc_id: str, required: bool) -> dict[str, Any] | None:
127
+ path = root / ".devflow/planning/features" / feature / "acceptance-test-bindings.json"
128
+ if not path.is_file():
129
+ if required:
130
+ raise PinError(f"missing acceptance-test bindings: {path.relative_to(root).as_posix()}")
131
+ return None
132
+ bindings = _read_json(path, "acceptance-test bindings")
133
+ cases = bindings.get("cases") if isinstance(bindings, dict) else None
134
+ if not isinstance(cases, list):
135
+ raise PinError("acceptance-test bindings cases must be an array")
136
+ matches = [entry for entry in cases if isinstance(entry, dict) and entry.get("tc_id") == tc_id]
137
+ if len(matches) > 1:
138
+ raise PinError(f"duplicate binding entries for {tc_id}")
139
+ if not matches:
140
+ if required:
141
+ raise PinError(f"no binding entry for {tc_id}")
142
+ return None
143
+ return matches[0]
144
+
145
+
146
+ def _contained_file(root: Path, raw: Any, base: Path, label: str, max_bytes: int) -> tuple[Path, str]:
147
+ if not isinstance(raw, str) or not raw.strip() or "\x00" in raw:
148
+ raise PinError(f"{label} must be a non-empty project-relative path")
149
+ candidate = Path(raw)
150
+ if candidate.is_absolute() or ".." in candidate.parts:
151
+ raise PinError(f"{label} escapes the project root: {raw}")
152
+ root_real = root.resolve()
153
+ resolved = (root_real / candidate).resolve()
154
+ try:
155
+ relative = resolved.relative_to(root_real)
156
+ resolved.relative_to(base.resolve())
157
+ except ValueError as error:
158
+ raise PinError(f"{label} is outside {base.relative_to(root).as_posix() if base != root else 'the project root'}: {raw}") from error
159
+ if not resolved.is_file():
160
+ raise PinError(f"{label} is missing: {raw}")
161
+ if resolved.stat().st_size > max_bytes:
162
+ raise PinError(f"{label} exceeds {max_bytes} bytes: {raw}")
163
+ return resolved, relative.as_posix()
164
+
165
+
166
+ def _truncate(value: Any, limit: int = SUMMARY_LIMIT) -> str:
167
+ text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True)
168
+ return text if len(text) <= limit else text[: limit - 1] + "…"
169
+
170
+
171
+ def _target_text(target: Any) -> str:
172
+ if not isinstance(target, dict):
173
+ return ""
174
+ for key in ("testid", "role", "label", "placeholder", "text", "css"):
175
+ if key in target:
176
+ text = f"{key}={target[key]}"
177
+ if key == "role" and target.get("name") is not None:
178
+ text += f"[name={target['name']}]"
179
+ return text
180
+ return _truncate(target, 120)
181
+
182
+
183
+ def assertion_summary(step: dict[str, Any]) -> str:
184
+ """Human-readable summary of one DSL step for the approval UI."""
185
+ action = str(step.get("action", "?"))
186
+ identifier = str(step.get("id", "?"))
187
+ if step.get("driver") == "api" or "request" in step or "method" in step:
188
+ request = step.get("request") if isinstance(step.get("request"), dict) else step
189
+ method = str(request.get("method", "")).upper()
190
+ url = request.get("url") or request.get("path") or ""
191
+ assertions = step.get("assertions", step.get("expect"))
192
+ return f"{identifier}: API {method} {url} 断言 {_truncate(assertions, 200)}".strip()
193
+ target = _target_text(step.get("target"))
194
+ parts = [f"{identifier}: {action}"]
195
+ if target:
196
+ parts.append(target)
197
+ if "expected" in step:
198
+ match = step.get("match", "exact")
199
+ parts.append(f"{match} = {_truncate(step.get('expected'), 200)}")
200
+ elif "url" in step and action == "assert_url":
201
+ parts.append(f"url {_truncate(step.get('url'), 200)}")
202
+ return " ".join(parts)
203
+
204
+
205
+ def dsl_steps(dsl: dict[str, Any]) -> list[dict[str, Any]]:
206
+ steps = [step for step in dsl.get("steps", []) if isinstance(step, dict)]
207
+ data = dsl.get("data") if isinstance(dsl.get("data"), dict) else {}
208
+ setup = [step for step in data.get("setup", []) if isinstance(step, dict)] if isinstance(data.get("setup"), list) else []
209
+ return setup + steps
210
+
211
+
212
+ def coverage_display(tc_data: dict[str, Any], dsl: dict[str, Any]) -> dict[str, Any]:
213
+ oracle_steps = []
214
+ covered_pre: set[int] = set()
215
+ covered_steps: set[int] = set()
216
+ for step in dsl_steps(dsl):
217
+ covers = step.get("covers") if isinstance(step.get("covers"), dict) else {}
218
+ if covers.get("oracle") is True:
219
+ oracle_steps.append(assertion_summary(step))
220
+ for key, target in (("preconditions", covered_pre), ("steps", covered_steps)):
221
+ values = covers.get(key)
222
+ if isinstance(values, list):
223
+ target.update(item for item in values if isinstance(item, int) and not isinstance(item, bool))
224
+ preconditions = tc_data.get("preconditions") if isinstance(tc_data.get("preconditions"), list) else []
225
+ steps = tc_data.get("steps") if isinstance(tc_data.get("steps"), list) else []
226
+ return {
227
+ "oracle_assertions": oracle_steps,
228
+ "uncovered_preconditions": [index for index in range(1, len(preconditions) + 1) if index not in covered_pre],
229
+ "uncovered_steps": [index for index in range(1, len(steps) + 1) if index not in covered_steps],
230
+ }
231
+
232
+
233
+ def structural_validation(tc_data: dict[str, Any], dsl: dict[str, Any]) -> tuple[str, list[str]]:
234
+ """Run the Core DSL validators when they are installed (K05/K06)."""
235
+ errors: list[str] = []
236
+ try:
237
+ import dsl_v1 # type: ignore
238
+ import dsl_coverage # type: ignore
239
+ except ImportError:
240
+ return "unavailable", []
241
+ errors.extend(str(item) for item in dsl_v1.validate(dsl) or [])
242
+ if not errors:
243
+ errors.extend(str(item) for item in dsl_coverage.check(tc_data, dsl) or [])
244
+ return ("failed" if errors else "passed"), errors
245
+
246
+
247
+ def tc_display(tc_id: str, data: dict[str, Any]) -> dict[str, Any]:
248
+ return {
249
+ "tc_id": tc_id,
250
+ "target": data.get("target"),
251
+ "oracle": data.get("oracle"),
252
+ "preconditions": data.get("preconditions") if isinstance(data.get("preconditions"), list) else [],
253
+ "steps": data.get("steps") if isinstance(data.get("steps"), list) else [],
254
+ "automation_mode": data.get("automation_mode"),
255
+ }
256
+
257
+
258
+ def _require_tc(subject_key: str) -> None:
259
+ if not TC_ID_RE.fullmatch(subject_key):
260
+ raise PinError(f"subject_key must be a TC id: {subject_key}")
261
+
262
+
263
+ def compute_execution_plan(root: Path, feature: str, tc_id: str, payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
264
+ _require_tc(tc_id)
265
+ suite = load_suite(root, feature)
266
+ pins_suite = suite_pins(suite)
267
+ data = frozen_case(suite, tc_id)
268
+ if data.get("automation_mode") != "automated":
269
+ raise PinError(f"execution_plan applies only to automated TCs: {tc_id} is {data.get('automation_mode')}")
270
+ base = root / ".devflow/planning/generated-cases" / feature
271
+ raw = payload.get("dsl_file") or f".devflow/planning/generated-cases/{feature}/{tc_id}.json"
272
+ path, relative = _contained_file(root, raw, base, "dsl_file", MAX_DSL_FILE_BYTES)
273
+ content = path.read_bytes()
274
+ try:
275
+ dsl = json.loads(content.decode("utf-8"))
276
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
277
+ raise PinError(f"DSL is not valid JSON: {error}") from error
278
+ if not isinstance(dsl, dict):
279
+ raise PinError("DSL root must be an object")
280
+ current_tc = tc_sha256(data)
281
+ if dsl.get("tc_id") != tc_id:
282
+ raise PinError(f"DSL tc_id {dsl.get('tc_id')!r} does not match {tc_id}")
283
+ if dsl.get("tc_sha256") != current_tc:
284
+ raise PinError("DSL tc_sha256 does not match the current frozen TC")
285
+ validation, validation_errors = structural_validation(data, dsl)
286
+ if validation_errors:
287
+ raise PinError("DSL structural validation failed: " + "; ".join(validation_errors[:20]))
288
+ pins = {
289
+ "suite_graph_revision": pins_suite["suite_graph_revision"],
290
+ "suite_fingerprint": pins_suite["suite_fingerprint"],
291
+ "tc_sha256": current_tc,
292
+ "dsl_sha256": hashlib.sha256(content).hexdigest(),
293
+ }
294
+ display = {**tc_display(tc_id, data), "dsl_file": relative, "structural_validation": validation, **coverage_display(data, dsl)}
295
+ return pins, display
296
+
297
+
298
+ def compute_environment_profile(root: Path, feature: str, name: str, payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
299
+ if not PROFILE_RE.fullmatch(name):
300
+ raise PinError(f"invalid environment profile name: {name}")
301
+ try:
302
+ import environment_profile # type: ignore
303
+ except ImportError as error: # pragma: no cover - depends on concurrent Core work
304
+ raise PinError(f"environment_profile module is unavailable: {error}") from error
305
+ profile, sha256, errors = environment_profile.load(root, name)
306
+ if errors:
307
+ raise PinError("environment profile is invalid: " + "; ".join(str(item) for item in errors))
308
+ if not isinstance(profile, dict) or not isinstance(sha256, str) or not sha256:
309
+ raise PinError("environment profile loader returned no profile hash")
310
+ secret_env = profile.get("secret_env") if isinstance(profile.get("secret_env"), dict) else {}
311
+ display = {
312
+ "profile": name,
313
+ "path": f".devflow/planning/test-environments/{name}.json",
314
+ "base_url": profile.get("base_url"),
315
+ "navigation_origins": profile.get("navigation_origins", []),
316
+ "api_origins": profile.get("api_origins", []),
317
+ "subresources": profile.get("subresources"),
318
+ # Names only: the profile maps aliases to environment variable names, never values.
319
+ "secret_env": {str(key): str(value) for key, value in secret_env.items()},
320
+ "trace": profile.get("trace"),
321
+ }
322
+ return {"profile_sha256": sha256}, display
323
+
324
+
325
+ def compute_mode_override(root: Path, feature: str, tc_id: str, payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
326
+ _require_tc(tc_id)
327
+ suite = load_suite(root, feature)
328
+ pins_suite = suite_pins(suite)
329
+ data = frozen_case(suite, tc_id)
330
+ entry = binding_entry(root, feature, tc_id, required=True)
331
+ assert entry is not None
332
+ source = data.get("automation_mode")
333
+ target = entry.get("mode")
334
+ if source != "automated" or target != "manual":
335
+ raise PinError(f"mode_override only approves automated -> manual; got {source} -> {target}")
336
+ pins = {
337
+ "suite_fingerprint": pins_suite["suite_fingerprint"],
338
+ "tc_sha256": tc_sha256(data),
339
+ "binding_entry_sha256": jcs_sha256(entry),
340
+ "from": source,
341
+ "to": target,
342
+ }
343
+ display = {**tc_display(tc_id, data), "from": source, "to": target, "binding_entry": entry}
344
+ return pins, display
345
+
346
+
347
+ def compute_human_result(root: Path, feature: str, tc_id: str, payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
348
+ _require_tc(tc_id)
349
+ suite = load_suite(root, feature)
350
+ pins_suite = suite_pins(suite)
351
+ data = frozen_case(suite, tc_id)
352
+ entry = binding_entry(root, feature, tc_id, required=False)
353
+ mode = entry.get("mode") if entry is not None else data.get("automation_mode")
354
+ if mode != "manual":
355
+ raise PinError(f"human_result applies only to manual TCs: {tc_id} is {mode}")
356
+ raw_files = payload.get("evidence_files", [])
357
+ if not isinstance(raw_files, list):
358
+ raise PinError("evidence_files must be an array of project-relative paths")
359
+ if len(raw_files) > MAX_EVIDENCE_FILES:
360
+ raise PinError(f"at most {MAX_EVIDENCE_FILES} evidence files are allowed")
361
+ evidence: dict[str, dict[str, Any]] = {}
362
+ for raw in raw_files:
363
+ path, relative = _contained_file(root, raw, root, "evidence file", MAX_EVIDENCE_FILE_BYTES)
364
+ if relative.startswith(".devflow/planning/test-runs/") and relative.endswith("human-acceptance-results.json"):
365
+ raise PinError("the human acceptance draft cannot be its own evidence")
366
+ content = path.read_bytes()
367
+ evidence[relative] = {"path": relative, "sha256": hashlib.sha256(content).hexdigest(), "bytes": len(content)}
368
+ ordered = [evidence[key] for key in sorted(evidence)]
369
+ pins = {
370
+ "suite_fingerprint": pins_suite["suite_fingerprint"],
371
+ "tc_sha256": tc_sha256(data),
372
+ "evidence": [{"path": item["path"], "sha256": item["sha256"]} for item in ordered],
373
+ }
374
+ display = {**tc_display(tc_id, data), "evidence": ordered}
375
+ return pins, display
376
+
377
+
378
+ COMPUTERS = {
379
+ "execution_plan": compute_execution_plan,
380
+ "environment_profile": compute_environment_profile,
381
+ "mode_override": compute_mode_override,
382
+ "human_result": compute_human_result,
383
+ }
384
+
385
+
386
+ def compute(root: Path, feature: str, kind: str, subject_key: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
387
+ """Return {ok, kind, feature, subject_key, state_revision, pins, display, errors}."""
388
+ result: dict[str, Any] = {"ok": False, "kind": kind, "feature": feature, "subject_key": subject_key, "errors": []}
389
+ try:
390
+ if kind not in COMPUTERS:
391
+ raise PinError(f"unknown approval kind: {kind}")
392
+ if not isinstance(feature, str) or not FEATURE_RE.fullmatch(feature):
393
+ raise PinError("feature must be a plain feature name")
394
+ if payload is not None and not isinstance(payload, dict):
395
+ raise PinError("payload must be an object")
396
+ state = workflow_state(root)
397
+ if state["current_feature"] != feature:
398
+ raise PinError(f"feature is not active: requested={feature}, current={state['current_feature'] or 'none'}")
399
+ pins, display = COMPUTERS[kind](root, feature, subject_key, payload or {})
400
+ except PinError as error:
401
+ result["errors"].append(str(error))
402
+ return result
403
+ result.update({"ok": True, "state_revision": state["revision"], "pins": pins, "display": display})
404
+ return result
405
+
406
+
407
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
408
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
409
+ sub = parser.add_subparsers(dest="command", required=True)
410
+ compute_parser = sub.add_parser("compute", help="compute current pins for one approval subject (read-only)")
411
+ compute_parser.add_argument("--root", default=".")
412
+ compute_parser.add_argument("--feature", required=True)
413
+ compute_parser.add_argument("--kind", required=True, choices=KINDS)
414
+ compute_parser.add_argument("--subject", required=True)
415
+ compute_parser.add_argument("--payload-json", default="{}")
416
+ compute_parser.add_argument("--json", action="store_true")
417
+ return parser.parse_args(argv)
418
+
419
+
420
+ def main(argv: list[str] | None = None) -> int:
421
+ args = parse_args(argv)
422
+ try:
423
+ payload = json.loads(args.payload_json)
424
+ except json.JSONDecodeError as error:
425
+ print(json.dumps({"ok": False, "errors": [f"invalid --payload-json: {error.msg}"]}, ensure_ascii=False))
426
+ return 1
427
+ result = compute(Path(args.root).resolve(), args.feature, args.kind, args.subject, payload)
428
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
429
+ return 0 if result["ok"] else 1
430
+
431
+
432
+ if __name__ == "__main__":
433
+ raise SystemExit(main())
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only access to Core approval records in control.db (变更清单 §1.8).
3
+
4
+ Approvals are written only by the Runtime extension's UI confirmation path.
5
+ This module never writes: it opens control.db with mode=ro and re-derives
6
+ validity by recomputing the current pins with approval_pins.compute (the single
7
+ pin implementation shared with the proposal/confirm side) and comparing them
8
+ with the stored pins_json. The
9
+ file-compatibility authority has no approval channel, so every lookup there
10
+ fails with approval_requires_database_authority.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sqlite3
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from tc_hash import canonicalize
21
+
22
+
23
+ APPROVAL_KINDS = frozenset({"execution_plan", "environment_profile", "mode_override", "human_result"})
24
+ REQUIRES_DATABASE = "approval_requires_database_authority"
25
+
26
+
27
+ def _project_id(root: Path) -> str | None:
28
+ try:
29
+ value = json.loads((root / ".devflow" / "project.json").read_text(encoding="utf-8"))["projectId"]
30
+ return value if isinstance(value, str) and value else None
31
+ except (OSError, KeyError, TypeError, ValueError):
32
+ return None
33
+
34
+
35
+ def devflow_home() -> Path:
36
+ """DEVFLOW_HOME rule shared with the TS writer (src/paths.ts normalizeDevflowHome) and hook_common.
37
+
38
+ Unset, empty or whitespace-only means unset -> ~/.devflow-pi. Otherwise only a leading '~' or '~/'
39
+ is expanded (never '~user'), then the path is made absolute with os.path.abspath (existence is not required).
40
+ """
41
+ value = os.environ.get("DEVFLOW_HOME")
42
+ value = "~/.devflow-pi" if value is None or not value.strip() else value.strip()
43
+ if value == "~" or value.startswith("~/"):
44
+ value = str(Path.home()) + value[1:]
45
+ return Path(os.path.abspath(value))
46
+
47
+
48
+ def _connect(root: Path) -> tuple[sqlite3.Connection | None, str | None]:
49
+ project_id = _project_id(root)
50
+ if project_id is None:
51
+ return None, None
52
+ database = devflow_home() / "projects" / project_id / "control.db"
53
+ if not database.is_file():
54
+ return None, project_id
55
+ # Path.as_uri() percent-quotes '?', '#', '%' etc., so they cannot be read as URI syntax.
56
+ connection = sqlite3.connect(f"{database.as_uri()}?mode=ro", uri=True)
57
+ connection.row_factory = sqlite3.Row
58
+ return connection, project_id
59
+
60
+
61
+ def _same(left: Any, right: Any) -> bool:
62
+ try:
63
+ return canonicalize(left) == canonicalize(right)
64
+ except ValueError:
65
+ return False
66
+
67
+
68
+ def latest_approval(root: Path, feature: str, kind: str, subject_key: str) -> dict[str, Any]:
69
+ """The newest non-superseded approval for (feature, kind, subject_key), without validity checks."""
70
+ if kind not in APPROVAL_KINDS:
71
+ return {"ok": False, "reason": "approval_invalid", "message": f"unknown approval kind: {kind}"}
72
+ try:
73
+ connection, project_id = _connect(root)
74
+ if connection is None:
75
+ return {"ok": False, "reason": REQUIRES_DATABASE, "message": "approvals require the database control-plane authority"}
76
+ try:
77
+ authority = connection.execute("SELECT authority FROM authority_state WHERE project_id=?", (project_id,)).fetchone()
78
+ if not authority or authority["authority"] != "database":
79
+ return {"ok": False, "reason": REQUIRES_DATABASE, "message": "approvals require the database control-plane authority"}
80
+ row = connection.execute(
81
+ """SELECT a.id, a.proposal_id, a.pins_json, a.decision_json, a.approved_via, a.approved_at, p.status AS proposal_status, p.payload_json,
82
+ p.kind AS proposal_kind, p.subject_key AS proposal_subject, p.feature AS proposal_feature, p.project_id AS proposal_project
83
+ FROM approvals a JOIN approval_proposals p ON p.id=a.proposal_id
84
+ WHERE a.project_id=? AND a.feature=? AND a.kind=? AND a.subject_key=? AND a.superseded_at IS NULL
85
+ ORDER BY a.approved_at DESC, a.rowid DESC LIMIT 1""",
86
+ (project_id, feature, kind, subject_key),
87
+ ).fetchone()
88
+ finally:
89
+ connection.close()
90
+ except sqlite3.Error as error:
91
+ return {"ok": False, "reason": "approval_invalid", "message": f"approval store is unreadable: {error}"}
92
+ if row is None:
93
+ return {"ok": False, "reason": "approval_missing", "message": f"no {kind} approval for {subject_key}"}
94
+ if (row["approved_via"] != "runtime_ui_confirm" or row["proposal_status"] != "consumed" or row["proposal_kind"] != kind
95
+ or row["proposal_subject"] != subject_key or row["proposal_feature"] != feature or row["proposal_project"] != project_id):
96
+ return {"ok": False, "reason": "approval_invalid", "message": "approval record is not bound to a consumed UI-confirmed proposal"}
97
+ try:
98
+ pins = json.loads(row["pins_json"])
99
+ decision = json.loads(row["decision_json"])
100
+ payload = json.loads(row["payload_json"] or "{}")
101
+ except (TypeError, json.JSONDecodeError):
102
+ return {"ok": False, "reason": "approval_invalid", "message": "approval record JSON is malformed"}
103
+ if not isinstance(pins, dict) or not isinstance(decision, dict) or not isinstance(payload, dict):
104
+ return {"ok": False, "reason": "approval_invalid", "message": "approval pins, decision and payload must be objects"}
105
+ request = payload.get("request", {})
106
+ return {"ok": True, "reason": None, "approval": {"id": row["id"], "proposal_id": row["proposal_id"], "kind": kind, "subject_key": subject_key,
107
+ "pins": pins, "decision": decision, "request": request if isinstance(request, dict) else {},
108
+ "approved_at": row["approved_at"]}}
109
+
110
+
111
+ def valid_approval(root: Path, feature: str, kind: str, subject_key: str, current_pins: dict[str, Any] | None = None) -> dict[str, Any]:
112
+ """Return {"ok", "reason", "message", "approval"}.
113
+
114
+ Valid only when the newest non-superseded approval's pins equal the pins
115
+ approval_pins.compute derives now from the proposal request; any key given
116
+ in ``current_pins`` (e.g. the hash of the DSL the binding actually names)
117
+ must additionally equal the stored pin.
118
+ """
119
+ found = latest_approval(root, feature, kind, subject_key)
120
+ if not found["ok"]:
121
+ return {**found, "approval": None}
122
+ approval = found["approval"]
123
+ try:
124
+ from approval_pins import compute
125
+ except ImportError as error:
126
+ return {"ok": False, "reason": "approval_invalid", "message": f"approval_pins is unavailable: {error}", "approval": None}
127
+ computed = compute(root, feature, kind, subject_key, approval["request"])
128
+ if not computed.get("ok"):
129
+ return {"ok": False, "reason": "approval_stale", "message": "current pins cannot be computed: " + "; ".join(map(str, computed.get("errors", []))), "approval": None}
130
+ expected = dict(computed["pins"])
131
+ for key, value in (current_pins or {}).items():
132
+ if not _same(expected.get(key), value):
133
+ return {"ok": False, "reason": "approval_stale", "message": f"approval does not cover the current {key}", "approval": None}
134
+ if not _same(approval["pins"], expected):
135
+ changed = sorted(key for key in set(approval["pins"]) | set(expected) if not _same(approval["pins"].get(key), expected.get(key)))
136
+ return {"ok": False, "reason": "approval_stale", "message": f"approval pins no longer match: {', '.join(changed)}", "approval": None}
137
+ return {"ok": True, "reason": None, "message": None, "approval": approval}