@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.
- package/README.md +4 -4
- package/node_modules/@playwright/test/LICENSE +202 -0
- package/node_modules/@playwright/test/NOTICE +5 -0
- package/node_modules/@playwright/test/README.md +318 -0
- package/node_modules/@playwright/test/cli.js +19 -0
- package/node_modules/@playwright/test/index.d.ts +18 -0
- package/node_modules/@playwright/test/index.js +17 -0
- package/node_modules/@playwright/test/index.mjs +18 -0
- package/node_modules/@playwright/test/package.json +35 -0
- package/node_modules/@playwright/test/reporter.d.ts +17 -0
- package/node_modules/@playwright/test/reporter.js +17 -0
- package/node_modules/@playwright/test/reporter.mjs +17 -0
- package/node_modules/playwright/LICENSE +202 -0
- package/node_modules/playwright/NOTICE +5 -0
- package/node_modules/playwright/README.md +318 -0
- package/node_modules/playwright/ThirdPartyNotices.txt +14 -0
- package/node_modules/playwright/cli.js +19 -0
- package/node_modules/playwright/index.d.ts +17 -0
- package/node_modules/playwright/index.js +17 -0
- package/node_modules/playwright/index.mjs +18 -0
- package/node_modules/playwright/jsx-runtime.js +42 -0
- package/node_modules/playwright/jsx-runtime.mjs +21 -0
- package/node_modules/playwright/lib/agents/agentParser.js +89 -0
- package/node_modules/playwright/lib/agents/copilot-setup-steps.yml +34 -0
- package/node_modules/playwright/lib/agents/generateAgents.js +395 -0
- package/node_modules/playwright/lib/agents/playwright-test-coverage.prompt.md +31 -0
- package/node_modules/playwright/lib/agents/playwright-test-generate.prompt.md +8 -0
- package/node_modules/playwright/lib/agents/playwright-test-generator.agent.md +88 -0
- package/node_modules/playwright/lib/agents/playwright-test-heal.prompt.md +6 -0
- package/node_modules/playwright/lib/agents/playwright-test-healer.agent.md +56 -0
- package/node_modules/playwright/lib/agents/playwright-test-plan.prompt.md +9 -0
- package/node_modules/playwright/lib/agents/playwright-test-planner.agent.md +74 -0
- package/node_modules/playwright/lib/cli/reportActions.js +78 -0
- package/node_modules/playwright/lib/cli/testActions.js +214 -0
- package/node_modules/playwright/lib/common/index.js +2923 -0
- package/node_modules/playwright/lib/common/index.js.txt +34 -0
- package/node_modules/playwright/lib/errorContext.js +129 -0
- package/node_modules/playwright/lib/globals.js +58 -0
- package/node_modules/playwright/lib/index.js +815 -0
- package/node_modules/playwright/lib/isomorphic.js +260 -0
- package/node_modules/playwright/lib/isomorphic.js.txt +9 -0
- package/node_modules/playwright/lib/loader/loaderProcessEntry.js +34 -0
- package/node_modules/playwright/lib/loader/loaderProcessEntry.js.txt +9 -0
- package/node_modules/playwright/lib/matchers/expect.js +13490 -0
- package/node_modules/playwright/lib/matchers/expect.js.LICENSE +640 -0
- package/node_modules/playwright/lib/matchers/expect.js.txt +67 -0
- package/node_modules/playwright/lib/mcp/test/browserBackend.js +135 -0
- package/node_modules/playwright/lib/mcp/test/generatorTools.js +122 -0
- package/node_modules/playwright/lib/mcp/test/plannerTools.js +150 -0
- package/node_modules/playwright/lib/mcp/test/seed.js +82 -0
- package/node_modules/playwright/lib/mcp/test/streams.js +44 -0
- package/node_modules/playwright/lib/mcp/test/testBackend.js +99 -0
- package/node_modules/playwright/lib/mcp/test/testContext.js +306 -0
- package/node_modules/playwright/lib/mcp/test/testTool.js +30 -0
- package/node_modules/playwright/lib/mcp/test/testTools.js +98 -0
- package/node_modules/playwright/lib/package.js +47 -0
- package/node_modules/playwright/lib/program.js +241 -0
- package/node_modules/playwright/lib/runner/index.js +8527 -0
- package/node_modules/playwright/lib/runner/index.js.txt +61 -0
- package/node_modules/playwright/lib/transform/babelBundle.js +72274 -0
- package/node_modules/playwright/lib/transform/babelBundle.js.LICENSE +2384 -0
- package/node_modules/playwright/lib/transform/babelBundle.js.txt +325 -0
- package/node_modules/playwright/lib/transform/esmLoader.js +8054 -0
- package/node_modules/playwright/lib/transform/esmLoader.js.LICENSE +355 -0
- package/node_modules/playwright/lib/transform/esmLoader.js.txt +60 -0
- package/node_modules/playwright/lib/util.js +373 -0
- package/node_modules/playwright/lib/worker/workerProcessEntry.js +1861 -0
- package/node_modules/playwright/lib/worker/workerProcessEntry.js.txt +19 -0
- package/node_modules/playwright/package.json +55 -0
- package/node_modules/playwright/test.d.ts +18 -0
- package/node_modules/playwright/test.js +24 -0
- package/node_modules/playwright/test.mjs +35 -0
- package/node_modules/playwright/types/test.d.ts +11074 -0
- package/node_modules/playwright/types/testReporter.d.ts +950 -0
- package/node_modules/playwright-core/LICENSE +202 -0
- package/node_modules/playwright-core/NOTICE +5 -0
- package/node_modules/playwright-core/README.md +3 -0
- package/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
- package/node_modules/playwright-core/bin/install_media_pack.ps1 +5 -0
- package/node_modules/playwright-core/bin/install_webkit_wsl.ps1 +33 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh +42 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh +13 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1 +24 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh +42 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh +12 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1 +24 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1 +23 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1 +23 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1 +24 -0
- package/node_modules/playwright-core/browsers.json +52 -0
- package/node_modules/playwright-core/cli.js +21 -0
- package/node_modules/playwright-core/index.d.ts +17 -0
- package/node_modules/playwright-core/index.js +17 -0
- package/node_modules/playwright-core/index.mjs +28 -0
- package/node_modules/playwright-core/lib/bootstrap.js +88 -0
- package/node_modules/playwright-core/lib/coreBundle.js +75813 -0
- package/node_modules/playwright-core/lib/entry/cliDaemon.js +5 -0
- package/node_modules/playwright-core/lib/entry/dashboardApp.js +3 -0
- package/node_modules/playwright-core/lib/entry/mcp.js +10 -0
- package/node_modules/playwright-core/lib/entry/oopBrowserDownload.js +3 -0
- package/node_modules/playwright-core/lib/package.js +50 -0
- package/node_modules/playwright-core/lib/server/chromium/appIcon.png +0 -0
- package/node_modules/playwright-core/lib/server/electron/loader.js +116 -0
- package/node_modules/playwright-core/lib/serverRegistry.js +1939 -0
- package/node_modules/playwright-core/lib/serverRegistry.js.LICENSE +65 -0
- package/node_modules/playwright-core/lib/tools/cli-client/channelSessions.js +141 -0
- package/node_modules/playwright-core/lib/tools/cli-client/cli.js +6 -0
- package/node_modules/playwright-core/lib/tools/cli-client/help.json +721 -0
- package/node_modules/playwright-core/lib/tools/cli-client/minimist.js +128 -0
- package/node_modules/playwright-core/lib/tools/cli-client/output.js +351 -0
- package/node_modules/playwright-core/lib/tools/cli-client/program.js +418 -0
- package/node_modules/playwright-core/lib/tools/cli-client/registry.js +180 -0
- package/node_modules/playwright-core/lib/tools/cli-client/session.js +259 -0
- package/node_modules/playwright-core/lib/tools/dashboard/appIcon.png +0 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/SKILL.md +425 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/element-attributes.md +23 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/playwright-tests.md +39 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/request-mocking.md +87 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/running-code.md +241 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/session-management.md +225 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/storage-state.md +275 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/test-generation.md +433 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/tracing.md +139 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/video-recording.md +143 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md +136 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md +144 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md +89 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md +69 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/typing.md +173 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md +77 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-trace/SKILL.md +174 -0
- package/node_modules/playwright-core/lib/tools/utils/extension.js +112 -0
- package/node_modules/playwright-core/lib/tools/utils/socketConnection.js +108 -0
- package/node_modules/playwright-core/lib/utilsBundle.js +85357 -0
- package/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +1890 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule--QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule-CQ8RZGBm.js +32 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codicon-DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-1bWoP6pv.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-beta-k3eOH_eK.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-nightly-Cp5nfeDT.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/index-AwgwcYie.css +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/index-hRvO4yU2.js +12 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/safari-na3_-uQk.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/index.html +29 -0
- package/node_modules/playwright-core/lib/vite/dashboard/playwright-logo.svg +24 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/index.html +16 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/report.css +2 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/report.js +32 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CVQWJZAA.js +32 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codicon-DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/index-BZpYJZ-P.css +1 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/index-hrlLqLtq.js +129 -0
- package/node_modules/playwright-core/lib/vite/recorder/index.html +29 -0
- package/node_modules/playwright-core/lib/vite/recorder/playwright-logo.svg +9 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-BbkfBe3n.js +32 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Ds6CBOo0.js +182 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-DywYcAf8.js +7 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/codicon.DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.Bqk9acqE.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.B4cLoZK3.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.html +44 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/manifest.webmanifest +16 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/playwright-logo.svg +9 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html +10 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js +4 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CU5KtEkS.js +5 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CyMfwkXJ.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +18 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css +1 -0
- package/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
- package/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
- package/node_modules/playwright-core/lib/xdg-open +1267 -0
- package/node_modules/playwright-core/package.json +34 -0
- package/node_modules/playwright-core/types/protocol.d.ts +24645 -0
- package/node_modules/playwright-core/types/structs.d.ts +52 -0
- package/node_modules/playwright-core/types/types.d.ts +26632 -0
- package/package.json +12 -2
- package/plugins/devflow-core/extensions/devflow-core.ts +431 -7
- package/plugins/devflow-core/runtime/hooks/hook_common.py +269 -0
- package/plugins/devflow-core/runtime/hooks/index_gate.py +29 -16
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +407 -71
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_pins.py +433 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_store.py +137 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +87 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +318 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +134 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_surface.py +138 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +515 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tc_hash.py +132 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +102 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +13 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +13 -34
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -1
- package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +68 -140
- package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +1 -1
- package/plugins/devflow-implementation/skills/workflow-execute-phase/references/execute-phase.md +3 -1
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +23 -4
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +872 -0
- package/plugins/devflow-quality/runtime/playwright/playwright.config.mjs +46 -0
- package/plugins/devflow-quality/runtime/playwright/reporter.mjs +311 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +1308 -0
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +11 -0
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +71 -58
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/api-mainflow-rules.md +20 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/codebase-discovery.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +115 -192
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/mainflow-selection.md +27 -23
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +78 -153
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +36 -83
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +497 -556
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/export_legacy_testagent.py +647 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/normalize_case_payloads.py +10 -3
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/validate_case_payloads.py +165 -275
- package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +61 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +4 -4
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +15 -9
- package/src/approval-store.ts +340 -0
- package/src/constants.ts +1 -1
- package/src/doctor.ts +259 -2
- package/src/initialization-store.ts +48 -1
- package/src/paths.ts +12 -1
- package/src/project-context.ts +24 -1
- 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}
|