From 111acaf703433a1ffd6aced459faf4bec4f5d442 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 5 Jul 2026 20:57:23 +0000 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20runtime-split-codex=20Draft=20PR?= =?UTF-8?q?=20=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 181f9ef48ed027e6e53a1e9f60f89ae12a5e30ad Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 5 Jul 2026 23:59:27 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20Codex=E7=89=88NDF=E3=83=97=E3=83=A9?= =?UTF-8?q?=E3=82=B0=E3=82=A4=E3=83=B3=E3=82=92=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/plugins/marketplace.json | 2 +- plugins/ndf-claude/skills/review/SKILL.md | 4 +- plugins/ndf-codex/.codex-plugin/plugin.json | 7 + plugins/ndf-codex/README.md | 58 + plugins/ndf-codex/hooks/hooks.json | 16 + .../ndf-codex/scripts/codex-slack-notify.js | 330 ++++ plugins/ndf-codex/scripts/ensure-retention.sh | 66 + .../scripts/install-official-skills.sh | 183 ++ plugins/ndf-codex/scripts/slack-notify.js | 777 +++++++++ .../ndf-codex/scripts/statusline-switch.sh | 214 +++ plugins/ndf-codex/scripts/statusline.sh | 60 + .../skills/branch-fix-strategy/SKILL.md | 87 + .../ndf-codex/skills/cherry-pick-pr/SKILL.md | 120 ++ plugins/ndf-codex/skills/clean/SKILL.md | 20 + .../ndf-codex/skills/cross-review/SKILL.md | 475 ++++++ .../cross-review/docs/01-state-and-review.md | 254 +++ .../cross-review/docs/02-fix-and-rotation.md | 384 +++++ .../skills/cross-review/scripts/_tmpdir.sh | 23 + .../cross-review/scripts/launch-codex.sh | 126 ++ .../cross-review/scripts/launch-gemini.sh | 193 +++ .../skills/cross-review/scripts/monitor.py | 806 +++++++++ .../skills/cross-review/scripts/rotate-pr.sh | 362 ++++ .../skills/cross-review/scripts/state.py | 1481 +++++++++++++++++ .../cross-review/scripts/wait-review.sh | 30 + .../skills/cross-review/tests/__init__.py | 0 .../skills/cross-review/tests/conftest.py | 47 + .../tests/test_default_worktree_base.py | 41 + .../tests/test_monitor_early_error.py | 126 ++ .../tests/test_monitor_import_safety.py | 105 ++ .../tests/test_monitor_progress_log.py | 23 + .../tests/test_monitor_result_age.py | 330 ++++ .../tests/test_monitor_stall_default.py | 119 ++ .../cross-review/tests/test_monitor_zombie.py | 91 + .../tests/test_state_auto_review_templates.py | 204 +++ .../tests/test_state_check_oscillation.py | 101 ++ .../test_state_extra_review_instructions.py | 35 + .../tests/test_state_merge_fix.py | 776 +++++++++ .../tests/test_state_read_result.py | 160 ++ plugins/ndf-codex/skills/deploy/SKILL.md | 114 ++ .../01-environment-detection.md | 84 + .../docker-container-access/02-dood-access.md | 134 ++ .../03-troubleshooting.md | 63 + .../skills/docker-container-access/SKILL.md | 76 + plugins/ndf-codex/skills/fix/SKILL.md | 303 ++++ .../skills/fix/scripts/fetch-pr-comments.sh | 47 + .../git-gh-operations/01-common-errors.md | 145 ++ .../skills/git-gh-operations/SKILL.md | 228 +++ .../skills/implementation-plan/SKILL.md | 98 ++ .../skills/investigation-rules/SKILL.md | 105 ++ .../skills/issue-plan-strategy/SKILL.md | 335 ++++ .../skills/logging-guidelines/SKILL.md | 112 ++ .../markdown-writing/01-diagram-guide.md | 144 ++ .../skills/markdown-writing/SKILL.md | 58 + plugins/ndf-codex/skills/merged/SKILL.md | 29 + .../ndf-codex/skills/ndf-policies/SKILL.md | 10 + .../ndf-codex/skills/plan-to-spec/SKILL.md | 182 ++ .../skills/playwright-execution/SKILL.md | 101 ++ .../skills/playwright-kit-ops/.gitignore | 11 + .../skills/playwright-kit-ops/SKILL.md | 119 ++ .../playwright_kit/__init__.py | 11 + .../playwright_kit/accessibility.py | 90 + .../playwright_kit/body_check.py | 118 ++ .../playwright_kit/config.py | 461 +++++ .../playwright_kit/fixtures/__init__.py | 19 + .../playwright_kit/fixtures/accessibility.py | 143 ++ .../playwright_kit/fixtures/auth.py | 318 ++++ .../playwright_kit/fixtures/body_check.py | 194 +++ .../playwright_kit/fixtures/evidence.py | 353 ++++ .../playwright_kit/fixtures/web_vitals.py | 109 ++ .../playwright_kit/overlay.py | 220 +++ .../playwright_kit/pytest_plugin.py | 491 ++++++ .../playwright_kit/pytest_report.py | 212 +++ .../playwright_kit/uploaders/__init__.py | 151 ++ .../playwright_kit/video.py | 71 + .../playwright_kit/web_vitals.py | 142 ++ .../skills/playwright-kit-ops/pyproject.toml | 50 + .../playwright-kit-ops/scripts/_drive_auth.py | 57 + .../scripts/build_gdoc_with_drive_links.py | 126 ++ .../playwright-kit-ops/scripts/check_cwv.py | 113 ++ .../scripts/classify_page_role.py | 238 +++ .../scripts/gdrive_upload_dir.py | 75 + .../scripts/init_project.bat | 196 +++ .../scripts/init_project.sh | 184 ++ .../scripts/record_scenario.py | 104 ++ .../scripts/run_a11y_scan.py | 104 ++ .../scripts/upload_evidence.py | 225 +++ .../scripts/upload_md_as_gdoc.py | 47 + .../templates/conftest.py.template | 127 ++ .../templates/pyproject.toml.runtime | 51 + .../playwright-kit-ops/templates/run.bat | 95 ++ .../playwright-kit-ops/templates/run.sh | 101 ++ .../templates/runtime-README.md | 130 ++ .../templates/runtime-gitignore | 28 + .../templates/scenario.config.yaml | 158 ++ .../templates/test_auth.py.template | 35 + .../templates/test_dashboard.py.template | 25 + .../templates/test_form.py.template | 33 + .../templates/test_list.py.template | 28 + .../playwright-kit-ops/tests/__init__.py | 0 .../playwright-kit-ops/tests/conftest.py | 23 + .../tests/test_a11y_cwv_routing.py | 80 + .../tests/test_auth_cache.py | 242 +++ .../tests/test_body_check.py | 394 +++++ .../tests/test_config_basic_auth.py | 163 ++ .../tests/test_evidence_fixture.py | 264 +++ .../tests/test_makereport_user_properties.py | 147 ++ .../tests/test_pytest_plugin_bootstrap.py | 135 ++ .../tests/test_pytest_report.py | 328 ++++ .../tests/test_pytest_terminal_summary.py | 372 +++++ .../tests/test_pytester_integration.py | 410 +++++ .../tests/test_same_origin.py | 45 + .../tests/test_upload_evidence.py | 57 + .../tests/test_video_default.py | 63 + .../skills/playwright-kit-ops/uv.lock | 809 +++++++++ .../skills/playwright-report/SKILL.md | 55 + .../playwright-script-creation/SKILL.md | 108 ++ .../skills/playwright-test-planning/SKILL.md | 97 ++ .../docs/01-methodology.md | 148 ++ .../docs/02-page-roles.md | 207 +++ .../docs/03-test-techniques.md | 284 ++++ .../docs/04-playwright-mapping.md | 249 +++ .../docs/05-bug-report.md | 207 +++ .../docs/06-pytest-playwright.md | 248 +++ .../playwright-test-planning/docs/README.md | 79 + .../docs/checklists/checklist-auth.md | 148 ++ .../checklists/checklist-cart-checkout.md | 158 ++ .../docs/checklists/checklist-common.md | 129 ++ .../docs/checklists/checklist-dashboard.md | 124 ++ .../docs/checklists/checklist-edit.md | 156 ++ .../docs/checklists/checklist-form.md | 160 ++ .../docs/checklists/checklist-item.md | 120 ++ .../docs/checklists/checklist-list.md | 138 ++ .../docs/checklists/checklist-lp.md | 97 ++ .../docs/checklists/checklist-modal-wizard.md | 161 ++ .../docs/checklists/checklist-search.md | 134 ++ plugins/ndf-codex/skills/pr-tests/SKILL.md | 31 + plugins/ndf-codex/skills/pr/SKILL.md | 161 ++ .../ndf-codex/skills/problem-solving/SKILL.md | 162 ++ .../skills/python-execution/01-uv-setup.md | 85 + .../python-execution/02-troubleshooting.md | 112 ++ .../skills/python-execution/SKILL.md | 86 + .../skills/resolve-pr-comments/SKILL.md | 146 ++ .../ndf-codex/skills/review-branch/SKILL.md | 129 ++ .../skills/review-pr-comments/SKILL.md | 110 ++ plugins/ndf-codex/skills/review/SKILL.md | 337 ++++ plugins/ndf-codex/skills/sync-main/SKILL.md | 48 + .../docs/06-pytest-playwright.md | 2 +- plugins/ndf-shared/skills/review/SKILL.md | 4 +- .../docs/06-pytest-playwright.md | 2 +- plugins/ndf/skills-codex/review/SKILL.md | 4 +- .../docs/06-pytest-playwright.md | 2 +- plugins/ndf/skills/review/SKILL.md | 4 +- 152 files changed, 24319 insertions(+), 12 deletions(-) create mode 100644 plugins/ndf-codex/.codex-plugin/plugin.json create mode 100644 plugins/ndf-codex/README.md create mode 100644 plugins/ndf-codex/hooks/hooks.json create mode 100755 plugins/ndf-codex/scripts/codex-slack-notify.js create mode 100755 plugins/ndf-codex/scripts/ensure-retention.sh create mode 100755 plugins/ndf-codex/scripts/install-official-skills.sh create mode 100755 plugins/ndf-codex/scripts/slack-notify.js create mode 100755 plugins/ndf-codex/scripts/statusline-switch.sh create mode 100755 plugins/ndf-codex/scripts/statusline.sh create mode 100644 plugins/ndf-codex/skills/branch-fix-strategy/SKILL.md create mode 100644 plugins/ndf-codex/skills/cherry-pick-pr/SKILL.md create mode 100644 plugins/ndf-codex/skills/clean/SKILL.md create mode 100644 plugins/ndf-codex/skills/cross-review/SKILL.md create mode 100644 plugins/ndf-codex/skills/cross-review/docs/01-state-and-review.md create mode 100644 plugins/ndf-codex/skills/cross-review/docs/02-fix-and-rotation.md create mode 100755 plugins/ndf-codex/skills/cross-review/scripts/_tmpdir.sh create mode 100755 plugins/ndf-codex/skills/cross-review/scripts/launch-codex.sh create mode 100755 plugins/ndf-codex/skills/cross-review/scripts/launch-gemini.sh create mode 100755 plugins/ndf-codex/skills/cross-review/scripts/monitor.py create mode 100755 plugins/ndf-codex/skills/cross-review/scripts/rotate-pr.sh create mode 100755 plugins/ndf-codex/skills/cross-review/scripts/state.py create mode 100755 plugins/ndf-codex/skills/cross-review/scripts/wait-review.sh create mode 100644 plugins/ndf-codex/skills/cross-review/tests/__init__.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/conftest.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_default_worktree_base.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_monitor_early_error.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_monitor_import_safety.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_monitor_progress_log.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_monitor_result_age.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_monitor_stall_default.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_monitor_zombie.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_state_auto_review_templates.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_state_check_oscillation.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_state_extra_review_instructions.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_state_merge_fix.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_state_read_result.py create mode 100644 plugins/ndf-codex/skills/deploy/SKILL.md create mode 100644 plugins/ndf-codex/skills/docker-container-access/01-environment-detection.md create mode 100644 plugins/ndf-codex/skills/docker-container-access/02-dood-access.md create mode 100644 plugins/ndf-codex/skills/docker-container-access/03-troubleshooting.md create mode 100644 plugins/ndf-codex/skills/docker-container-access/SKILL.md create mode 100644 plugins/ndf-codex/skills/fix/SKILL.md create mode 100755 plugins/ndf-codex/skills/fix/scripts/fetch-pr-comments.sh create mode 100644 plugins/ndf-codex/skills/git-gh-operations/01-common-errors.md create mode 100644 plugins/ndf-codex/skills/git-gh-operations/SKILL.md create mode 100644 plugins/ndf-codex/skills/implementation-plan/SKILL.md create mode 100644 plugins/ndf-codex/skills/investigation-rules/SKILL.md create mode 100644 plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md create mode 100644 plugins/ndf-codex/skills/logging-guidelines/SKILL.md create mode 100644 plugins/ndf-codex/skills/markdown-writing/01-diagram-guide.md create mode 100644 plugins/ndf-codex/skills/markdown-writing/SKILL.md create mode 100644 plugins/ndf-codex/skills/merged/SKILL.md create mode 100644 plugins/ndf-codex/skills/ndf-policies/SKILL.md create mode 100644 plugins/ndf-codex/skills/plan-to-spec/SKILL.md create mode 100644 plugins/ndf-codex/skills/playwright-execution/SKILL.md create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/.gitignore create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/SKILL.md create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/__init__.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/accessibility.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/body_check.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/config.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/overlay.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_report.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/video.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/web_vitals.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/pyproject.toml create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/_drive_auth.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/check_cwv.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/classify_page_role.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/gdrive_upload_dir.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/init_project.bat create mode 100755 plugins/ndf-codex/skills/playwright-kit-ops/scripts/init_project.sh create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/record_scenario.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/run_a11y_scan.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/upload_evidence.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/scripts/upload_md_as_gdoc.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/conftest.py.template create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/pyproject.toml.runtime create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/run.bat create mode 100755 plugins/ndf-codex/skills/playwright-kit-ops/templates/run.sh create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/runtime-README.md create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/runtime-gitignore create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/scenario.config.yaml create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/test_auth.py.template create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/test_dashboard.py.template create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/test_form.py.template create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/templates/test_list.py.template create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/__init__.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/conftest.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_a11y_cwv_routing.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_auth_cache.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_body_check.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_config_basic_auth.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_evidence_fixture.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_makereport_user_properties.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytest_plugin_bootstrap.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytest_report.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytest_terminal_summary.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_pytester_integration.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_same_origin.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_upload_evidence.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/tests/test_video_default.py create mode 100644 plugins/ndf-codex/skills/playwright-kit-ops/uv.lock create mode 100644 plugins/ndf-codex/skills/playwright-report/SKILL.md create mode 100644 plugins/ndf-codex/skills/playwright-script-creation/SKILL.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/SKILL.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/01-methodology.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/02-page-roles.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/03-test-techniques.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/04-playwright-mapping.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/05-bug-report.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/06-pytest-playwright.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/README.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-auth.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-cart-checkout.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-common.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-dashboard.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-edit.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-form.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-item.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-list.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-lp.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-modal-wizard.md create mode 100644 plugins/ndf-codex/skills/playwright-test-planning/docs/checklists/checklist-search.md create mode 100644 plugins/ndf-codex/skills/pr-tests/SKILL.md create mode 100644 plugins/ndf-codex/skills/pr/SKILL.md create mode 100644 plugins/ndf-codex/skills/problem-solving/SKILL.md create mode 100644 plugins/ndf-codex/skills/python-execution/01-uv-setup.md create mode 100644 plugins/ndf-codex/skills/python-execution/02-troubleshooting.md create mode 100644 plugins/ndf-codex/skills/python-execution/SKILL.md create mode 100644 plugins/ndf-codex/skills/resolve-pr-comments/SKILL.md create mode 100644 plugins/ndf-codex/skills/review-branch/SKILL.md create mode 100644 plugins/ndf-codex/skills/review-pr-comments/SKILL.md create mode 100644 plugins/ndf-codex/skills/review/SKILL.md create mode 100644 plugins/ndf-codex/skills/sync-main/SKILL.md diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 959f663..d10864f 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -5,7 +5,7 @@ "name": "ndf", "source": { "source": "local", - "path": "./plugins/ndf" + "path": "./plugins/ndf-codex" }, "policy": { "installation": "AVAILABLE", diff --git a/plugins/ndf-claude/skills/review/SKILL.md b/plugins/ndf-claude/skills/review/SKILL.md index cfb920b..5dfeb4a 100644 --- a/plugins/ndf-claude/skills/review/SKILL.md +++ b/plugins/ndf-claude/skills/review/SKILL.md @@ -268,7 +268,7 @@ GitHub は **自分の PR には `REQUEST_CHANGES` で投稿できない**(`HT ### `codex` 指定時 -呼び出し手順の詳細は `/ndf:codex` skill(`plugins/ndf/skills/codex/SKILL.md`)に従う。要点: +呼び出し手順の詳細は、利用 runtime に `/ndf:codex` skill が同梱されている場合はその skill に従う。要点: - プロンプトを `/tmp/codex-review-pr<番号>-prompt.md` に書き出し - 出力先ファイルを `/tmp/codex-output-review-pr<番号>.md` として **プロンプト内で `apply_patch` 書き出しを必須化** @@ -283,7 +283,7 @@ GitHub は **自分の PR には `REQUEST_CHANGES` で投稿できない**(`HT ### `gemini` 指定時 -呼び出し手順の詳細は `/ndf:gemini` skill(`plugins/ndf/skills/gemini/SKILL.md`)に従う。要点: +呼び出し手順の詳細は、利用 runtime に `/ndf:gemini` skill が同梱されている場合はその skill に従う。要点: - プロンプトを `/tmp/gemini-review-pr<番号>-prompt.md` に書き出し - **AI 直接投稿フローでは `--yolo` 必須**(`gh api -X POST` がシェル実行のため、`plan` / `auto_edit` だとブロックされる) diff --git a/plugins/ndf-codex/.codex-plugin/plugin.json b/plugins/ndf-codex/.codex-plugin/plugin.json new file mode 100644 index 0000000..ddd954f --- /dev/null +++ b/plugins/ndf-codex/.codex-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "ndf", + "version": "4.19.0", + "description": "Codex plugin with focused NDF skills for PR/review workflows, cross-review, implementation planning, Playwright testing, Docker container access, GitHub operations, and optional Slack completion notifications.", + "skills": "./skills/", + "hooks": "./hooks/hooks.json" +} diff --git a/plugins/ndf-codex/README.md b/plugins/ndf-codex/README.md new file mode 100644 index 0000000..ff0e11e --- /dev/null +++ b/plugins/ndf-codex/README.md @@ -0,0 +1,58 @@ +# NDF Codex Plugin + +Codex CLI 向けの NDF プラグインです。PR 運用、レビュー、cross-review、実装計画、仕様書化、Playwright テスト運用、Docker container access、GitHub 操作補助などの Codex 用 skills と、Codex 終了時の任意 Slack 通知 hook を提供します。 + +## インストール + +Codex で marketplace を追加し、`ndf` をインストールします。 + +```bash +codex plugin marketplace add https://github.com/devbasex/ai-plugins +codex plugin add ndf@ai-plugins +``` + +## 同梱内容 + +- `.codex-plugin/plugin.json`: Codex plugin manifest +- `skills/`: Codex 向けに公開する NDF skills +- `hooks/hooks.json`: Codex Stop hook +- `scripts/`: hook と skill から利用する同梱スクリプト + +Claude Code 専用の agents、statusline 自動設定、transcript retention 自動設定は含めません。Codex runtime が読むファイルはこの `plugins/ndf-codex` 配下だけで完結します。 + +## Slack 通知 + +Codex 版の Stop hook は `NDF_CODEX_SLACK_NOTIFY=true` が設定されている場合だけ Slack 通知を送ります。通知を使う場合は、利用プロジェクト側で以下の環境変数を設定します。 + +```bash +NDF_CODEX_SLACK_NOTIFY=true +SLACK_BOT_TOKEN=xoxb-... +SLACK_CHANNEL_ID=C0123456789 +SLACK_USER_MENTION=<@U0123456789> +``` + +`SLACK_USER_MENTION` は任意です。機密値は `.env` などで管理し、リポジトリへコミットしないでください。 + +Codex の hook は初回実行前に Codex 側の hooks trust 設定が必要になる場合があります。`/hooks` で対象 hook を確認し、利用するプロジェクトで明示的に有効化してください。 + +## 検証 + +Codex plugin schema を検証できる CLI が利用できる場合は、Codex 側の validate / install smoke を実行してください。CLI に検証コマンドが無い環境では、manifest JSON と参照パスの存在を確認します。 + +```bash +python3 -m json.tool plugins/ndf-codex/.codex-plugin/plugin.json >/dev/null +python3 -m json.tool plugins/ndf-codex/hooks/hooks.json >/dev/null +test -d plugins/ndf-codex/skills +test -d plugins/ndf-codex/scripts +``` + +install smoke を行う場合は、別の一時プロジェクトで marketplace から `ndf@ai-plugins` を追加し、代表 skill が読み込まれることと Stop hook が trust 対象として表示されることを確認します。 + +## 開発者向け + +`skills/` と `scripts/` は `plugins/ndf-shared` から生成される commit 対象の生成物です。Skill や共通スクリプトを変更する場合は `plugins/ndf-shared` を編集し、build を実行します。 + +```bash +bash scripts/build-runtime-plugins.sh +bash scripts/build-runtime-plugins.sh --check +``` diff --git a/plugins/ndf-codex/hooks/hooks.json b/plugins/ndf-codex/hooks/hooks.json new file mode 100644 index 0000000..bf0e8bc --- /dev/null +++ b/plugins/ndf-codex/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'if [ -n \"$PLUGIN_ROOT\" ]; then node \"$PLUGIN_ROOT/scripts/codex-slack-notify.js\"; fi; exit 0'", + "timeout": 15, + "statusMessage": "Sending Codex completion notification" + } + ] + } + ] + } +} diff --git a/plugins/ndf-codex/scripts/codex-slack-notify.js b/plugins/ndf-codex/scripts/codex-slack-notify.js new file mode 100755 index 0000000..af01f50 --- /dev/null +++ b/plugins/ndf-codex/scripts/codex-slack-notify.js @@ -0,0 +1,330 @@ +#!/usr/bin/env node +/** + * Slack notification script for Codex Stop hooks. + * + * Opt-in only: set NDF_CODEX_SLACK_NOTIFY=true plus the same Slack variables + * used by the Claude Code hook: SLACK_BOT_TOKEN, SLACK_CHANNEL_ID, and + * optional SLACK_USER_MENTION. The script avoids model calls and summarizes + * from Codex's local session JSONL when available. + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawnSync } = require('child_process'); +const os = require('os'); +const crypto = require('crypto'); + +const CONFIG = { + MAX_SESSION_FILES: 80, + MAX_LINES: 240, + MAX_TEXT: 180, + LOCK_TIMEOUT_MS: 30000, + COOLDOWN_MS: 5000, + DELETE_DELAY_MS: 500, + FALLBACK_SUMMARY: 'Codexの作業が完了しました', + LOG_DIR: path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'log'), +}; + +const RUN_ID = crypto.randomBytes(4).toString('hex'); + +const isEnabled = () => /^(1|true|yes|on)$/i.test(process.env.NDF_CODEX_SLACK_NOTIFY || ''); +const isDebug = () => /^(1|true|yes|on)$/i.test(process.env.DEBUG_CODEX_SLACK_NOTIFY || ''); + +function log(message, ...args) { + if (!isDebug()) return; + const line = `[${new Date().toISOString()}] [codex-slack:${RUN_ID}] ${message} ${args.map(String).join(' ')}\n`; + process.stderr.write(line); + try { + fs.mkdirSync(CONFIG.LOG_DIR, { recursive: true }); + fs.appendFileSync(path.join(CONFIG.LOG_DIR, 'ndf-codex-slack-notify.log'), line); + } catch (_) { + // Debug logging must never break hook execution. + } +} + +function safeJsonParse(text) { + try { + return JSON.parse(text); + } catch (_) { + return null; + } +} + +async function readStdinJson() { + if (process.stdin.isTTY) return {}; + let input = ''; + for await (const chunk of process.stdin) input += chunk; + log('stdin bytes:', input.length); + return safeJsonParse(input) || {}; +} + +function loadEnvFile() { + let current = process.cwd(); + while (current && current !== path.dirname(current)) { + const envFile = path.join(current, '.env'); + if (fs.existsSync(envFile)) { + for (const rawLine of fs.readFileSync(envFile, 'utf8').split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const match = line.match(/^([^=]+)=(.*)$/); + if (!match) continue; + const key = match[1].trim(); + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + process.env[key] ??= value; + } + return; + } + if (fs.existsSync(path.join(current, '.git'))) return; + current = path.dirname(current); + } +} + +function lockPath() { + const id = `${process.cwd()}:${process.env.CODEX_SESSION_ID || ''}`; + const hash = crypto.createHash('sha256').update(id).digest('hex').slice(0, 12); + return path.join(os.tmpdir(), `ndf-codex-slack-${hash}.lock`); +} + +function acquireLock() { + const file = lockPath(); + const now = Date.now(); + const existing = fs.existsSync(file) ? safeJsonParse(fs.readFileSync(file, 'utf8')) : null; + if (existing?.completedAt && now - existing.completedAt < CONFIG.COOLDOWN_MS) return false; + if (existing?.timestamp && !existing.completedAt && now - existing.timestamp < CONFIG.LOCK_TIMEOUT_MS) return false; + fs.writeFileSync(file, JSON.stringify({ pid: process.pid, timestamp: now, completedAt: null })); + return true; +} + +function releaseLock() { + const file = lockPath(); + const data = fs.existsSync(file) ? safeJsonParse(fs.readFileSync(file, 'utf8')) || {} : {}; + data.completedAt = Date.now(); + fs.writeFileSync(file, JSON.stringify(data)); +} + +function gitValue(args, fallback = '') { + const result = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8' }); + return result.status === 0 ? result.stdout.trim() : fallback; +} + +function repoInfo() { + const root = gitValue(['rev-parse', '--show-toplevel'], process.cwd()); + const branch = gitValue(['branch', '--show-current'], ''); + return { + name: path.basename(root || process.cwd()), + root, + branch, + }; +} + +function codexHome() { + return process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); +} + +function findSessionFiles() { + const base = path.join(codexHome(), 'sessions'); + if (!fs.existsSync(base)) return []; + const files = []; + const stack = [base]; + while (stack.length) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) stack.push(p); + else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push({ path: p, mtimeMs: fs.statSync(p).mtimeMs }); + } + } + } + return files.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, CONFIG.MAX_SESSION_FILES); +} + +function textFromContent(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map((item) => item?.text || item?.content || '').filter(Boolean).join('\n'); + } + if (content && typeof content === 'object') return content.text || content.message || ''; + return ''; +} + +function textFromItem(item) { + const payload = item?.payload || {}; + if (payload.type === 'message') { + return textFromContent(payload.content); + } + if (payload.type === 'agent_message') return payload.message || ''; + if (item.type === 'event_msg' && payload.message) return payload.message; + return ''; +} + +function summarizeText(text) { + const oneLine = String(text || '') + .replace(/```[\s\S]*?```/g, '') + .replace(/\s+/g, ' ') + .trim(); + if (!oneLine) return null; + return oneLine.length > CONFIG.MAX_TEXT ? `${oneLine.slice(0, CONFIG.MAX_TEXT - 1)}…` : oneLine; +} + +function readSessionSummary(hookInput) { + const explicitPath = hookInput.transcript_path || hookInput.session_path || hookInput.session_file; + const candidates = explicitPath ? [{ path: explicitPath }] : findSessionFiles(); + const cwd = hookInput.cwd || process.cwd(); + const sessionId = hookInput.session_id || hookInput.thread_id || process.env.CODEX_SESSION_ID || ''; + const lastAssistantSummary = summarizeText(textFromContent(hookInput.last_assistant_message)); + + for (const candidate of candidates) { + if (!candidate.path || !fs.existsSync(candidate.path)) continue; + const lines = fs.readFileSync(candidate.path, 'utf8').trim().split('\n').slice(-CONFIG.MAX_LINES); + const parsed = lines.map(safeJsonParse).filter(Boolean); + const meta = parsed.find((item) => item.type === 'session_meta')?.payload || {}; + if (!explicitPath && meta.cwd && meta.cwd !== cwd) continue; + if (!explicitPath && sessionId && meta.session_id && meta.session_id !== sessionId) continue; + + const final = [...parsed].reverse().find((item) => { + const payload = item.payload || {}; + return payload.phase === 'final_answer' || payload.type === 'agent_message'; + }); + const user = [...parsed].reverse().find((item) => { + const payload = item.payload || {}; + return payload.type === 'message' && payload.role === 'user'; + }); + const tokenEvent = [...parsed].reverse().find((item) => item.type === 'event_msg' && item.payload?.type === 'token_count'); + + return { + sessionId: meta.session_id || sessionId, + summary: lastAssistantSummary || summarizeText(textFromItem(final)) || summarizeText(textFromItem(user)), + model: meta.model || meta.model_slug || '', + tokenInfo: tokenEvent?.payload?.info || null, + file: candidate.path, + }; + } + + return { sessionId, summary: lastAssistantSummary, model: '', tokenInfo: null, file: null }; +} + +function formatTokenInfo(info) { + const usage = info?.total_token_usage || info?.last_token_usage; + const window = info?.model_context_window; + if (!usage?.total_tokens || !window) return ''; + const pct = Math.round((usage.total_tokens / window) * 100); + return `tokens: ${usage.total_tokens}/${window} (${pct}%)`; +} + +function formatMessage(repo, session, includeMention = false) { + const mention = includeMention && process.env.SLACK_USER_MENTION ? `${process.env.SLACK_USER_MENTION} ` : ''; + const parts = [ + `${mention}[${repo.name}] Codex: ${session.summary || CONFIG.FALLBACK_SUMMARY}`, + repo.branch ? `branch: ${repo.branch}` : '', + session.model ? `model: ${session.model}` : '', + formatTokenInfo(session.tokenInfo), + session.sessionId ? `session: ${session.sessionId}` : '', + `cwd: ${process.cwd()}`, + ].filter(Boolean); + return parts.join('\n'); +} + +function slackApiRequest(apiPath, data) { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) return Promise.resolve(null); + + return new Promise((resolve) => { + const body = JSON.stringify(data); + const req = https.request({ + hostname: 'slack.com', + port: 443, + path: apiPath, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }, + }, (res) => { + let response = ''; + res.on('data', (chunk) => { response += chunk; }); + res.on('end', () => { + const json = safeJsonParse(response); + log('slack api:', apiPath, 'ok:', json?.ok, 'error:', json?.error || ''); + resolve(json?.ok === true ? json : null); + }); + }); + req.on('error', (error) => { + log('slack error:', error.message); + resolve(null); + }); + req.write(body); + req.end(); + }); +} + +function postSlack(text) { + const channel = process.env.SLACK_CHANNEL_ID; + if (!channel) return Promise.resolve(null); + return slackApiRequest('/api/chat.postMessage', { channel, text }); +} + +function deleteSlack(ts) { + const channel = process.env.SLACK_CHANNEL_ID; + if (!channel || !ts) return Promise.resolve(null); + return slackApiRequest('/api/chat.delete', { channel, ts }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + loadEnvFile(); + if (!isEnabled()) { + log('disabled'); + return; + } + if (!process.env.SLACK_BOT_TOKEN || !process.env.SLACK_CHANNEL_ID) { + log('missing slack env'); + return; + } + if (!acquireLock()) { + log('lock rejected'); + return; + } + try { + const hookInput = await readStdinJson(); + const repo = repoInfo(); + const session = readSessionSummary(hookInput); + if (!process.env.SLACK_USER_MENTION) { + await postSlack(formatMessage(repo, session, false)); + return; + } + + const mentionResult = await postSlack(formatMessage(repo, session, true)); + if (!mentionResult) { + log('failed to send mention message'); + return; + } + + await sleep(CONFIG.DELETE_DELAY_MS); + + const cleanResult = await postSlack(formatMessage(repo, session, false)); + if (!cleanResult) { + log('failed to send clean message'); + return; + } + + const deleteResult = await deleteSlack(mentionResult.ts); + if (!deleteResult) log('failed to delete mention message'); + } finally { + releaseLock(); + } +} + +main().catch((error) => { + log('fatal:', error.stack || error.message); + releaseLock(); + process.exit(0); +}); diff --git a/plugins/ndf-codex/scripts/ensure-retention.sh b/plugins/ndf-codex/scripts/ensure-retention.sh new file mode 100755 index 0000000..e511363 --- /dev/null +++ b/plugins/ndf-codex/scripts/ensure-retention.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# NDF plugin: ~/.claude/settings.json の cleanupPeriodDays を最低 90 日に保つ。 +# - 既存値 >= 90 なら何もしない +# - 既存値 < 90 or 未設定 なら 90 に更新 +# - 前回チェックから 7 日経っていなければスキップ (多重実行防止) +# - 複数セッション同時起動時の race condition を flock で回避 (flock 不在時は atomic rename のみ) +set -euo pipefail + +MIN_DAYS=90 +GUARD_DAYS=7 +SETTINGS="$HOME/.claude/settings.json" +FLAG="$HOME/.claude/.ndf-retention-checked" +LOCK="$HOME/.claude/.ndf-retention.lock" + +# jq 必須 +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +# 7 日以内に実行済みならスキップ +if [ -f "$FLAG" ]; then + if find "$FLAG" -mtime "-${GUARD_DAYS}" -print -quit 2>/dev/null | grep -q .; then + exit 0 + fi +fi + +# settings.json が存在しない場合は新規作成 +mkdir -p "$(dirname "$SETTINGS")" +if [ ! -f "$SETTINGS" ]; then + echo "{}" > "$SETTINGS" +fi + +# 更新処理をひとつの関数にまとめ、ロック内で呼び出す +update_retention() { + # ロック取得後に再チェック (並行セッションが先に更新済みの可能性) + local current tmp + current=$(jq -r '.cleanupPeriodDays // 0' "$SETTINGS" 2>/dev/null || echo 0) + case "$current" in + ''|*[!0-9]*) current=0 ;; + esac + if [ "$current" -ge "$MIN_DAYS" ]; then + return 0 + fi + tmp="$(mktemp)" + if jq --argjson v "$MIN_DAYS" '.cleanupPeriodDays = $v' "$SETTINGS" > "$tmp"; then + mv "$tmp" "$SETTINGS" + echo "[ndf] cleanupPeriodDays を ${current} → ${MIN_DAYS} に更新しました (~/.claude/settings.json)" + else + rm -f "$tmp" + return 1 + fi +} + +# ロック取得: flock があればアトミック更新、無ければ atomic rename のみに依存 +if command -v flock >/dev/null 2>&1; then + ( + # 最大 5 秒待機。取得できなければ他プロセスが更新中なので何もしない + flock -x -w 5 200 || exit 0 + update_retention || true + ) 200>"$LOCK" +else + # flock 不在環境 (macOS 標準など): mv 自体は POSIX で atomic なので大きな破損は起きない + update_retention || true +fi + +touch "$FLAG" diff --git a/plugins/ndf-codex/scripts/install-official-skills.sh b/plugins/ndf-codex/scripts/install-official-skills.sh new file mode 100755 index 0000000..bba6467 --- /dev/null +++ b/plugins/ndf-codex/scripts/install-official-skills.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# Anthropic公式Skillsのインストーラ +# +# Usage: +# bash install-official-skills.sh [SKILL_NAMES...] +# bash install-official-skills.sh --all +# bash install-official-skills.sh --list +# bash install-official-skills.sh --scope project [SKILL_NAMES...] +# bash install-official-skills.sh --update +# +# Examples: +# bash install-official-skills.sh docx pptx xlsx # 指定Skillをインストール (~/.claude/skills/) +# bash install-official-skills.sh --scope project pdf # プロジェクト .claude/skills/ に配置 +# bash install-official-skills.sh --all # 全Skillインストール +# bash install-official-skills.sh --update # 公式リポジトリを最新化 + +set -euo pipefail + +REPO_URL="https://github.com/anthropics/skills.git" +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/anthropic-skills" +USER_SKILLS_DIR="$HOME/.claude/skills" +PROJECT_SKILLS_DIR=".claude/skills" + +SCOPE="user" +DO_ALL=false +DO_LIST=false +DO_UPDATE=false +SKILLS=() + +# 引数パース +while [[ $# -gt 0 ]]; do + case "$1" in + --scope) + SCOPE="$2" + shift 2 + ;; + --all) + DO_ALL=true + shift + ;; + --list) + DO_LIST=true + shift + ;; + --update) + DO_UPDATE=true + shift + ;; + --help|-h) + sed -n '2,13p' "$0" | sed 's/^# \?//' + exit 0 + ;; + --*) + echo "ERROR: unknown option $1" >&2 + exit 1 + ;; + *) + SKILLS+=("$1") + shift + ;; + esac +done + +# --- クローン or 更新 --- +if [ ! -d "$CACHE_DIR/.git" ]; then + echo "==> 公式Skillsリポジトリを取得中: $CACHE_DIR" + mkdir -p "$(dirname "$CACHE_DIR")" + git clone --depth 1 "$REPO_URL" "$CACHE_DIR" +elif [ "$DO_UPDATE" = true ]; then + echo "==> 公式Skillsを最新化中" + git -C "$CACHE_DIR" pull --ff-only +fi + +# --- --list: 利用可能Skill一覧を表示 --- +if [ "$DO_LIST" = true ]; then + echo "" + echo "=== 利用可能なSkill ===" + for dir in "$CACHE_DIR"/skills/*/; do + name=$(basename "$dir") + license_file="$dir/LICENSE.txt" + if [ -f "$license_file" ]; then + if grep -q "Apache License" "$license_file"; then + license="Apache-2.0" + elif grep -q "Proprietary\|All rights reserved" "$license_file"; then + license="Proprietary" + else + license="?" + fi + else + license="-" + fi + printf " %-25s [%s]\n" "$name" "$license" + done + echo "" + echo "Proprietary Skillは各自の環境にインストール可能ですが、再配布は禁止です。" + exit 0 +fi + +# --- インストール先を決定 --- +case "$SCOPE" in + user) + DEST="$USER_SKILLS_DIR" + ;; + project) + if [ ! -d .git ] && [ ! -f .claude-plugin/plugin.json ] && [ ! -f package.json ] && [ ! -f pyproject.toml ]; then + echo "WARN: プロジェクトルートらしきファイルが見当たりません (.git, package.json 等)。カレントディレクトリ: $(pwd)" + fi + DEST="$PROJECT_SKILLS_DIR" + ;; + *) + echo "ERROR: --scope は user / project のいずれか" >&2 + exit 1 + ;; +esac + +mkdir -p "$DEST" +echo "==> インストール先: $DEST ($SCOPE scope)" + +# --- 対象Skill決定 --- +if [ "$DO_ALL" = true ]; then + SKILLS=() + for dir in "$CACHE_DIR"/skills/*/; do + SKILLS+=("$(basename "$dir")") + done +fi + +if [ "${#SKILLS[@]}" -eq 0 ]; then + echo "ERROR: インストールするSkillが指定されていません。" >&2 + echo "利用可能な一覧は: bash $0 --list" >&2 + exit 1 +fi + +# --- シンボリックリンク作成 --- +INSTALLED=() +SKIPPED=() +NOT_FOUND=() +for skill in "${SKILLS[@]}"; do + src="$CACHE_DIR/skills/$skill" + dst="$DEST/$skill" + + if [ ! -d "$src" ]; then + NOT_FOUND+=("$skill") + continue + fi + + if [ -e "$dst" ] || [ -L "$dst" ]; then + if [ -L "$dst" ] && [ "$(readlink "$dst")" = "$src" ]; then + SKIPPED+=("$skill (既にリンク済み)") + continue + fi + echo "WARN: $dst が既に存在します。上書きしますか? [y/N]" + read -r ans + if [ "$ans" != "y" ] && [ "$ans" != "Y" ]; then + SKIPPED+=("$skill (ユーザーキャンセル)") + continue + fi + rm -rf "$dst" + fi + + ln -s "$src" "$dst" + INSTALLED+=("$skill") +done + +# --- 結果報告 --- +echo "" +echo "=== インストール結果 ===" +if [ "${#INSTALLED[@]}" -gt 0 ]; then + echo "インストール済み (${#INSTALLED[@]}個):" + for s in "${INSTALLED[@]}"; do echo " ✓ $s"; done +fi +if [ "${#SKIPPED[@]}" -gt 0 ]; then + echo "スキップ (${#SKIPPED[@]}個):" + for s in "${SKIPPED[@]}"; do echo " - $s"; done +fi +if [ "${#NOT_FOUND[@]}" -gt 0 ]; then + echo "見つからない (${#NOT_FOUND[@]}個):" + for s in "${NOT_FOUND[@]}"; do echo " ✗ $s"; done + echo "" + echo "一覧確認: bash $0 --list" +fi + +echo "" +echo "Claude Codeを再起動するか /plugin reload で反映してください。" diff --git a/plugins/ndf-codex/scripts/slack-notify.js b/plugins/ndf-codex/scripts/slack-notify.js new file mode 100755 index 0000000..38b9991 --- /dev/null +++ b/plugins/ndf-codex/scripts/slack-notify.js @@ -0,0 +1,777 @@ +#!/usr/bin/env node +/** + * Slack notification script for Claude Code session completion + * + * Reads transcript_path from stdin (hook input JSON) and generates + * a session-specific summary using Claude CLI. + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawn } = require('child_process'); +const os = require('os'); +const crypto = require('crypto'); + +// ============================================================================ +// Constants +// ============================================================================ + +// Generate unique run ID for this execution to trace logs +const RUN_ID = crypto.randomBytes(4).toString('hex'); + +const CONFIG = { + CLI_TIMEOUT_MS: 60000, + MAX_RESPONSES: 5, + MAX_TRANSCRIPT_LINES: 100, + MAX_FILE_SIZE_BYTES: 10 * 1024 * 1024, // 10MB + MIN_TEXT_LENGTH: 5, + MIN_RESPONSE_LENGTH: 10, + MAX_CONTENT_LENGTH: 500, + MAX_CONTEXT_LENGTH: 2500, + FALLBACK_REPO_NAME: 'unknown', + NO_SUMMARY_MESSAGE: 'Claude Codeのセッションが終了しました(要約なし)', + LOCK_TIMEOUT_MS: 30000, // Lock expires after 30 seconds + NOTIFICATION_COOLDOWN_MS: 5000, // Prevent duplicate notifications within 5 seconds + DELETE_DELAY_MS: 500, // Wait before deleting mention message + LOG_DIR: path.join(os.homedir(), '.claude', 'logs') +}; + +const META_CONTENT_PREFIXES = ['Caveat:', '']; + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Get log file path for today's date + * Format: ~/.claude/logs/slack-notify-YYYY-MM-DD.log + */ +function getLogFilePath() { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + return path.join(CONFIG.LOG_DIR, `slack-notify-${today}.log`); +} + +/** + * Ensure log directory exists + */ +function ensureLogDirectory() { + if (!fs.existsSync(CONFIG.LOG_DIR)) { + fs.mkdirSync(CONFIG.LOG_DIR, { recursive: true }); + } +} + +const isDebugMode = () => process.env.DEBUG_SLACK_NOTIFY === 'true'; + +const debugLog = (message, ...args) => { + if (isDebugMode()) { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [RUN:${RUN_ID}] [PID:${process.pid}] ${message}`; + + // Format additional arguments + const formattedArgs = args.map(arg => { + if (typeof arg === 'object') { + return JSON.stringify(arg); + } + return String(arg); + }).join(' '); + + const fullMessage = formattedArgs ? `${logMessage} ${formattedArgs}\n` : `${logMessage}\n`; + + // Write to stderr for backward compatibility + process.stderr.write(fullMessage); + + // Write to log file + try { + ensureLogDirectory(); + fs.appendFileSync(getLogFilePath(), fullMessage, 'utf8'); + } catch (error) { + // If file write fails, at least stderr output is available + process.stderr.write(`[ERROR] Failed to write to log file: ${error.message}\n`); + } + } +}; + +const safeJsonParse = (str) => { + try { + return JSON.parse(str); + } catch (e) { + debugLog('JSON parse error:', e.message, 'Input:', str?.substring(0, 100)); + return null; + } +}; + +const readFileLines = (filePath) => { + if (!fs.existsSync(filePath)) { + debugLog('File does not exist:', filePath); + return null; + } + try { + const stats = fs.statSync(filePath); + debugLog('Transcript file stats:', { + path: filePath, + size: stats.size, + mtime: stats.mtime.toISOString(), + mtimeMs: stats.mtimeMs + }); + if (stats.size > CONFIG.MAX_FILE_SIZE_BYTES) { + debugLog('Transcript file too large:', stats.size, 'bytes'); + return null; + } + const content = fs.readFileSync(filePath, 'utf8').trim(); + const lines = content.split('\n'); + debugLog('Read', lines.length, 'lines from transcript | File size:', stats.size); + return lines; + } catch (error) { + debugLog('Error reading transcript file:', error.message); + return null; + } +}; + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// ============================================================================ +// Lock Management (prevent duplicate execution) +// ============================================================================ + +function getLockFilePath() { + // Use working directory hash instead of session ID for robust deduplication + // This prevents multiple Claude Code instances in the same project from sending duplicate notifications + const cwd = process.cwd(); + const hash = crypto.createHash('md5').update(cwd).digest('hex').substring(0, 8); + return path.join(os.tmpdir(), `slack-notify-${hash}.lock`); +} + +function acquireLock() { + const lockFile = getLockFilePath(); + const now = Date.now(); + + debugLog('Attempting to acquire lock:', lockFile); + + try { + // Check if lock exists + if (fs.existsSync(lockFile)) { + const lockData = safeJsonParse(fs.readFileSync(lockFile, 'utf8')); + debugLog('Existing lock found:', lockData); + if (lockData) { + // Check if notification was sent recently (cooldown period) + if (lockData.completedAt && (now - lockData.completedAt) < CONFIG.NOTIFICATION_COOLDOWN_MS) { + const elapsed = now - lockData.completedAt; + debugLog('LOCK REJECTED: Notification sent recently | Elapsed:', elapsed, 'ms | Cooldown:', CONFIG.NOTIFICATION_COOLDOWN_MS, 'ms | Previous PID:', lockData.pid); + return false; + } + // Check if another process is still holding the lock + if (!lockData.completedAt && (now - lockData.timestamp) < CONFIG.LOCK_TIMEOUT_MS) { + const elapsed = now - lockData.timestamp; + debugLog('LOCK REJECTED: Lock held by another process | PID:', lockData.pid, '| Elapsed:', elapsed, 'ms | Timeout:', CONFIG.LOCK_TIMEOUT_MS, 'ms'); + return false; + } + } + debugLog('Lock expired or cooldown ended, proceeding'); + } else { + debugLog('No existing lock file found'); + } + + // Create lock + const lockData = { + pid: process.pid, + runId: RUN_ID, + timestamp: now, + completedAt: null + }; + fs.writeFileSync(lockFile, JSON.stringify(lockData)); + debugLog('LOCK ACQUIRED:', lockData); + return true; + } catch (error) { + debugLog('Lock acquisition error:', error.message); + return false; + } +} + +function releaseLock() { + const lockFile = getLockFilePath(); + try { + if (fs.existsSync(lockFile)) { + // Update lock with completion time instead of deleting + // This enables cooldown period to prevent duplicate notifications + const lockData = safeJsonParse(fs.readFileSync(lockFile, 'utf8')) || {}; + lockData.completedAt = Date.now(); + lockData.releasedByRunId = RUN_ID; + fs.writeFileSync(lockFile, JSON.stringify(lockData)); + debugLog('LOCK RELEASED:', lockData); + } else { + debugLog('Lock file not found during release'); + } + } catch (error) { + debugLog('Lock release error:', error.message); + } +} + +// ============================================================================ +// Environment Management +// ============================================================================ + +function loadEnvFile() { + let currentDir = __dirname; + + while (currentDir !== path.dirname(currentDir)) { + currentDir = path.dirname(currentDir); + const envFile = path.join(currentDir, '.env'); + + if (fs.existsSync(envFile)) { + debugLog('Loading env file:', envFile); + parseEnvFile(envFile); + break; + } + if (fs.existsSync(path.join(currentDir, '.git'))) break; + } +} + +function parseEnvFile(envFile) { + const content = fs.readFileSync(envFile, 'utf8'); + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const match = line.match(/^([^=]+)=(.*)$/); + if (!match) continue; + + const key = match[1].trim(); + let value = match[2].trim(); + + // Remove surrounding quotes + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + process.env[key] ??= value; + } +} + +// ============================================================================ +// Transcript Processing +// ============================================================================ + +function isMetaContent(content) { + return typeof content === 'string' && + META_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix)); +} + +function extractTextFromContent(content) { + if (typeof content === 'string') { + return isMetaContent(content) ? null : content.substring(0, CONFIG.MAX_CONTENT_LENGTH); + } + + if (Array.isArray(content)) { + const textItem = content.find(item => + item?.type === 'text' && typeof item.text === 'string' + ); + return textItem?.text?.substring(0, CONFIG.MAX_CONTENT_LENGTH) ?? null; + } + + return null; +} + +function parseTranscriptData(transcriptPath) { + debugLog('Parsing transcript:', transcriptPath); + const allLines = readFileLines(transcriptPath); + if (!allLines) { + debugLog('No lines read from transcript'); + return { firstRequest: null, assistantResponses: [] }; + } + + // Process only recent lines to avoid memory issues with large files + const lines = allLines.slice(-CONFIG.MAX_TRANSCRIPT_LINES); + debugLog('Processing', lines.length, 'recent lines'); + + let firstRequest = null; + const assistantResponses = []; + let parsedCount = 0; + let userCount = 0; + let assistantCount = 0; + + for (const line of lines) { + const data = safeJsonParse(line); + if (!data?.message?.role) continue; + parsedCount++; + + const { role, content } = data.message; + const text = extractTextFromContent(content); + + // Get first valid user request (within recent lines) + if (!firstRequest && + role === 'user' && + data.type === 'user' && + !data.isMeta && + text?.length >= CONFIG.MIN_TEXT_LENGTH) { + firstRequest = text; + userCount++; + debugLog('Found user request:', text.substring(0, 50) + '...'); + } + + // Collect assistant responses + if (role === 'assistant' && text?.length >= CONFIG.MIN_RESPONSE_LENGTH) { + assistantResponses.push(text); + assistantCount++; + } + } + + debugLog('Parsed messages:', parsedCount, '| User:', userCount, '| Assistant:', assistantCount); + + return { + firstRequest, + assistantResponses: assistantResponses.slice(-CONFIG.MAX_RESPONSES) + }; +} + +function formatContextForSummary({ firstRequest, assistantResponses }) { + if (!firstRequest && assistantResponses.length === 0) { + debugLog('No content for summary context'); + return null; + } + + const parts = []; + if (firstRequest) { + parts.push(`【ユーザーのリクエスト】\n${firstRequest}`); + } + if (assistantResponses.length > 0) { + parts.push(`【アシスタントの応答】\n${assistantResponses.join('\n\n')}`); + } + + const context = parts.join('\n\n'); + debugLog('Context length:', context.length); + return context.length >= CONFIG.MIN_RESPONSE_LENGTH ? context : null; +} + +function createSummarizationPrompt(context) { + return `以下の情報から、**このセッションで実施した作業**を日本語で40文字以内で要約してください。 + +## 重要 +- ユーザーのリクエストとアシスタントの応答から、このセッション固有の作業内容を特定してください +- 「調査」「確認」などの一般的な表現より、具体的な作業内容(例:「Slack通知の要約生成を修正」)を優先してください + +## 出力形式 +日本語の要約文のみ(1行、40文字以内) + +## 必須事項 +- 必ず日本語で出力すること +- このセッションで実際に行った具体的な作業を要約すること +- 技術的な作業内容を含めること(ファイル名、機能名など) + +## 禁止事項 +- 英語での出力 +- 「ユーザーさん」「調査結果」などの前置き +- 「要約:」「作業内容:」などのラベル +- 「〜を確認しました」「〜を調査しました」などの曖昧な表現(具体的な結果を書く) +- 挨拶文や説明文 +- 複数行の出力 + +## 良い例 +- Slack通知の要約生成ロジックを修正 +- BigQueryクエリのパフォーマンスを最適化 +- JWT認証のセキュリティレビューを実施 +- PRマージ後のブランチクリーンアップを実行 + +## 悪い例 +- Added new MCP server(英語は禁止) +- コードを調査しました(曖昧すぎる) +- ユーザーさん、調査結果をまとめます: +- 要約:〜を実施しました + +## セッション情報 +${context.substring(0, CONFIG.MAX_CONTEXT_LENGTH)} + +## 日本語の要約(40文字以内):`; +} + +// ============================================================================ +// Claude CLI Integration (with MCP tools disabled) +// ============================================================================ + +// Auth priority: OAuth > Bedrock > API Key (cost optimization) +const AUTH_CHECKS = [ + { + name: 'oauth', + detect: () => { + try { + const creds = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', '.credentials.json'), 'utf8')); + const oauth = creds?.claudeAiOauth; + return oauth?.accessToken && oauth?.expiresAt > Date.now(); + } catch (e) { + debugLog('OAuth detection failed:', e.message); + return false; + } + }, + removeKeys: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_USE_BEDROCK'], + }, + { + name: 'bedrock', + detect: () => !!process.env.CLAUDE_CODE_USE_BEDROCK, + removeKeys: ['ANTHROPIC_API_KEY'], + }, + { + name: 'apikey', + detect: () => process.env.ANTHROPIC_API_KEY?.startsWith('sk-ant-'), + removeKeys: ['CLAUDE_CODE_USE_BEDROCK'], + }, +]; + +const SESSION_VARS = ['CLAUDECODE', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_ENTRYPOINT']; + +function buildCleanEnv(skip = []) { + const env = { ...process.env }; + for (const key of SESSION_VARS) delete env[key]; + + const auth = AUTH_CHECKS.find(a => !skip.includes(a.name) && a.detect()) || { name: 'none', removeKeys: [] }; + for (const key of auth.removeKeys) delete env[key]; + if (auth.name === 'none' && env.ANTHROPIC_API_KEY && !env.ANTHROPIC_API_KEY.startsWith('sk-ant-')) { + delete env.ANTHROPIC_API_KEY; + } + debugLog(`Auth: ${auth.name} | removed: [${[...SESSION_VARS, ...auth.removeKeys].join(', ')}]`); + return { env, authName: auth.name }; +} + +function spawnClaudeCLI(prompt, env) { + return new Promise((resolve) => { + const args = [ + '--print', '--no-session-persistence', + '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', + '--tools', '', '--model', 'haiku', '-p', prompt + ]; + + const claude = spawn('claude', args, { stdio: ['ignore', 'pipe', 'pipe'], env }); + if (claude.pid) debugLog('Claude CLI spawned with PID:', claude.pid); + + let stdout = '', stderr = '', resolved = false; + const safeResolve = (value, reason) => { + if (resolved) return; + resolved = true; + clearTimeout(timeoutId); + debugLog('CLI resolved:', reason, '| Output length:', value?.length || 0); + resolve(value); + }; + + claude.stdout.on('data', (d) => { stdout += d; }); + claude.stderr.on('data', (d) => { stderr += d; }); + + const timeoutId = setTimeout(() => { + debugLog('Claude CLI timeout after', CONFIG.CLI_TIMEOUT_MS, 'ms'); + claude.kill('SIGTERM'); + safeResolve(null, 'timeout'); + }, CONFIG.CLI_TIMEOUT_MS); + + claude.on('close', (code, signal) => { + if (signal) { safeResolve(null, 'signal:' + signal); return; } + debugLog('Claude CLI exit code:', code); + debugLog('CLI stdout:', stdout.substring(0, 200)); + debugLog('CLI stderr:', stderr.substring(0, 200)); + (code === 0 && stdout.trim()) ? safeResolve(stdout.trim(), 'success') + : safeResolve(null, 'error:' + code); + }); + + claude.on('error', (err) => { + debugLog('Claude CLI spawn error:', err.message); + safeResolve(null, 'spawn-error'); + }); + }); +} + +async function callClaudeCLI(prompt) { + debugLog('=== Calling Claude CLI for summary generation ==='); + const skip = []; + // Try each auth method in priority order, falling back on failure + for (let i = 0; i <= AUTH_CHECKS.length; i++) { + const { env, authName } = buildCleanEnv(skip); + const result = await spawnClaudeCLI(prompt, env); + if (result) return result; + if (authName === 'none') break; + debugLog(`Auth ${authName} failed, trying next`); + skip.push(authName); + } + return null; +} + +function cleanSummaryResponse(summary) { + if (!summary) { + debugLog('No summary to clean'); + return null; + } + + debugLog('Raw summary:', summary); + + const cleaned = summary + .replace(/^(要約[::]?\s*|作業内容[::]?\s*)/i, '') + .replace(/^[\s\n]+/, '') + .split('\n')[0] + .trim(); + + debugLog('Cleaned summary:', cleaned); + return cleaned.length >= CONFIG.MIN_TEXT_LENGTH ? cleaned : null; +} + +async function generateSummary(transcriptPath) { + debugLog('=== Starting summary generation ==='); + + const transcriptData = parseTranscriptData(transcriptPath); + debugLog('Transcript data:', { + hasFirstRequest: !!transcriptData.firstRequest, + responseCount: transcriptData.assistantResponses.length + }); + + const context = formatContextForSummary(transcriptData); + if (!context) { + debugLog('No context available for summary'); + return null; + } + + const prompt = createSummarizationPrompt(context); + debugLog('Prompt length:', prompt.length); + + const output = await callClaudeCLI(prompt); + const result = cleanSummaryResponse(output); + + debugLog('=== Summary generation complete:', result ? 'SUCCESS' : 'FAILED', '==='); + return result; +} + +// ============================================================================ +// Slack API Integration +// ============================================================================ + +function makeSlackApiRequest(apiPath, data, token) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(data); + debugLog('Slack API request:', apiPath, '| Payload size:', payload.length); + + const req = https.request({ + hostname: 'slack.com', + port: 443, + path: apiPath, + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(payload) + } + }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + const result = safeJsonParse(body); + debugLog('Slack API response:', apiPath, '| ok:', result?.ok, '| error:', result?.error || 'none'); + if (result?.ok) { + resolve(result); + } else { + debugLog('Slack API error details:', result); + resolve(null); + } + }); + }); + + req.on('error', (error) => { + debugLog('Slack API network error:', error.message); + reject(error); + }); + req.write(payload); + req.end(); + }); +} + +const sendSlackMessage = (channelId, token, text) => + makeSlackApiRequest('/api/chat.postMessage', { channel: channelId, text }, token); + +const deleteSlackMessage = (channelId, token, ts) => + makeSlackApiRequest('/api/chat.delete', { channel: channelId, ts }, token); + +// ============================================================================ +// Git Integration +// ============================================================================ + +function getRepositoryName() { + return new Promise((resolve) => { + const git = spawn('git', ['rev-parse', '--show-toplevel']); + let output = ''; + + git.stdout.on('data', (data) => { output += data; }); + git.on('close', (code) => { + const repoName = code === 0 && output.trim() + ? path.basename(output.trim()) + : process.env.GIT_REPO || CONFIG.FALLBACK_REPO_NAME; + debugLog('Repository name:', repoName); + resolve(repoName); + }); + git.on('error', () => resolve(process.env.GIT_REPO || CONFIG.FALLBACK_REPO_NAME)); + }); +} + +// ============================================================================ +// Hook Input Processing +// ============================================================================ + +async function readHookInput() { + debugLog('=== Reading hook input from stdin ==='); + if (process.stdin.isTTY) { + debugLog('stdin is TTY, no hook input available'); + return { transcriptPath: null, stopHookActive: false }; + } + + let input = ''; + for await (const chunk of process.stdin) { + input += chunk; + } + + debugLog('Raw hook input length:', input.length, '| Preview:', input.substring(0, 200)); + const parsed = safeJsonParse(input); + if (parsed) { + debugLog('Parsed hook input:', JSON.stringify(parsed, null, 2)); + } else { + debugLog('Failed to parse hook input'); + } + + const result = { + transcriptPath: parsed?.transcript_path ?? null, + stopHookActive: parsed?.stop_hook_active === true, + assistantResponse: parsed?.assistant_response ?? null + }; + debugLog('Extracted hook input values:', result); + + return result; +} + +// ============================================================================ +// Message Formatting +// ============================================================================ + +function formatNotificationMessage(repoName, summary, includeMention = false) { + const userMention = process.env.SLACK_USER_MENTION; + const content = summary || CONFIG.NO_SUMMARY_MESSAGE; + + // Add debug info to message if in debug mode + const debugInfo = isDebugMode() + ? ` [RUN:${RUN_ID}|PID:${process.pid}]` + : ''; + + const message = `[${repoName}] ${content}${debugInfo}`; + + return includeMention && userMention ? `${userMention} ${message}` : message; +} + +// ============================================================================ +// Main Flow +// ============================================================================ + +async function main() { + debugLog('=== Slack notify script started ==='); + debugLog('RUN_ID:', RUN_ID); + debugLog('PID:', process.pid); + debugLog('CWD:', process.cwd()); + debugLog('ENV - CLAUDE_SESSION_ID:', process.env.CLAUDE_SESSION_ID || 'not set'); + debugLog('ENV - DEBUG_SLACK_NOTIFY:', process.env.DEBUG_SLACK_NOTIFY || 'not set'); + + loadEnvFile(); + + const { SLACK_CHANNEL_ID: channelId, SLACK_BOT_TOKEN: token } = process.env; + if (!channelId || !token) { + debugLog('Missing required env vars - SLACK_CHANNEL_ID:', !!channelId, '| SLACK_BOT_TOKEN:', !!token); + process.exit(0); + } + + // Prevent duplicate execution + if (!acquireLock()) { + debugLog('EXITING: Could not acquire lock'); + process.exit(0); + } + + try { + const [hookInput, repoName] = await Promise.all([ + readHookInput(), + getRepositoryName() + ]); + + const { transcriptPath, stopHookActive, assistantResponse } = hookInput; + + debugLog('Hook input received:', { + transcriptPath, + stopHookActive, + assistantResponse: assistantResponse ? assistantResponse.substring(0, 100) : null, + hasTranscriptPath: !!transcriptPath + }); + + // Skip if this is a recursive hook call (e.g., from internal Claude CLI for summary generation) + if (stopHookActive) { + debugLog('EXITING: stop_hook_active is true (preventing recursive execution)'); + releaseLock(); + process.exit(0); + } + + debugLog('Transcript path:', transcriptPath); + debugLog('Repository name:', repoName); + + const summaryStartTime = Date.now(); + let summary = null; + if (assistantResponse) { + // Kiro CLI: assistant_responseから直接要約生成 + const context = assistantResponse.length > CONFIG.MAX_CONTEXT_LENGTH + ? assistantResponse.substring(0, CONFIG.MAX_CONTEXT_LENGTH) + : assistantResponse; + const prompt = createSummarizationPrompt(`【アシスタントの応答】\n${context}`); + const output = await callClaudeCLI(prompt); + summary = cleanSummaryResponse(output); + } else if (transcriptPath) { + // Claude Code: transcript_pathから要約生成 + summary = await generateSummary(transcriptPath); + } + const summaryDuration = Date.now() - summaryStartTime; + debugLog('Final summary:', summary, '| Generation time:', summaryDuration, 'ms'); + + // Send message with mention to trigger notification + const mentionMessage = formatNotificationMessage(repoName, summary, true); + debugLog('=== Sending mention message ===', mentionMessage.substring(0, 100)); + const mentionSendTime = Date.now(); + const mentionResult = await sendSlackMessage(channelId, token, mentionMessage); + if (!mentionResult) { + debugLog('FAILED to send mention message'); + return; + } + debugLog('Mention message sent | ts:', mentionResult.ts, '| Duration:', Date.now() - mentionSendTime, 'ms'); + + // Wait a bit before sending clean message and deleting + debugLog('Waiting', CONFIG.DELETE_DELAY_MS, 'ms before cleanup'); + await sleep(CONFIG.DELETE_DELAY_MS); + + // Send clean message without mention + const cleanMessage = formatNotificationMessage(repoName, summary, false); + debugLog('=== Sending clean message ===', cleanMessage.substring(0, 100)); + const cleanSendTime = Date.now(); + const cleanResult = await sendSlackMessage(channelId, token, cleanMessage); + if (!cleanResult) { + debugLog('FAILED to send clean message'); + } else { + debugLog('Clean message sent | ts:', cleanResult.ts, '| Duration:', Date.now() - cleanSendTime, 'ms'); + } + + // Delete the mention message + if (mentionResult.ts) { + debugLog('=== Deleting mention message | ts:', mentionResult.ts, '==='); + const deleteStartTime = Date.now(); + const deleteResult = await deleteSlackMessage(channelId, token, mentionResult.ts); + if (deleteResult) { + debugLog('Mention message deleted successfully | Duration:', Date.now() - deleteStartTime, 'ms'); + } else { + debugLog('FAILED to delete mention message'); + } + } + + debugLog('=== Slack notify script completed successfully | Total runtime:', Date.now() - summaryStartTime + summaryDuration, 'ms ==='); + } finally { + releaseLock(); + } +} + +main().catch((error) => { + debugLog('Fatal error:', error.message, error.stack); + releaseLock(); + process.exit(0); +}); diff --git a/plugins/ndf-codex/scripts/statusline-switch.sh b/plugins/ndf-codex/scripts/statusline-switch.sh new file mode 100755 index 0000000..bccb616 --- /dev/null +++ b/plugins/ndf-codex/scripts/statusline-switch.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# NDF plugin: statusline の導入・切替・復元を行う。 +# +# Usage: statusline-switch.sh +# ensure : SessionStart hook 用。statusLine 未設定の場合のみ NDF 版を設定する +# (既存設定があれば何もしない)。NDF 版利用中はスクリプト更新のみ追従。 +# set : 既存の statusLine 設定をバックアップした上で NDF 版に切り替える。 +# restore : バックアップから元の statusLine 設定を復元する +# (バックアップが無ければ statusLine 設定を削除しデフォルト表示に戻す)。 +# status : 現在の statusLine 設定とバックアップの有無を表示する。 +set -euo pipefail + +CMD="${1:-status}" +SETTINGS="$HOME/.claude/settings.json" +TARGET="$HOME/.claude/ndf-statusline.sh" +BACKUP="$HOME/.claude/.ndf-statusline-backup.json" +LOCK="$HOME/.claude/.ndf-statusline.lock" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$SCRIPT_DIR/statusline.sh" +NDF_COMMAND="bash ~/.claude/ndf-statusline.sh" + +# jq 必須 (ensure は SessionStart hook から毎回呼ばれるためノイズ防止で黙ってスキップ) +if ! command -v jq >/dev/null 2>&1; then + if [ "$CMD" != "ensure" ]; then + echo "[ndf:statusline] jq が見つからないため処理をスキップしました" >&2 + fi + exit 0 +fi + +mkdir -p "$HOME/.claude" +if [ ! -f "$SETTINGS" ]; then + echo "{}" > "$SETTINGS" +fi + +# プラグイン同梱の statusline.sh を ~/.claude/ndf-statusline.sh に配置 (差分時のみ) +deploy_script() { + if [ ! -f "$TARGET" ] || ! cmp -s "$SRC" "$TARGET"; then + cp "$SRC" "$TARGET" + chmod +x "$TARGET" + fi +} + +current_command() { + jq -r '.statusLine.command // empty' "$SETTINGS" 2>/dev/null || true +} + +is_ndf_statusline() { + case "$(current_command)" in + *ndf-statusline.sh*) return 0 ;; + *) return 1 ;; + esac +} + +# current_command から実行スクリプトのパスを抽出し、先頭の ~ を $HOME に展開する。 +# 例: "bash ~/.claude/statusline-command.sh" -> "/home/user/.claude/statusline-command.sh" +current_script_path() { + local cmd path + cmd="$(current_command)" + [ -z "$cmd" ] && return 0 + # 末尾の *.sh トークンを実行対象スクリプトとみなす。 + # grep -o は GNU 拡張のため、POSIX 準拠かつ BusyBox でも動く sed で抽出する。 + # NDF が配置するコマンドは "bash ~/.claude/.sh" 形式 (パスにスペースを含まない) を想定。 + # 1つ目の置換がマッチしたら t で分岐して二重出力を防ぐ (tail -n1 のパイプを削減)。 + path="$(printf '%s\n' "$cmd" | sed -n 's/.*[[:space:]]\([^[:space:]]*\.sh\).*/\1/p; t; s/^\([^[:space:]]*\.sh\)$/\1/p')" + [ -z "$path" ] && return 0 + case "$path" in + "~/"*) path="$HOME/${path#\~/}" ;; + "~") path="$HOME" ;; + esac + printf '%s\n' "$path" +} + +# 指定スクリプトが NDF 由来 (マーカー付き、または既知のレガシーコピー) か判定する。 +# ユーザー独自の statusline を誤って上書きしないため、判定は厳格に行う。 +is_ndf_managed_copy() { + local path="$1" + [ -f "$path" ] || return 1 + # ① マーカーがあれば NDF 管理コピー確定 (今後配置される全コピーが該当) + if grep -Fq 'ndf-statusline: managed' "$path" 2>/dev/null; then + return 0 + fi + # ② レガシー救済: マーカー導入前の既知の旧コピー名で、かつ NDF statusline 特有の + # ロジック (ctx ラベル + コンテナ名取得) を両方含む場合のみ移行対象とする + case "$(basename "$path")" in + statusline-command.sh) + if grep -Fq '[ctx:' "$path" 2>/dev/null && grep -Fq 'container_name' "$path" 2>/dev/null; then + return 0 + fi + ;; + esac + return 1 +} + +# settings.json を atomic に書き換える。引数: jq フィルタ +update_settings() { + local tmp + tmp="$(mktemp)" + if jq "$@" "$SETTINGS" > "$tmp"; then + mv "$tmp" "$SETTINGS" + else + rm -f "$tmp" + return 1 + fi +} + +set_ndf_statusline() { + update_settings --arg cmd "$NDF_COMMAND" \ + '.statusLine = {type: "command", command: $cmd}' +} + +# NDF 由来の旧 statusline を検出した際に、既存設定をバックアップした上で +# 正規パス (~/.claude/ndf-statusline.sh) 参照へ移行する。 +migrate_to_ndf_statusline() { + local existing + existing=$(jq -c '.statusLine // empty' "$SETTINGS" 2>/dev/null || true) + if [ -n "$existing" ] && [ ! -f "$BACKUP" ]; then + printf '%s\n' "$existing" > "$BACKUP" + fi + set_ndf_statusline + echo "[ndf:statusline] NDF 由来の旧 statusline を検出したため正規パス (~/.claude/ndf-statusline.sh) へ移行しました (旧設定は $BACKUP に退避)" +} + +cmd_ensure() { + deploy_script + # 既に statusLine が設定されている場合 + if [ -n "$(jq -r '.statusLine // empty' "$SETTINGS" 2>/dev/null)" ]; then + # 正規パスを指していれば deploy_script で本体が追従済み (何もしない) + if is_ndf_statusline; then + return 0 + fi + # NDF が過去に配置したコピー (マーカー付き or レガシー statusline-command.sh) を + # 指している場合のみ、正規パス参照へ移行してバージョンアップ追従を回復する + local cur_path + cur_path="$(current_script_path)" + if [ -n "$cur_path" ] && is_ndf_managed_copy "$cur_path"; then + migrate_to_ndf_statusline + return 0 + fi + # それ以外はユーザー独自設定として尊重し、何もしない + return 0 + fi + set_ndf_statusline + echo "[ndf] statusLine を NDF 標準 statusline に設定しました (~/.claude/settings.json)" +} + +cmd_set() { + deploy_script + if is_ndf_statusline; then + echo "[ndf:statusline] 既に NDF 標準 statusline が設定されています" + return 0 + fi + # 既存設定があればバックアップしてから切り替え + local existing + existing=$(jq -c '.statusLine // empty' "$SETTINGS" 2>/dev/null || true) + if [ -n "$existing" ]; then + printf '%s\n' "$existing" > "$BACKUP" + echo "[ndf:statusline] 既存設定をバックアップしました: $BACKUP" + fi + set_ndf_statusline + echo "[ndf:statusline] NDF 標準 statusline に切り替えました" +} + +cmd_restore() { + if [ -f "$BACKUP" ]; then + update_settings --slurpfile bk "$BACKUP" '.statusLine = $bk[0]' + rm -f "$BACKUP" + echo "[ndf:statusline] バックアップから statusLine 設定を復元しました" + elif is_ndf_statusline; then + update_settings 'del(.statusLine)' + echo "[ndf:statusline] バックアップが無いため statusLine 設定を削除しました (デフォルト表示に戻ります)" + else + echo "[ndf:statusline] 復元対象がありません (NDF statusline は未使用、バックアップ無し)" + fi +} + +cmd_status() { + local cur + cur=$(current_command) + if [ -z "$cur" ]; then + echo "statusLine: 未設定 (デフォルト表示)" + elif is_ndf_statusline; then + echo "statusLine: NDF 標準 statusline ($cur)" + else + echo "statusLine: カスタム設定 ($cur)" + fi + if [ -f "$BACKUP" ]; then + echo "バックアップ: あり ($(cat "$BACKUP"))" + else + echo "バックアップ: なし" + fi +} + +run_cmd() { + case "$CMD" in + ensure) cmd_ensure ;; + set) cmd_set ;; + restore) cmd_restore ;; + status) cmd_status ;; + *) + echo "Usage: $(basename "$0") " >&2 + exit 1 + ;; + esac +} + +# 複数セッション同時起動時の race condition を flock で回避 (flock 不在時は atomic rename のみ) +if command -v flock >/dev/null 2>&1; then + ( + flock -x -w 5 200 || exit 0 + run_cmd + ) 200>"$LOCK" +else + run_cmd +fi diff --git a/plugins/ndf-codex/scripts/statusline.sh b/plugins/ndf-codex/scripts/statusline.sh new file mode 100755 index 0000000..19d7c09 --- /dev/null +++ b/plugins/ndf-codex/scripts/statusline.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# ndf-statusline: managed (do not edit; auto-updated by ndf:statusline) +# NDF plugin 標準 statusline: +# <コンテナ名 or ホスト名> [<モデル名>: 使用トークン / 全体 (使用率%)] +input=$(cat) + +# コンテナ名を取得する。コンテナでなければホスト名にフォールバック +container_name="" + +# Docker/コンテナ環境かどうかを /.dockerenv で判定 +if [ -f /.dockerenv ]; then + # CONTAINER_NAME 環境変数が明示設定されていればそちらを優先 + container_name="${CONTAINER_NAME:-}" + + # Docker ソケットが使えれば docker inspect で compose 上のコンテナ名を取得 + # (/etc/hostname はコンテナIDなので、それをキーに引く) + if [ -z "$container_name" ] && [ -S /var/run/docker.sock ] && command -v docker >/dev/null 2>&1; then + container_id=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') + if [ -n "$container_id" ]; then + container_name=$(docker inspect --format '{{.Name}}' "$container_id" 2>/dev/null | sed 's|^/||') + fi + fi + + # 取れなければ /etc/hostname(コンテナID)をフォールバックとして使用 + if [ -z "$container_name" ] && [ -f /etc/hostname ]; then + container_name=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') + fi +fi + +# コンテナ名が取れなければ hostname コマンドにフォールバック +if [ -z "$container_name" ]; then + container_name=$(hostname -s 2>/dev/null || hostname) +fi + +dir="$container_name" + +# claude root のパスを取得(project_dir を優先し、なければ current_dir を使用) +# jq 不在や無効な JSON 入力時に stderr が statusLine 描画に漏れないよう 2>/dev/null で抑制 +claude_root=$(echo "$input" | jq -r '.workspace.project_dir // .workspace.current_dir // empty' 2>/dev/null) + +total_input=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty' 2>/dev/null) +ctx_size=$(echo "$input" | jq -r '.context_window.context_window_size // empty' 2>/dev/null) +used_pct=$(echo "$input" | jq -r '.context_window.used_percentage // empty' 2>/dev/null) + +# モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック +model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null) +ctx_label="${model_name:-ctx}" + +ctx_info="" +if [ -n "$total_input" ] && [ -n "$ctx_size" ] && [ -n "$used_pct" ]; then + total_input_k=$(awk "BEGIN { printf \"%.1f\", $total_input / 1000 }") + ctx_size_k=$(awk "BEGIN { printf \"%.0f\", $ctx_size / 1000 }") + ctx_info=$(printf " \033[0;36m[%s: %sk / %sk tokens (%.0f%%)]" "$ctx_label" "$total_input_k" "$ctx_size_k" "$used_pct") +fi + +if [ -n "$claude_root" ]; then + printf "\033[01;34m%s\033[00m \033[0;33m%s\033[00m%s" "$dir" "$claude_root" "$ctx_info" +else + printf "\033[01;34m%s\033[00m%s" "$dir" "$ctx_info" +fi diff --git a/plugins/ndf-codex/skills/branch-fix-strategy/SKILL.md b/plugins/ndf-codex/skills/branch-fix-strategy/SKILL.md new file mode 100644 index 0000000..a4714a3 --- /dev/null +++ b/plugins/ndf-codex/skills/branch-fix-strategy/SKILL.md @@ -0,0 +1,87 @@ +--- +name: branch-fix-strategy +description: "Plan multi-branch fixes and cherry-picks." +when_to_use: "同じ修正を複数ブランチ (qa/staging/release等) に適用する必要があるとき。Triggers: 'cherry-pick', '環境ブランチに修正適用', 'qaに反映', 'stagingに反映', 'release branchへ', 'multi-branch fix', 'apply to qa/staging'" +--- + +# ブランチ修正適用戦略 + +## 適用タイミング + +- featureブランチの修正を `qa/*`, `staging/*`, `release/*` 等の環境ブランチにも適用する必要がある場合 +- 同じ修正を複数ブランチに並行適用する場面全般 + +## 核心ルール + +### 1. 修正は feature ブランチに先に commit → cherry-pick で環境ブランチへ + +``` +✅ feature に commit → cherry-pick して短命ブランチ → 環境ブランチへ PR +❌ 短命ブランチに先に commit → feature に手作業で再実装(二重作業・不整合リスク) +``` + +### 2. 環境ブランチを feature ブランチに merge しない(main 汚染禁止) + +``` +❌ feature/xxx ← merge qa/staging(conflict 解消目的でも禁止) +``` + +環境ブランチを featureブランチにmergeすると、後で `feature → main` のPRに環境固有コードが混入する。 + +### 3. origin/main を必ず取り込む + +短命ブランチを push する前に必ず `git merge origin/main` する。CI で最新 main 必須の Workflow があるため。 + +### 4. マージ済みブランチに push しない + +環境ブランチ向けの短命ブランチに push する前に `gh pr list --head ` で PR 状態を確認する。マージ済みなら新ブランチ + 新 PR を作成する(サフィックス `-v2`, `-v3` を付ける)。 + +## 実行手順 + +`/ndf:cherry-pick-pr ` で自動化されている。手動で行う場合のみ以下を参照。 + +```bash +# 1. feature ブランチで修正を commit +git checkout feature/xxx +git add && git commit -m "fix: 修正内容" +git log --oneline -1 # commit hash を記録 + +# 2. 短命ブランチを作成 +git fetch origin qa/staging +git checkout -b feature/xxx-for-staging origin/qa/staging + +# 3. origin/main を取り込む(必須) +git fetch origin main +git merge origin/main --no-edit + +# 4. cherry-pick(-x で元 commit hash を参照に残す) +git cherry-pick -x + +# 5. push して PR 作成 +git push -u origin feature/xxx-for-staging +gh pr create --base qa/staging --title "fix: 修正内容(staging検証用)" + +# 6. 元のブランチに戻る +git checkout feature/xxx +``` + +## なぜこの順序が重要か + +| 観点 | 正しい順序 | 誤った順序 | +|------|-----------|-----------| +| 単一ソース | feature ブランチが唯一の正 | 二箇所で実装 | +| 一貫性 | cherry-pick で完全一致 | 手書き差分でズレる | +| 追跡性 | `-x` で元 commit が明記 | 関連 commit 不明確 | + +## revert 操作の注意 + +revertの連鎖(revert → reapply → revert...)ではなく、**最終的なあるべき状態を直接コミット**するのが望ましい。履歴上の意図が明確になり、後の cherry-pick も簡単になる。 + +## 関連コマンド・スキル + +| リソース | 用途 | +|---------|------| +| `/ndf:cherry-pick-pr` | cherry-pick + 短命ブランチ + origin/main 取り込み + PR 作成を自動化 | +| `/ndf:pr` | 通常のPR作成。非 main ベースは `cherry-pick-pr` に誘導される | +| `/ndf:sync-main` | 現在のブランチに最新 main を取り込む | +| `/ndf:deploy` | 環境ブランチへのデプロイPR作成(ブランチ全体をmerge main経由で適用) | diff --git a/plugins/ndf-codex/skills/cherry-pick-pr/SKILL.md b/plugins/ndf-codex/skills/cherry-pick-pr/SKILL.md new file mode 100644 index 0000000..b5dc134 --- /dev/null +++ b/plugins/ndf-codex/skills/cherry-pick-pr/SKILL.md @@ -0,0 +1,120 @@ +--- +name: cherry-pick-pr +description: "Create cherry-pick PRs for environment branches." +argument-hint: " (例: qa/staging, release/v2)" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Grep +--- + +# cherry-pick PR 作成コマンド + +featureブランチから指定ベースブランチへ、短命ブランチ経由で cherry-pick PR を作成する。`feature → main` の PR にベースブランチ固有コードが混入するのを防ぐ。 + +## 使用方法 + +``` +/ndf:cherry-pick-pr qa/staging +/ndf:cherry-pick-pr release/v2 +``` + +## なぜ必要か + +featureブランチに環境ブランチ(`qa/staging`等)を merge して conflict を解消すると、`feature → main` の PR に環境ブランチ固有のコードが混入する(main汚染)。短命ブランチ + cherry-pick で、必要なコミットだけを対象ブランチに届ける。 + +詳細な原則は `/ndf:branch-fix-strategy` スキル参照。 + +## 処理フロー + +### 1. 引数・現状確認 +- 引数からベースブランチ名を取得(必須。未指定なら確認) +- `git branch --show-current` で現在ブランチを取得 + +### 2. 既存PRのマージ済みチェック(必須) + +同じベースブランチ向けの短命ブランチに既存PRがないか確認する。 + +```bash +# 同名パターンのブランチでマージ済みPRがないか確認 +gh pr list --head "-for-" --state merged \ + --json number,mergedAt --jq '.[]' +``` + +マージ済みPRが見つかった場合、**同じブランチ名は使えない**。サフィックスを付ける(例: `-v2`, `-v3`)。 + +### 3. コミット一覧の確認 + +```bash +git log --oneline main..HEAD +``` + +ユーザーに cherry-pick 対象コミットを確認(全コミット or 選択)。 + +### 4. 短命ブランチ作成 + +```bash +git fetch origin +git checkout -b -for- origin/ +``` + +- ``: ベースブランチのスラッシュ以降(例: `qa/staging` → `staging`) +- 例: `feature/add-auth-for-staging` + +### 5. origin/main を取り込む(必須) + +```bash +git fetch origin main +git merge origin/main --no-edit +``` + +CIで最新main必須のWorkflowがあるため、取り込み忘れるとconflictやCIエラーになる。 + +### 6. cherry-pick 実行 + +```bash +git cherry-pick -x ... +``` + +`-x` オプションで元のcommit hashが参照として残り、追跡性が向上する。 + +conflict が発生した場合: +- `git diff --name-only --diff-filter=U` でconflictファイル一覧 +- 解消を試み、ユーザーに確認後 `git cherry-pick --continue` + +### 7. push して PR 作成 + +```bash +git push -u origin +gh pr create --base --title "<タイトル>" --body "$(cat <<'EOF' +## Summary +- feature/xxx からcherry-pickした<環境名>向けPR +- 元コミット: + +## Test plan +- [ ] <環境名>で動作確認 + + +EOF +)" +``` + +### 8. 元ブランチに戻る + +```bash +git checkout +``` + +## 注意事項 + +- 短命ブランチは PR マージ後に削除してよい +- `feature → main` の PR には影響しない +- ベースブランチを feature ブランチに merge するのは **禁止**(main汚染の原因) +- `-x` オプションで元commit参照を残す(追跡性) + +## 関連 + +- `/ndf:branch-fix-strategy` — なぜこの手順が必要かの原則 +- `/ndf:pr` — 通常のPR作成(base=main) +- `/ndf:deploy` — ブランチ全体を環境へデプロイ(cherry-pickとは別用途) diff --git a/plugins/ndf-codex/skills/clean/SKILL.md b/plugins/ndf-codex/skills/clean/SKILL.md new file mode 100644 index 0000000..2f75e54 --- /dev/null +++ b/plugins/ndf-codex/skills/clean/SKILL.md @@ -0,0 +1,20 @@ +--- +name: clean +description: "Delete local and remote merged branches." +disable-model-invocation: true +allowed-tools: + - Bash +--- + +# ブランチクリーンアップコマンド + +mainマージ済みブランチをローカル/リモート削除。 + +## 手順 + +1. `git branch --merged main`確認 +2. main・現在ブランチ除外 +3. `git branch -d ` +4. `git push origin --delete ` + +**注意**: 削除前確認・main除外・現在ブランチ除外 diff --git a/plugins/ndf-codex/skills/cross-review/SKILL.md b/plugins/ndf-codex/skills/cross-review/SKILL.md new file mode 100644 index 0000000..208437b --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/SKILL.md @@ -0,0 +1,475 @@ +--- +name: cross-review +description: "Run iterative Codex and Gemini PR reviews." +argument-hint: "[PR番号] [--max-rounds N] [--rotate-after K] [--rotate-mode light|squash] [--only codex|gemini] [--focus TEXT] [--extra-instructions-file PATH]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read + - Edit + - Write + - Glob + - Grep +--- + +# クロスレビュー収束ループ + +PR を **codex / gemini 両方** にレビューさせ、両者が `APPROVE` を返すまで +`/ndf:review` と `/ndf:fix` を自動で回す。 + +/goalの引数として呼ばれた場合は、codex / gemini が`APPROVE` になるまで/cross-reviewを繰り返す。 + * codex / geminiのいずれかが不具合などで実行できなくなった場合は異常終了とする + * /goalで呼ばれた場合はPR ローテーションは実施しなくてよい。 + * 振動検知した場合はAIが判断して正しい状態を決める。 + +詳細手順は `docs/` 配下に、主要コマンドは `scripts/` 配下に分割している: + +- [docs/01-state-and-review.md](docs/01-state-and-review.md) — Step 0〜4 (state init / round / 並列レビュー / 判定 / 振動検知) +- [docs/02-fix-and-rotation.md](docs/02-fix-and-rotation.md) — Step 5〜8 (サブエージェント修正 / PR ローテーション / 終了処理) +- [scripts/state.py](scripts/state.py) — state.json 操作(uv 自己完結スクリプト、stdlib のみ) +- [scripts/launch-codex.sh](scripts/launch-codex.sh) / [scripts/launch-gemini.sh](scripts/launch-gemini.sh) — レビューランチャ +- [scripts/monitor.py](scripts/monitor.py) — codex/gemini プロセス多軸監視 (sentinel / pidfile / 早期エラー / stall / hard timeout / result.json) +- [scripts/wait-review.sh](scripts/wait-review.sh) — `monitor.py` の薄ラッパ(互換用) +- [scripts/rotate-pr.sh](scripts/rotate-pr.sh) — PR ローテーション + +メインセッションからは `$SCRIPTS/state.py ` 形式で呼ぶだけで、 +state.json の読み書きや AI launcher 起動・完了待ちは全て委譲される。 + +## 設計方針 + +長丁場が予想されるため **メインセッションの context 消費を最小化** する: + +| 観点 | 方針 | +|---|---| +| レビュー投稿 | **AI 自身が `gh api` で PR に直接投稿**。メインはペイロードを保持しない | +| 修正 | **必ずサブエージェント (`general-purpose`) で実行**。メイン context に diff は載せない | +| ユーザ問い合わせ | 自動判断を最大化(`critical`/`major`/`minor` は自動修正、ループ中の `nit` は deferred) | +| 取りこぼし防止 | **ループ終了時(approved / max_rounds / oscillation / error いずれも)に最終スイープを必須実行**。`/ndf:fix` を再実行し、残った open review thread(最終 APPROVE ラウンドの minor/nit インラインコメント含む)を **全て解消**。修正可能なものは修正 + push、判断保留 nit も reply + resolveReviewThread して **open thread 0 で終了** | +| 状態の永続化 | `/.cross_review/cross-review-pr<番号>-state.json` に集約。中断・再開可能 | +| 長尺PR対策 | **`--rotate-after` ラウンドで PR をローテーション**(default=light: 同ブランチで PR 巻き直し / squash: 新ブランチ + squash 統合) | +| 振動検知 | 同じ指摘が 2 round で 50%以上重複したら中断 | + +## 引数 + +| 引数 | 意味 | 既定 | +|---|---|---| +| `[PR番号]` | 対象 PR(省略時は直前 PR / 現在ブランチ) | — | +| `--max-rounds N` | 全体最大ラウンド数(PR ローテーションを含む通算) | `12` | +| `--rotate-after K` | この round 数で未収束なら PR ローテーション | `8` | +| `--rotate-mode light\|squash` | ローテーション方式。`light`: 同ブランチで旧 PR を close → 新 PR (title/body は現状の差分・実装から再生成)。`squash`: squash 統合 + 新ブランチ + `(rotated)` suffix | `light` | +| `--only codex` / `--only gemini` | 片方だけで回す(デバッグ用) | 両方 | +| `--focus TEXT` | 自動レビュー観点に上乗せして codex / gemini 両方に渡す追加観点。短い重点チェック向け | なし | +| `--extra-instructions-file PATH` | 自動レビュー観点に上乗せして codex / gemini 両方に渡す追加観点を UTF-8 テキストファイルから読む。長いチェックリスト向け | なし | + +例: + +``` +/ndf:cross-review 123 +/ndf:cross-review 123 --max-rounds 4 --rotate-after 2 +/ndf:cross-review 123 --rotate-mode squash +/ndf:cross-review 123 --only codex +/ndf:cross-review 123 --focus "ドキュメントとコードの整合性を重点的に確認" +/ndf:cross-review 123 --extra-instructions-file /tmp/review-focus.md +``` + +### 自動レビュー観点テンプレート + +`state.py init` は GitHub API の `pulls//files --paginate` で変更ファイルを全件取得して分類し、 +codex / gemini 両 launcher に同じ追加観点を渡す。`--focus` / +`--extra-instructions-file` は、この自動テンプレートの後ろに上乗せされる。 + +自動カテゴリ: + +- `common`: PR 全体の目的、変更範囲、保守性、テスト、ロールバック容易性 +- `docs_only`: ドキュメントのみ PR。企画・説明の妥当性、コード/設定/コマンド/他 docs との整合性 +- `code`: 設計、正確性、可読性、冗長・重複、言語らしさ、セキュリティ、関数/ファイルの責務とサイズ +- `db_migration`: データ設計、型、NULL/default/制約/index、既存データ、backfill、ロールバック +- `test`: テストの仕様性、境界値、失敗系、flaky リスク +- `dependency`: 依存追加/更新、lockfile、ライセンス、互換性、セキュリティ +- `config_ci`: CI/設定、権限、secret、cache、環境差分 +- `api_contract`: API 契約、互換性、schema、status、エラー形式、認可 +- `auth_security`: 認証/認可、secret/PII、CSRF/CORS/session/JWT/OAuth +- `frontend`: UI 状態、アクセシビリティ、レスポンシブ、状態管理、表示文言 +- `performance`: N+1、I/O、メモリ、ロック、cache、queue、冪等性 +- `deletion_rename`: 削除/リネーム参照漏れ、後方互換、移行手順 +- `generated`: 生成物、lockfile、再生成手順、差分ノイズ +- `i18n`: 翻訳キー、fallback、変数展開、表示幅、文言整合 +- `infra`: IaC / Docker / Kubernetes 等の権限、secret、公開範囲、ロールバック + +### `--rotate-mode` の選び方 + +- **`light` (default)**: PR を読む人 (将来のレビュアー / 後続 PR を作る人) が cross-review の存在を意識せずに済む。release branch 戦略・TODO 参照・コミット単位レビューを破壊しない。**通常はこちらを使う** +- **`squash`**: 巨大 PR を 1 commit に潰したい / `(rotated)` suffix で rotation 履歴を PR title に残したい場合のみ。release branch 戦略を使う運用とは併用しない + +## 前提 + +- `/ndf:review` が **AI 直接投稿**(外部 AI 自身が `gh api` で投稿)に対応 +- `/ndf:fix` が **サブエージェント起動 + 重要度ベース自動修正 + Resolve Conversation** に対応 +- `codex` / `gemini` CLI が動作し、`gh` CLI が認証済み +- `Agent(subagent_type="general-purpose", ...)` でサブエージェントを起動可能 + +## 事前確認(`state.py init` が自動実施) + +ループ開始前に **4 つのプリチェック** が必要だが、すべて `scripts/state.py init` +が内部で実施する。メインは結果を KEY=VALUE 形式で受け取るだけで良い。 + +| # | 対策 | スクリプト側で何をするか | +|---|---|---| +| 1 | 自分の PR 判定(422 回避) | `gh api user` と `gh pr view --json author` を比較し `is_own_pr` / `event_downgrade` を state.json に書く | +| 2 | worktree 分離 | `git worktree add /--/pr ` を冪等実行(`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決)。パスが存在しても現リポジトリの登録済み worktree でなければ `.stale-` に退避して作り直す | +| 3 | gemini trusted directory | `launch-gemini.sh` が `GEMINI_CLI_TRUST_WORKSPACE=true` + `--skip-trust` を必ず併用。**tmp dir は `/.cross_review/`** を採用し、gemini の workspace 制約 (workspace 外の `write_file` がブロックされる) を根本回避 | +| 4 | 既存コメント差分 | `fix/scripts/fetch-pr-comments.sh` で 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を一括取得し `$TMP_DIR/cross-review-pr-existing-comments.txt` に保存。gemini プロンプトには **内容をインライン埋め込み**、codex プロンプトには path を渡す | + +### `` の解決順 + +`state.py init` は worktree の親ディレクトリを以下の優先順で解決する: + +1. `NDF_WORKTREE_BASE` 環境変数(明示オーバーライド) +2. `<システム tmpdir>/ndf-worktrees`(Python `tempfile.gettempdir()`。非永続領域のため + コンテナ再作成で自動消滅し、共有 volume を消費しない) + +worktree の実パスは `/--/pr` 形式で、リポジトリ slug を含める +ことで**他リポジトリの同一 PR 番号と衝突しない**。永続 volume(旧 `/work/worktrees`)を +使っていた頃は別プロジェクトの残骸 worktree を誤って流用する事故があったため、 +パスが存在しても `git worktree list` に登録されていなければ `.stale-` に +退避して作り直すガードも入っている。 + +解決した実パスは `state.json` の `worktree_path` に書かれるため、後続スクリプトや +サブエージェント prompt は state.json から読めば追従できる。 + +### intent / posted_as の両保持(最重要) + +GitHub は **自分の PR には `REQUEST_CHANGES` でレビューを投稿できない** +(`HTTP 422`)。state.json には **両方** を保持する: + +```json +"codex": { + "intent": "REQUEST_CHANGES", // AI の本来判定。ループ収束判定に使う + "posted_as": "COMMENT", // 422 回避でダウングレードした結果 + "comments": 5, "review_url": "..." +} +``` + +`state.py judge` は `intent` を見るので、ダウングレード投稿してもループは続行する。 + +## 全体フロー + +```mermaid +flowchart TD + Start([事前確認 / loop 開始前に 1 回だけ]):::phase --> Init["worktree 作成 + state.json 初期化
・自分の PR 判定 → event downgrade 設定
・<worktree-base>/pr<PR> を用意
・既存コメントスナップショット保存"] + Init --> Round["Round N start
current_pr = PR#"]:::phase + + Round -.並列バックグラウンド.-> Codex["/ndf:review <PR> codex
(AI が gh api で直接投稿)
body 先頭: cross-review / round N / codex / intent
→ result.json (intent + posted_as)"] + Round -.並列バックグラウンド.-> Gemini["/ndf:review <PR> gemini
--skip-trust 必須
body 先頭: cross-review / round N / gemini / intent
→ result.json (intent + posted_as)"] + + Codex --> Decide{"判定 (intent ベース)"} + Gemini --> Decide + + Decide -->|両方 APPROVE / SKIP| Approved([final = approved]):::ok + Decide -->|一方でも REQUEST_CHANGES| Fix["Agent (general-purpose)
/ndf:fix <PR> --defer-nit を worktree 内で実行
・critical/major/minor 修正 + push
・reply + resolveReviewThread
・deferred/rejected は reply のみ
→ $TMP_DIR/fix-pr<#>-result.json"] + + Fix --> Check{収束チェック} + Check -->|max-rounds 到達| MaxR([final = max_rounds]):::stop + Check -->|振動検知 50% 重複| Osc([final = oscillation]):::stop + Check -->|CI failure code-related| Err([final = error]):::stop + Check -->|"CI failure meta-only (Assignees 等)"| Round + Check -->|round_in_pr >= rotate-after| Rotate["PR rotation
light (default): 同ブランチで巻き直し
squash (opt-in): 新ブランチ + squash 統合
light は Agent が title/body 再生成"] + Check -->|それ以外| Round + Rotate --> Round + + Approved --> Sweep["最終スイープ (必須)
Agent (general-purpose)
/ndf:fix <PR> を再実行
・残 open review thread を全て確認
・修正可能な minor/nit は修正 + push
・判断保留 nit も reply + resolveReviewThread
→ open thread 0 で終了"] + MaxR --> Sweep + Osc --> Sweep + Err --> Sweep + Sweep --> Report[ラウンドサマリ報告
+ 残 deferred nit を参考として列挙] + + classDef phase fill:#eef,stroke:#557 + classDef ok fill:#dfd,stroke:#383 + classDef stop fill:#fdd,stroke:#933 +``` + +## 実行ステップ概要(メインの bash 骨組み) + +各ステップの詳細は `docs/` 参照。メインは以下のテンプレートで scripts/ を呼ぶだけ: + +```bash +PLUGIN_ROOT="${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" +SCRIPTS="$PLUGIN_ROOT/skills/cross-review/scripts" + +# STATE_PR は state.json のキー (= 最初に init した PR 番号)。 +# rotation 後も state.json のパスは変わらないため、scripts/ への引数には常に +# STATE_PR を渡す。「現在レビュー中の PR」は state.json の current_pr を内部参照する。 +STATE_PR=$INITIAL_PR + +# ROTATE_MODE は引数 --rotate-mode (default=light) から取得。light なら Step 6b で +# Agent (general-purpose) を起動して新 PR の title/body を生成する。 +ROTATE_MODE=${ROTATE_MODE:-light} + +# Step 0: state 初期化 / 再開 +eval "$("$SCRIPTS/state.py" init "$STATE_PR" \ + --max-rounds "$MAX_ROUNDS" --rotate-after "$ROTATE_AFTER" \ + ${ONLY:+--only "$ONLY"} \ + ${FOCUS:+--focus "$FOCUS"} \ + ${EXTRA_INSTRUCTIONS_FILE:+--extra-instructions-file "$EXTRA_INSTRUCTIONS_FILE"})" +# eval で TMP_DIR がセットされる。後続スクリプトに env として伝播させる。 +export CROSS_REVIEW_TMP_DIR="$TMP_DIR" +cd "$WORKTREE" + +while :; do + # Step 1: round 開始判定 (max_rounds 到達で exit 1) + eval "$("$SCRIPTS/state.py" start-round "$STATE_PR")" + + # Step 2: 並列レビュー + [ "$ONLY" != "gemini" ] && "$SCRIPTS/launch-codex.sh" "$STATE_PR" "$ROUND" + [ "$ONLY" != "codex" ] && "$SCRIPTS/launch-gemini.sh" "$STATE_PR" "$ROUND" + # 監視: 既定 timeout=7 分 / stall=3 分。失敗時は対象プロセスを kill して返す。 + "$SCRIPTS/monitor.py" "$STATE_PR" "${ONLY:-both}" || handle_review_failure $? + + [ "$ONLY" != "gemini" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" codex + [ "$ONLY" != "codex" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" gemini + + # Step 3: 判定 (0=approved/2=continue) + if "$SCRIPTS/state.py" judge "$STATE_PR"; then break; fi + + # Step 4: 振動検知 (4=oscillation) + "$SCRIPTS/state.py" check-oscillation "$STATE_PR" || [ $? -eq 2 ] || exit 4 + + # Step 5: 修正サブエージェント起動 (Agent tool) → $TMP_DIR/fix-pr-result.json + # - メインで Agent(subagent_type=general-purpose, ...) を呼ぶ。docs/02 参照 + # - tmp パスは launcher / monitor.py と同じく **STATE_PR ベース** で統一 + # Step 5 後段: fix 戻り値マージ + CI 分類 (3=code-fail で中断) + "$SCRIPTS/state.py" merge-fix "$STATE_PR" + + # Step 6: PR ローテーション判定 (0=rotate/2=keep)。state.json の current_pr を内部更新。 + if "$SCRIPTS/state.py" should-rotate "$STATE_PR"; then + # Step 6a: 旧 PR の素材 (title/body/isDraft + git log/diff stat) を dump + eval "$("$SCRIPTS/rotate-pr.sh" prepare "$STATE_PR")" + + # Step 6b: light モードのみ。**メインセッション側で Agent(subagent_type="general-purpose") を起動して** + # prepare.json を読ませ、現状の差分・実装を反映した title/body を + # $TMP_DIR/rotate-pr-newtext.json に書き出させる。 + # 詳細プロンプトは docs/02-fix-and-rotation.md Step 6b 参照。 + # squash モードでは Step 6b 不要。 + # + # ⚠ Bash 単体では Agent ツールを呼べない。下記のフローは「メイン会話セッション側で」 + # 実行される前提なので、bash の while ループそのものは pseudo-code として読み、 + # 実際には以下の 3 段階を **メインが順に駆動する** こと: + # (1) bash 側で `rotate-pr.sh prepare $STATE_PR` を実行 (これは普通の bash) + # (2) メインが Agent(...) を起動して newtext.json を書かせる (bash の外) + # (3) bash 側で `rotate-pr.sh execute $STATE_PR --mode light` を実行 + # 下記の if exit 10 は **誤ってメイン介在なしで Step 6c に進むことを防ぐガード** であり、 + # exit 10 を観測したらメインは Step 6b の Agent を起動し、newtext.json が + # 生成されてから **同じ STATE_PR で Step 6c (execute) を直接呼び直す**。 + # state.json は完全に再開可能 (prepare.json はそのまま再利用される)。 + NEWTEXT_JSON="$TMP_DIR/rotate-pr$STATE_PR-newtext.json" + if [ "$ROTATE_MODE" = "light" ] && [ ! -s "$NEWTEXT_JSON" ]; then + echo "⏸ light モード: メインセッションで Agent(general-purpose) を起動し" >&2 + echo " $NEWTEXT_JSON を生成してから rotate-pr.sh execute $STATE_PR --mode light を実行してください" >&2 + echo " (docs/02-fix-and-rotation.md Step 6b 参照 / 再開プロトコルは下記 '## 再開プロトコル')" >&2 + exit 10 + fi + + # Step 6c: 実行。NEW_PR / NEW_PR_URL / NEW_BRANCH を eval で取り込む。 + eval "$("$SCRIPTS/rotate-pr.sh" execute "$STATE_PR" --mode "$ROTATE_MODE")" + + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + # NOTE: STATE_PR は変えない。次ループの scripts も $STATE_PR を渡す。 + fi +done + +# Step 7.5: 最終スイープ (必須) — どの終了経路 (approved / max_rounds / oscillation / +# error) でも、ループを抜けた直後に **メインが Agent(general-purpose) を起動** し、 +# /ndf:fix $STATE_PR を再実行して残った open review thread を全て解消する。 +# ⚠ bash 単体では Agent ツールを呼べないため、while ループを抜けたらメインが +# Step 7.5 の Agent を駆動し、$TMP_DIR/sweep-pr$STATE_PR-result.json を生成させる +# (プロンプトテンプレートは docs/02-fix-and-rotation.md Step 7.5)。 +# 最終 APPROVE ラウンドで投稿された minor/nit インラインコメントはループ内 fix を +# 経由しないため、ここで拾わないと PR 上に未解決スレッドが残る。 +# sweep 結果 (sweep-pr$STATE_PR-result.json) はメインが Step 8 の報告に折り込む。 + +# Step 8: 終了処理 (deferred nit + ラウンドサマリ) +"$SCRIPTS/state.py" report "$STATE_PR" +``` + +各ステップの内容と契約(state.json / result.json スキーマ等)の詳細は: + +- Step 0〜4 — [docs/01-state-and-review.md](docs/01-state-and-review.md) +- Step 5〜8 (最終スイープ Step 7.5 含む) — [docs/02-fix-and-rotation.md](docs/02-fix-and-rotation.md) + +## light モード rotation の再開プロトコル (exit 10 を観測した時) + +bash ループは Agent tool を呼べないため、light モードでは Step 6b の介入が必須。 +メインセッションはループ全体を 1 回の bash で完結させず、以下のように駆動する: + +1. **通常のループ実行** — Step 0〜6a まで bash で進めると、newtext.json が未生成の + ため `exit 10` で停止する。state.json には prepare.json までの状態が + 永続化されているので **そのまま再開可能**。 +2. **Step 6b (Agent 起動)** — メインが Agent(subagent_type=`general-purpose`) を + 起動し、prepare.json を読ませて `$TMP_DIR/rotate-pr$STATE_PR-newtext.json` を + 書き出させる (プロンプトテンプレートは docs/02-fix-and-rotation.md Step 6b)。 +3. **Step 6c (execute) を直接呼ぶ** — メインが bash で以下を実行: + + ```bash + eval "$("$SCRIPTS/rotate-pr.sh" execute "$STATE_PR" --mode light)" + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + ``` + +4. **ループ再開** — Step 7 (次ラウンド) からループ全体を再開する。`STATE_PR` は + 不変なので、`start-round` 以降は通常通り進む。 + +> ⚠ exit 10 はエラーではなく **メイン介入待ちの一時停止シグナル**。final ステータスには +> 反映しない (中断扱いではない)。次回 round カウントにも影響しない。 + +## レビュー出力の制約 + +**目的**: PR 上に Resolve 義務を伴うインラインコメントを増やさない。 +**修正アクションを伴わない記述は一切出さない** ことを両 launcher プロンプトで強制する。 + +### 1. body 先頭 identifier prefix(必須) + +人間アカウントから AI が投稿するため、GitHub UI 上では誰のレビューか分からない。 +body 先頭に必ず以下を入れる: + +``` +## 🤖 cross-review | round 1 | codex | REQUEST_CHANGES +``` + +書式: `## 🤖 cross-review | round | | ` + +- ``: `codex` / `gemini` のいずれか +- ``: AI の本来の判定(`REQUEST_CHANGES` / `APPROVE` / `COMMENT`) + `posted_as` ではなく `intent` を書く + +### 2. インラインコメントの最小化(最重要) + +インラインコメントは GitHub 上で **Resolve 操作が必須** になるため、本当に直すものだけ作る: + +| 重要度 | インライン化 | 説明 | +|---|---|---| +| `critical` / `major` | ✅ する | 修正必須 | +| `minor` | ✅ する | 明らかな改善のみ。判断が割れるなら出さない | +| `nit` | ❌ **出さない** | 好み・スタイルはコメント化禁止。気になっても無視する | + +**1 インラインコメント = 1 修正アクション** を厳守。 +コメント本文は `[重要度 / カテゴリ] 修正提案` の 1 文で完結させ、 +コード引用ブロック(``` ... ```)や現状説明だけのコメントは作らない。 + +### 3. body(総評)に書かないこと + +- ❌ **「良い点」/「Strengths」/「Positives」/「評価できる点」セクション** — 一切書かない +- ❌ 個別ファイル・関数の褒め言葉 +- ❌ 「特に問題ありません」「概ね良好です」等の評価文 +- ❌ 対応不要な観察コメント(「〜のようです」「〜と思われます」止まり) + +body に書くのは **設計レベル・PR 横断の修正提案** のみ。 +書くことが無ければ body は `## 🤖 cross-review ...` の prefix 行 + 1 行サマリのみで良い。 + +### 4. event 判定 + +- `APPROVE` — 修正必須の指摘なし(minor 以下しか無い場合も APPROVE で良い) +- `REQUEST_CHANGES` — critical / major の指摘あり +- `COMMENT` — **基本使わない**。雑感だけの投稿は禁止 + +## CI failure の分類(誤中断防止) + +「CI 失敗 → 即 `final=error`」は乱暴。`scripts/state.py merge-fix` が +fix 戻り値ファイル (`$TMP_DIR/fix-pr-result.json`) を受け取った際に +`ci_failed_checks` を以下で分類する: + +| 分類 | パターン | 振る舞い | +|---|---|---| +| code-fail | `pint` / `larastan` / `phpstan` / `test` / `lint` / `type` / `build` / `ruff` / `eslint` / `tsc` / `mypy` | `final=error` で中断 (exit 3) | +| meta-only | `check_pr_requirements` / `assignees` / `reviewers` / `labels` / `meta` | `ci_note` に記録して継続 | +| 不明 | 上記以外 | 保守的に **code-fail 扱い** | + +PR メタデータ系の check(Assignees / Reviewers / Labels)は **継続**、 +pint / larastan / test / build などは **中断** を原則とする。 + +## アンチパターン + +- ❌ **修正をメインセッション内で行う** — context が一気に膨れる。必ずサブエージェント +- ❌ **AI に Markdown だけ返させる** — メインがパース・投稿する設計は禁物。AI 直接投稿 +- ❌ **nit を都度ユーザに問う** — ループ中は deferred 記録のみ。最終スイープ (Step 7.5) で Resolve +- ❌ **未解決スレッドを残したまま終了する** — approved/max_rounds 等いずれの終了経路でも + Step 7.5 の最終スイープを必ず実行し、open review thread 0 で終える。特に **最終 APPROVE + ラウンドの minor/nit インラインコメント**はループ内 fix を通らないため取りこぼしやすい +- ❌ **`max-rounds` なしで回す** — 無限ループの温床 +- ❌ **PR ローテーションを忘れる** — 100+ コメントの巨大 PR になる +- ❌ **light モードで Agent (general-purpose) 呼び出しを省略する** — newtext.json が無いと `rotate-pr.sh execute --mode light` はエラーで止まる。prepare → Agent → execute の 3 段は不可分 +- ❌ **light モードで新 PR の title/body に内部用語を漏らす** — 「round N」「rotated」「cross-review」「レビュー指摘で〜」等は禁止 (Agent プロンプトで明示禁止) +- ❌ **newtext.json に旧 PR の title/body をそのままコピーする** — 「現状の差分・実装を反映」が必須。古い説明が残ると後続 PR / 将来のレビュアーが混乱 +- ❌ **`rotate-pr.sh` 内から `claude` CLI を呼んで title/body を生成する** — 環境依存・コスト管理外。Agent tool でメイン側から呼ぶ +- ❌ **CI 失敗を一律で中断** — コード関連/メタチェックを分類(上記参照) +- ❌ **自分の PR に `REQUEST_CHANGES` で投稿** — 必ず 422。事前判定 + COMMENT ダウングレード +- ❌ **`gemini --yolo` だけで起動** — trusted directory で YOLO 無効化。`--skip-trust` 併用 +- ❌ **`pgrep -fa ` で完了判定** — gemini は long prompt が引数に乗り検知失敗。pidfile 必須 +- ❌ **sentinel 単独で完了判定** — codex がクラッシュすると永遠に出ない。`monitor.py` の多軸判定 (pidfile / sentinel / 早期エラー / stall / hard timeout / result.json) を使うこと +- ❌ **タイムアウトなしで wait** — ハング検知不能。`monitor.py` の hard timeout (30 分既定) + stall timeout (10 分既定) を必ず効かせる +- ❌ **EARLY_ERROR の曖昧パターンで kill する** — 行頭の生 `Error:` / `Traceback` は codex がレビュー対象 diff の test コード片を echo するケースで誤検知する。明確な致命 (auth / quota / sandbox / HTTP 401-403-429 / gemini の YOLO 降格) **のみ** kill 対象とし、曖昧パターンは警告ログに留める。誤検知が再発する場合は `--no-early-error` / `MONITOR_NO_EARLY_ERROR=1` で検知自体を無効化する (sentinel / result.json / timeout で十分判定可能) + +## monitor.py が誤って kill する場合の手順 + +`monitor.py` が EARLY_ERROR で codex / gemini を即時 kill してしまい、`result.json` が +生成されないケースは以下で切り分け・回避できる: + +1. **err.log の冒頭を確認**: 検知パターン (`fatal_err` の `early error (fatal) in err.log: ...`) が + 本当に致命なのか、それとも diff body の echo / config validation 警告なのかを判別 + - **v4.11.0 で benign 自動判定を強化**: `_match_is_quoted()` が backtick / 「」 に加え + **ダブル/シングルクォート文字列リテラル** (`"quota exceeded: ..."`) を、`EARLY_ERROR_BENIGN` + が **grep 形式のソース引用行** (`path/to/file.py:22: `) を自動で benign 扱いする。 + codex が tests/*.py 等のテスト用文字列 (`"quota exceeded"`, `"sandbox error"`) を + レビュー中に echo しても誤 kill しなくなった(旧版で PR #23 round 2 に発生した事例) +2. **gemini の `Error in: mcpServers.` 警告**: `.gemini/settings.json` に `disabled: false` + 等の非互換キーがあると毎回出る。`launch-gemini.sh` の sanitize ロジック (v4.7.2+) で + 自動退避するため、最新版にアップデートすれば解消する +3. **誤検知が継続する場合**: `monitor.py --no-early-error` (もしくは `MONITOR_NO_EARLY_ERROR=1` + 環境変数) で EARLY_ERROR 検知自体を無効化し、hard timeout / stall / sentinel / result.json + のみで判定するモードに切り替える +4. **新しい致命パターンを観測した場合**: `EARLY_ERROR_FATAL` に追記する (PR で plugin に反映)。 + 曖昧パターンは `EARLY_ERROR_WARN` 側に置き、kill 対象にはしない +- ❌ **fix サブエージェントが Resolve をスキップ** — reply だけでは未対応扱い。Resolve まで実行 +- ❌ **review body に identifier prefix を付け忘れる** — GitHub UI 上で誰のレビューか不明になる + +## メイン context 節約の工夫 + +1. **大きいファイルはメイン context に載せない**: payload / err.log / diff は + すべて `$TMP_DIR/` (= `state.py _tmp_dir()` の解決先) に置き、メインは + state.json と result.json だけ読む +2. **サブエージェント分離**: 修正は別 context window で実行 +3. **PR ローテーション**: 1 PR あたりの会話履歴を抑える +4. **AI 直接投稿**: 中間ペイロードがメインを通らない +5. **state.json で再開可能**: メインが落ちても次回起動時に続きから + +## 作業完了報告(必須) + +ループ終了後、メインからユーザへの報告: + +- **最終ステータス**: `approved` / `max_rounds` / `oscillation` / `error` +- **総ラウンド数 / PR 数**: 例: `5 rounds / 2 PRs (rotated 1 回)` +- **PR 履歴**: 各 PR 番号 + closed/open 状態 + round 数 +- **各ラウンドのサマリ表**: + + | round | PR | codex | gemini | fix | CI | + |---|---|---|---|---|---| + | 1 | #123 | REQ (5) | REQ (3) | abc123 (5 fixed, 2 deferred) | ✅ | + | 2 | #123 | REQ (2) | APP | def456 (2 fixed) | ✅ | + | 3 | #145 | APP | APP | — | — | + +- **最終スイープ結果** (Step 7.5): `sweep-pr-result.json` の `resolved` / + `fixed_in_sweep` / `remaining_open`。**`remaining_open` は 0 が正常**(残 open + thread あり = 取りこぼし)。0 にできなかった場合は理由を明記 +- **残 deferred nit リスト**(Step 7.5 で Resolve 済み。再対応が要るものがあれば参考列挙) +- **rejected 件数**(bot 誤指摘で却下したもの) +- **最終 PR URL** + +詳細は PR 上のインラインコメントと state.json に残っているため、本報告では +繰り返さない。 + +## 関連 + +- `/ndf:review` — 単発レビュー(AI 直接投稿対応) +- `/ndf:fix` — 修正対応(サブエージェント起動対応) +- `/ndf:codex` — codex CLI 呼び出し手順 +- `/ndf:gemini` — gemini CLI 呼び出し手順 +- `/ndf:resolve-pr-comments` — Resolve Conversation の詳細 +- `general-purpose` エージェント — fix 実行用サブエージェント diff --git a/plugins/ndf-codex/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf-codex/skills/cross-review/docs/01-state-and-review.md new file mode 100644 index 0000000..b2a1cd4 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/docs/01-state-and-review.md @@ -0,0 +1,254 @@ +# 01: 状態管理 + レビュー実行 (Step 0〜4) + +`SKILL.md` 本体から呼び出される **状態ファイル初期化 / ラウンド開始 / +並列レビュー / 判定 / 振動検知** までの詳細手順。 + +主要処理は `scripts/` 配下のコマンドに切り出し済み: + +| script | 役割 | +|---|---| +| `scripts/state.py init` | Step 0 — state 初期化 / 再開 + プリチェック | +| `scripts/state.py start-round` | Step 1 — round 開始判定 | +| `scripts/launch-codex.sh` / `scripts/launch-gemini.sh` | Step 2 — review launcher | +| `scripts/monitor.py` | Step 2 — codex/gemini プロセス多軸監視 | +| `scripts/wait-review.sh` | Step 2 — `monitor.py` の薄ラッパ(互換用) | +| `scripts/state.py read-result` | Step 2.5 — result.json マージ | +| `scripts/state.py judge` | Step 3 — intent ベース pass 判定 | +| `scripts/state.py check-oscillation` | Step 4 — 振動検知 | + +このドキュメントは **state.json スキーマと AI への入出力契約** を一次資料として残す。 +スクリプト側の挙動はソースを直接参照のこと。 + +## 状態ファイル + +`$TMP_DIR/cross-review-pr<番号>-state.json`: + +```json +{ + "started_at": "2026-05-12T...", + "max_rounds": 12, + "rotate_after": 8, + "only": null, + "current_pr": 123, + "worktree_path": "/tmp/ndf-worktrees/owner--name/pr123", + "repo": "owner/name", + "head_branch": "feature/foo", + "base_branch": "main", + "pr_author": "someone", + "is_own_pr": false, + "event_downgrade": false, + "pr_history": [ + {"pr": 123, "opened_at": "...", "closed_at": null, "rounds": 2} + ], + "rounds": [ + { + "round": 1, + "pr": 123, + "started_at": "...", + "codex": {"intent": "REQUEST_CHANGES", "posted_as": "COMMENT", + "comments": 5, "review_url": "...", + "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0}}, + "gemini": {"intent": "REQUEST_CHANGES", "posted_as": "COMMENT", + "comments": 3, "review_url": "...", + "by_severity": {"critical": 0, "major": 2, "minor": 1, "nit": 0}}, + "fix": {"commit": "abc1234", "fixed": 6, "deferred": 2, "rejected": 0, + "resolved_threads": 4, "ci": "SUCCESS", "ci_note": null}, + "ended_at": "..." + } + ], + "deferred_nits": [ + {"pr": 123, "round": 1, "path": "src/foo.py", "line": 42, "severity": "nit", + "summary": "...", "comment_url": "..."} + ], + "final": null +} +``` + +`final` 値: `approved` / `max_rounds` / `oscillation` / `error` + +### 重要なフィールド + +- `worktree_path` — 並行セッションとの分離。サブエージェントへの cwd 指示にも使う +- `is_own_pr` / `event_downgrade` — 自分の PR の場合 `REQUEST_CHANGES → COMMENT` 強制ダウングレード +- `rounds[].codex.intent` — AI の本来判定。**ループ判定はこれを見る** +- `rounds[].codex.posted_as` — GitHub に実際に送った event。`is_own_pr=true` なら `COMMENT` になる +- `rounds[].fix.resolved_threads` — `resolveReviewThread` で resolve した**件数(int)**。 + ここ (state.json 側) は int だが、fix サブエージェントが返す戻り値ファイル + (`fix-pr-result.json`) 側の `resolved_threads` は **list**(`docs/02` の戻り値スキーマ参照)。 + `state.py merge-fix` が fix結果の list を `len()` して state.json に int で保存する。 + 混同して fix結果側に int を書くと過去 `merge-fix` が落ちていたため、現在は int/list 両受理 +- `rounds[].fix.ci_note` — コード無関係の CI 失敗時に「Assignees 未設定」等の理由を残す + +## Step 0: 準備 + 既存 state 引き継ぎ + +```bash +PLUGIN_ROOT="${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" +SCRIPTS="$PLUGIN_ROOT/skills/cross-review/scripts" # or 直接の絶対パス + +# state 初期化 / 再開(プリチェック・worktree 作成・既存コメントスナップショットを内部実行) +eval "$("$SCRIPTS/state.py" init "$STATE_PR" \ + --max-rounds "$MAX_ROUNDS" --rotate-after "$ROTATE_AFTER" \ + ${ONLY:+--only "$ONLY"} \ + ${FOCUS:+--focus "$FOCUS"} \ + ${EXTRA_INSTRUCTIONS_FILE:+--extra-instructions-file "$EXTRA_INSTRUCTIONS_FILE"})" + +# eval で取り込まれる変数: PR, WORKTREE, REPO, HEAD_BRANCH, BASE_BRANCH, +# IS_OWN_PR, EVENT_DOWNGRADE, RESUMED +cd "$WORKTREE" +``` + +`state.py init` が内部で行う処理: + +1. 既存 state.json があり `final == null` なら再開 +2. 自分の PR 判定(`gh api user` と `gh pr view --json author` を比較) +3. worktree 作成(`/--/pr`。`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決。既存パスが現リポジトリの登録済み worktree でなければ `.stale-` に退避して作り直す。実 path は state.json の `worktree_path` を参照) +4. 既存コメントスナップショット (`fix/scripts/fetch-pr-comments.sh` で 3 ソース一括取得) → `$TMP_DIR/cross-review-pr-existing-comments.txt` +5. state.json 書き出し + +**重要**: 以降の全ステップで `cd $WORKTREE` を強制。 +サブエージェント(fix)を起動するときも、prompt 内で worktree path を明示する。 + +## Step 1: Round 開始判定 + +```bash +eval "$("$SCRIPTS/state.py" start-round "$STATE_PR")" +# eval で取り込まれる変数: ROUND, ROUND_IN_PR, PR, MAX_ROUNDS, ROTATE_AFTER +``` + +`state.py start-round` は `max_rounds` 超過なら `final=max_rounds` を書いて exit 1。 +それ以外は新しい round エントリを state.rounds に push して KEY=VALUE を吐く。 + +## Step 2: codex / gemini 並列レビュー(AI 直接投稿) + +**要点**: メインは launcher を **並列バックグラウンド** で起動するだけ。 +各 AI が `gh api` で投稿し `$TMP_DIR/-review-pr-result.json` に +サマリを書く。**ペイロード本体はメイン context に載せない**。 + +### 2.1 launcher 起動 + monitor + +```bash +[ "$ONLY" != "gemini" ] && "$SCRIPTS/launch-codex.sh" "$STATE_PR" "$ROUND" +[ "$ONLY" != "codex" ] && "$SCRIPTS/launch-gemini.sh" "$STATE_PR" "$ROUND" + +# monitor.py が多軸で完了判定。exit code で失敗種別を分岐。 +if ! "$SCRIPTS/monitor.py" "$STATE_PR" "${ONLY:-both}"; then + case $? in + 2) echo "❌ timeout" ;; # hard timeout 超過 + 3) echo "❌ no result" ;; # プロセス終了したが result.json 未生成 + 4) echo "💥 early error" ;; # err.log に致命的パターン + 5) echo "🛑 stalled" ;; # 進捗ログ更新なし + 6) echo "❓ pidfile bad" ;; # 起動失敗 / 不正 + esac + # ラウンドを失敗マークしてリトライ or 中断(state.py side で判断) +fi +``` + +#### `monitor.py` の多軸監視 + +| 軸 | 内容 | +|---|---| +| pidfile + `kill -0` | プロセス生存確認。alive 確認後に `/proc//cmdline` で agent 名一致も検証 (PID 再利用対策)。**プロセスが既に死んでいる場合は result.json の有無のみで OK 判定**する (死亡直後 cmdline 不一致で誤検知しないため) | +| codex sentinel | err.log に `^tokens used$` 出現で正常完了マーク | +| early-error | **行頭限定** で `^Error:` / `^FATAL:` / `^panic:` / `^Traceback ` / `^HTTP/1.1 401\|403\|429` / `^Approval mode overridden to "default"` / `^Authentication failed` / 「quota exceeded」「rate limit exceeded」「API key not found/missing/invalid」「sandbox error」を含む行を検出 (diff/doc 引用文中の同語句は誤検知しないよう anchor + benign フィルタ併用) | +| stall timeout | err.log + stdout.log + progress.log の合計サイズが一定時間変化しなければ STALLED で中断。既定は **agent 別** (codex=**180s** / gemini=**480s**)。gemini は err.log がほぼ無音のため大きめに取る。codex 側既定は不変。上書き方法: CLI `--stall-timeout` (明示優先) > env `MONITOR_STALL_` (per-agent) > env `MONITOR_STALL` (両 agent 共通) > agent 別ビルトイン | +| hard timeout | 既定 **7 分**。`--timeout` or `MONITOR_TIMEOUT` env で上書き | +| progress.log heartbeat | launcher が任意で `-review-pr-progress.log` への短いフェーズマーカー出力を要求し、monitor が最終行を stderr の heartbeat に表示する。内部推論ではなく `scan` / `analyze` / `post` / `done` などの監視用ステータスだけを出す | +| result.json 存在 | プロセス終了後、result.json が無ければ NO_RESULT (exit 3) | +| **result.json + age fallback** | sentinel を持たない agent (gemini) 向け。プロセスが alive のまま result.json の mtime が **30 秒以上前**なら完了とみなし kill → OK。gemini が MCP 切断待ち等でハングするケースに対応 (codex は sentinel チェックが先に発火するため影響なし) | +| **失敗時 kill** | TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るときは対象プロセスに SIGTERM → 3 秒後 SIGKILL。残存プロセスが後から `gh api` 投稿や result.json 書き込みを行うのを防ぐ | + +> ⚠ **罠**: `nohup ... &` でラッパーシェルは即終了し、ハーネスから +> 「タスク完了」通知が飛んでくる。これに惑わされず、`monitor.py` で +> 実プロセスの完了を pidfile / sentinel で確認すること。 +> +> ⚠ **`pgrep -fa ` で完了判定しない**: gemini は long `-p` プロンプトを +> 引数に持つため、`grep` のキーワード選定で誤検知する。**pidfile 必須**。 +> +> ⚠ **sentinel 単独で完了判定しない**: codex がクラッシュすると `tokens used` が +> 永遠に出ない。`monitor.py` は sentinel と pidfile/result.json/err.log を併用する。 +> +> ⚠ **Docker 環境ではゾンビプロセスに注意**: `nohup ... & disown` で起動した +> プロセスは、終了後にゾンビ化する (PID 1 が proper init でない場合)。 +> `monitor.py` は `/proc//status` でゾンビを検出して dead 扱いする。 +> **推奨: Docker 実行時に `--init` フラグを付ける** (tini が PID 1 になりゾンビを reap する)。 + +### 2.2 AI への入出力契約(両 launcher 共通) + +launcher が生成するプロンプトに以下を強制している: + +- **headRefOid (commit_id) を明示**: AI が自前で取得すると baseRefOid を誤って入れる事故が多発 +- **作業 worktree の絶対パス**: 「ファイル読み取りは必ず worktree 配下の絶対パスを使う」(実 path は state.json の `worktree_path` を参照。`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決) +- **event ダウングレード警告**: `event_downgrade=true` のときは payload の `event` を `COMMENT` に +- **既存コメント差分**: `$TMP_DIR/cross-review-pr-existing-comments.txt` を読んで重複指摘禁止 +- **自動レビュー観点**: GitHub API の `pulls//files --paginate` で変更ファイルを全件取得して分類し、`common` / `docs_only` / `code` / `db_migration` / `test` / `dependency` / `config_ci` / `api_contract` / `auth_security` / `frontend` / `performance` / `deletion_rename` / `generated` / `i18n` / `infra` の該当テンプレートを state.json の `auto_review_instructions` に保存する +- **手動追加レビュー観点**: `--focus` / `--extra-instructions-file` が指定されていれば state.json の `manual_extra_review_instructions` に保存し、自動テンプレートの後ろに連結した `review_instructions` を codex / gemini 両 launcher が同じ「追加レビュー観点」セクションとしてプロンプトに差し込む +- **進捗マーカー**: Gemini には `$TMP_DIR/gemini-review-pr-progress.log` へ短いフェーズ名を追記させ、monitor の heartbeat で表示する。内部推論や長文説明は書かせない +- **review body 先頭 prefix**: + ``` + ## 🤖 cross-review | round | | + ``` + `` は **本来の intent**(`posted_as` ではない)。 + 例: 自分PR で REQUEST_CHANGES を COMMENT にダウングロードしても、prefix は `REQUEST_CHANGES` のまま。 +- **出力禁止事項**(SKILL.md「レビュー出力の制約」と一致): + - 「良い点」「Strengths」などの褒めセクションを body に書かない + - 修正アクションを伴わないインラインコメントは作らない(nit はインライン化しない) + - コード引用のみで指摘内容が無いコメント禁止 + - 雑感だけの `event=COMMENT` 投稿禁止(直すべき点が無ければ `APPROVE`) + +### 2.3 AI が書き出すファイル契約 + +各 launcher は AI に以下 2 ファイルの書き出しを指示する: + +| ファイル | 内容 | +|---|---| +| `$TMP_DIR/-review-pr-result.json` | `{event, posted_as, comments_count, review_url, by_severity}` のサマリ | +| `$TMP_DIR/-review-pr-round-payload.json` | `{comments: [{path, line, body, severity}, ...]}` 振動検知用 | + +`/ndf:review` の result.json 出力規約に `posted_as` フィールドを含むこと +(自分PR ダウングレード時に GitHub に実際送った event。デフォルトは `event` と同値)。 + +### 2.4 result.json を state にマージ + +```bash +[ "$ONLY" != "gemini" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" codex +[ "$ONLY" != "codex" ] && "$SCRIPTS/state.py" read-result "$STATE_PR" gemini +``` + +`state.rounds[-1].` に `intent / posted_as / comments / review_url / by_severity` を分離保存する。 + +## Step 3: 判定(intent ベース) + +```bash +if "$SCRIPTS/state.py" judge "$STATE_PR"; then + : # exit 0 = approved。ループ終了。 +elif [ $? -eq 2 ]; then + : # exit 2 = continue → Step 5 (fix) +else + exit 1 +fi +``` + +**判定ロジック**: + +- `APPROVE` / `SKIP` は pass +- `COMMENT` は `by_severity.critical == 0 && major == 0` のみ pass(軽微な指摘のみなら通す) +- `--only` 指定時は反対側を SKIP 扱い +- ループ収束判定は **必ず `intent`** を見る(`posted_as` ではない) + +自分の PR で `REQUEST_CHANGES → COMMENT` にダウングレード投稿していても、 +intent が `REQUEST_CHANGES` なら継続する。 + +## Step 4: 振動検知 + +```bash +if "$SCRIPTS/state.py" check-oscillation "$STATE_PR"; then + : # ここには来ない(成功は exit 2 = continue) +elif [ $? -eq 4 ]; then + exit 4 # final=oscillation で中断 +fi +``` + +各ラウンドの `$TMP_DIR/-review-pr-round-payload.json` から +`path:line` を抽出し、前ラウンドとの重複率を計算。**50% 以上重複で中断**。 + +PR ローテーション直後 (`round_in_pr < 2`) はスキップ。 diff --git a/plugins/ndf-codex/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf-codex/skills/cross-review/docs/02-fix-and-rotation.md new file mode 100644 index 0000000..aaaecc3 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/docs/02-fix-and-rotation.md @@ -0,0 +1,384 @@ +# 02: 修正 (Step 5) + PR ローテーション (Step 6) + 終了処理 (Step 8) + +主要処理は `scripts/` 配下に切り出し済み: + +| script | 役割 | +|---|---| +| (Agent) | Step 5 — 修正サブエージェント起動(メインからの責務) | +| `scripts/state.py merge-fix` | Step 5 後段 — fix 戻り値マージ + CI 分類 | +| `scripts/state.py should-rotate` | Step 6 — rotate 要否判定 | +| `scripts/rotate-pr.sh prepare` | Step 6a — 旧 PR の素材を `rotate-pr-prepare.json` に dump | +| (Agent) | Step 6b — light モードのみ。新 PR の title/body を再生成して `rotate-pr-newtext.json` に書き出し | +| `scripts/rotate-pr.sh execute` | Step 6c — 旧 PR close + 新 PR 作成 (light は同ブランチ / squash は新ブランチ) | +| `scripts/state.py set-current-pr` | Step 6 — rotation 後の state 更新 | +| `scripts/state.py report` | Step 8 — deferred nit + ラウンドサマリ | + +## Step 5: 修正 — **必ずサブエージェント経由** + +**メインセッションでは修正コードを書かない。** `/ndf:fix` を +`general-purpose` サブエージェントで起動する。 + +**サブエージェントの責務(必須 6 点)**: + +1. critical / major / minor の修正コミット +2. 修正テストの追加・実行 +3. 修正対象の thread に **reply 投稿** + **`resolveReviewThread` で Resolve** +4. nit / 判断が割れる minor は **修正せず deferred 記録**(reply は `[deferred / nit]` ラベル付き、Resolve しない) +5. **PR レベルの Summary コメントを `gh pr comment` で投稿**(対応件数 / 重要度別 / deferred 件数 / rejected 件数 / commit SHA を含む) +6. 戻り値ファイル `$TMP_DIR/fix-pr-result.json` を必ず書き出す + (`$TMP_DIR` は env `CROSS_REVIEW_TMP_DIR` > `/.cross_review/` の順で解決。 + 詳細は `scripts/state.py _tmp_dir()` 参照。`/tmp/` 直書きでも `state.py merge-fix` は legacy fallback で拾う) + +> ⚠ inline thread への reply + Resolve **だけでは不十分**。PR ページの +> conversation タブに表示される **PR レベルコメント** がレビュアーへの +> サマリ通知として必須(`/ndf:fix` SKILL.md の手順 7 で規定)。 +> サブエージェント起動プロンプトでも明示的に指示すること。 + +### サブエージェント起動例 + +```python +Agent( + subagent_type="general-purpose", + description=f"Fix PR #{PR} (round {ROUND})", + prompt=f""" +/ndf:fix {PR} --defer-nit を実行してください。 + +**作業ディレクトリ厳守**: cd {WORKTREE_PATH} で作業すること。 +別セッションが /work/ 側で並行作業している可能性があり、 +worktree 外を触ると競合します。 + +## コンテキスト +- リポジトリ: {OWNER_REPO} +- PR: #{PR} (round {ROUND_IN_PR}/{ROTATE_AFTER}) +- worktree: {WORKTREE_PATH} +- ブランチ: {HEAD_BRANCH} +- ベース: {BASE_BRANCH} +- headRefOid: {HEAD_OID} +- 前ラウンドのレビュー結果: + - codex review: {CODEX_REVIEW_URL} + (intent={CODEX_INTENT}, posted_as={CODEX_POSTED_AS}, {CODEX_COMMENT_COUNT}件) + - gemini review: {GEMINI_REVIEW_URL} + (intent={GEMINI_INTENT}, posted_as={GEMINI_POSTED_AS}, {GEMINI_COMMENT_COUNT}件) +- 既存コメントスナップショット: $TMP_DIR/cross-review-pr{PR}-existing-comments.txt + +## ポリシー +- 重要度ラベルは **AI agent の付与を鵜呑みにせず**、コードを読んで独自に再判定する +- critical / major は自動修正 +- minor / nit のうち **パフォーマンス・可読性・重複コード排除** に該当するものは修正 + (特にトータル行数が減る方向は積極実施 / +30 行を超えそうなら deferred + ユーザ問い合わせ) +- それ以外の nit は deferred として記録のみ(修正しない、Resolve しない) +- bot 指摘が誤読していたら修正せず reply で説明(rejected として記録、Resolve しない) +- **重複指摘(codex/gemini が同じ箇所を別 thread で指摘)は全 thread に reply + Resolve** +- PR テスト範囲外の **flaky テストも見つけ次第このループで修正**(放置はリポジトリ品質を劣化させる) + +## 必須実行手順(順序厳守) + +1. PR コメント取得 (3 ソース): `fix/scripts/fetch-pr-comments.sh {OWNER_REPO} {PR}` でインラインコメント / レビュー body / PR レベルコメントを一括取得 +2. 重要度を独自再判定(AI agent のラベルは参考値) +3. CI 状態スナップショット: `gh pr checks {PR} --json name,state` (**完了待ちはしない**、PENDING は無視して FAILURE のみ修正対象に取り込む) +4. critical/major + 該当 minor/nit の修正コミット(worktree 内のみ) +5. `./pint-changed.sh && ./larastan-changed.sh` 等の品質チェック +6. push: `git push origin {HEAD_BRANCH}` (--force / --no-verify 禁止) +7. **CI 再実行は待たない**(push 後の `--watch` 等は行わない、`ci_status` は push 時点での既知失敗のみ反映) +8. **各 thread に reply 投稿**: + - 修正済み: 「対応しました — <ファイル>:<行> で〇〇 (commit )」 + - deferred: 「[deferred / nit] 後続 PR で対応予定」 + - rejected: 「bot 指摘は誤読です — 理由: ...」 +9. **修正済み thread を `resolveReviewThread` で Resolve**: + ```bash + # thread_id は GraphQL で取得 + gh api graphql -f query=' + query {{ repository(owner:"...", name:"...") {{ + pullRequest(number: {PR}) {{ reviewThreads(first:100) {{ + nodes {{ id isResolved path line }} + }} }} + }} }}' + # 修正済みのみ resolve + gh api graphql -f query=' + mutation($id: ID!) {{ + resolveReviewThread(input: {{threadId: $id}}) {{ thread {{ isResolved }} }} + }}' -f id="$THREAD_ID" + ``` + - deferred / rejected の thread は **Resolve しない** +10. **PR レベル Summary コメントを投稿**(必須・inline reply とは別物): + ```bash + gh pr comment {PR} --body "$(cat <<'EOMD' + ## 🔧 /ndf:fix サマリ (round N) + + 対応件数: critical=X / major=Y / minor=Z (合計 N 件) + deferred: D 件 / rejected: R 件 + commit: + CI: SUCCESS | FAILURE | NONE + + ### 詳細 + - 各 thread の対応概要(行リンク付き) + EOMD + )" + ``` + - inline reply + Resolve だけでは「PR ページの Conversation タブ」に + まとめが出ず、レビュアー視点で見落とされる。**必ず投稿する** +11. 戻り値ファイル書き出し(下記フォーマット)。`summary_comment_url` には + 手順 10 の URL を入れる + +## 戻り値ファイル $TMP_DIR/fix-pr{PR}-result.json + +```json +{{ + "pr": {PR}, + "fix_commit": "abc1234", + "ci_status": "SUCCESS" | "FAILURE" | "PENDING", + "ci_failed_checks": [], + "fixed_count": 6, + "by_severity": {{"critical": 0, "major": 4, "minor": 2, "nit": 0}}, + "resolved_threads": [ + {{"thread_id": "PRRT_...", "comment_id": 123, "path": "...", "line": 42}} + ], + "deferred": [ + {{"thread_id": "...", "path": "...", "line": 31, "severity": "nit", + "summary": "...", "comment_url": "..."}} + ], + "rejected": [ + {{"thread_id": "...", "summary": "...", "reason_for_rejection": "..."}} + ] +}} +``` + +> **重要**: `resolved_threads` / `deferred` / `rejected` は必ず **list(配列)** で返すこと。 +> **件数(int) を書かないこと**(例: `"resolved_threads": 3` は誤り。`[]` 形式で返す)。 +> `state.py merge-fix` がこの list を `len()` して state.json には件数(int)で保存する。 +> int を書くと過去 `merge-fix` が `TypeError` で落ちていた(現在は後方互換で int も受理するが、 +> 正は list)。対応した thread が無い場合は空配列 `[]` を返す。 +""", +) +``` + +### Step 5 後段: fix 戻り値マージ + CI 分類 + +```bash +if "$SCRIPTS/state.py" merge-fix "$STATE_PR"; then + : # exit 0 = continue +elif [ $? -eq 3 ]; then + exit 3 # final=error(コード関連 CI 失敗 or fix 戻り値ファイル欠落) +fi +``` + +`state.py merge-fix` が内部で行う処理: + +1. `$TMP_DIR/fix-pr-result.json` を読んで `state.rounds[-1].fix` にマージ +2. `deferred` を `state.deferred_nits` に追記 +3. **CI 失敗の分類**: + - code-fail (`pint` / `larastan` / `phpstan` / `test` / `lint` / `type` / `build` / `ruff` / `eslint` / `tsc` / `mypy`): `final=error` で中断 (exit 3) + - meta-only (`check_pr_requirements` / `assignees` / `reviewers` / `labels` / `meta`): `ci_note` に記録して継続 + - 不明: 保守的に code-fail 扱い + +**例**: `check_pr_requirements`(Assignees 未設定)はループ継続、 +`laravel/pint` や `phpstan` の失敗は即中断してユーザ判断。 + +## Step 6: PR ローテーション (prepare → Agent → execute の 3 段) + +`rotate-pr.sh` は **light モード (default) と squash モード (opt-in)** を持つ。 +両者ともメインからは `prepare → (light のみ Agent) → execute` の 3 段で呼ぶ。 + +```bash +if "$SCRIPTS/state.py" should-rotate "$STATE_PR"; then + # Step 6a: 旧 PR の素材 dump (title / body / isDraft / git log / git diff --stat) + eval "$("$SCRIPTS/rotate-pr.sh" prepare "$STATE_PR")" + + # Step 6b: light モードのみ。Agent(subagent_type="general-purpose") で + # 現状の差分・実装を反映した新 title/body を生成し、 + # $TMP_DIR/rotate-pr-newtext.json に書き出させる。 + # (squash モードでは Step 6b は不要) + + # Step 6c: 実行 (NEW_PR / NEW_PR_URL / NEW_BRANCH を取り込む) + eval "$("$SCRIPTS/rotate-pr.sh" execute "$STATE_PR" --mode "$ROTATE_MODE")" + + "$SCRIPTS/state.py" set-current-pr "$STATE_PR" "$NEW_PR" + # NOTE: STATE_PR は **絶対に変えない**。次ループの scripts も $STATE_PR で呼ぶ。 +fi +``` + +`should-rotate` は `round_in_pr >= rotate_after && total_rounds < max_rounds` で +exit 0 を返す(rotate 要)。それ以外は exit 2(keep)。 + +### Step 6a: `rotate-pr.sh prepare ` + +state.json から旧 PR / worktree を解決し、以下の素材を 1 つの JSON に dump する: + +```json +{ + "state_pr": 217, + "old_pr": 217, + "old_pr_url": "https://github.com/.../pull/217", + "worktree_path": "/tmp/ndf-worktrees/owner--name/pr217", + "head_branch": "feature/...", + "base_branch": "release/...", + "is_draft": true, + "round_in_pr": 5, + "old_title": "...", + "old_body": "...", + "git_log": "abc1234 メッセージ\n...", + "git_diff_stat": " path/to/file | 12 +-\n ..." +} +``` + +ファイル: `$TMP_DIR/rotate-pr-prepare.json`。 +stdout にも `OLD_PR=` / `HEAD_BRANCH=` / `BASE_BRANCH=` / `IS_DRAFT=` / `PREPARE_JSON=` を出すので +`eval` で取り込める。 + +### Step 6b: Agent (general-purpose) で新 title/body を生成 (light モードのみ) + +メインセッションから以下のように Agent を起動する。プロンプトは +**書いて良いこと / 禁止事項** を必ず明示する +(外側の prompt フェンスは内側に ```json を含むため 4 連バッククォートで囲む): + +````python +Agent( + subagent_type="general-purpose", + description=f"Generate light-rotation PR text for PR #{OLD_PR}", + prompt=f""" +PR rotation の light モードで作成する新 PR の title / body を生成してください。 + +## 素材 +- prepare.json: $TMP_DIR/rotate-pr{STATE_PR}-prepare.json + - 元 PR の title / body / git log $BASE..HEAD / git diff --stat +- 必要なら worktree 内のファイルを直接読んで実装内容を確認してよい + (worktree: {WORKTREE_PATH}) + +## 出力ファイル +$TMP_DIR/rotate-pr{STATE_PR}-newtext.json に JSON で書き出してください: + +```json +{{ + "title": "新 PR の title (元 PR の title をそのままコピーしない。現状の実装を反映)", + "body": "新 PR の body (Markdown)。以下のセクションを含む:\\n## 何のために\\n## 何を\\n## Test plan" +}} +``` + +## 書いて **良い** こと +- 何のために (背景・動機) — 元 PR の背景セクションは再利用可 +- 何を (変更内容) — 現在のブランチの実態を git log / git diff から反映 +- Test plan — 元 PR から継承可 + +## 書いて **はいけない** こと (内部用語の漏洩防止) +- 「round N で〜」「cross-review で〜」「レビュー指摘で〜」 +- 「(rotated)」のような automated suffix +- 「fix された問題」の列挙 / レビューサイクルの存在自体への言及 +- 「旧 PR」「巻き直し」等の rotation 内部用語 + +PR を読む人は cross-review の存在を意識しないため、最終 PR を初めて見る読者向けに +書く。元 title / body をそのままコピーするのは **禁止** (現状の実装を反映)。 +""", +) +```` + +> ⚠ `rotate-pr.sh` 内部から `claude` / `codex` / `gemini` CLI を直接呼んで生成 +> させてはならない (環境依存・コスト管理外)。**メイン側の Agent tool で行う**。 + +### Step 6c: `rotate-pr.sh execute --mode light|squash` + +`--mode` で実際の rotation を実行する: + +| mode | 振る舞い | +|---|---| +| `light` (default) | prepare.json と newtext.json を読み、**同ブランチ・同 base** で旧 PR を close → 新 PR を作成。`is_draft=true` なら新 PR も Draft で作る。title/body は newtext から流す | +| `squash` (opt-in) | `-rHHMMSS` の新ブランチを作って `git reset --soft origin/$BASE` で squash 統合 → 旧 PR close → 新 PR (`(rotated)` suffix + automated body) | + +stdout には両モードとも以下を KEY=VALUE で出す: + +- `NEW_PR=` +- `NEW_PR_URL=` +- `NEW_BRANCH=` (light モードでは元ブランチと同じ) + +`state.py set-current-pr` が `state.json` の `current_pr` / `pr_history` を更新する。 +state.json の **キーは元 PR 番号 (STATE_PR) のまま** なので、light/squash どちらでも +後続スクリプトへの第 1 引数は `$STATE_PR` を渡し続ければよい。 + +### 後方互換: 旧 1 引数形式 + +`rotate-pr.sh ` (引数 1 つ) は `execute --mode squash` 相当として動くが、 +stderr に deprecation warning を出す。新規呼び出しは必ず prepare → execute 形式へ移行。 + +> ⚠ **重要**: state.json のファイル名は **最初に init した PR 番号** がキー +> (`$STATE_PR`)。rotation 後も全 scripts の **第 1 引数には常に `$STATE_PR`** を渡す。 +> 内部的に `state.json.current_pr` を読んで「現在の PR」を解決する設計。 +> `PR=$NEW_PR` 等で shell 変数の側を切り替えると、次ループの `state.py start-round` +> が `$TMP_DIR/cross-review-pr-state.json` を探して `state.json not found` で +> 止まる。 + +## Step 7: 次ラウンドへ + +Step 1 に戻る。 + +## Step 7.5: 最終スイープ(必須)— 取りこぼし防止 + +**目的**: ループを抜けた時点で PR 上に **未解決 (open) のレビュースレッドを 1 件も残さない**。 + +### なぜ必要か + +ループ内の修正フェーズ (Step 5) は「一方でも REQUEST_CHANGES」のラウンドでしか走らない。 +そのため以下が取りこぼされる: + +- **最終 APPROVE ラウンドのインラインコメント**: skill のレビュー方針上、`minor` 以下しか + 無ければ `APPROVE` で良い。つまり **APPROVE でも minor/nit のインラインコメントが + 投稿されている**ことがあり、両者 APPROVE でループを抜けるとこれらが未対応のまま残る。 +- **ループ中に deferred 記録した nit**: `state.deferred_nits` に積まれたまま reply のみで + Resolve されていないスレッド。 + +これらを放置すると、PR レビュー画面に「Unresolved」スレッドが残り、人間のレビュアーや +後続作業者が「未対応の指摘がある」と誤認する。 + +### 実行(メインが Agent を駆動) + +ループを抜けたら(`final` がどの値でも)、**メインが `Agent(subagent_type="general-purpose")` +を起動**し、`/ndf:fix ` を再実行させる。bash 単体では Agent を呼べないため、 +while ループ脱出後にメインが以下のプロンプトでサブエージェントを起動する。 + +> **対象 PR**: ``(state.json の `current_pr`。rotation していれば最新 PR) +> **worktree**: state.json の `worktree_path` +> +> PR の **全 open review thread**(インライン / レビュー body / PR レベルコメント)を +> `gh api` で洗い出し、cross-review の codex/gemini が残したものを中心に **すべて解消**せよ: +> 1. 修正可能な `minor`/`nit` → コード修正 + push(同ブランチ、main へは push しない)し、 +> reply + GraphQL `resolveReviewThread` で Resolve。 +> 2. 修正しない(好み・判断保留)`nit` → 「[deferred / nit] 対応見送り: <理由>」を日本語で +> reply した上で **Resolve まで実行**(スレッドを open のまま残さない)。 +> 3. bot 誤指摘 → 却下理由を reply して Resolve。 +> 修正で push した場合は `claude plugin validate` を通すこと。 +> 完了後、`$TMP_DIR/sweep-pr-result.json` に +> `{"resolved": N, "fixed_in_sweep": M, "commit": "", "remaining_open": K, +> "items": ["<1行要約>", ...]}` を書き出し、最終メッセージで内訳を日本語報告せよ。 +> **`remaining_open` は 0 を目標**とし、0 にできない場合は理由を明記すること。 + +> ⚠ 最終スイープは「修正の追加」ではなく **後始末**。新しい設計変更や大きな +> リファクタは行わない(行う必要があれば deferred として report に残す)。 +> push が走った場合でも、それに対する再レビューはループ終了後のため行わない +> (次回 cross-review か通常レビューに委ねる)。 + +### 再開性 + +sweep 中にメインが落ちても、`sweep-pr-result.json` が無ければ Step 7.5 から +再実行すれば良い(Resolve は冪等。既 Resolve スレッドは skip される)。 + +## Step 8: 終了処理 — ラウンドサマリ + 残 deferred の参考列挙 + +最終スイープ (Step 7.5) 完了後、ラウンドサマリを表示: + +```bash +"$SCRIPTS/state.py" report "$STATE_PR" +``` + +`report` は以下を Markdown で吐く: + +- 最終ステータス(`approved` / `max_rounds` / `oscillation` / `error`) +- PR 履歴 +- ラウンドサマリ表 +- 残 deferred nit 一覧 + +メインは `report` の出力に **Step 7.5 の sweep 結果**(`sweep-pr-result.json` の +`resolved` / `fixed_in_sweep` / `remaining_open`)を折り込んで最終報告する。 + +> **方針変更(v4.11.0)**: 従来は deferred nit を「AskUserQuestion で 1 回問い合わせ」て +> いたが、未解決スレッドを残さない方針に変更。**Step 7.5 で nit も含め全 open thread を +> Resolve する**ため、Step 8 のユーザ問い合わせは原則不要。deferred nit は「対応見送りの +> 記録」として report に **参考列挙**するに留める(再対応が要るものがあればユーザが +> その場で指示できる)。`remaining_open > 0` の場合のみ、残った理由を添えて報告する。 diff --git a/plugins/ndf-codex/skills/cross-review/scripts/_tmpdir.sh b/plugins/ndf-codex/skills/cross-review/scripts/_tmpdir.sh new file mode 100755 index 0000000..8df7371 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/scripts/_tmpdir.sh @@ -0,0 +1,23 @@ +# shellcheck shell=bash +# cross-review 共通: tmp ディレクトリ決定ヘルパ。 +# +# Usage: +# . "$(dirname "$0")/_tmpdir.sh" +# TMP_DIR=$(tmpdir) +# +# 優先順位: +# 1. 環境変数 CROSS_REVIEW_TMP_DIR (明示) +# 2. /.cross_review/ (worktree 内。gemini の workspace 制約を根本回避) + +tmpdir() { + if [ -n "${CROSS_REVIEW_TMP_DIR:-}" ]; then + mkdir -p "$CROSS_REVIEW_TMP_DIR" + echo "$CROSS_REVIEW_TMP_DIR" + return + fi + # サブディレクトリから呼ばれた場合でも worktree root を正しく特定する + local root + root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" + mkdir -p "$root/.cross_review" + echo "$root/.cross_review" +} diff --git a/plugins/ndf-codex/skills/cross-review/scripts/launch-codex.sh b/plugins/ndf-codex/skills/cross-review/scripts/launch-codex.sh new file mode 100755 index 0000000..2054366 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/scripts/launch-codex.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# cross-review codex launcher. +# +# Usage: launch-codex.sh +# +# 引数 STATE_PR は **state.json の key (= 最初に init した PR 番号)**。 +# rotation 後も state.json の場所は変わらないため、ここに渡すのは常に初期 PR。 +# gh コマンドに使う「現在のレビュー対象 PR」は state.json の `current_pr` を読む。 +# +# tmp ディレクトリは `_tmpdir.sh` の `tmpdir()` 関数で決定: +# CROSS_REVIEW_TMP_DIR env → $PWD/.cross_review/ +# worktree 内に配置することで gemini の workspace 制約を根本回避。 +# codex は --dangerously-bypass-approvals-and-sandbox を維持 +# (-s workspace-write は bwrap 非対応環境で失敗するため)。 +# +# 状態ファイル: $TMP_DIR/codex-review-pr-{result,err,stdout,pid}.json +# (パスは STATE_PR ベースで固定 — monitor.py / state.py と一致させる。) + +set -euo pipefail + +STATE_PR=${1:?STATE_PR required} +ROUND=${2:?ROUND required} + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_tmpdir.sh +. "$SCRIPT_DIR/_tmpdir.sh" +TMP_DIR=$(tmpdir) + +STATE=$TMP_DIR/cross-review-pr$STATE_PR-state.json +[ -s "$STATE" ] || { echo "state.json not found: $STATE" >&2; exit 1; } + +WORKTREE=$(jq -r '.worktree_path' "$STATE") +REPO=$(jq -r '.repo' "$STATE") +EVENT_DOWNGRADE=$(jq -r '.event_downgrade // false' "$STATE") +EXTRA_REVIEW_INSTRUCTIONS=$(jq -r '.review_instructions // .extra_review_instructions // ""' "$STATE") +# PR (=current_pr) は gh コマンドのレビュー対象 PR 番号として使う。 +# tmp パス側は STATE_PR で固定 (monitor.py / state.py が同じ STATE_PR 起点で +# 読みに来るため、ここを揃えないと PR rotation 後に読み書きパスが食い違う)。 +PR=$(jq -r '.current_pr' "$STATE") +SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + +PROMPT=$TMP_DIR/codex-review-pr$STATE_PR-prompt.md +EXISTING=$TMP_DIR/cross-review-pr$STATE_PR-existing-comments.txt +EXTRA_REVIEW_BLOCK= +if [ -n "$EXTRA_REVIEW_INSTRUCTIONS" ]; then + EXTRA_REVIEW_BLOCK=$(cat < "$PROMPT" <\` には **本来の intent** を書く。 +- 既存コメントスナップショット: $EXISTING (重複指摘禁止) +$EXTRA_REVIEW_BLOCK + +## 出力契約 +- review body の **先頭行** に必ず以下を入れる(fence 不要、Markdown 見出しとして): + \`\`\` + ## 🤖 cross-review | round $ROUND | codex | + \`\`\` + 例: \`## 🤖 cross-review | round $ROUND | codex | REQUEST_CHANGES\` + - \`\` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) + +### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) +- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない +- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 +- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) +- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無いコメント** +- ❌ **\`event=COMMENT\` での雑感投稿** — 直すべき点が無ければ \`APPROVE\` にする + +### インラインコメントの書式 +- \`[重要度 / カテゴリ]\` プレフィックス必須 (例: \`[major / 正確性]\`) +- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit はインライン化しない) +- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く + +### body (総評) の書き方 +- 設計レベル・PR 横断の **修正提案のみ** 書く +- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) + +- 投稿後、サマリを **$TMP_DIR/codex-review-pr$STATE_PR-result.json** に書く: + \`\`\`json + { + "event": "REQUEST_CHANGES", + "posted_as": "COMMENT", + "comments_count": 5, + "review_url": "https://github.com/.../pull/$PR#pullrequestreview-...", + "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0} + } + \`\`\` +- payload(全コメント詳細)は **$TMP_DIR/codex-review-pr$STATE_PR-round$ROUND-payload.json** に保存 + (振動検知用、\`{ "comments": [{path, line, body, severity}, ...] }\` 形式) + +## 守るべきこと +- リポジトリ編集は行わない(コード修正は別ステップ) +- worktree 外のパスは触らない +- gh api 失敗時は err.log にエラー詳細を残して即時終了 +EOF + +cd "$WORKTREE" +nohup codex exec --dangerously-bypass-approvals-and-sandbox \ + --config reasoning.effort=medium -C "$WORKTREE" \ + < "$PROMPT" \ + > $TMP_DIR/codex-review-pr$STATE_PR-stdout.log \ + 2> $TMP_DIR/codex-review-pr$STATE_PR-err.log & +echo $! > $TMP_DIR/codex-review-pr$STATE_PR.pid +disown +echo "🚀 codex launched (pid=$(cat $TMP_DIR/codex-review-pr$STATE_PR.pid))" >&2 diff --git a/plugins/ndf-codex/skills/cross-review/scripts/launch-gemini.sh b/plugins/ndf-codex/skills/cross-review/scripts/launch-gemini.sh new file mode 100755 index 0000000..e0b921e --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/scripts/launch-gemini.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# cross-review gemini launcher (trusted directory 対策込み). +# +# Usage: launch-gemini.sh +# +# 引数 STATE_PR は state.json の key (= 最初に init した PR 番号)。 +# レビュー対象の PR は state.json の `current_pr` を読む。 +# +# 注意: +# - worktree のような新規パスは untrusted 判定で --yolo が "default" に降格する。 +# `--skip-trust` と `GEMINI_CLI_TRUST_WORKSPACE=true` を **両方** 必須とする。 +# - 完了判定は monitor.py が pidfile + sentinel + result.json で多軸判定する。 + +set -euo pipefail + +STATE_PR=${1:?STATE_PR required} +ROUND=${2:?ROUND required} + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_tmpdir.sh +. "$SCRIPT_DIR/_tmpdir.sh" +TMP_DIR=$(tmpdir) + +STATE=$TMP_DIR/cross-review-pr$STATE_PR-state.json +[ -s "$STATE" ] || { echo "state.json not found: $STATE" >&2; exit 1; } + +WORKTREE=$(jq -r '.worktree_path' "$STATE") +REPO=$(jq -r '.repo' "$STATE") +EVENT_DOWNGRADE=$(jq -r '.event_downgrade // false' "$STATE") +EXTRA_REVIEW_INSTRUCTIONS=$(jq -r '.review_instructions // .extra_review_instructions // ""' "$STATE") +# PR (=current_pr) は gh コマンドのレビュー対象 PR 番号として使う。 +# tmp パス側は STATE_PR で固定 (monitor.py / state.py との読み書き整合のため)。 +PR=$(jq -r '.current_pr' "$STATE") +SHA=$(gh pr view "$PR" --json headRefOid -q .headRefOid) + +PROMPT=$TMP_DIR/gemini-review-pr$STATE_PR-prompt.md +# 既存コメントは **プロンプトにインライン埋め込み** する。 +# tmp dir は `/.cross_review/` を使うが、念のため +# プロンプト埋め込み方式も維持 (gemini が read_file を呼ばずに済むので確実)。 +EXISTING_FILE=$TMP_DIR/cross-review-pr$STATE_PR-existing-comments.txt +if [ -s "$EXISTING_FILE" ]; then + EXISTING_INLINE=$(cat "$EXISTING_FILE") +else + EXISTING_INLINE="(なし)" +fi +EXTRA_REVIEW_BLOCK= +if [ -n "$EXTRA_REVIEW_INSTRUCTIONS" ]; then + EXTRA_REVIEW_BLOCK=$(cat < "$PROMPT" <\` は本来の intent を書く。 + +## 既存コメントスナップショット(重複指摘禁止) +workspace 外を読まなくて済むよう、以下にインライン展開する: + +\`\`\` +$EXISTING_INLINE +\`\`\` +$EXTRA_REVIEW_BLOCK + +## 出力契約 +- review body の **先頭行** に必ず以下を入れる: + \`\`\` + ## 🤖 cross-review | round $ROUND | gemini | + \`\`\` + - \`\` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) + +### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) +- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない +- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 +- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) +- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無いコメント** +- ❌ **\`event=COMMENT\` での雑感投稿** — 直すべき点が無ければ \`APPROVE\` にする + +### インラインコメントの書式 +- \`[重要度 / カテゴリ]\` プレフィックス必須 (例: \`[major / 正確性]\`) +- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit はインライン化しない) +- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く + +### body (総評) の書き方 +- 設計レベル・PR 横断の **修正提案のみ** 書く +- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) + +### 進捗マーカー(監視用) +- 無言ハングと区別できるよう、作業フェーズが進むたびに + **$TMP_DIR/gemini-review-pr$STATE_PR-progress.log** へ短い 1 行を追記すること +- 内部の推論や長い説明は書かず、以下のようなフェーズ名 + 対象だけを書く: + - \`start: review PR #$PR round $ROUND\` + - \`scan: diff and existing comments\` + - \`analyze: candidate findings\` + - \`post: submit review\` + - \`done: result.json written\` + +- 投稿後、サマリを **$TMP_DIR/gemini-review-pr$STATE_PR-result.json** に + **必ず以下のキーで** 書く: + \`\`\`json + { + "event": "APPROVE", + "posted_as": "COMMENT", + "comments_count": 3, + "review_url": "https://github.com/.../pull/$PR#pullrequestreview-...", + "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0} + } + \`\`\` + - \`intent\` / \`comment_count\` 等の別名は使わないこと + - \`event\` の値は \`APPROVE\` / \`REQUEST_CHANGES\` / \`COMMENT\` のいずれか + - \`event_downgrade=true\` のとき \`posted_as\` は \`COMMENT\` にダウングレード可 +- payload は **$TMP_DIR/gemini-review-pr$STATE_PR-round$ROUND-payload.json** に保存 + (振動検知用、\`{ "comments": [{path, line, body, severity}, ...] }\` 形式) + +## 守るべきこと +- **リポジトリ編集禁止**。gh api での投稿のみ許可 +- worktree 外のパスは触らない +- gh api 失敗時は err.log にエラー詳細を残して即時終了 +EOF + +cd "$WORKTREE" + +# gemini-cli 最新版は mcpServers エントリの `disabled` キーを Unrecognized 扱いし、 +# 起動時に `Error in: mcpServers.` 警告を err.log に出す。文字列としては +# `Error: ...` ではなく `Error in: ...` だが、monitor.py 旧版が誤検知して +# プロセスを kill する原因になっていた (REPORT01 参照)。 +# launcher 側でも sanitize して警告自体を抑制する: `disabled` キーを再帰的に +# 削除した settings.json を起動時のみ差し込み、gemini が読み終わったら復元する。 +SETTINGS=$WORKTREE/.gemini/settings.json +SETTINGS_BACKUP= + +# trap で EXIT / INT / TERM / HUP のいずれでも必ず settings.json を復元する。 +# sleep 2 中や復元前に Ctrl-C / SIGTERM / シェル終了で止まっても、sanitize 済み +# settings.json が worktree に残らないようにするため。冪等に書いてあるので +# 多重実行されても安全。 +restore_settings() { + # SETTINGS_BACKUP が未設定 or バックアップ不在なら何もしない + if [ -n "${SETTINGS_BACKUP:-}" ] && [ -f "$SETTINGS_BACKUP" ]; then + mv -f "$SETTINGS_BACKUP" "$SETTINGS" 2>/dev/null || true + SETTINGS_BACKUP= + fi +} +trap restore_settings EXIT INT TERM HUP + +if [ -f "$SETTINGS" ] && command -v jq >/dev/null 2>&1; then + SETTINGS_BACKUP=$TMP_DIR/gemini-review-pr$STATE_PR-settings-backup.json + cp "$SETTINGS" "$SETTINGS_BACKUP" + SANITIZED=$TMP_DIR/gemini-review-pr$STATE_PR-settings-sanitized.json + if jq 'walk(if type == "object" then del(.disabled) else . end)' "$SETTINGS_BACKUP" > "$SANITIZED" 2>/dev/null; then + cp "$SANITIZED" "$SETTINGS" + else + # jq が失敗したら sanitize を諦め、バックアップも破棄して元のまま起動 + rm -f "$SETTINGS_BACKUP" + SETTINGS_BACKUP= + fi +fi + +# ⚠ --skip-trust と GEMINI_CLI_TRUST_WORKSPACE=true は両方必須 +GEMINI_CLI_TRUST_WORKSPACE=true nohup gemini --yolo --skip-trust --output-format text \ + -p "" \ + > $TMP_DIR/gemini-review-pr$STATE_PR-stdout.log \ + 2> $TMP_DIR/gemini-review-pr$STATE_PR-err.log < "$PROMPT" & +GEMINI_PID=$! +echo $GEMINI_PID > $TMP_DIR/gemini-review-pr$STATE_PR.pid +disown + +# gemini は起動時に 1 度 settings.json を読む。読み込み完了を待ってから元の +# ファイルを復元する (worktree を dirty なままにしないため)。 +# sleep 中に signal が来ても trap restore_settings が必ず復元するため安全。 +if [ -n "$SETTINGS_BACKUP" ] && [ -f "$SETTINGS_BACKUP" ]; then + sleep 2 + restore_settings +fi + +echo "🚀 gemini launched (pid=$GEMINI_PID)" >&2 diff --git a/plugins/ndf-codex/skills/cross-review/scripts/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/monitor.py new file mode 100755 index 0000000..544a912 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/scripts/monitor.py @@ -0,0 +1,806 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""cross-review codex/gemini プロセス監視 CLI。 + +`launch-codex.sh` / `launch-gemini.sh` で起動したバックグラウンドプロセスを +**複数の根拠で多重監視** し、失敗パターン (sentinel 不在 / 早期エラー / ハング / +pidfile stale / result.json 不在) を構造化して扱う。 + +監視軸: + 1. **pidfile** + `kill -0` でプロセス生存確認 + - 可能なら `/proc//cmdline` で codex/gemini であることを再確認 (PID 再利用対策) + 2. **sentinel** (codex のみ): err.log に `^tokens used$` 出現 + 3. **early-error pattern**: err.log に既知の致命的キーワードが出たら即中断 + - **FATAL** (auth/quota/sandbox 等の明確な致命): 検知時に kill + - **WARN** (生の `Error:` / `Traceback` 等の曖昧パターン): 警告ログのみ、kill せず通常判定を継続 + - `--no-early-error` / `MONITOR_NO_EARLY_ERROR=1` で検知自体を無効化可 + 4. **result.json**: プロセス終了後に `/.cross_review/-review-pr-result.json` が + 生成されていなければ失敗扱い + 5. **hard timeout**: 既定 7 分。`--timeout` または `MONITOR_TIMEOUT` で上書き可 + 6. **stall timeout**: err.log + stdout.log の合計サイズが一定時間変化しなければ + STALLED として中断。既定は agent 別 (codex=180s, gemini=480s。gemini は err.log + にほぼ進捗を出さないため大きめ)。`--stall-timeout` で CLI 明示、 + `MONITOR_STALL_` env で per-agent 上書き、`MONITOR_STALL` env で共通上書き可 + 7. **progress.log heartbeat**: agent が任意で書く短いフェーズマーカーを stderr に表示。 + Gemini の stdout/stderr が静かな時間でも、内部推論ではなく監視用の作業段階を確認できる + 8. **result.json + age fallback**: sentinel を持たない agent (gemini) 向け。 + result.json の mtime が 30 秒以上前なら完了とみなし kill → OK + 9. **失敗時 kill**: TIMEOUT / STALLED / EARLY_ERROR (FATAL のみ) / PIDFILE_BAD で + 返るとき、対象プロセスを SIGTERM (3 秒後に SIGKILL) で停止する + +Usage: + monitor.py target ∈ {codex, gemini, both} + monitor.py both --timeout 1200 --stall-timeout 600 + monitor.py both --no-early-error # EARLY_ERROR 検知を完全無効化 + +Exit codes (target=both は最悪値を返す): + 0 OK プロセス正常終了 + result.json 確認 + 1 USAGE / IO error + 2 TIMEOUT hard timeout 超過 + 3 NO_RESULT プロセス終了したが result.json 未生成 + 4 EARLY_ERROR err.log に致命的パターン検出 + 5 STALLED err.log が一定時間進捗なし + 6 PIDFILE_BAD pidfile が無い / 内容が不正 / プロセスが起動していない + +Stdout: 各 agent の最終ステータスを JSON で 1 行ずつ吐く(メインがパース可能)。 +Stderr: 人間向けの進捗ログ(poll ごとに 1 行)。 +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import signal +import sys +import threading +import time +from dataclasses import dataclass +from typing import Optional + + +# ---------- 設定 ---------- + +# 既定値は import 時に **固定数値** で保持する。env (`MONITOR_TIMEOUT` / +# `MONITOR_STALL` / `MONITOR_POLL`) の解釈は **呼び出し時** に try/except +# 付きで行い、非数値 env でも import / 監視プロセスがクラッシュしないようにする。 +# (codex round 5 指摘: import 時の `int(os.environ.get(...))` は +# `MONITOR_STALL=abc` のような誤設定で `_agent_stall_default()` に到達する前に +# ValueError で落ちてしまうため) +DEFAULT_TIMEOUT = 420 # 7 min — `--timeout` / env `MONITOR_TIMEOUT` で上書き可 +# 既定 stall timeout (後方互換のため env MONITOR_STALL は残す)。 +# 両 agent 共通のデフォルトとして引き続き受け付ける (解釈は `_agent_stall_default()` 内)。 +DEFAULT_STALL = 180 # 3 min no progress +# per-agent 上書き: gemini は err.log がほぼ無音なため大きめに取る。 +# 解決順は `_agent_stall_default()` 参照。 +DEFAULT_STALL_AGENT_BUILTIN = { + "codex": 180, # 推論ログを逐次出すので 3 min で十分 + "gemini": 480, # err.log が静かなため 8 min まで許容 +} +DEFAULT_POLL = 15 # 15 sec — env `MONITOR_POLL` で上書き可 +# result.json が書き込まれた後もプロセスがハングするケース (gemini で観測) の +# fallback: mtime から RESULT_AGE_GRACE 秒以上経過していれば完了とみなす。 +RESULT_AGE_GRACE = 30 +# `MONITOR_NO_EARLY_ERROR=1` で EARLY_ERROR 検知を無効化 (escape hatch) +DEFAULT_NO_EARLY_ERROR = os.environ.get("MONITOR_NO_EARLY_ERROR", "").lower() in { + "1", "true", "yes", "on", +} + +# err.log の行頭に近い形で出る **明確な致命** パターン (kill 対象)。 +# auth / quota / sandbox / HTTP 401-403-429 / gemini の YOLO 降格など、 +# プロセスが続行しても result を生成できないと判明しているケースだけを入れる。 +EARLY_ERROR_FATAL = [ + # HTTP エラーステータス行 (`HTTP/1.1 401 Unauthorized` 等) + re.compile(r"^HTTP/\d\S* (?:401|403|429) ", re.MULTILINE), + # gemini 固有: untrusted directory で YOLO が降格される + re.compile(r'^Approval mode overridden to "default"', re.MULTILINE), + # 認証 / 権限系(行頭限定) + re.compile(r"^(?:Authentication failed|Permission denied)", re.MULTILINE), + # quota / rate limit (`m.start()` をキーワード位置に合わせるため `^.*` を付けない。 + # `_match_is_quoted()` が backtick / 「」 引用を判定するために match 開始位置を使うため) + re.compile(r"\b(?:quota exceeded|rate limit exceeded)\b", re.IGNORECASE), + # API key 系 + re.compile(r"\bAPI key (?:not found|missing|invalid)\b", re.IGNORECASE), + # codex 固有: sandbox エラー + re.compile(r"\bsandbox error\b", re.IGNORECASE), +] + +# 行頭の生 `Error:` / `Traceback` 系は **kill しない警告のみ** に降格。 +# - gemini-cli の `Error in: mcpServers.` 警告 (起動時の config 検証) +# - codex がレビュー対象 diff の test コード片を echo して `Traceback` が混入するケース +# など、続行可能な誤検知が頻発するため。プロセスは sentinel / result.json / timeout +# で別途判定する。 +EARLY_ERROR_WARN = [ + re.compile(r"^(?:Error|FATAL|fatal|panic|PANIC|Traceback)[: ]", re.MULTILINE), +] + +# 文脈に含まれていたら benign(doc 引用 / コードレビューコメント等)と見なし誤検知扱い。 +# FATAL / WARN 双方のスキャンに適用される。 +EARLY_ERROR_BENIGN = [ + # gemini-cli の config validation 警告 (`Error in: mcpServers.` / `Error in: ...`): + # 設定の Unrecognized キーを通知するだけで、gemini 本体は起動継続する。 + re.compile(r"^Error in: mcpServers\.", re.MULTILINE), + re.compile(r"^Error in: \S+\s*$", re.MULTILINE), + # diff のコンテキスト行 (` `, `+`, `-` で始まり、その後 markdown 表記が来る) + re.compile(r"^[ +-].*[\|`]", re.MULTILINE), + # markdown のリスト / 引用 + re.compile(r"^\s*[-*>]\s", re.MULTILINE), + # markdown の表セル行 (`| ... | ...` 形式)。SKILL.md / docs/*.md が + # 検知パターンを表で列挙しており、それを codex が echo すると誤検知する。 + re.compile(r"^\|", re.MULTILINE), + # grep / ripgrep 形式のソース引用行 (`path/to/file.ext:42: `)。 + # codex がレビュー対象のテストコード片を grep 形式で echo すると、 + # その文字列リテラル内の FATAL キーワードを誤検知する (PR #23 round 2 で発生)。 + re.compile(r"^\S+\.[A-Za-z0-9]+:\d+:", re.MULTILINE), + # warning は致命ではない + re.compile(r"^warning: ", re.IGNORECASE | re.MULTILINE), +] + + +def _match_is_quoted(line: str, match_start: int, match_end: int) -> bool: + """マッチ位置がドキュメント引用 / コード文字列リテラルに囲まれているか判定。 + + - backtick: マッチ開始までの `` ` `` カウントが奇数 かつ マッチ終了以降に `` ` `` がある + - 日本語クォート: マッチ開始までに直近の `「` が `」` よりも後 かつ マッチ終了以降に `」` がある + - ダブル/シングルクォート文字列リテラル: マッチ開始までの `"` (or `'`) カウントが + 奇数 かつ マッチ終了以降に同じクォートがある (= リテラルの内側) + + Why: SKILL.md / docs/*.md 内で FATAL キーワードを `「quota exceeded」` のように + 引用列挙しており、codex がそれを echo する。さらに tests/*.py の + `"quota exceeded: please upgrade"` のような **テスト用文字列リテラル** を + codex がレビュー中に echo するケース (PR #23 round 2 で実際に発生) もある。 + いずれも引用形であり本物のエラーではないため benign 扱いする。 + """ + before = line[:match_start] + after = line[match_end:] + if before.count("`") % 2 == 1 and "`" in after: + return True + if before.rfind("「") > before.rfind("」") and "」" in after: + return True + # コード文字列リテラル (ダブル / シングルクォート)。 + # エスケープされたクォート (`\"` / `\'`) はリテラルを開閉しないため + # パリティ計算から除外する。これを数えると、文字列内にエスケープ + # クォートを含む行で「引用内/外」の判定がずれ、本物のエラー行を + # 誤って benign 扱い (= FATAL 見逃し) する恐れがある。 + for q in ('"', "'"): + if _unescaped_count(before, q) % 2 == 1 and q in after: + return True + return False + + +def _unescaped_count(text: str, quote: str) -> int: + """`quote` のうちバックスラッシュでエスケープされていない出現数を数える。 + + 直前の連続バックスラッシュ数が奇数なら、そのクォートはエスケープ + されている (リテラルを開閉しない) ものとして除外する。 + """ + count = 0 + for i, ch in enumerate(text): + if ch != quote: + continue + backslashes = 0 + j = i - 1 + while j >= 0 and text[j] == "\\": + backslashes += 1 + j -= 1 + if backslashes % 2 == 0: + count += 1 + return count + +CODEX_SENTINEL = re.compile(r"^tokens used$", re.MULTILINE) + + +def _safe_int_env(name: str, fallback: int) -> int: + """env を safe に int parse する。 + + 非数値時は warn を stderr に出して fallback 値を返す。 + `_agent_stall_default()` と同じく、env 設定ミスでプロセスを落とさないため。 + + Note (codex round 5 指摘): `MONITOR_STALL` 等の env を import 時 / 呼び出し時に + 生の `int(...)` で読むと、非数値設定で監視プロセスが起動できなくなる。 + `DEFAULT_TIMEOUT` / `DEFAULT_POLL` も同じ問題を持つため、共通ヘルパに集約。 + """ + if name not in os.environ: + return fallback + raw = os.environ[name] + try: + return int(raw) + except (ValueError, TypeError): + print( + f"⚠ env {name}={raw!r} が int に変換できません — {fallback} を使用", + file=sys.stderr, flush=True, + ) + return fallback + + +def _agent_stall_default(agent: str) -> int: + """agent ごとの既定 stall timeout を解決する。 + + 解決順: + 1. env `MONITOR_STALL_` (per-agent 明示) + 2. env `MONITOR_STALL` (両 agent 共通) + 3. `DEFAULT_STALL_AGENT_BUILTIN[agent]` (codex=180, gemini=480) + 4. `DEFAULT_STALL` (フォールバック) + + Note (codex round 3 指摘): 2 は **呼び出し時** に `os.environ["MONITOR_STALL"]` + を再評価する。`DEFAULT_STALL` モジュール定数は import 時に env を読むため + プロセス起動後の env 変更に追随できず、テストの monkeypatch も効かない。 + + Note (gemini round 4 指摘): env が非数値だった場合 (`int(...)` で + `ValueError` / `TypeError` が裸で上がる) は warn を出して + `DEFAULT_STALL_AGENT_BUILTIN` / `DEFAULT_STALL` にフォールバックする。 + 監視プロセスを env 設定ミスでクラッシュさせない。 + """ + builtin = DEFAULT_STALL_AGENT_BUILTIN.get(agent, DEFAULT_STALL) + env_key = f"MONITOR_STALL_{agent.upper()}" + if env_key in os.environ: + return _safe_int_env(env_key, builtin) + if "MONITOR_STALL" in os.environ: + return _safe_int_env("MONITOR_STALL", builtin) + return builtin + + +def _tmp_dir() -> pathlib.Path: + """cross-review 用 tmp ディレクトリ。 + + state.py の `_tmp_dir()` と同じロジック。優先: + 1. `CROSS_REVIEW_TMP_DIR` env + 2. `/.cross_review/` (worktree 内。gemini の workspace 制約を根本回避) + + worktree root は `git rev-parse --show-toplevel` で取得する。 + サブディレクトリから起動した場合でも一貫したパスを返す。 + """ + env = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env: + d = pathlib.Path(env).resolve() + d.mkdir(parents=True, exist_ok=True) + return d + # git worktree root を取得。サブディレクトリから起動しても一貫したパスにする。 + import subprocess as _sp + r = _sp.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, + ) + if r.returncode == 0 and r.stdout.strip(): + root = pathlib.Path(r.stdout.strip()).resolve() + else: + root = pathlib.Path.cwd().resolve() + d = root / ".cross_review" + d.mkdir(parents=True, exist_ok=True) + return d + + +# ---------- データ型 ---------- + +@dataclass +class AgentPaths: + agent: str + pr: int + pidfile: pathlib.Path + err_log: pathlib.Path + stdout_log: pathlib.Path + progress_log: pathlib.Path + result: pathlib.Path + + @classmethod + def for_(cls, agent: str, pr: int) -> "AgentPaths": + base = _tmp_dir() / f"{agent}-review-pr{pr}" + return cls( + agent=agent, pr=pr, + pidfile=pathlib.Path(f"{base}.pid"), + err_log=pathlib.Path(f"{base}-err.log"), + stdout_log=pathlib.Path(f"{base}-stdout.log"), + progress_log=pathlib.Path(f"{base}-progress.log"), + result=pathlib.Path(f"{base}-result.json"), + ) + + +@dataclass +class AgentStatus: + agent: str + status: str = "RUNNING" + exit_code: int = 0 + pid: Optional[int] = None + elapsed: float = 0.0 + detail: str = "" + err_log_size: int = 0 + stdout_log_size: int = 0 + progress_log_size: int = 0 + progress_tail: str = "" + idle_seconds: float = 0.0 + result_exists: bool = False + sentinel_seen: bool = False + + +# ---------- 監視ロジック ---------- + +def _read_pidfile(p: pathlib.Path) -> Optional[int]: + try: + s = p.read_text().strip() + return int(s) if s else None + except (FileNotFoundError, ValueError): + return None + + +def _pid_alive(pid: int) -> bool: + """`kill -0` + ゾンビ検出。 + + `os.kill(pid, 0)` はゾンビプロセスに対しても成功する (PID エントリが + 残っているため)。Docker without `--init` 環境では orphan プロセスが + ゾンビ化して永久に残るため、`/proc//status` で State: Z を検出する。 + """ + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError): + return False + except OSError: + return False + try: + status_text = pathlib.Path(f"/proc/{pid}/status").read_text() + for line in status_text.splitlines(): + if line.startswith("State:"): + return "Z" not in line + except (FileNotFoundError, PermissionError, OSError): + pass + return True + + +def _is_zombie(pid: int) -> bool: + """PID がゾンビかどうか。_pid_alive() とは独立に呼べるユーティリティ。""" + try: + status_text = pathlib.Path(f"/proc/{pid}/status").read_text() + for line in status_text.splitlines(): + if line.startswith("State:"): + return "Z" in line + except (FileNotFoundError, PermissionError, OSError): + pass + return False + + +def _kill_pid(pid: int, sigterm_grace: float = 3.0) -> None: + """対象プロセスに SIGTERM、`sigterm_grace` 秒後も生きていたら SIGKILL。 + + TIMEOUT / STALLED / EARLY_ERROR で監視を打ち切るとき、対象プロセスが残ったまま + だと後から `gh api` 投稿や result.json 書き込みを実行してメインフローと + 競合する。失敗扱いで返るときは必ず停止させる。 + ゾンビプロセスにはシグナルを送れないためスキップする。 + """ + if pid <= 0: + return + if _is_zombie(pid): + return + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return + deadline = time.monotonic() + sigterm_grace + while time.monotonic() < deadline: + if not _pid_alive(pid): + return + time.sleep(0.5) + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def _pid_cmdline_matches(pid: int, expected: str) -> Optional[bool]: + """`/proc//cmdline` を読んで `expected` を含むか。 + + /proc が読めない環境では None を返す(PID 再利用チェック非対応)。 + """ + try: + cmdline = pathlib.Path(f"/proc/{pid}/cmdline").read_text() + return expected.lower() in cmdline.lower() + except (FileNotFoundError, PermissionError, OSError): + return None + + +def _scan_patterns( + path: pathlib.Path, patterns: list[re.Pattern[str]] +) -> Optional[str]: + """err.log を末尾 200KB だけ読み、`patterns` の最初の **non-benign** ヒット行を返す。 + + BENIGN フィルタは **マッチ行そのもの** に対して適用し、誤検知 (markdown 引用 / + diff body / gemini の config validation 警告) を除外する。 + + 重要 1: `pat.search()` ではなく `pat.finditer()` を使い、benign で除外された場合も + 後続の一致を継続して走査する。これにより benign な先行ヒットの後ろにある本物の + エラーを見逃さない。 + + 重要 2: benign 判定は「マッチ行」だけを対象にする。以前は前後 40 文字の文脈窓を + 使っていたが、それだと benign 行が直前にあるだけで後続の本物エラーを誤って benign + 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で + Traceback が誤抑制された)。 + """ + if not path.exists(): + return None + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > 200 * 1024: + f.seek(sz - 200 * 1024) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return None + + for pat in patterns: + for m in pat.finditer(data): + line_start = data.rfind("\n", 0, m.start()) + 1 + line_end = data.find("\n", m.end()) + line_end = line_end if line_end != -1 else len(data) + line = data[line_start:line_end] + # benign パターンはマッチ行そのものに当てる。markdown 引用や + # `Error in: mcpServers.X` のような行単位パターンは「その行」だけを + # 評価すれば判定可能で、文脈窓を広げると誤判定の原因になる。 + if any(b.search(line) for b in EARLY_ERROR_BENIGN): + continue + # マッチ部位が backtick / 日本語「」 で引用されている場合も benign。 + if _match_is_quoted(line, m.start() - line_start, m.end() - line_start): + continue + return line.strip() + return None + + +def _scan_early_fatal(path: pathlib.Path) -> Optional[str]: + return _scan_patterns(path, EARLY_ERROR_FATAL) + + +def _scan_early_warn(path: pathlib.Path) -> Optional[str]: + return _scan_patterns(path, EARLY_ERROR_WARN) + + +def _scan_codex_sentinel(path: pathlib.Path) -> bool: + if not path.exists(): + return False + try: + # 末尾 64KB を読む(sentinel は最後の方に出る) + sz = path.stat().st_size + with path.open("rb") as f: + if sz > 64 * 1024: + f.seek(sz - 64 * 1024) + tail = f.read().decode("utf-8", errors="replace") + except OSError: + return False + return bool(CODEX_SENTINEL.search(tail)) + + +def _safe_size(path: pathlib.Path) -> int: + try: + return path.stat().st_size if path.exists() else 0 + except OSError: + return 0 + + +def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: + """監視用 progress.log の末尾 1 行を安全に読む。 + + Gemini に書かせるのは短いフェーズマーカーだけなので、末尾数 KB で十分。 + 壊れた UTF-8 や読み取り競合があっても monitor 自体は落とさない。 + """ + if not path.exists(): + return "" + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > limit: + f.seek(sz - limit) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return "" + if sz > limit and "\n" in data: + data = data.split("\n", 1)[1] + for line in reversed(data.splitlines()): + stripped = line.strip() + if stripped: + return stripped[:200] + return "" + + +def monitor_agent( + agent: str, + pr: int, + timeout: int, + stall_timeout: int, + poll: int, + require_result: bool, + no_early_error: bool = False, + log_prefix: str = "", +) -> AgentStatus: + """1 agent を監視する。 + + `no_early_error=True` のとき、EARLY_ERROR 検知 (FATAL/WARN とも) を完全に無効化し、 + hard timeout / stall / sentinel / result.json のみで判定する。 + """ + paths = AgentPaths.for_(agent, pr) + status = AgentStatus(agent=agent) + started = time.monotonic() + + # 起動チェック: 30 秒待っても pidfile が無ければ起動失敗 + grace_end = started + 30 + while time.monotonic() < grace_end: + if paths.pidfile.exists(): + break + time.sleep(2) + pid = _read_pidfile(paths.pidfile) + if pid is None: + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pidfile not found: {paths.pidfile}" + _emit_log(log_prefix, agent, status) + return status + + status.pid = pid + # cmdline 検証 (PID 再利用対策) は **プロセスが生きていると確認できたときのみ** 行う。 + # 起動直後に既にプロセスが exit していると /proc/ が消えるか、別プロセスに + # 再利用されている可能性があり、ここで PIDFILE_BAD を返すと「完了している(result.json は出ている)」 + # ケースを誤って失敗にしてしまう。alive=True と確認した瞬間のみ cmdline 一致を検証する。 + + started_wall = time.time() + last_progress_size = ( + _safe_size(paths.err_log) + + _safe_size(paths.stdout_log) + + _safe_size(paths.progress_log) + ) + last_progress = time.monotonic() + cmdline_validated = False + warned_early_error = False + + while True: + elapsed = time.monotonic() - started + status.elapsed = elapsed + + # 1. プロセス生存確認 → 死んでいたら最終判定へ (result.json 存在をチェック) + alive = _pid_alive(pid) + if agent == "codex": + status.sentinel_seen = _scan_codex_sentinel(paths.err_log) + + # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status + + # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: + # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status + + if alive and not cmdline_validated: + # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと + # ファイル不在で None 扱いになり判定不能のため。 + cmdline_ok = _pid_cmdline_matches(pid, agent) + if cmdline_ok is False: + _kill_pid(pid) + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pid {pid} cmdline does not contain '{agent}' (stale pidfile?)" + _emit_log(log_prefix, agent, status) + return status + if cmdline_ok is True: + cmdline_validated = True + + # 2. hard timeout + if elapsed >= timeout: + if alive: + _kill_pid(pid) + status.status = "TIMEOUT" + status.exit_code = 2 + status.detail = f"hard timeout {timeout}s reached (pid {pid})" + _emit_log(log_prefix, agent, status) + return status + + # 3. early error + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = f"early error (fatal) in err.log: {fatal_err[:200]}" + _emit_log(log_prefix, agent, status) + return status + + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True + + if not alive: + # プロセス終了 — result.json を確認 + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status + + # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 + # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には + # launcher が要求した短いフェーズマーカーが出るため、いずれかが + # 更新されれば progress として扱う) + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = ( + status.err_log_size + status.stdout_log_size + status.progress_log_size + ) + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + if alive: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status + + # poll 中の進捗ログ + _emit_progress(log_prefix, agent, status) + time.sleep(poll) + + +def _emit_progress(prefix: str, agent: str, st: AgentStatus) -> None: + progress = f" progress={st.progress_tail!r}" if st.progress_tail else "" + print( + f"{prefix}⏳ {agent} elapsed={st.elapsed:.0f}s pid={st.pid} " + f"idle={st.idle_seconds:.0f}s " + f"err={st.err_log_size}B stdout={st.stdout_log_size}B " + f"progress_log={st.progress_log_size}B " + f"sentinel={'Y' if st.sentinel_seen else '-'}{progress}", + file=sys.stderr, flush=True, + ) + + +def _emit_log(prefix: str, agent: str, st: AgentStatus) -> None: + icon = { + "OK": "✅", "TIMEOUT": "⏰", "NO_RESULT": "❌", + "EARLY_ERROR": "💥", "STALLED": "🛑", "PIDFILE_BAD": "❓", + }.get(st.status, "?") + print( + f"{prefix}{icon} {agent} {st.status} ({st.elapsed:.0f}s) — {st.detail}", + file=sys.stderr, flush=True, + ) + + +# ---------- CLI ---------- + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("pr", type=int) + p.add_argument("target", choices=["codex", "gemini", "both"]) + # env (MONITOR_TIMEOUT / MONITOR_POLL) は呼び出し時に safe parse で読む。 + # 非数値設定でも fixed default (`DEFAULT_TIMEOUT` / `DEFAULT_POLL`) に戻す。 + timeout_default = _safe_int_env("MONITOR_TIMEOUT", DEFAULT_TIMEOUT) + poll_default = _safe_int_env("MONITOR_POLL", DEFAULT_POLL) + p.add_argument("--timeout", type=int, default=timeout_default, + help=f"hard timeout in seconds (default: {timeout_default})") + p.add_argument("--stall-timeout", type=int, default=None, + help="stall timeout (err.log no progress) in seconds. " + "未指定時は agent 別既定 (codex=180, gemini=480) または " + "env MONITOR_STALL_ / MONITOR_STALL を参照") + p.add_argument("--poll", type=int, default=poll_default, + help=f"poll interval in seconds (default: {poll_default})") + p.add_argument("--no-require-result", action="store_true", + help="プロセス終了後に result.json が無くても OK 扱い") + p.add_argument("--no-early-error", action="store_true", + default=DEFAULT_NO_EARLY_ERROR, + help="EARLY_ERROR 検知を無効化 " + "(hard timeout / stall / sentinel / result.json のみで判定) " + f"[env: MONITOR_NO_EARLY_ERROR; default: {DEFAULT_NO_EARLY_ERROR}]") + args = p.parse_args() + + agents = ["codex", "gemini"] if args.target == "both" else [args.target] + require_result = not args.no_require_result + + results: dict[str, AgentStatus] = {} + + def run(agent: str) -> None: + stall = args.stall_timeout if args.stall_timeout is not None \ + else _agent_stall_default(agent) + results[agent] = monitor_agent( + agent=agent, pr=args.pr, + timeout=args.timeout, stall_timeout=stall, + poll=args.poll, require_result=require_result, + no_early_error=args.no_early_error, + log_prefix=f"[{agent}] ", + ) + + threads = [threading.Thread(target=run, args=(a,), daemon=False) for a in agents] + for t in threads: + t.start() + for t in threads: + t.join() + + # 結果出力: 1 行 1 JSON + for agent in agents: + st = results[agent] + print(json.dumps({ + "agent": agent, + "status": st.status, + "exit_code": st.exit_code, + "pid": st.pid, + "elapsed": round(st.elapsed, 1), + "detail": st.detail, + "err_log_size": st.err_log_size, + "stdout_log_size": st.stdout_log_size, + "progress_log_size": st.progress_log_size, + "progress_tail": st.progress_tail, + "idle_seconds": round(st.idle_seconds, 1), + "result_exists": st.result_exists, + "sentinel_seen": st.sentinel_seen, + }, ensure_ascii=False)) + + # exit code: 全エージェントの最大値(OK=0 が最良、それ以外は失敗) + sys.exit(max(results[a].exit_code for a in agents)) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf-codex/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf-codex/skills/cross-review/scripts/rotate-pr.sh new file mode 100755 index 0000000..9b063f4 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/scripts/rotate-pr.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# PR rotation — light モード (default) / squash モード +# +# Usage: +# rotate-pr.sh prepare +# 旧 PR の title/body/isDraft + git log $BASE..HEAD + git diff --stat を +# $TMP_DIR/rotate-pr-prepare.json に dump する。 +# light モードでは メインセッションの Agent が prepare.json を読み、 +# 現状の差分・実装を反映した新 title/body を生成して +# $TMP_DIR/rotate-pr-newtext.json に書き出すこと。 +# +# rotate-pr.sh execute [--mode light|squash] (default: light) +# light : 同ブランチで旧 PR を close → 同 head/base で新 PR を作成。 +# title/body は newtext.json から流す。元 PR の isDraft をコピー。 +# PR title に内部用語 (rotated/round/cross-review) は付与しない。 +# squash : squash 統合した新ブランチ (-rHHMMSS suffix) + 新 PR。 +# title 末尾に "(rotated)"、body は automated text。 +# いずれも stdout に NEW_PR= / NEW_PR_URL= / NEW_BRANCH= を出力。 +# +# rotate-pr.sh (deprecated) +# 旧 1 引数形式。`execute --mode squash` 相当として動くが、stderr に +# deprecation warning を出す。新規呼び出しは prepare → execute 形式へ移行。 +# +# 引数 STATE_PR は state.json の key (= 最初に init した PR 番号)。 +# 閉じる「現在の PR」は state.json の `current_pr` を読む。 +# state.json の current_pr / pr_history 更新は `state.py set-current-pr` で別途行う。 + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=_tmpdir.sh +. "$SCRIPT_DIR/_tmpdir.sh" +TMP_DIR=$(tmpdir) + +usage() { + cat >&2 <<'USAGE' +Usage: + rotate-pr.sh prepare + rotate-pr.sh execute [--mode light|squash] (default: light) + rotate-pr.sh (deprecated, = execute --mode squash) +USAGE +} + +# close 後に新 PR create が失敗した場合の rollback hook (light/squash 共通)。 +# OLD_PR はグローバル (load_state で set される) を参照する。 +# 両モードから `trap reopen_old_pr_on_failure ERR` で登録し、create 成功直後に +# `trap - ERR` で解除する (gemini round 6 指摘: 関数定義の重複排除 + EXIT ではなく +# ERR で hook して PR 作成後の処理失敗による誤発火を避ける)。 +reopen_old_pr_on_failure() { + local exit_code=$? + echo "⚠ 新 PR 作成系処理に失敗 (exit=$exit_code) — 旧 PR #${OLD_PR:-?} を reopen します" >&2 + if [ -n "${OLD_PR:-}" ]; then + gh pr reopen "$OLD_PR" >&2 || echo "⚠ 旧 PR #$OLD_PR の reopen にも失敗しました。手動で確認してください。" >&2 + fi +} + +# state.json から共通情報を読み出して shell 変数にセットする。 +# 呼び出し後: STATE_FILE / WORKTREE / OLD_PR / ROUND_IN_PR が使える。 +load_state() { + local state_pr=$1 + STATE_FILE=$TMP_DIR/cross-review-pr$state_pr-state.json + [ -s "$STATE_FILE" ] || { echo "state.json not found: $STATE_FILE" >&2; exit 1; } + WORKTREE=$(jq -r '.worktree_path' "$STATE_FILE") + OLD_PR=$(jq -r '.current_pr' "$STATE_FILE") + ROUND_IN_PR=$(jq --argjson p "$OLD_PR" '[.rounds[] | select(.pr == $p)] | length' "$STATE_FILE") +} + +cmd_prepare() { + local state_pr=${1:?STATE_PR required} + load_state "$state_pr" + + cd "$WORKTREE" + + local pr_json + pr_json=$(gh pr view "$OLD_PR" \ + --json number,url,title,body,headRefName,baseRefName,isDraft) + local head_branch base_branch + head_branch=$(jq -r '.headRefName' <<<"$pr_json") + base_branch=$(jq -r '.baseRefName' <<<"$pr_json") + + # base が origin にあることを保証 (git log/diff のため) + if ! git fetch --quiet origin "$base_branch"; then + echo "⚠ git fetch origin $base_branch に失敗しました。ローカル参照のみで継続します。" >&2 + fi + if ! git rev-parse --verify --quiet "origin/$base_branch" >/dev/null; then + echo "⚠ origin/$base_branch が見つかりません。git_log / git_diff_stat は空になります。" >&2 + fi + + local range="origin/$base_branch..HEAD" + local git_log git_diff_stat + git_log=$(git log --pretty=format:'%h %s' "$range" 2>/dev/null || echo "") + git_diff_stat=$(git diff --stat "$range" 2>/dev/null || echo "") + + local out=$TMP_DIR/rotate-pr$state_pr-prepare.json + jq -n \ + --argjson state_pr "$state_pr" \ + --argjson pr "$pr_json" \ + --argjson round_in_pr "$ROUND_IN_PR" \ + --arg worktree "$WORKTREE" \ + --arg git_log "$git_log" \ + --arg git_diff_stat "$git_diff_stat" \ + '{ + state_pr: $state_pr, + old_pr: $pr.number, + old_pr_url: $pr.url, + worktree_path: $worktree, + head_branch: $pr.headRefName, + base_branch: $pr.baseRefName, + is_draft: $pr.isDraft, + round_in_pr: $round_in_pr, + old_title: $pr.title, + old_body: ($pr.body // ""), + git_log: $git_log, + git_diff_stat: $git_diff_stat + }' > "$out" + + echo "✅ prepare.json 書き出し: $out" >&2 + # 呼び出し側 (SKILL.md / docs) は eval で stdout を取り込む契約。 + # PR の head/base 由来の値は shell メタ文字を含み得るため必ず printf '%q' で + # シェルエスケープしてから出力する (例: ブランチ名に "; rm -rf / 等が来ても安全)。 + printf 'PREPARE_JSON=%q\n' "$out" + printf 'OLD_PR=%q\n' "$OLD_PR" + printf 'HEAD_BRANCH=%q\n' "$head_branch" + printf 'BASE_BRANCH=%q\n' "$base_branch" + printf 'IS_DRAFT=%q\n' "$(jq -r '.isDraft' <<<"$pr_json")" +} + +# light モード本体: 同ブランチで旧 PR を close → 同 head/base で新 PR 作成。 +execute_light() { + local state_pr=$1 + load_state "$state_pr" + + local prep=$TMP_DIR/rotate-pr$state_pr-prepare.json + local newtext=$TMP_DIR/rotate-pr$state_pr-newtext.json + [ -s "$prep" ] || { echo "prepare.json not found: $prep — 先に rotate-pr.sh prepare $state_pr を実行してください" >&2; exit 1; } + [ -s "$newtext" ] || { echo "newtext.json not found: $newtext — Agent (general-purpose) で title/body を生成して書き出してください" >&2; exit 1; } + + local head_branch base_branch is_draft new_title new_body + head_branch=$(jq -r '.head_branch' "$prep") + base_branch=$(jq -r '.base_branch' "$prep") + is_draft=$(jq -r '.is_draft' "$prep") + new_title=$(jq -r '.title' "$newtext") + new_body=$(jq -r '.body' "$newtext") + + [ -n "$new_title" ] && [ "$new_title" != "null" ] || { echo "newtext.json に .title がない" >&2; exit 1; } + # body は空文字列を許容 (GitHub は空 body を許容)。null のみ拒否。 + [ "$new_body" != "null" ] || { echo "newtext.json に .body がない (null)" >&2; exit 1; } + + cd "$WORKTREE" + + echo "🔄 PR #$OLD_PR rotation (light): 同ブランチ $head_branch で巻き直し (base=$base_branch)" >&2 + + # 1. 作業ディレクトリに未 push のコミットがある可能性に備え、close 前に push する。 + # push しないと新 PR に最新コミットが反映されないケースがあるため必須 (gemini 指摘)。 + # state.py init は worktree を detached HEAD で作るため、ブランチ名のみで push すると + # detached HEAD 上の修正コミットが push されない。HEAD: 形式で現在の HEAD を + # 明示する (codex 指摘)。--force / --no-verify は禁止。 + echo "🔼 git push origin HEAD:$head_branch (未 push commit が無ければ no-op)" >&2 + git push origin HEAD:"$head_branch" + + # 2. 旧 PR を close (コメント残し) + gh pr comment "$OLD_PR" --body "ℹ️ レビューコメント履歴整理のため本 PR を一度 close し、同じブランチ \`$head_branch\` で新 PR を作り直します。ブランチの内容・base は変えません。" + gh pr close "$OLD_PR" + + # close 後に create が失敗した場合は旧 PR を reopen して rotation の途中停止を回避する + # (関数定義は file 冒頭で共通化, gemini round 6 指摘) + trap reopen_old_pr_on_failure ERR + + # 3. 新 PR を同 head/base で作成 (Draft 状態は元 PR から継承)。 + # body は --body-file - 経由で stdin から渡し、argv 長制限を回避する (gemini 指摘)。 + local create_args=(--base "$base_branch" --head "$head_branch" --title "$new_title" --body-file -) + if [ "$is_draft" = "true" ]; then + create_args+=(--draft) + fi + local new_pr_url + new_pr_url=$(printf '%s' "$new_body" | gh pr create "${create_args[@]}") + + # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても + # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 + trap - ERR + + # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, + # gemini round 6 指摘)。URL 形式: https://github.com///pull/ + local new_pr=${new_pr_url##*/} + + echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 + # eval される契約。head_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape + printf 'NEW_PR=%q\n' "$new_pr" + printf 'NEW_PR_URL=%q\n' "$new_pr_url" + printf 'NEW_BRANCH=%q\n' "$head_branch" +} + +# squash モード本体。 +execute_squash() { + local state_pr=$1 + load_state "$state_pr" + + cd "$WORKTREE" + + local branch base title new_branch pr_meta prep + prep=$TMP_DIR/rotate-pr$state_pr-prepare.json + + # PR メタ情報 (base / title / head) は、まず prepare.json があればそこから読み出し、 + # 無い場合のみ gh pr view にフォールバックする (execute_light と同じ方針で + # 不要な API 呼び出しを排除, gemini round 8 指摘)。 + if [ -s "$prep" ]; then + base=$(jq -r '.base_branch // empty' "$prep") + title=$(jq -r '.old_title // empty' "$prep") + fi + if [ -z "${base:-}" ] || [ -z "${title:-}" ]; then + pr_meta=$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title) + [ -n "${base:-}" ] || base=$(printf '%s' "$pr_meta" | jq -r '.baseRefName') + [ -n "${title:-}" ] || title=$(printf '%s' "$pr_meta" | jq -r '.title') + fi + + # state.py init は worktree を `git worktree add --detach origin/` で作るため、 + # `git branch --show-current` は空文字を返す。空のまま new_branch を生成すると + # `-rHHMMSS` だけのブランチ名になってしまうので、フォールバック順を以下に固定する: + # 1. git branch --show-current (通常 worktree なら使える) + # 2. prepare.json の head_branch (prepare 済みなら最も信頼できる) + # 3. gh pr view --json headRefName (prepare 未実行でも復元可能) + # (codex round 4 指摘) + branch=$(git branch --show-current) + if [ -z "$branch" ] && [ -s "$prep" ]; then + branch=$(jq -r '.head_branch // empty' "$prep") + fi + if [ -z "$branch" ]; then + pr_meta=${pr_meta:-$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title)} + branch=$(printf '%s' "$pr_meta" | jq -r '.headRefName') + fi + [ -n "$branch" ] || { echo "head branch を復元できませんでした (detached worktree かつ prepare.json / gh pr view から取得失敗)" >&2; exit 1; } + new_branch="${branch}-r$(date +%H%M%S)" + + # 既に title 末尾に "(rotated)" / "(rotated2)" 等が付いている場合は除去してから + # "(rotated)" を 1 つだけ付与し、ローテーションのたびに suffix が重複しないようにする + # (gemini round 8 指摘)。 + # 例: + # "Fix foo" → "Fix foo (rotated)" + # "Fix foo (rotated)" → "Fix foo (rotated)" + # "Fix foo (rotated2)" → "Fix foo (rotated)" + # "Fix foo (rotated)(rotated)" → "Fix foo (rotated)" + local title_stripped=$title + while [[ $title_stripped =~ [[:space:]]*\(rotated[0-9]*\)$ ]]; do + title_stripped=${title_stripped%"${BASH_REMATCH[0]}"} + done + local new_title="$title_stripped (rotated)" + + echo "🔄 PR #$OLD_PR rotation (squash): $branch → $new_branch (base=$base)" >&2 + + # 1. 既存ブランチを squash して新ブランチに + git checkout -b "$new_branch" + git reset --soft "origin/$base" + # commit message は -m を複数指定で分割して渡す。$(cat < +EOF +) + local new_pr_url + new_pr_url=$(printf '%s' "$new_body" | gh pr create --base "$base" --title "$new_title" --body-file -) + + # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても + # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 + trap - ERR + + # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, + # gemini round 6 指摘)。URL 形式: https://github.com///pull/ + local new_pr=${new_pr_url##*/} + + echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 + # eval される契約。new_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape + printf 'NEW_PR=%q\n' "$new_pr" + printf 'NEW_PR_URL=%q\n' "$new_pr_url" + printf 'NEW_BRANCH=%q\n' "$new_branch" +} + +cmd_execute() { + local state_pr=${1:?STATE_PR required} + shift + # --mode 未指定時は light を default (SKILL.md / 02-fix-and-rotation.md / スクリプト + # 冒頭コメントの「light モード (default)」表記に CLI 契約を揃える, codex round 4 指摘) + local mode="light" + while [ $# -gt 0 ]; do + case $1 in + --mode) + mode=${2:?--mode requires light|squash} + shift 2 + ;; + --mode=*) + mode=${1#--mode=} + shift + ;; + *) + echo "unknown arg: $1" >&2 + usage + exit 2 + ;; + esac + done + case $mode in + light) execute_light "$state_pr" ;; + squash) execute_squash "$state_pr" ;; + *) echo "invalid --mode: $mode (light|squash)" >&2; exit 2 ;; + esac +} + +# ---- entrypoint ---- + +if [ $# -eq 0 ]; then + usage + exit 2 +fi + +case $1 in + prepare) + shift + cmd_prepare "$@" + ;; + execute) + shift + cmd_execute "$@" + ;; + -h|--help) + usage + ;; + *) + # 旧形式: rotate-pr.sh → squash 相当 + if [ $# -eq 1 ] && [[ $1 =~ ^[0-9]+$ ]]; then + echo "⚠ DEPRECATED: rotate-pr.sh 形式は廃止予定です。新形式に移行してください:" >&2 + echo " rotate-pr.sh prepare $1" >&2 + echo " rotate-pr.sh execute $1 --mode light|squash" >&2 + echo " (本実行は --mode squash 相当で継続します)" >&2 + execute_squash "$1" + else + usage + exit 2 + fi + ;; +esac diff --git a/plugins/ndf-codex/skills/cross-review/scripts/state.py b/plugins/ndf-codex/skills/cross-review/scripts/state.py new file mode 100755 index 0000000..9e91f11 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/scripts/state.py @@ -0,0 +1,1481 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""cross-review state.json 操作 CLI。 + +`/.cross_review/cross-review-pr-state.json` の初期化 / 読み書きと、 +ループ判定(round 開始 / 収束 / 振動 / PR ローテーション要否 / fix 結果マージ / +deferred nit レポート)を 1 つの CLI に集約する。 + +Subcommands: + init Step 0 state 初期化 or 再開(プリチェック込み) + start-round Step 1 round 開始判定 (ROUND/ROUND_IN_PR/PR を stdout に出す) + read-result Step 2.5 codex/gemini の result.json を state にマージ + judge Step 3 intent ベース pass 判定 (exit 0=approved, 2=continue) + check-oscillation Step 4 path:line 重複率を計算 + merge-fix Step 5 post fix サブエージェント戻り値を state にマージ + CI 分類 + should-rotate Step 6 rotate_after 到達判定 (exit 0=rotate, 2=keep) + set-current-pr PR ローテーション後の current_pr 更新 + report Step 8 deferred nit + ラウンドサマリ表示 + +すべての出力は人間可読 + KEY=VALUE 形式(eval / read で取り回し可能)。 +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import pathlib +import shlex +import subprocess +import sys +import tempfile +import time +from typing import Any + + +# ---------------- helpers ---------------- + +DOC_EXTENSIONS = { + ".md", ".mdx", ".rst", ".txt", ".adoc", ".asciidoc", +} +DOC_FILENAMES = { + "readme", "license", "changelog", "contributing", "codeowners", +} +CODE_EXTENSIONS = { + ".c", ".cc", ".cpp", ".cs", ".css", ".dart", ".ex", ".exs", ".go", ".h", + ".hpp", ".html", ".java", ".js", ".jsx", ".kt", ".kts", ".php", ".py", + ".rb", ".rs", ".scala", ".scss", ".sh", ".sql", ".swift", ".ts", ".tsx", + ".vue", ".yaml", ".yml", +} +MIGRATION_PATH_MARKERS = ( + "/migrations/", "/migration/", "/db/migrate/", "/database/migrations/", + "/alembic/versions/", "/prisma/migrations/", +) +MIGRATION_NAME_MARKERS = ( + "migration", "migrate", "schema.sql", "schema.prisma", +) +TEST_PATH_MARKERS = ( + "/test/", "/tests/", "/spec/", "/specs/", "__tests__/", +) +TEST_NAME_MARKERS = ( + ".test.", ".spec.", "_test.", "_spec.", "test_", "spec_", +) +DEPENDENCY_FILENAMES = { + "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", + "composer.json", "composer.lock", "gemfile", "gemfile.lock", + "go.mod", "go.sum", "requirements.txt", "requirements-dev.txt", + "pyproject.toml", "poetry.lock", "uv.lock", "cargo.toml", "cargo.lock", + "pom.xml", "build.gradle", "build.gradle.kts", +} +CI_CONFIG_MARKERS = ( + "/.github/workflows/", "/.gitlab-ci", "/.circleci/", "/.buildkite/", + "/.kiro/", "/.claude/", "/.codex/", +) +CONFIG_EXTENSIONS = { + ".json", ".toml", ".yaml", ".yml", ".ini", ".env", ".example", +} +API_CONTRACT_MARKERS = ( + "/api/", "/routes/", "/controllers/", "/openapi", "/swagger", + "/proto/", "/graphql/", "/schemas/", +) +AUTH_SECURITY_TOKEN_MARKERS = ( + "auth", "authn", "authz", "permission", "policy", "role", "oauth", "jwt", + "session", "csrf", "cors", "token", +) +AUTH_SECURITY_SUBSTRING_MARKERS = ( + "authentication", "authorization", "authenticat", "authoriz", + "secret", "password", "credential", +) +FRONTEND_EXTENSIONS = { + ".css", ".scss", ".sass", ".less", ".html", ".jsx", ".tsx", ".vue", ".svelte", +} +PERFORMANCE_MARKERS = ( + "cache", "queue", "job", "worker", "concurrent", "parallel", + "batch", "stream", "pagination", "performance", +) +GENERATED_MARKERS = ( + "/dist/", "/build/", "/generated/", "/vendor/", "/node_modules/", +) +I18N_MARKERS = ( + "/locales/", "/locale/", "/i18n/", "/translations/", +) +I18N_EXTENSIONS = {".po", ".pot"} +INFRA_MARKERS = ( + "/terraform/", "/helm/", "/k8s/", "/kubernetes/", "/docker/", + "dockerfile", "docker-compose", +) +INFRA_EXTENSIONS = {".tf", ".tfvars"} + + +COMMON_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 共通 +- PR の目的と変更範囲が一貫しているか。余分な変更、未説明の仕様変更、将来の保守を難しくする設計がないか。 +- 変更が既存仕様・既存コメント・既存コードの前提と矛盾しないか。重複指摘は避け、根拠がある修正アクションだけを指摘する。 +- テスト、検証手順、エラーハンドリング、ロールバック容易性が変更リスクに見合っているか。""" + +DOCS_ONLY_REVIEW_TEMPLATE = """## 自動追加レビュー観点: ドキュメントのみ PR +- ドキュメントの主張・企画・手順が妥当で、読者の意思決定や作業を誤らせないか。 +- コード、設定、コマンド、既存 README / docs / CHANGELOG との整合性が取れているか。古い名称、存在しないパス、実装と違う説明がないか。 +- ドキュメント間で用語、前提、バージョン、責務分担が矛盾していないか。 +- 追加・更新された説明が必要十分で、曖昧な表現や未検証の断定がないか。""" + +CODE_REVIEW_TEMPLATE = """## 自動追加レビュー観点: コード変更 PR +- 設計、正確性、可読性、保守性、単純さを確認する。不要に複雑な分岐、責務の混在、過剰な抽象化がないか。 +- 冗長・重複コード、既存ヘルパや標準 API で置き換えられる処理、言語・フレームワークらしくない実装がないか。 +- 関数・クラス・ファイルのサイズと責務が適切か。長すぎる関数、肥大化したファイル、名前と実態がずれた単位がないか。 +- セキュリティ観点として、入力検証、出力エンコード、認可、秘密情報、ログ、例外、外部コマンド、SQL/HTML/パス操作の扱いを確認する。 +- テストの有無と質、境界値、失敗系、後方互換性、性能・並行性・リソース解放のリスクを確認する。""" + +DB_MIGRATION_REVIEW_TEMPLATE = """## 自動追加レビュー観点: DB migration / schema 変更 +- データ設計としてテーブル、カラム、型、NULL 可否、default、制約、外部キー、unique/index がドメイン要件に合っているか。 +- 型の粒度が妥当か。文字列で持つべきでない値、過剰に広い型、精度不足、timezone、JSON の濫用がないか。 +- 既存データへの影響、backfill、ロック時間、index 作成、ロールバック、アプリケーションの段階的デプロイ順序に問題がないか。 +- migration とモデル、クエリ、ドキュメント、テストデータの整合性が取れているか。""" + +TEST_REVIEW_TEMPLATE = """## 自動追加レビュー観点: テスト変更 +- テストが実装詳細ではなくユーザー影響・仕様・境界値・失敗系を検証しているか。 +- flaky になりやすい時間、乱数、順序、外部サービス、並行実行、共有状態への依存がないか。 +- テスト名、fixture、期待値が読みやすく、失敗時に原因を特定しやすいか。""" + +DEPENDENCY_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 依存関係変更 +- 追加・更新された依存の必要性、ライセンス、メンテナンス状況、既存依存との重複を確認する。 +- lockfile と manifest の整合性、間接依存の大きな変化、ビルド/実行環境への影響を確認する。 +- 依存更新がセキュリティ、互換性、バンドルサイズ、起動時間に与える影響を確認する。""" + +CONFIG_CI_REVIEW_TEMPLATE = """## 自動追加レビュー観点: CI / 設定変更 +- CI 条件、権限、secret 参照、cache key、artifact、並列実行、失敗時の検知性が妥当か。 +- 設定変更がローカル・CI・本番で食い違わないか。環境変数の既定値と `.env.example` 相当の説明が揃っているか。 +- 自動化が過剰な権限や予期せぬ副作用を持たないか。""" + +API_CONTRACT_REVIEW_TEMPLATE = """## 自動追加レビュー観点: API / 契約変更 +- 入出力スキーマ、HTTP status、エラー形式、互換性、バージョニング、既存クライアントへの影響を確認する。 +- バリデーション、認可、ページング、冪等性、レート制限、監査ログが要件に合っているか。 +- API ドキュメント、型定義、テスト、実装の整合性が取れているか。""" + +AUTH_SECURITY_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 認証・認可・機密情報 +- 認証、認可、ロール、所有者チェック、テナント境界が欠落または過剰許可になっていないか。 +- token、password、secret、PII がログ、例外、レスポンス、コミット差分に漏れていないか。 +- CSRF/CORS/session/JWT/OAuth などの設定が安全で、失効・更新・リプレイ対策が妥当か。""" + +FRONTEND_REVIEW_TEMPLATE = """## 自動追加レビュー観点: フロントエンド / UX +- UI 状態、loading/error/empty、キーボード操作、アクセシビリティ、レスポンシブ表示が破綻しないか。 +- コンポーネント責務、重複 UI、状態管理、不要な再レンダリング、バンドルサイズへの影響を確認する。 +- 表示文言、フォーム validation、ユーザー操作後のフィードバックが仕様と一致しているか。""" + +PERFORMANCE_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 性能・並行性 +- N+1、不要な全件取得、過剰な同期 I/O、メモリ保持、ロック、競合、リトライ嵐がないか。 +- cache、batch、pagination、stream、queue/worker の使い方が正しく、失敗時の再実行や重複実行に耐えるか。 +- 計測・ログ・アラートが問題発生時の切り分けに足りるか。""" + +DELETION_RENAME_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 削除・リネーム +- 削除・リネーム対象への参照がコード、設定、CI、ドキュメント、テスト、外部連携に残っていないか。 +- 後方互換性、migration、deprecation、利用者への移行手順が必要ないか。 +- 同名別ファイルや大文字小文字差による環境依存の問題がないか。""" + +GENERATED_REVIEW_TEMPLATE = """## 自動追加レビュー観点: 生成物・ロックファイル +- 生成物が本当にコミット対象か。生成元との差分、再生成手順、不要なノイズが混入していないか。 +- lockfile の差分が意図した依存変更に対応しているか。手編集や不整合がないか。""" + +I18N_REVIEW_TEMPLATE = """## 自動追加レビュー観点: i18n / 文言 +- 翻訳キー、fallback、変数展開、複数形、日付・数値・通貨・タイムゾーン表記が妥当か。 +- 原文と翻訳、UI 表示幅、アクセシビリティラベル、ドキュメント文言の整合性を確認する。""" + +INFRA_REVIEW_TEMPLATE = """## 自動追加レビュー観点: インフラ / デプロイ +- 環境差分、権限、secret、ネットワーク公開範囲、永続化、バックアップ、スケール、ロールバック容易性を確認する。 +- IaC / manifest / Dockerfile の設定が最小権限・再現可能・運用監視しやすい形になっているか。 +- 既存環境への破壊的変更、手動作業、順序依存、ダウンタイムのリスクが説明されているか。""" + +def _default_worktree_base() -> pathlib.Path: + """worktree の親ディレクトリを解決する。 + + 優先順位: + 1. 環境変数 NDF_WORKTREE_BASE (明示オーバーライド) + 2. <システム tmpdir>/ndf-worktrees (非永続領域。コンテナ再作成で自動消滅) + + かつての /work/worktrees ($HOME/work/worktrees) は共有の永続 volume 上にあり、 + 別リポジトリの pr と衝突する・明示削除が必要・volume を消費する問題が + あったため廃止した。 + """ + env = os.environ.get("NDF_WORKTREE_BASE") + if env: + # 相対パスのまま state.json に保存されると後続のパス比較が壊れるため、 + # 常に絶対パスへ解決して返す。 + return pathlib.Path(env).resolve() + return pathlib.Path(tempfile.gettempdir()) / "ndf-worktrees" + + +def _repo_slug(repo: str) -> str: + """`owner/name` を path-safe なディレクトリ名 `owner--name` に変換する。""" + return repo.replace("/", "--") + + +def _is_registered_worktree(path: str) -> bool: + """path が現リポジトリに登録済みの worktree かどうか。 + + パスが存在しても別リポジトリの残骸や git 管理外ディレクトリの可能性があり、 + 流用すると git 操作が壊れるため、流用前に必ずこれで検証する。 + """ + out = _sh(["git", "worktree", "list", "--porcelain"], check=False) + target = str(pathlib.Path(path).resolve()) + return any(line == f"worktree {target}" for line in out.splitlines()) + + +def _create_worktree(worktree: str, pr: int, head_branch: str) -> None: + """origin/ から detached worktree を作成する (フォーク PR はフォールバック)。""" + pathlib.Path(worktree).parent.mkdir(parents=True, exist_ok=True) + # worktree を /tmp 等の非永続領域に置くと、実体だけ消えて親リポジトリの + # 登録 (prunable) が残ることがある。その状態で `git worktree add` すると + # 「パス登録済み」として失敗するため、追加前に prune で掃除しておく。 + subprocess.run( + ["git", "worktree", "prune"], + capture_output=True, text=True, + ) + # フォーク PR の場合 origin に head_branch がないことがある。 + # fetch 失敗時は gh pr checkout --detach でフォールバックする。 + fetch_result = subprocess.run( + ["git", "fetch", "origin", head_branch], + capture_output=True, text=True, + ) + if fetch_result.returncode == 0: + # head branch が既に別の worktree で checkout されている場合を避けるため + # detached で展開する。cross-review はファイル参照しかしないので問題ない。 + _sh(["git", "worktree", "add", "--detach", worktree, f"origin/{head_branch}"]) + info(f"✅ worktree 作成 (detached @ origin/{head_branch}): {worktree}") + else: + info(f"⚠ git fetch origin {head_branch} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") + _sh(["git", "worktree", "add", "--detach", worktree, "HEAD"]) + # worktree 内で gh pr checkout を実行して正しいコミットに切り替え + checkout_result = subprocess.run( + ["gh", "pr", "checkout", str(pr), "--detach"], + capture_output=True, text=True, + cwd=worktree, + ) + if checkout_result.returncode != 0: + # HEAD (親コミット) 指向のまま残すと、次回実行時に + # _is_registered_worktree() を通過して不正流用されるため、 + # die() の前に作成済み worktree をロールバックする。 + subprocess.run( + ["git", "worktree", "remove", "--force", worktree], + capture_output=True, text=True, + ) + die(f"gh pr checkout --detach #{pr} 失敗: {checkout_result.stderr.strip()}") + info(f"✅ worktree 作成 (gh pr checkout --detach #{pr}): {worktree}") + + +def _git_toplevel() -> str | None: + """git worktree root を取得する。失敗時は None を返す。""" + try: + r = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, + ) + if r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + except OSError: + pass + return None + + +def _tmp_dir(workspace: str | None = None) -> pathlib.Path: + """cross-review 用 tmp ディレクトリを決定する。 + + 優先順位: + 1. 環境変数 `CROSS_REVIEW_TMP_DIR` (明示) + 2. `/.cross_review/` (worktree 内。gemini の workspace 制約を根本回避) + + `workspace` 未指定なら `git rev-parse --show-toplevel` で worktree root を + 取得する。サブディレクトリから実行してもパス不一致が発生しない。 + git コマンドが失敗した場合のみ `os.getcwd()` にフォールバックする。 + """ + env = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env: + d = pathlib.Path(env).resolve() + d.mkdir(parents=True, exist_ok=True) + return d + ws = pathlib.Path(workspace or _git_toplevel() or os.getcwd()).resolve() + d = ws / ".cross_review" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _resolve_tmp_dir(pr: int | None = None) -> pathlib.Path: + """state.json に保存された tmp_dir を優先し、_tmp_dir() をフォールバックとする。 + + init 時に確定した tmp_dir を再利用することで、CWD や環境変数の変化による + パス不一致リスクを回避する。 + + 注意: この関数は成果物パスの解決にのみ使用する。state ファイル自体のパス解決 + には _tmp_dir() を直接使うこと(循環参照を防ぐため)。 + 副作用: フォールバック時に _tmp_dir() を呼ぶため、ディレクトリ作成 (mkdir) が + 発生する可能性がある。 + """ + if pr is not None: + # state.json から tmp_dir を読み出す (存在する場合) + candidate = _tmp_dir() / f"cross-review-pr{pr}-state.json" + if candidate.exists(): + try: + st = json.loads(candidate.read_text(encoding="utf-8")) + saved = st.get("tmp_dir") + if saved: + p = pathlib.Path(saved) + if p.exists(): + return p + except (OSError, json.JSONDecodeError): + pass + return _tmp_dir() + + +def _state_path(pr: int) -> pathlib.Path: + return _tmp_dir() / f"cross-review-pr{pr}-state.json" + + +def _payload_path(agent: str, pr: int, round_: int) -> pathlib.Path: + return _resolve_tmp_dir(pr) / f"{agent}-review-pr{pr}-round{round_}-payload.json" + + +def _existing_comments_path(pr: int) -> pathlib.Path: + return _resolve_tmp_dir(pr) / f"cross-review-pr{pr}-existing-comments.txt" + + +def _now() -> str: + return _dt.datetime.now(_dt.timezone.utc).astimezone().isoformat(timespec="seconds") + + +def _parse_pr_files_payload(output: str) -> list[dict[str, Any]]: + """`gh pr view --json files` の JSON を分類用の最小構造に正規化する。""" + try: + payload = json.loads(output) + except json.JSONDecodeError: + return [] + files = payload.get("files") if isinstance(payload, dict) else None + if not isinstance(files, list): + return [] + + status_map = { + "ADDED": "A", + "MODIFIED": "M", + "DELETED": "D", + "RENAMED": "R", + "COPIED": "C", + "CHANGED": "M", + } + entries: list[dict[str, Any]] = [] + for f in files: + if not isinstance(f, dict): + continue + path = f.get("path") + if not isinstance(path, str) or not path: + continue + change_type = str(f.get("changeType") or "MODIFIED").upper() + status = status_map.get(change_type, change_type[:1] or "M") + paths = [] + previous = f.get("previousPath") or f.get("previous_filename") + if isinstance(previous, str) and previous and previous != path: + paths.append(previous) + paths.append(path) + entries.append({"status": status, "paths": paths}) + return entries + + +def _parse_pr_files_api_lines(output: str) -> list[dict[str, Any]]: + """GitHub API の PR files を TSV(JSON jq) 出力から分類用構造に変換する。""" + status_map = { + "added": "A", + "modified": "M", + "removed": "D", + "renamed": "R", + "copied": "C", + "changed": "M", + } + entries: list[dict[str, Any]] = [] + for raw in output.splitlines(): + if not raw.strip(): + continue + cols = raw.split("\t") + status_raw = cols[0].strip().lower() if cols else "modified" + path = cols[1].strip() if len(cols) > 1 else "" + previous = cols[2].strip() if len(cols) > 2 else "" + if not path: + continue + paths = [] + if previous and previous != path: + paths.append(previous) + paths.append(path) + entries.append({"status": status_map.get(status_raw, status_raw[:1].upper() or "M"), "paths": paths}) + return entries + + +def _fetch_changed_files(pr: int, repo: str) -> list[dict[str, Any]]: + r = subprocess.run( + [ + "gh", "api", f"repos/{repo}/pulls/{pr}/files", + "--paginate", + "--jq", '.[] | [.status, .filename, (.previous_filename // "")] | @tsv', + ], + capture_output=True, text=True, + ) + if r.returncode == 0: + entries = _parse_pr_files_api_lines(r.stdout) + if entries: + return entries + info("⚠ PR files API の結果が空、または解析できません。gh pr view fallback を試行") + + else: + info(f"⚠ PR files API 取得に失敗。gh pr view fallback を試行: {r.stderr.strip()[:200]}") + + fallback = subprocess.run( + ["gh", "pr", "view", str(pr), "--json", "files"], + capture_output=True, text=True, + ) + if fallback.returncode != 0: + info(f"⚠ PR 変更ファイル一覧の取得に失敗。自動レビュー観点は共通のみ: {fallback.stderr.strip()[:200]}") + return [] + entries = _parse_pr_files_payload(fallback.stdout) + if not entries: + info("⚠ PR 変更ファイル一覧が空、または解析できません。自動レビュー観点は共通のみ") + return entries + + +def _path_info(path: str) -> tuple[str, str, str, str]: + p = pathlib.PurePosixPath(path.replace("\\", "/")) + lower = str(p).lower() + normalized = "/" + lower.lstrip("./") + return lower, normalized, p.name.lower(), pathlib.PurePosixPath(lower).suffix + + +def _contains_any(text: str, needles: tuple[str, ...]) -> bool: + return any(n in text for n in needles) + + +def _path_tokens(path: str) -> set[str]: + _, normalized, _, _ = _path_info(path) + return { + token + for token in normalized.replace(".", "/").replace("-", "/").replace("_", "/").split("/") + if token + } + + +def _is_doc_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + stem = pathlib.PurePosixPath(lower).stem + return ( + ext in DOC_EXTENSIONS + or stem in DOC_FILENAMES + or normalized.startswith("/docs/") + or "/docs/" in normalized + or normalized.startswith("/documentation/") + or "/documentation/" in normalized + ) + + +def _is_code_path(path: str) -> bool: + _, _, _, ext = _path_info(path) + return ext in CODE_EXTENSIONS + + +def _is_migration_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + return ( + ext == ".sql" + or _contains_any(normalized, MIGRATION_PATH_MARKERS) + or any(marker in name or marker in lower for marker in MIGRATION_NAME_MARKERS) + ) + + +def _is_test_path(path: str) -> bool: + lower, normalized, name, _ = _path_info(path) + return _contains_any(normalized, TEST_PATH_MARKERS) or any(m in name or m in lower for m in TEST_NAME_MARKERS) + + +def _is_dependency_path(path: str) -> bool: + _, _, name, _ = _path_info(path) + return name in DEPENDENCY_FILENAMES + + +def _is_config_ci_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + return ( + _contains_any(normalized, CI_CONFIG_MARKERS) + or name.startswith(".env") + or name in {"dockerfile", "makefile", ".editorconfig"} + or lower.startswith(".github/") + or (ext in CONFIG_EXTENSIONS and ("/config/" in normalized or "/configs/" in normalized)) + ) + + +def _is_api_contract_path(path: str) -> bool: + lower, normalized, _, _ = _path_info(path) + return _contains_any(normalized, API_CONTRACT_MARKERS) or "openapi" in lower or "swagger" in lower + + +def _is_auth_security_path(path: str) -> bool: + lower, _, _, _ = _path_info(path) + tokens = _path_tokens(path) + return ( + bool(tokens.intersection(AUTH_SECURITY_TOKEN_MARKERS)) + or any(marker in lower for marker in AUTH_SECURITY_SUBSTRING_MARKERS) + ) + + +def _is_frontend_path(path: str) -> bool: + _, normalized, _, ext = _path_info(path) + return ext in FRONTEND_EXTENSIONS or "/components/" in normalized or "/pages/" in normalized + + +def _is_performance_path(path: str) -> bool: + return bool(_path_tokens(path).intersection(PERFORMANCE_MARKERS)) + + +def _is_generated_path(path: str) -> bool: + _, normalized, name, _ = _path_info(path) + return _contains_any(normalized, GENERATED_MARKERS) or name in { + "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "composer.lock", + "gemfile.lock", "go.sum", "poetry.lock", "uv.lock", "cargo.lock", + } + + +def _is_i18n_path(path: str) -> bool: + _, normalized, _, ext = _path_info(path) + return _contains_any(normalized, I18N_MARKERS) or ext in I18N_EXTENSIONS + + +def _is_infra_path(path: str) -> bool: + lower, normalized, name, ext = _path_info(path) + return ( + _contains_any(normalized, INFRA_MARKERS) + or name in {"dockerfile", "docker-compose.yml", "docker-compose.yaml"} + or ext in INFRA_EXTENSIONS + or lower.endswith(".tfvars.json") + ) + + +PATH_CATEGORY_RULES = ( + ("code", _is_code_path), + ("db_migration", _is_migration_path), + ("test", _is_test_path), + ("dependency", _is_dependency_path), + ("config_ci", _is_config_ci_path), + ("api_contract", _is_api_contract_path), + ("auth_security", _is_auth_security_path), + ("frontend", _is_frontend_path), + ("performance", _is_performance_path), + ("generated", _is_generated_path), + ("i18n", _is_i18n_path), + ("infra", _is_infra_path), +) + + +def _classify_changed_files(entries: list[dict[str, Any]]) -> list[str]: + paths = [p for entry in entries for p in entry.get("paths", []) if isinstance(p, str)] + categories: list[str] = ["common"] + if not paths: + return categories + + if paths and all(_is_doc_path(p) for p in paths): + categories.append("docs_only") + + categories.extend( + category + for category, predicate in PATH_CATEGORY_RULES + if any(predicate(path) for path in paths) + ) + if any(str(entry.get("status", "")).startswith(("D", "R")) for entry in entries): + categories.append("deletion_rename") + + # rename の旧パスだけで検知したカテゴリが混ざるのは有用だが、docs_only は + # 旧パス/新パス両方が docs であるときだけ採用するため上で all(paths) にしている。 + return list(dict.fromkeys(categories)) + + +def _auto_review_instructions(categories: list[str]) -> str: + templates = { + "common": COMMON_REVIEW_TEMPLATE, + "docs_only": DOCS_ONLY_REVIEW_TEMPLATE, + "code": CODE_REVIEW_TEMPLATE, + "db_migration": DB_MIGRATION_REVIEW_TEMPLATE, + "test": TEST_REVIEW_TEMPLATE, + "dependency": DEPENDENCY_REVIEW_TEMPLATE, + "config_ci": CONFIG_CI_REVIEW_TEMPLATE, + "api_contract": API_CONTRACT_REVIEW_TEMPLATE, + "auth_security": AUTH_SECURITY_REVIEW_TEMPLATE, + "frontend": FRONTEND_REVIEW_TEMPLATE, + "performance": PERFORMANCE_REVIEW_TEMPLATE, + "deletion_rename": DELETION_RENAME_REVIEW_TEMPLATE, + "generated": GENERATED_REVIEW_TEMPLATE, + "i18n": I18N_REVIEW_TEMPLATE, + "infra": INFRA_REVIEW_TEMPLATE, + } + return "\n\n".join(templates[c] for c in categories if c in templates) + + +def _combined_review_instructions(auto: str, manual: str) -> str: + return "\n\n".join(part for part in (auto.strip(), manual.strip()) if part) + + +def _extra_review_instructions(args: argparse.Namespace) -> str: + """cross-review launcher に渡す追加レビュー観点を組み立てる。 + + `--focus` は短い観点を直接渡す用途、`--extra-instructions-file` は長めの + チェックリストを渡す用途。両方指定された場合は順に連結する。 + """ + parts: list[str] = [] + focus = getattr(args, "focus", None) + if focus and str(focus).strip(): + parts.append(str(focus).strip()) + + extra_file = getattr(args, "extra_instructions_file", None) + if extra_file: + path = pathlib.Path(extra_file) + try: + text = path.read_text(encoding="utf-8").strip() + except OSError as exc: + die(f"追加レビュー観点ファイルを読めません: {path} ({exc})") + if text: + parts.append(text) + + return "\n\n".join(parts) + + +def _round_started_unixtime(round_entry: dict[str, Any]) -> float | None: + """``round.started_at`` (ISO 8601) を UNIX time (秒) に変換する。 + + fix 戻り値ファイルの mtime と比較して、round 開始前に書かれた古い + ファイルを fallback から除外するために使う。 + パース失敗時は None を返し、呼び出し側で「検証スキップ」を選ばせる。 + """ + started = round_entry.get("started_at") + if not started: + return None + try: + return _dt.datetime.fromisoformat(started).timestamp() + except (TypeError, ValueError): + return None + + +def _is_fresh_fix_result( + path: pathlib.Path, + pr: int, + round_started_ts: float | None, + is_canonical: bool = False, +) -> tuple[bool, dict[str, Any] | None]: + """fallback 候補の fix 戻り値ファイルを採用してよいか判定する。 + + 検証項目: + 1. ファイル mtime が `round_started_ts` 以降であること + (round 開始前に作られた = 古い実行 / 別リポジトリの同番号 PR の残骸) + 2. JSON 内に `pr` フィールドがある場合は対象 PR と一致すること + (`pr` フィールドが無い場合は 1 のみで判定) + + Returns: + ``(is_fresh, parsed_payload)`` のタプル。``is_fresh=True`` の場合のみ + ``parsed_payload`` (dict) が返る。呼び出し側はこれを使って再パースを省略できる。 + + 挙動: + - 古い候補・`pr` 不一致は警告を stderr に出して ``(False, None)`` を返し、 + 呼び出し側で次の候補へ進む。 + - `is_canonical=True` (= ``$TMP_DIR/fix-pr-result.json`` 正規パス) で + **読み取り失敗 (OSError / JSONDecodeError)** が発生した場合のみ + 即時 ``die(code=3)`` する (codex round 2 指摘: 正規パスが壊れているのに + 後続候補へ流れて別 PR の戻り値を誤マージする事故を防ぐ)。 + 正規パスでも `pr` 不一致 / stale mtime は fallback 継続対象とする。 + """ + # 1. mtime チェック + if round_started_ts is not None: + try: + mtime = path.stat().st_mtime + except OSError as exc: + info(f"⚠ fallback 候補 stat 失敗 ({path}): {exc} — skip") + return False, None + if mtime < round_started_ts: + info( + f"⚠ fallback 候補が round 開始前の古いファイル ({path}, " + f"mtime={_dt.datetime.fromtimestamp(mtime).isoformat(timespec='seconds')} " + f"< round_started={_dt.datetime.fromtimestamp(round_started_ts).isoformat(timespec='seconds')}) " + "— skip" + ) + return False, None + + # 2. JSON 内 `pr` フィールドの一致 (任意) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + if is_canonical: + die( + f"正規パスの fix 戻り値ファイルの読み取りに失敗 ({path}): {exc}。" + " 後続 fallback への流れ込みを防ぐため即時中断。", + code=3, + ) + info(f"⚠ fallback 候補 JSON 解析失敗 ({path}): {exc} — skip") + return False, None + # gemini round 3 指摘: `json.loads` は dict 以外 (list 等) も返す。 + # 後続の `payload.get(...)` や cmd_merge_fix 側の `.get()` でクラッシュしないよう、 + # dict でない場合は warn を出して fallback 不採用 ((False, None)) として扱う。 + # + # codex round 4 指摘: ただし `is_canonical=True` (= 正規パス) で非 dict が返った + # 場合は parse 失敗と同じく即時 die(code=3) する。skip で後続 `/tmp/` fallback に + # 流れると、壊れた正規出力を無視して別実行の戻り値を誤マージする経路が残るため。 + if not isinstance(payload, dict): + if is_canonical: + die( + f"正規パスの fix 戻り値ファイルが dict ではない " + f"({path}, type={type(payload).__name__})。" + " 後続 fallback への流れ込みを防ぐため即時中断。", + code=3, + ) + info( + f"⚠ fallback 候補 JSON が dict ではない ({path}, type={type(payload).__name__}) " + "— skip" + ) + return False, None + file_pr = payload.get("pr") + if file_pr is not None: + try: + file_pr_int = int(file_pr) + except (TypeError, ValueError): + info( + f"⚠ fallback 候補の pr フィールドが数値として解釈できない " + f"({path}, file_pr={file_pr!r}) — skip" + ) + return False, None + if file_pr_int != int(pr): + info( + f"⚠ fallback 候補の pr 不一致 ({path}, file_pr={file_pr} != pr={pr}) " + "— 別 PR の戻り値の可能性。skip" + ) + return False, None + return True, payload + + +def _load(pr: int) -> dict[str, Any]: + p = _state_path(pr) + if not p.exists(): + die(f"state.json not found: {p}") + return json.loads(p.read_text(encoding="utf-8")) + + +def _save(pr: int, state: dict[str, Any]) -> None: + p = _state_path(pr) + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + tmp.replace(p) + + +def _sh(cmd: list[str], check: bool = True) -> str: + r = subprocess.run(cmd, capture_output=True, text=True) + if check and r.returncode != 0: + die(f"command failed ({' '.join(cmd)}): {r.stderr.strip()}") + return r.stdout.strip() + + +def die(msg: str, code: int = 1) -> None: + print(f"❌ {msg}", file=sys.stderr) + sys.exit(code) + + +def info(msg: str) -> None: + print(msg, file=sys.stderr) + + +# ---------------- subcommands ---------------- + +def cmd_init(args: argparse.Namespace) -> None: + """Step 0 — state 初期化 or 既存 state 引き継ぎ + プリチェック。""" + pr = args.pr + manual_extra_review = _extra_review_instructions(args) + # worktree path を先に解決してから tmp_dir を決定する。 + # tmp_dir は /.cross_review/ に配置し、gemini の workspace 制約を根本回避。 + # path には repo slug を含め、他リポジトリの同一 PR 番号と衝突しないようにする。 + repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) + worktree = str(pathlib.Path(args.worktree).resolve()) if args.worktree else str( + _default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + + # worktree 存在チェック用: _tmp_dir() は mkdir するため、先に呼ぶと + # worktree ディレクトリが副作用で作成され exists() が常に true になる。 + # そのため _tmp_dir() 呼び出しは worktree 作成/確認の後に行う。 + + # 再開チェック: CROSS_REVIEW_TMP_DIR が設定されている場合はそちらを優先し、 + # 未設定なら /.cross_review/ を直接パスとして組む。 + # _tmp_dir() は mkdir 副作用があるため使用せず、パス解決のみ行う。 + env_tmp = os.environ.get("CROSS_REVIEW_TMP_DIR") + if env_tmp: + resume_dir = pathlib.Path(env_tmp).resolve() + else: + resume_dir = pathlib.Path(worktree) / ".cross_review" + resume_state_file = resume_dir / f"cross-review-pr{pr}-state.json" + if resume_state_file.exists(): + st = json.loads(resume_state_file.read_text(encoding="utf-8")) + if st.get("final") is None: + state_changed = False + if "auto_review_instructions" not in st: + changed_files = _fetch_changed_files(pr, st.get("repo") or repo) + categories = _classify_changed_files(changed_files) + st["changed_files"] = changed_files + st["auto_review_categories"] = categories + st["auto_review_instructions"] = _auto_review_instructions(categories) + state_changed = True + if manual_extra_review: + st["manual_extra_review_instructions"] = manual_extra_review + # 後方互換: 旧 key も manual 指示として保持する。 + st["extra_review_instructions"] = manual_extra_review + state_changed = True + manual = st.get("manual_extra_review_instructions") or st.get("extra_review_instructions") or "" + combined = _combined_review_instructions( + st.get("auto_review_instructions") or "", + manual, + ) + if st.get("review_instructions") != combined: + st["review_instructions"] = combined + state_changed = True + if state_changed: + resume_state_file.write_text( + json.dumps(st, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + info("↻ 追加レビュー観点を state に反映して再開") + tmp_dir = _tmp_dir(worktree) + wt = st.get("worktree_path") or "" + info(f"↻ 前回中断 state から再開(round={len(st.get('rounds', []))})") + print(f'PR={st["current_pr"]}') + print(f'WORKTREE={shlex.quote(str(wt))}') + print(f'TMP_DIR={shlex.quote(str(tmp_dir))}') + print(f'REPO={shlex.quote(str(st.get("repo") or ""))}') + print(f'HEAD_BRANCH={shlex.quote(str(st.get("head_branch") or ""))}') + print(f'BASE_BRANCH={shlex.quote(str(st.get("base_branch") or ""))}') + print(f"IS_OWN_PR={'1' if st.get('is_own_pr') else '0'}") + print(f"EVENT_DOWNGRADE={'1' if st.get('event_downgrade') else '0'}") + print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if st.get('review_instructions') else '0'}") + print(f"RESUMED=1") + return + + # 新規 init: プリチェック + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = _sh(["gh", "pr", "view", str(pr), "--json", "author", "--jq", ".author.login"]) + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + head_branch = _sh(["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"]) + base_branch = _sh(["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"]) + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") + + state = { + "started_at": _now(), + "max_rounds": args.max_rounds, + "rotate_after": args.rotate_after, + "only": args.only, + "current_pr": pr, + "worktree_path": worktree, + "tmp_dir": str(tmp_dir), + "repo": repo, + "head_branch": head_branch, + "base_branch": base_branch, + "pr_author": author, + "is_own_pr": is_own, + "event_downgrade": event_downgrade, + "changed_files": changed_files, + "auto_review_categories": auto_review_categories, + "auto_review_instructions": auto_review, + "manual_extra_review_instructions": manual_extra_review, + # 後方互換: 旧 key は manual 指示を保持する。 + "extra_review_instructions": manual_extra_review, + "review_instructions": review_instructions, + "pr_history": [{"pr": pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "final": None, + } + state_file.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + info(f"✅ state 初期化: {state_file}") + print(f"PR={pr}") + print(f'WORKTREE={shlex.quote(str(worktree))}') + print(f'TMP_DIR={shlex.quote(str(tmp_dir))}') + print(f'REPO={shlex.quote(str(repo))}') + print(f'HEAD_BRANCH={shlex.quote(str(head_branch))}') + print(f'BASE_BRANCH={shlex.quote(str(base_branch))}') + print(f"IS_OWN_PR={'1' if is_own else '0'}") + print(f"EVENT_DOWNGRADE={'1' if event_downgrade else '0'}") + print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if review_instructions else '0'}") + print("RESUMED=0") + + +def cmd_start_round(args: argparse.Namespace) -> None: + """Step 1 — round 開始判定。""" + st = _load(args.pr) + total = len(st["rounds"]) + max_r = st["max_rounds"] + if total >= max_r: + st["final"] = "max_rounds" + st["ended_at"] = _now() + _save(args.pr, st) + die(f"max_rounds={max_r} 到達。中断。", code=1) + + pr = st["current_pr"] + round_no = total + 1 + round_in_pr = sum(1 for r in st["rounds"] if r["pr"] == pr) + 1 + + # round エントリを開く + st["rounds"].append({ + "round": round_no, + "pr": pr, + "started_at": _now(), + }) + _save(args.pr, st) + + info(f"=== Round {round_no} / {max_r} (PR #{pr}, round_in_pr={round_in_pr}) ===") + print(f"ROUND={round_no}") + print(f"ROUND_IN_PR={round_in_pr}") + print(f"PR={pr}") + print(f"MAX_ROUNDS={max_r}") + print(f"ROTATE_AFTER={st['rotate_after']}") + + +def cmd_read_result(args: argparse.Namespace) -> None: + """Step 2.5 — codex/gemini の result.json を state にマージ。""" + agent = args.agent + pr = args.pr + rfile = pathlib.Path(args.file or _resolve_tmp_dir(pr) / f"{agent}-review-pr{pr}-result.json") + if not rfile.exists() or rfile.stat().st_size == 0: + die(f"{agent}: result 未生成 ({rfile})") + + try: + r = json.loads(rfile.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + die(f"{agent}: result.json の parse に失敗 ({rfile}): {exc}", code=3) + + # gemini round 4 指摘: result.json は本来 dict だが、launcher の出力バグや + # 別実行の残骸で list / str が入り込むと `r.get(...)` で AttributeError になる。 + # 不正な review result はバグなので即時 die(code=3) で停止させる。 + if not isinstance(r, dict): + die( + f"{agent}: result.json が dict ではない " + f"({rfile}, type={type(r).__name__})。review launcher の出力形式不正。", + code=3, + ) + + # 別名フィールドへのフォールバック (gemini が `intent` / `comment_count` を使う変則 JSON を + # 書き出す既知のケースに対応する。仕様としては `event` / `comments_count` が正) + intent = r.get("event") or r.get("intent") + posted_as = r.get("posted_as") or intent + comments = r.get("comments_count") + if comments is None: + comments = r.get("comment_count") + + if intent is None: + die( + f"{agent}: result.json に event / intent フィールドが無い ({rfile})。" + " launcher prompt のスキーマ違反の可能性。" + ) + + st = _load(pr) + if not st.get("rounds"): + die(f"{agent}: state.rounds が空。`state.py start-round` を先に呼んでください") + st["rounds"][-1][agent] = { + "intent": intent, + "posted_as": posted_as, + "comments": comments, + "review_url": r.get("review_url"), + "by_severity": r.get("by_severity", {}), + } + _save(pr, st) + info(f"✅ {agent}: intent={intent} posted_as={posted_as} comments={comments}") + + +def cmd_judge(args: argparse.Namespace) -> None: + """Step 3 — intent ベース pass 判定。 + + Exit code: 0=approved, 2=continue, 1=error + """ + pr = args.pr + st = _load(pr) + if not st.get("rounds"): + die("state.rounds が空。`state.py start-round` を先に呼んでください") + last = st["rounds"][-1] + only = st.get("only") + + def is_pass(intent: str | None, severity: dict[str, int] | None) -> bool: + if intent in ("APPROVE", "SKIP"): + return True + if intent == "COMMENT": + sev = severity or {} + return (sev.get("critical", 0) == 0 and sev.get("major", 0) == 0) + return False + + codex_intent = (last.get("codex") or {}).get("intent", "SKIP") + gemini_intent = (last.get("gemini") or {}).get("intent", "SKIP") + codex_sev = (last.get("codex") or {}).get("by_severity") + gemini_sev = (last.get("gemini") or {}).get("by_severity") + + codex_pass = (only == "gemini") or is_pass(codex_intent, codex_sev) + gemini_pass = (only == "codex") or is_pass(gemini_intent, gemini_sev) + + print(f"CODEX_INTENT={codex_intent}") + print(f"GEMINI_INTENT={gemini_intent}") + + if codex_pass and gemini_pass: + st["final"] = "approved" + st["ended_at"] = _now() + _save(pr, st) + info("✅ 両方 APPROVE。収束。") + sys.exit(0) + + info(f"→ codex={codex_intent} gemini={gemini_intent}。修正へ。") + sys.exit(2) + + +def cmd_check_oscillation(args: argparse.Namespace) -> None: + """Step 4 — path:line 重複率を計算。 + + 前ラウンドと現ラウンドで重複が 50% 以上なら final=oscillation で中断。 + rotation 直後は round_in_pr<2 なのでスキップ。 + """ + pr = args.pr + st = _load(pr) + rounds = st["rounds"] + current_pr = st["current_pr"] + same_pr = [r for r in rounds if r["pr"] == current_pr] + if len(same_pr) < 2: + info("⏭ round_in_pr<2: 振動検知スキップ") + sys.exit(2) # continue + + prev_round_no = same_pr[-2]["round"] + curr_round_no = same_pr[-1]["round"] + + def collect_keys(round_no: int) -> set[str]: + keys: set[str] = set() + for agent in ("codex", "gemini"): + p = _payload_path(agent, pr, round_no) + if not p.exists(): + continue + try: + payload = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + # gemini round 4 指摘: payload は本来 dict (comments: [...]) だが、 + # launcher のバグで list / str が入り込むと `payload.get(...)` で + # AttributeError になる。不正な review payload はバグなので + # 即時 die(code=3) で停止させる。 + if not isinstance(payload, dict): + die( + f"{agent}: payload.json が dict ではない " + f"({p}, type={type(payload).__name__})。" + " review launcher の出力形式不正。", + code=3, + ) + for c in payload.get("comments", []): + if not isinstance(c, dict): + # comments エントリが dict でない場合も同様に致命扱い + die( + f"{agent}: payload.comments のエントリが dict ではない " + f"({p}, type={type(c).__name__})。", + code=3, + ) + path = c.get("path") + line = c.get("line") or c.get("start_line") + if path and line is not None: + keys.add(f"{path}:{line}") + return keys + + prev = collect_keys(prev_round_no) + curr = collect_keys(curr_round_no) + if not curr: + info("⏭ 現ラウンドの payload なし: 振動検知スキップ") + sys.exit(2) + overlap = prev & curr + ratio = len(overlap) / len(curr) + info(f"振動検知: overlap={len(overlap)}/{len(curr)} ({ratio:.0%})") + + if ratio >= 0.5: + st["final"] = "oscillation" + st["ended_at"] = _now() + _save(pr, st) + die(f"振動検知 — 同一箇所が {ratio:.0%} 重複。中断。", code=4) + sys.exit(2) + + +def _count(v: Any) -> int: + """int(件数) でも list でも None でも件数(int)に正規化する。 + + fix 結果スキーマ上 deferred/rejected/resolved_threads は list が正だが、 + fix サブエージェントが int(件数) を書いてしまうケースがあり、その場合に + len() が `TypeError: object of type 'int' has no len()` で落ちるのを防ぐ。 + """ + if isinstance(v, bool): + # bool は int のサブクラスだが件数として扱わない + return 0 + if isinstance(v, int): + return v + if isinstance(v, dict): + # LLM が単一要素を list ではなく dict 単体で返すケースを 1 件として扱う。 + return 1 + if isinstance(v, (list, tuple)): + return len(v) + if isinstance(v, str) and v.strip().isdigit(): + # LLM が件数を数値文字列 (例: "3" や " 3 ") で返すケースを許容する。 + # strip 後 isdigit() なので前後空白を許し、負号・小数点は引き続き弾く + # (件数は非負整数なので十分)。 + return int(v.strip()) + return 0 + + +def cmd_merge_fix(args: argparse.Namespace) -> None: + """Step 5 後段 — fix サブエージェント戻り値を state にマージ + CI 分類。 + + Exit code: 0=continue, 3=ci-code-fail (final=error) + """ + pr = args.pr + + # state を先に読み、fallback 検証用の round 開始時刻を取得する + # (round 開始前の古いファイルや、別リポジトリの同番号 PR の戻り値を + # 誤マージするのを防ぐ)。 + st = _load(pr) + if not st.get("rounds"): + die("state.rounds が空。`state.py start-round` を先に呼んでください", code=3) + round_started_ts = _round_started_unixtime(st["rounds"][-1]) + + # 戻り値ファイルの探索順: + # 1. --file 明示 (ユーザー指定なので mtime/pr 検証はスキップ) + # 2. $TMP_DIR/fix-pr-result.json (正規; _tmp_dir() 解決先) + # 3. /tmp/fix-pr-result.json (旧プロンプトで /tmp を指定したサブエージェント救済) + # 2, 3 は PR 番号だけで命名されているため、別 round / 別リポジトリの + # 古い結果を拾わないよう mtime と (あれば) JSON 内の `pr` で検証する。 + explicit = pathlib.Path(args.file) if args.file else None + canonical_path = _resolve_tmp_dir(pr) / f"fix-pr{pr}-result.json" + legacy_tmp_path = pathlib.Path(f"/tmp/fix-pr{pr}-result.json") + # (path, is_canonical) のタプル: 正規パス (canonical) の parse 失敗は die(code=3) する + fallback_candidates: list[tuple[pathlib.Path, bool]] = [ + (canonical_path, True), + (legacy_tmp_path, False), + ] + + ffile: pathlib.Path | None = None + fix: dict[str, Any] | None = None + if explicit is not None: + # codex round 4 指摘: `--file` 明示時は fallback 探索に進まず即時失敗させる。 + # ユーザーが特定ファイルを指定しているのに、それが存在しない / 空 / JSON 不正 + # だった場合、無言で fallback に流れて別実行の戻り値を誤マージすると事故になる。 + if not explicit.exists(): + die( + f"--file で指定されたパスが存在しません: {explicit}", + code=3, + ) + if explicit.stat().st_size == 0: + die( + f"--file で指定されたファイルが空です: {explicit}", + code=3, + ) + try: + fix = json.loads(explicit.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + die( + f"--file 指定の fix 戻り値ファイルの読み取り / parse に失敗 " + f"({explicit}): {exc}", + code=3, + ) + # gemini round 3 指摘: `--file` で `list` 等の non-dict JSON が渡されると + # 後続の `fix.get(...)` でクラッシュする。即時 die(code=3) で中断。 + if not isinstance(fix, dict): + die( + f"--file 指定の fix 戻り値ファイルが dict ではない " + f"({explicit}, type={type(fix).__name__})。" + " fix サブエージェント出力の形式不正。", + code=3, + ) + ffile = explicit + # 明示指定は stale 検証スキップ + else: + for c, is_canonical in fallback_candidates: + if not (c.exists() and c.stat().st_size > 0): + continue + is_fresh, parsed = _is_fresh_fix_result(c, pr, round_started_ts, is_canonical=is_canonical) + if not is_fresh: + continue + ffile = c + fix = parsed # 既にパース済みのデータを再利用 (gemini round 2 指摘の性能改善) + break + + if ffile is None or fix is None: + checked = ([str(explicit)] if explicit else []) + [str(c) for c, _ in fallback_candidates] + die( + "fix サブエージェントが戻り値ファイルを生成しなかった " + f"(checked: {checked})", + code=3, + ) + + # key 名 fallback (サブエージェントが別名で書いた場合の救済)。 + # 正規は fix_commit / fixed_count、別名は commit_sha / fixed のみ受理する。 + fix_commit = fix.get("fix_commit") or fix.get("commit_sha") + fixed_count = fix.get("fixed_count") + if fixed_count is None: + fixed_count = fix.get("fixed", 0) + + # `st` は冒頭の fallback 検証で既に load 済み。 + round_no = st["rounds"][-1]["round"] + + # deferred は list が正だが、LLM がスキーマを無視して文字列リスト + # (例: ["nit: ..."]) や単一 dict、int(件数) を返すケースがある。後段の + # deferred_nits 展開ループは dict 以外をスキップするため、まず dict 要素のみへ + # 正規化する (単一 dict は 1 件として包む)。 + _deferred_raw = fix.get("deferred") + if isinstance(_deferred_raw, list): + _deferred_nits = [d for d in _deferred_raw if isinstance(d, dict)] + elif isinstance(_deferred_raw, dict): # 単一 dict フォールバック (gemini #3) + _deferred_nits = [_deferred_raw] + else: + _deferred_nits = [] + + # 保存件数の単一整合ルール: + # - 構造化データ (list / dict) は per-item を保持できるので、展開件数 + # (len(_deferred_nits)) を保存し deferred_nits の件数と一致させる。 + # - int / 数値文字列は per-item データを失った「劣化表現」なので、件数を + # 失わないよう _count() の値を保存する (展開はできないので nits は空)。 + if isinstance(_deferred_raw, (list, dict)): + _deferred_count = len(_deferred_nits) + else: + _deferred_count = _count(_deferred_raw) + + st["rounds"][-1]["fix"] = { + "commit": fix_commit, + "fixed": fixed_count, + # deferred は上記の単一整合ルールで算出した件数を保存する。 + # resolved_threads / rejected は件数しか保存せず後段ループが無いため _count() で可。 + "deferred": _deferred_count, + "rejected": _count(fix.get("rejected")), + "resolved_threads": _count(fix.get("resolved_threads")), + "ci": fix.get("ci_status"), + "ci_failed_checks": fix.get("ci_failed_checks", []) or [], + "ci_note": fix.get("ci_note"), + "by_severity": fix.get("by_severity", {}), + } + st["rounds"][-1]["ended_at"] = _now() + for d in _deferred_nits: + st["deferred_nits"].append({**d, "pr": pr, "round": round_no}) + _save(pr, st) + + # CI 分類 + if (fix.get("ci_status") or "").upper() != "FAILURE": + info(f"✅ fix マージ完了 (commit={fix_commit} fixed={fixed_count})") + return + + code_patterns = ("pint", "larastan", "phpstan", "test", "lint", "type", + "build", "ruff", "eslint", "tsc", "mypy") + meta_patterns = ("check_pr_requirements", "assignees", "reviewers", "labels", "meta") + failed = fix.get("ci_failed_checks") or [] + code_fail = False + for name in failed: + low = name.lower() + if any(p in low for p in meta_patterns): + continue + elif any(p in low for p in code_patterns): + code_fail = True + else: + code_fail = True # 不明は code-fail(保守的) + + if code_fail: + st["final"] = "error" + st["ended_at"] = _now() + _save(pr, st) + die(f"コード関連 CI 失敗。中断: {failed}", code=3) + + # meta only: 継続 + note = f"メタチェックのみ失敗: {failed} — コードと無関係のため継続" + st["rounds"][-1]["fix"]["ci_note"] = note + _save(pr, st) + info(f"⚠ メタチェックのみ失敗 ({failed}) — 継続") + + +def cmd_should_rotate(args: argparse.Namespace) -> None: + """Step 6 — PR ローテーション要否。Exit 0=rotate, 2=keep. + + 判定は ``round_in_pr >= rotate_after && total < max_rounds`` のみで、 + rotate-pr.sh の ``--mode light|squash`` どちらでも同じ条件を使う。 + state.json の key は ``STATE_PR`` (最初に init した PR 番号) で固定なので、 + light モードで head_branch が変わらない場合でも整合する。 + """ + pr = args.pr + st = _load(pr) + current_pr = st["current_pr"] + round_in_pr = sum(1 for r in st["rounds"] if r["pr"] == current_pr) + total = len(st["rounds"]) + rotate_after = st["rotate_after"] + max_r = st["max_rounds"] + if round_in_pr >= rotate_after and total < max_r: + info(f"🔄 PR #{current_pr} が {round_in_pr} round 経過 — ローテーション必要") + print(f"CURRENT_PR={current_pr}") + print(f"ROUND_IN_PR={round_in_pr}") + sys.exit(0) + sys.exit(2) + + +def cmd_set_current_pr(args: argparse.Namespace) -> None: + """PR ローテーション完了後の state 更新。 + + rotate-pr.sh の light / squash どちらでも、新 PR 番号を受け取って + ``current_pr`` を切り替え、``pr_history`` に新 PR エントリを追加する。 + state.json のファイル名は ``STATE_PR`` (= ``args.pr``) ベースで不変なので、 + light モードで head_branch が変わらないケースでも問題なく追跡できる。 + """ + pr = args.pr # 旧 PR (state file の key) + new_pr = args.new_pr + st = _load(pr) + old_pr = st["current_pr"] + now = _now() + # 旧 PR の history を closed に + for h in st["pr_history"]: + if h["pr"] == old_pr and h["closed_at"] is None: + h["closed_at"] = now + h["rounds"] = sum(1 for r in st["rounds"] if r["pr"] == old_pr) + break + st["pr_history"].append({"pr": new_pr, "opened_at": now, "closed_at": None, "rounds": 0}) + st["current_pr"] = new_pr + _save(pr, st) + info(f"✅ current_pr: {old_pr} → {new_pr}") + + +def cmd_report(args: argparse.Namespace) -> None: + """Step 8 — deferred nit + ラウンドサマリ表示。""" + pr = args.pr + st = _load(pr) + final = st.get("final") or "in_progress" + total = len(st["rounds"]) + prs = [h["pr"] for h in st["pr_history"]] + rotated = max(0, len(prs) - 1) + + print(f"## 最終ステータス: {final}") + print(f"## 総ラウンド数: {total} / PR数: {len(prs)} (rotated {rotated} 回)") + print() + print("## PR 履歴") + for h in st["pr_history"]: + state_str = "closed" if h.get("closed_at") else "open" + print(f"- #{h['pr']} ({state_str}, {h.get('rounds', 0)} rounds)") + print() + print("## ラウンドサマリ") + print("| round | PR | codex | gemini | fix | CI |") + print("|---|---|---|---|---|---|") + for r in st["rounds"]: + codex = r.get("codex") or {} + gemini = r.get("gemini") or {} + fix = r.get("fix") or {} + codex_s = f"{codex.get('intent', '-')} ({codex.get('comments', '-')})" if codex else "-" + gemini_s = f"{gemini.get('intent', '-')} ({gemini.get('comments', '-')})" if gemini else "-" + fix_s = "-" + if fix: + fix_s = f"{(fix.get('commit') or '')[:7]} ({fix.get('fixed', 0)} fixed, {fix.get('deferred', 0)} deferred)" + ci_s = fix.get("ci") or "-" + print(f"| {r['round']} | #{r['pr']} | {codex_s} | {gemini_s} | {fix_s} | {ci_s} |") + print() + + nits = st.get("deferred_nits") or [] + if nits: + print(f"## 残 deferred nit ({len(nits)} 件)") + for n in nits: + print(f"- [{n.get('severity')}] {n.get('path')}:{n.get('line')} — {n.get('summary')}") + print() + print("これらの nit を一括対応する場合は再度 `/ndf:fix ` を起動してください。") + else: + print("## 残 deferred nit: なし") + + +# ---------------- main ---------------- + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("init", help="Step 0 — state 初期化 or 再開") + sp.add_argument("pr", type=int) + sp.add_argument("--max-rounds", type=int, default=12) + sp.add_argument("--rotate-after", type=int, default=8) + sp.add_argument("--only", choices=["codex", "gemini"], default=None) + sp.add_argument("--worktree", default=None) + sp.add_argument( + "--focus", + default=None, + help="追加レビュー観点。例: ドキュメントとコードの整合性を重点的に確認", + ) + sp.add_argument( + "--extra-instructions-file", + default=None, + help="追加レビュー観点を記載した UTF-8 テキストファイル", + ) + sp.set_defaults(func=cmd_init) + + sp = sub.add_parser("start-round", help="Step 1 — round 開始判定") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_start_round) + + sp = sub.add_parser("read-result", help="Step 2.5 — review result を state にマージ") + sp.add_argument("pr", type=int) + sp.add_argument("agent", choices=["codex", "gemini"]) + sp.add_argument("--file", default=None) + sp.set_defaults(func=cmd_read_result) + + sp = sub.add_parser("judge", help="Step 3 — intent ベース pass 判定 (0=approved/2=continue)") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_judge) + + sp = sub.add_parser("check-oscillation", help="Step 4 — path:line 重複率を計算") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_check_oscillation) + + sp = sub.add_parser("merge-fix", help="Step 5 post — fix 戻り値マージ + CI 分類") + sp.add_argument("pr", type=int) + sp.add_argument("--file", default=None) + sp.set_defaults(func=cmd_merge_fix) + + sp = sub.add_parser("should-rotate", help="Step 6 — rotate 要否 (0=rotate/2=keep)") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_should_rotate) + + sp = sub.add_parser("set-current-pr", help="rotation 後の current_pr 更新") + sp.add_argument("pr", type=int, help="state file の元 PR") + sp.add_argument("new_pr", type=int) + sp.set_defaults(func=cmd_set_current_pr) + + sp = sub.add_parser("report", help="Step 8 — deferred nit + サマリ表示") + sp.add_argument("pr", type=int) + sp.set_defaults(func=cmd_report) + + args = p.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf-codex/skills/cross-review/scripts/wait-review.sh b/plugins/ndf-codex/skills/cross-review/scripts/wait-review.sh new file mode 100755 index 0000000..8741d36 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/scripts/wait-review.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Wait for codex / gemini review processes — monitor.py の薄いラッパ。 +# +# Usage: wait-review.sh [codex|gemini|both] [--timeout SEC] [--stall-timeout SEC] +# +# 既定値: +# timeout 1800s (= 30 min) env MONITOR_TIMEOUT で上書き +# stall-timeout 600s (= 10 min) env MONITOR_STALL で上書き +# poll 15s env MONITOR_POLL で上書き +# +# Exit codes は monitor.py に準拠: +# 0 OK +# 1 USAGE / IO error +# 2 TIMEOUT +# 3 NO_RESULT (プロセス終了したが result.json 未生成) +# 4 EARLY_ERROR (err.log に致命的パターン) +# 5 STALLED (err.log 進捗なし) +# 6 PIDFILE_BAD (pidfile 不正 / プロセス未起動) +# +# 旧 wait_codex / wait_gemini (sentinel + pidfile のみ) は信頼性が低かったため +# Python 側に多軸監視を集約した。本ラッパは既存呼び出し互換のために残す。 + +set -euo pipefail + +PR=${1:?PR required} +TARGET=${2:-both} +shift 2 || shift $# + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec "$SCRIPT_DIR/monitor.py" "$PR" "$TARGET" "$@" diff --git a/plugins/ndf-codex/skills/cross-review/tests/__init__.py b/plugins/ndf-codex/skills/cross-review/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ndf-codex/skills/cross-review/tests/conftest.py b/plugins/ndf-codex/skills/cross-review/tests/conftest.py new file mode 100644 index 0000000..a5b257b --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/conftest.py @@ -0,0 +1,47 @@ +"""pytest 共通フィクスチャ。 + +`scripts/state.py` は uv self-contained script として `#!/usr/bin/env -S uv run --script` +で起動される運用だが、テストでは関数を直接 import したい。 +importlib.util で source loader 経由で読み込む。 +""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import types + +_HERE = pathlib.Path(__file__).resolve().parent +_SCRIPT = _HERE.parent / "scripts" / "state.py" +_MONITOR = _HERE.parent / "scripts" / "monitor.py" + + +def _load_module(name: str, path: pathlib.Path) -> types.ModuleType: + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"failed to load {path}") + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def _load_state_module() -> types.ModuleType: + return _load_module("cross_review_state", _SCRIPT) + + +def _load_monitor_module() -> types.ModuleType: + return _load_module("cross_review_monitor", _MONITOR) + + +import pytest + + +@pytest.fixture(scope="session") +def state_mod() -> types.ModuleType: + return _load_state_module() + + +@pytest.fixture(scope="session") +def monitor_mod() -> types.ModuleType: + return _load_monitor_module() diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_default_worktree_base.py b/plugins/ndf-codex/skills/cross-review/tests/test_default_worktree_base.py new file mode 100644 index 0000000..b4d38da --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_default_worktree_base.py @@ -0,0 +1,41 @@ +"""state.py `_default_worktree_base()` / `_repo_slug()` の解決テスト。 + +優先順位: + 1. 環境変数 NDF_WORKTREE_BASE + 2. <システム tmpdir>/ndf-worktrees (非永続領域) + +worktree path は `/--/pr` 形式で、他リポジトリの +同一 PR 番号と衝突しない。 +""" +from __future__ import annotations + +import pathlib + + +def test_env_override_takes_precedence(monkeypatch, tmp_path, state_mod): + explicit = tmp_path / "custom-base" + monkeypatch.setenv("NDF_WORKTREE_BASE", str(explicit)) + assert state_mod._default_worktree_base() == explicit + + +def test_default_is_tmpdir_ndf_worktrees(monkeypatch, tmp_path, state_mod): + """既定では永続 volume ではなくシステム tmpdir 配下を使う。""" + monkeypatch.delenv("NDF_WORKTREE_BASE", raising=False) + monkeypatch.setattr(state_mod.tempfile, "gettempdir", lambda: str(tmp_path)) + assert state_mod._default_worktree_base() == tmp_path / "ndf-worktrees" + + +def test_repo_slug_is_path_safe(state_mod): + assert state_mod._repo_slug("devbasex/ai-plugins") == "devbasex--ai-plugins" + + +def test_is_registered_worktree_rejects_foreign_dir(monkeypatch, tmp_path, state_mod): + """`git worktree list` に載っていないパスは流用しない (別リポジトリの残骸対策)。""" + registered = tmp_path / "registered" + foreign = tmp_path / "foreign" + monkeypatch.setattr( + state_mod, "_sh", + lambda cmd, check=True: f"worktree {registered}\nHEAD abc\n", + ) + assert state_mod._is_registered_worktree(str(registered)) is True + assert state_mod._is_registered_worktree(str(foreign)) is False diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_monitor_early_error.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_early_error.py new file mode 100644 index 0000000..a7e3f6b --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_early_error.py @@ -0,0 +1,126 @@ +"""monitor.py `_scan_early_fatal()` の誤検知防止テスト。 + +回帰: SKILL.md / docs/*.md 内の以下のような行は **fatal 検知してはいけない**。 +- Markdown 表セル (`| ... | quota exceeded ... |`) +- backtick で囲まれた quote (`` `quota exceeded` ``) +- 日本語クォートで囲まれた quote (`「quota exceeded」`) +- ダブル/シングルクォートで囲まれた **文字列リテラル** (`"quota exceeded: ..."`) +- grep / ripgrep 形式の **ソース引用行** (`path/to/file.py:22: "quota exceeded..."`) +codex がレビュー対象の doc / テストコードを echo した際に上記が err.log に書かれることで、 +従来の monitor.py は無関係なドキュメント・コード記述を fatal とみなしてプロセスを kill していた。 +""" +from __future__ import annotations + +import pathlib + + +def _write(p: pathlib.Path, content: str) -> pathlib.Path: + p.write_text(content, encoding="utf-8") + return p + + +def test_real_quota_exceeded_is_detected(tmp_path, monitor_mod): + """行頭の本物 `quota exceeded` は依然として fatal 扱い。""" + log = _write(tmp_path / "err.log", "quota exceeded: please upgrade\n") + assert monitor_mod._scan_early_fatal(log) is not None + + +def test_markdown_table_row_is_benign(tmp_path, monitor_mod): + """Markdown 表セル行内のキーワードは doc 引用として無視。""" + log = _write( + tmp_path / "err.log", + "| early-error | `^Error:` / 「quota exceeded」「sandbox error」を含む行 |\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_backtick_quoted_keyword_is_benign(tmp_path, monitor_mod): + """backtick 引用内のキーワードは doc 引用として無視。""" + log = _write( + tmp_path / "err.log", + "explanation of `quota exceeded` pattern handling here\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_japanese_quote_wrapped_keyword_is_benign(tmp_path, monitor_mod): + """日本語「」内のキーワードは doc 引用として無視。""" + log = _write( + tmp_path / "err.log", + "「quota exceeded」を含む行を検出 (diff/doc 引用文中の同語句は誤検知しない)\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_sandbox_error_in_table_is_benign(tmp_path, monitor_mod): + """表セル内の `sandbox error` も無視。""" + log = _write( + tmp_path / "err.log", + "| pattern | codex 固有: 「sandbox error」を含む行を検出 |\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_real_sandbox_error_still_detected(tmp_path, monitor_mod): + """素の `sandbox error` は依然として fatal 扱い。""" + log = _write(tmp_path / "err.log", "Internal sandbox error: cannot start\n") + assert monitor_mod._scan_early_fatal(log) is not None + + +def test_grep_style_source_citation_is_benign(tmp_path, monitor_mod): + """codex がレビュー対象のテストコードを grep 形式 (`path.py:22: code`) で + echo した行は fatal 扱いしない。 + + 回帰: 本 skill 自身の tests/test_monitor_early_error.py を cross-review した際、 + `quota exceeded` を含むテスト用文字列リテラル行が echo され、誤って + EARLY_ERROR で codex が kill された (PR #23 round 2 で実際に発生)。 + """ + log = _write( + tmp_path / "err.log", + '/work/worktrees/pr23/plugins/ndf/skills/cross-review/tests/' + 'test_monitor_early_error.py:22: log = _write(tmp_path / "err.log", ' + '"quota exceeded: please upgrade\\n")\n', + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_double_quoted_string_literal_is_benign(tmp_path, monitor_mod): + """ダブルクォート文字列リテラル内のキーワードは benign(コード片の echo)。""" + log = _write( + tmp_path / "err.log", + ' raise RuntimeError("quota exceeded: please upgrade")\n', + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_single_quoted_string_literal_is_benign(tmp_path, monitor_mod): + """シングルクォート文字列リテラル内のキーワードも benign。""" + log = _write( + tmp_path / "err.log", + " msg = 'sandbox error occurred in test fixture'\n", + ) + assert monitor_mod._scan_early_fatal(log) is None + + +def test_match_is_quoted_double_quote(monitor_mod): + """`_match_is_quoted()` がダブルクォート文字列リテラルを検出する。""" + line = 'raise RuntimeError("quota exceeded: please upgrade")' + start = line.index("quota") + end = start + len("quota exceeded") + assert monitor_mod._match_is_quoted(line, start, end) + + +def test_match_is_quoted_helper(monitor_mod): + """`_match_is_quoted()` 単体テスト。""" + line_backtick = "see `quota exceeded` doc" + start = line_backtick.index("quota") + end = start + len("quota exceeded") + assert monitor_mod._match_is_quoted(line_backtick, start, end) + + line_jp = "「quota exceeded」と書かれた行" + start = line_jp.index("quota") + end = start + len("quota exceeded") + assert monitor_mod._match_is_quoted(line_jp, start, end) + + line_raw = "quota exceeded happened" + assert not monitor_mod._match_is_quoted(line_raw, 0, len("quota exceeded")) diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_monitor_import_safety.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_import_safety.py new file mode 100644 index 0000000..36efc48 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_import_safety.py @@ -0,0 +1,105 @@ +"""monitor.py の import 時 env 評価 safety テスト (PLAN21 round 6 / 最終). + +codex round 5 指摘: `DEFAULT_TIMEOUT` / `DEFAULT_STALL` / `DEFAULT_POLL` を +`int(os.environ.get(name, ...))` で **import 時** に評価していると、 +`MONITOR_STALL=abc` のような非数値 env が設定されているだけで +`_agent_stall_default()` に到達する前に `int()` の `ValueError` で +monitor.py の import そのものが落ち、監視プロセスが起動できなくなる。 + +修正方針: + - `DEFAULT_TIMEOUT` / `DEFAULT_STALL` / `DEFAULT_POLL` は import 時に + **固定数値** (420 / 180 / 15) で保持する + - env の解釈は `_agent_stall_default()` / `_safe_int_env()` 内で + try/except 付きで行う (既に round 4 で実装済み) + +本テストは fixture 経由ではなく **毎回 fresh に source loader で import** し、 +非数値 env が設定された状態でも import が成功することを確認する。 +""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import types + +import pytest + + +_HERE = pathlib.Path(__file__).resolve().parent +_MONITOR = _HERE.parent / "scripts" / "monitor.py" + + +def _fresh_import_monitor(name: str = "cross_review_monitor_fresh") -> types.ModuleType: + """毎回 fresh に monitor.py を import する (sys.modules キャッシュを使わない)。 + + session-scoped fixture (`monitor_mod`) は最初の import 時の env を保持してしまい + 本テストでは「非数値 env がセットされた状態での import が安全か」を検証できない。 + """ + sys.modules.pop(name, None) + spec = importlib.util.spec_from_file_location(name, _MONITOR) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def test_import_succeeds_with_non_numeric_monitor_stall(monkeypatch): + """MONITOR_STALL=abc でも monitor.py の import が成功すること (codex round 5).""" + monkeypatch.setenv("MONITOR_STALL", "abc") + mod = _fresh_import_monitor("cross_review_monitor_fresh_stall") + # 固定 default が保たれる + assert mod.DEFAULT_STALL == 180 + # 呼び出し時の解決も builtin にフォールバック (既存 round 4 挙動と同じ) + assert mod._agent_stall_default("codex") == 180 + assert mod._agent_stall_default("gemini") == 480 + + +def test_import_succeeds_with_non_numeric_monitor_timeout(monkeypatch): + """MONITOR_TIMEOUT=xxx でも monitor.py の import が成功すること.""" + monkeypatch.setenv("MONITOR_TIMEOUT", "xxx") + mod = _fresh_import_monitor("cross_review_monitor_fresh_timeout") + # 固定 default が保たれる + assert mod.DEFAULT_TIMEOUT == 420 + # safe parse ヘルパも fallback を返す + assert mod._safe_int_env("MONITOR_TIMEOUT", mod.DEFAULT_TIMEOUT) == 420 + + +def test_import_succeeds_with_non_numeric_monitor_poll(monkeypatch): + """MONITOR_POLL=foo でも monitor.py の import が成功すること.""" + monkeypatch.setenv("MONITOR_POLL", "foo") + mod = _fresh_import_monitor("cross_review_monitor_fresh_poll") + assert mod.DEFAULT_POLL == 15 + assert mod._safe_int_env("MONITOR_POLL", mod.DEFAULT_POLL) == 15 + + +def test_import_succeeds_with_all_non_numeric_envs(monkeypatch): + """3 つ全て非数値でも import に失敗しないこと (組み合わせ防御).""" + monkeypatch.setenv("MONITOR_STALL", "abc") + monkeypatch.setenv("MONITOR_TIMEOUT", "xxx") + monkeypatch.setenv("MONITOR_POLL", "???") + mod = _fresh_import_monitor("cross_review_monitor_fresh_all") + assert mod.DEFAULT_STALL == 180 + assert mod.DEFAULT_TIMEOUT == 420 + assert mod.DEFAULT_POLL == 15 + + +def test_safe_int_env_returns_value_when_numeric(monkeypatch, monitor_mod): + """数値が設定されている場合は素直に int を返す.""" + monkeypatch.setenv("SOME_TEST_INT_ENV", "42") + assert monitor_mod._safe_int_env("SOME_TEST_INT_ENV", 1) == 42 + + +def test_safe_int_env_returns_fallback_when_missing(monkeypatch, monitor_mod): + """env が未設定なら fallback を返す.""" + monkeypatch.delenv("SOME_TEST_INT_ENV", raising=False) + assert monitor_mod._safe_int_env("SOME_TEST_INT_ENV", 99) == 99 + + +def test_safe_int_env_warns_on_non_numeric(monkeypatch, monitor_mod, capsys): + """非数値時は stderr に警告を出して fallback を返す.""" + monkeypatch.setenv("SOME_TEST_INT_ENV", "not-a-number") + assert monitor_mod._safe_int_env("SOME_TEST_INT_ENV", 7) == 7 + captured = capsys.readouterr() + assert "SOME_TEST_INT_ENV" in captured.err + assert "int に変換できません" in captured.err diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_monitor_progress_log.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_progress_log.py new file mode 100644 index 0000000..7b98015 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_progress_log.py @@ -0,0 +1,23 @@ +"""monitor.py の progress.log heartbeat 補助関数テスト。""" + + +def test_tail_last_nonempty_line_returns_latest_marker(tmp_path, monitor_mod): + log = tmp_path / "gemini-review-pr1-progress.log" + log.write_text("start: review PR #1\n\nscan: diff\n \npost: submit review\n") + + assert monitor_mod._tail_last_nonempty_line(log) == "post: submit review" + + +def test_tail_last_nonempty_line_handles_missing_file(tmp_path, monitor_mod): + assert monitor_mod._tail_last_nonempty_line(tmp_path / "missing.log") == "" + + +def test_safe_size_handles_missing_file(tmp_path, monitor_mod): + assert monitor_mod._safe_size(tmp_path / "missing.log") == 0 + + +def test_tail_last_nonempty_line_drops_partial_first_line(tmp_path, monitor_mod): + log = tmp_path / "gemini-review-pr1-progress.log" + log.write_text("partial line without beginning\npost: submit review\n", encoding="utf-8") + + assert monitor_mod._tail_last_nonempty_line(log, limit=28) == "post: submit review" diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_monitor_result_age.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_result_age.py new file mode 100644 index 0000000..f3e2a28 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_result_age.py @@ -0,0 +1,330 @@ +"""monitor.py の result.json + age fallback (RESULT_AGE_GRACE) テスト。 + +gemini がレビュー完了後にプロセスがハングするケースで、result.json の +mtime が RESULT_AGE_GRACE 秒以上前なら OK 判定する fallback の検証。 +""" +from __future__ import annotations + +import json +import os +import pathlib +import time +from unittest import mock + + +def test_result_age_grace_constant(monitor_mod): + assert hasattr(monitor_mod, "RESULT_AGE_GRACE") + assert monitor_mod.RESULT_AGE_GRACE == 30 + + +def test_gemini_result_age_fallback_triggers_ok(monitor_mod, tmp_path): + """result.json が RESULT_AGE_GRACE 秒以上前に書かれていれば OK で返る。""" + pr = 999 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12345") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + # time.time() を side_effect で進める: + # call 1 (started_wall): base_time + # call 2+ (result age check): base_time + 40 + # result_mtime = base_time + 5 → started_wall(base_time) 以降かつ age=35s >= 30s + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_call_count = [0] + def fake_time(): + time_call_count[0] += 1 + if time_call_count[0] == 1: + return base_time + return base_time + 40 + + kill_called = [] + poll_count = [0] + + def fake_pid_alive(pid): + return True + + def fake_kill_pid(pid, sigterm_grace=3.0): + kill_called.append(pid) + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 5: + raise RuntimeError("too many polls — fallback should have triggered") + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=fake_kill_pid), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "result.json exists for" in st.detail + assert 12345 in kill_called + + +def test_gemini_result_age_too_young_continues(monitor_mod, tmp_path): + """result.json が書かれたばかり (age < RESULT_AGE_GRACE) なら fallback しない。""" + pr = 998 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12346") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + # mtime を 5 秒前に設定 (RESULT_AGE_GRACE=30 未満) + recent_mtime = time.time() - 5 + os.utime(result, (recent_mtime, recent_mtime)) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # result.json は young なので fallback せず、プロセス終了後の通常 OK になる + assert st.status == "OK" + assert st.exit_code == 0 + assert "process exited" in st.detail + + +def test_fresh_but_young_result_continues_monitoring(monitor_mod, tmp_path): + """started_wall 後に書かれたが age < RESULT_AGE_GRACE なら fallback せず監視継続。""" + pr = 994 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12350") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + + # time.time() side_effect: + # call 1 (started_wall): base_time + # call 2+ (age check): base_time + 10 → age = 5s < 30s + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_call_count = [0] + def fake_time(): + time_call_count[0] += 1 + if time_call_count[0] == 1: + return base_time + return base_time + 10 # age = 10 - 5 = 5s < 30s + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # age < 30s なので fallback せず、プロセス終了後の通常 OK + assert st.status == "OK" + assert "process exited" in st.detail + + +def test_codex_sentinel_takes_priority(monitor_mod, tmp_path): + """codex は sentinel + result.json が先に発火し、age fallback は通らない。""" + pr = 997 + agent = "codex" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12347") + err_log.write_text("some output\ntokens used\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + old_mtime = time.time() - 60 + os.utime(result, (old_mtime, old_mtime)) + + kill_called = [] + + def fake_pid_alive(pid): + return True + + def fake_kill_pid(pid, sigterm_grace=3.0): + kill_called.append(pid) + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=fake_kill_pid), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + # codex sentinel チェックによる OK + assert "codex sentinel" in st.detail + assert 12347 in kill_called + + +def test_stale_result_json_from_previous_round_ignored(monitor_mod, tmp_path): + """前 round の古い result.json (mtime < started_wall) は fallback しない。""" + pr = 996 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12348") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + # mtime を「現在の 120 秒前」に設定 → started_wall より前 → stale + stale_mtime = time.time() - 120 + os.utime(result, (stale_mtime, stale_mtime)) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # stale result.json なので fallback せず、プロセス終了後の通常 OK + assert st.status == "OK" + assert "process exited" in st.detail + + +def test_cmdline_not_validated_skips_fallback(monitor_mod, tmp_path): + """cmdline 未検証 (None) だと age fallback は発火しない。""" + pr = 995 + agent = "gemini" + base = tmp_path / f"{agent}-review-pr{pr}" + + pidfile = pathlib.Path(f"{base}.pid") + err_log = pathlib.Path(f"{base}-err.log") + stdout_log = pathlib.Path(f"{base}-stdout.log") + result = pathlib.Path(f"{base}-result.json") + + pidfile.write_text("12349") + err_log.write_text("YOLO mode is enabled.\n") + stdout_log.write_text("") + result.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 2: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + # cmdline_matches が None → cmdline_validated は False のまま + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent=agent, pr=pr, + timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + # cmdline 未検証なので fallback せず、プロセス終了後の通常 OK + assert st.status == "OK" + assert "process exited" in st.detail diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_stall_default.py new file mode 100644 index 0000000..77a4f6b --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_stall_default.py @@ -0,0 +1,119 @@ +"""monitor.py の per-agent stall timeout 既定値テスト (PLAN21). + +`_agent_stall_default(agent)` の解決優先度: + 1. env `MONITOR_STALL_` (per-agent 明示) + 2. env `MONITOR_STALL` (両 agent 共通) + 3. `DEFAULT_STALL_AGENT_BUILTIN[agent]` (codex=180, gemini=480) + +gemini は err.log にほぼ進捗を出さないため、ビルトイン既定を 480s と大きめに +取って 1 度目の STALLED 誤検知を避ける。codex は従来通り 180s で変更なし。 +""" +from __future__ import annotations + +import pytest + + +def test_builtin_default_codex(monkeypatch, monitor_mod): + """codex のビルトイン既定は 180s。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + assert monitor_mod._agent_stall_default("codex") == 180 + + +def test_builtin_default_gemini(monkeypatch, monitor_mod): + """gemini のビルトイン既定は 480s (codex より大きい)。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + assert monitor_mod._agent_stall_default("gemini") == 480 + + +def test_per_agent_env_overrides_builtin(monkeypatch, monitor_mod): + """env `MONITOR_STALL_GEMINI` 設定で gemini 既定が上書きされる。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.setenv("MONITOR_STALL_GEMINI", "600") + assert monitor_mod._agent_stall_default("gemini") == 600 + # codex は影響を受けない + assert monitor_mod._agent_stall_default("codex") == 180 + + +def test_shared_env_applies_to_both(monkeypatch, monitor_mod): + """env `MONITOR_STALL` 共通指定は両 agent に同じ値が適用される (旧挙動互換)。 + + codex round 3 指摘: `_agent_stall_default()` は呼び出し時に env を再評価する + (import 時固定の `DEFAULT_STALL` ではなく `int(os.environ["MONITOR_STALL"])` を返す)。 + 本テストは monkeypatch で `MONITOR_STALL=240` に書き換え、両 agent が 240 を + 返すことを確認する (= 共通 env が実際に反映されることの検証)。 + """ + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + monkeypatch.setenv("MONITOR_STALL", "240") + # 共通 env が両 agent に効く (per-agent 上書きなしの場合) + assert monitor_mod._agent_stall_default("codex") == 240 + assert monitor_mod._agent_stall_default("gemini") == 240 + + +def test_per_agent_env_takes_precedence_over_shared(monkeypatch, monitor_mod): + """per-agent env > 共通 env の優先順位を確認する。""" + monkeypatch.setenv("MONITOR_STALL", "240") + monkeypatch.setenv("MONITOR_STALL_GEMINI", "777") + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + assert monitor_mod._agent_stall_default("gemini") == 777 + # codex 側は per-agent env が無いので 共通 env (= 240) にフォールバック + assert monitor_mod._agent_stall_default("codex") == 240 + + +def test_unknown_agent_falls_back_to_default_stall(monkeypatch, monitor_mod): + """ビルトインに無い agent 名は `DEFAULT_STALL` にフォールバックする。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.delenv("MONITOR_STALL_UNKNOWN", raising=False) + assert monitor_mod._agent_stall_default("unknown") == monitor_mod.DEFAULT_STALL + + +# ---------------- PLAN21 round 5: env が非数値だった場合のフォールバック ---------------- + + +def test_shared_env_non_numeric_falls_back_to_builtin(monkeypatch, monitor_mod, capsys): + """env `MONITOR_STALL` が非数値なら builtin にフォールバック (ValueError で落ちない)。 + + gemini round 4 指摘: `int(os.environ[...])` は非数値で ValueError を出す。 + 監視プロセスを env 設定ミスでクラッシュさせないため、try/except で builtin に戻す。 + """ + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + monkeypatch.delenv("MONITOR_STALL_GEMINI", raising=False) + monkeypatch.setenv("MONITOR_STALL", "abc") + # codex / gemini とも builtin 既定 (180 / 480) に戻る + assert monitor_mod._agent_stall_default("codex") == 180 + assert monitor_mod._agent_stall_default("gemini") == 480 + captured = capsys.readouterr() + # 警告メッセージが stderr に出る + assert "MONITOR_STALL" in captured.err + assert "int に変換できません" in captured.err + + +def test_per_agent_env_non_numeric_falls_back_to_builtin( + monkeypatch, monitor_mod, capsys +): + """env `MONITOR_STALL_` が非数値なら builtin にフォールバック。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.setenv("MONITOR_STALL_GEMINI", "not-a-number") + monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) + # gemini は builtin (480) にフォールバック + assert monitor_mod._agent_stall_default("gemini") == 480 + # codex は env 未設定なので builtin (180) + assert monitor_mod._agent_stall_default("codex") == 180 + captured = capsys.readouterr() + assert "MONITOR_STALL_GEMINI" in captured.err + assert "int に変換できません" in captured.err + + +def test_per_agent_env_non_numeric_does_not_affect_other_agent( + monkeypatch, monitor_mod +): + """non-numeric な per-agent env は対象 agent だけに影響する。""" + monkeypatch.delenv("MONITOR_STALL", raising=False) + monkeypatch.setenv("MONITOR_STALL_GEMINI", "xxx") + monkeypatch.setenv("MONITOR_STALL_CODEX", "200") # codex 側は正常 + assert monitor_mod._agent_stall_default("codex") == 200 + assert monitor_mod._agent_stall_default("gemini") == 480 diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_monitor_zombie.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_zombie.py new file mode 100644 index 0000000..c15d2f4 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_zombie.py @@ -0,0 +1,91 @@ +"""monitor.py のゾンビプロセス検出テスト。 + +Docker without --init 環境では、nohup/disown で起動したプロセスが終了後に +ゾンビ化する。`_pid_alive()` が /proc//status の State: Z を検出して +False を返すことを検証する。 +""" +from __future__ import annotations + +import pathlib +from unittest import mock + + +def test_pid_alive_returns_false_for_zombie(monitor_mod): + """State: Z のプロセスに対して _pid_alive() が False を返す。""" + proc_status = "Name:\tbash\nState:\tZ (zombie)\nTgid:\t12345\nPid:\t12345\n" + + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", return_value=proc_status + ), + ): + assert monitor_mod._pid_alive(12345) is False + + +def test_pid_alive_returns_true_for_running(monitor_mod): + """State: S (sleeping) のプロセスに対して True を返す。""" + proc_status = "Name:\tgemini\nState:\tS (sleeping)\nTgid:\t12345\nPid:\t12345\n" + + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", return_value=proc_status + ), + ): + assert monitor_mod._pid_alive(12345) is True + + +def test_pid_alive_returns_true_for_running_state_r(monitor_mod): + """State: R (running) のプロセスに対して True を返す。""" + proc_status = "Name:\tgemini\nState:\tR (running)\nTgid:\t12345\nPid:\t12345\n" + + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", return_value=proc_status + ), + ): + assert monitor_mod._pid_alive(12345) is True + + +def test_pid_alive_returns_false_for_dead_process(monitor_mod): + """kill -0 が ProcessLookupError を返すプロセスに対して False。""" + with mock.patch("os.kill", side_effect=ProcessLookupError): + assert monitor_mod._pid_alive(99999) is False + + +def test_pid_alive_proc_unreadable_falls_back_to_alive(monitor_mod): + """/proc//status が読めない場合 (non-Linux) は kill -0 の結果のみで True。""" + with ( + mock.patch("os.kill", return_value=None), + mock.patch.object( + pathlib.Path, "read_text", side_effect=FileNotFoundError + ), + ): + assert monitor_mod._pid_alive(12345) is True + + +def test_is_zombie_helper(monitor_mod): + """_is_zombie() ヘルパーの動作確認。""" + zombie_status = "Name:\tbash\nState:\tZ (zombie)\nTgid:\t1\nPid:\t1\n" + alive_status = "Name:\tgemini\nState:\tS (sleeping)\nTgid:\t1\nPid:\t1\n" + + with mock.patch.object(pathlib.Path, "read_text", return_value=zombie_status): + assert monitor_mod._is_zombie(1) is True + + with mock.patch.object(pathlib.Path, "read_text", return_value=alive_status): + assert monitor_mod._is_zombie(1) is False + + with mock.patch.object(pathlib.Path, "read_text", side_effect=FileNotFoundError): + assert monitor_mod._is_zombie(1) is False + + +def test_kill_pid_skips_zombie(monitor_mod): + """ゾンビプロセスに対して _kill_pid() はシグナルを送らない。""" + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=True), + mock.patch("os.kill") as mock_kill, + ): + monitor_mod._kill_pid(12345) + mock_kill.assert_not_called() diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_state_auto_review_templates.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_auto_review_templates.py new file mode 100644 index 0000000..c0dbd9e --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_state_auto_review_templates.py @@ -0,0 +1,204 @@ +"""PR 変更ファイルに応じた自動レビュー観点の分類テスト。""" + +from __future__ import annotations + + +def test_parse_pr_files_payload_normalizes_gh_files_json(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + { + "files": [ + {"path": "src/app.py", "changeType": "MODIFIED"}, + {"path": "docs/new.md", "previousPath": "docs/old.md", "changeType": "RENAMED"}, + {"path": "src/old.py", "changeType": "DELETED"} + ] + } + """ + ) + + assert entries == [ + {"status": "M", "paths": ["src/app.py"]}, + {"status": "R", "paths": ["docs/old.md", "docs/new.md"]}, + {"status": "D", "paths": ["src/old.py"]}, + ] + + +def test_parse_pr_files_api_lines_normalizes_paginated_api_output(state_mod): + entries = state_mod._parse_pr_files_api_lines( + "modified\tsrc/app.py\t\n" + "renamed\tdocs/new.md\tdocs/old.md\n" + "removed\tsrc/old.py\t\n" + ) + + assert entries == [ + {"status": "M", "paths": ["src/app.py"]}, + {"status": "R", "paths": ["docs/old.md", "docs/new.md"]}, + {"status": "D", "paths": ["src/old.py"]}, + ] + + +def test_docs_only_pr_adds_docs_template(state_mod): + entries = state_mod._parse_pr_files_payload( + '{"files":[{"path":"docs/plan.md","changeType":"MODIFIED"},{"path":"README.md","changeType":"ADDED"}]}' + ) + + categories = state_mod._classify_changed_files(entries) + + assert categories == ["common", "docs_only"] + instructions = state_mod._auto_review_instructions(categories) + assert "ドキュメントのみ PR" in instructions + assert "コード、設定、コマンド" in instructions + + +def test_code_pr_detects_code_test_and_frontend(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + {"files":[ + {"path":"src/components/UserCard.tsx","changeType":"MODIFIED"}, + {"path":"tests/UserCard.test.tsx","changeType":"ADDED"} + ]} + """ + ) + + categories = state_mod._classify_changed_files(entries) + + assert "code" in categories + assert "test" in categories + assert "frontend" in categories + + +def test_db_migration_detects_schema_concerns(state_mod): + entries = state_mod._parse_pr_files_payload( + '{"files":[{"path":"database/migrations/20260101_create_users.sql","changeType":"ADDED"}]}' + ) + + categories = state_mod._classify_changed_files(entries) + + assert "db_migration" in categories + assert "DB migration / schema 変更" in state_mod._auto_review_instructions(categories) + + +def test_dependency_ci_api_security_performance_i18n_infra_are_detected(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + {"files":[ + {"path":"package-lock.json","changeType":"MODIFIED"}, + {"path":".github/workflows/test.yml","changeType":"MODIFIED"}, + {"path":"app/api/auth/token_controller.py","changeType":"MODIFIED"}, + {"path":"src/cache/worker.go","changeType":"MODIFIED"}, + {"path":"locales/ja.json","changeType":"MODIFIED"}, + {"path":"terraform/main.tf","changeType":"MODIFIED"} + ]} + """ + ) + + categories = state_mod._classify_changed_files(entries) + + assert "dependency" in categories + assert "config_ci" in categories + assert "api_contract" in categories + assert "auth_security" in categories + assert "performance" in categories + assert "i18n" in categories + assert "infra" in categories + + +def test_deletion_and_rename_are_detected(state_mod): + entries = state_mod._parse_pr_files_payload( + """ + {"files":[ + {"path":"src/old.py","changeType":"DELETED"}, + {"path":"src/b.py","previousPath":"src/a.py","changeType":"RENAMED"} + ]} + """ + ) + + categories = state_mod._classify_changed_files(entries) + + assert "deletion_rename" in categories + + +def test_index_entrypoint_does_not_trigger_performance(state_mod): + entries = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/index.ts","changeType":"MODIFIED"}]}' + ) + + assert "performance" not in state_mod._classify_changed_files(entries) + + +def test_i18n_extension_detection_does_not_match_po_substrings(state_mod): + false_positive = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/import.polyfill.js","changeType":"MODIFIED"}]}' + ) + real_po = state_mod._parse_pr_files_payload( + '{"files":[{"path":"locales/messages.po","changeType":"MODIFIED"}]}' + ) + + assert "i18n" not in state_mod._classify_changed_files(false_positive) + assert "i18n" in state_mod._classify_changed_files(real_po) + + +def test_env_files_are_config_and_author_is_not_auth_security(state_mod): + env_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":".env.example","changeType":"MODIFIED"}]}' + ) + author_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/components/AuthorCard.tsx","changeType":"MODIFIED"}]}' + ) + auth_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/auth/session.py","changeType":"MODIFIED"}]}' + ) + authz_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/security/authz.py","changeType":"MODIFIED"}]}' + ) + authentication_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/security/authentication.py","changeType":"MODIFIED"}]}' + ) + authorizer_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/security/authorizer.ts","changeType":"MODIFIED"}]}' + ) + + assert "config_ci" in state_mod._classify_changed_files(env_file) + assert "auth_security" not in state_mod._classify_changed_files(author_file) + assert "auth_security" in state_mod._classify_changed_files(auth_file) + assert "auth_security" in state_mod._classify_changed_files(authz_file) + assert "auth_security" in state_mod._classify_changed_files(authentication_file) + assert "auth_security" in state_mod._classify_changed_files(authorizer_file) + + +def test_tokenizer_async_jobcard_and_tflite_do_not_trigger_categories(state_mod): + tokenizer_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/tokenizer.py","changeType":"MODIFIED"}]}' + ) + token_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"app/auth/token.py","changeType":"MODIFIED"}]}' + ) + async_helper = state_mod._parse_pr_files_payload( + '{"files":[{"path":"utils/async_helpers.ts","changeType":"MODIFIED"}]}' + ) + job_card = state_mod._parse_pr_files_payload( + '{"files":[{"path":"components/JobCard.tsx","changeType":"MODIFIED"}]}' + ) + worker_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"src/worker/queue.ts","changeType":"MODIFIED"}]}' + ) + tflite_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"models/config.tflite","changeType":"MODIFIED"}]}' + ) + terraform_file = state_mod._parse_pr_files_payload( + '{"files":[{"path":"terraform/main.tf","changeType":"MODIFIED"}]}' + ) + + assert "auth_security" not in state_mod._classify_changed_files(tokenizer_file) + assert "auth_security" in state_mod._classify_changed_files(token_file) + assert "performance" not in state_mod._classify_changed_files(async_helper) + assert "performance" not in state_mod._classify_changed_files(job_card) + assert "performance" in state_mod._classify_changed_files(worker_file) + assert "infra" not in state_mod._classify_changed_files(tflite_file) + assert "infra" in state_mod._classify_changed_files(terraform_file) + + +def test_combined_review_instructions_puts_auto_before_manual(state_mod): + assert state_mod._combined_review_instructions("auto", "manual") == "auto\n\nmanual" + assert state_mod._combined_review_instructions("auto", "") == "auto" + assert state_mod._combined_review_instructions("", "manual") == "manual" diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_state_check_oscillation.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_check_oscillation.py new file mode 100644 index 0000000..f00d189 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_state_check_oscillation.py @@ -0,0 +1,101 @@ +"""state.py cmd_check_oscillation の non-dict 防御テスト (PLAN21 round 5). + +gemini round 4 指摘: payload.json が dict 以外 (list 等) の場合、 +`payload.get("comments", [])` で AttributeError になる前に明示的に止める。 +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + + +PR = 7777 + + +def _seed_state_two_rounds(tmp_dir: pathlib.Path) -> None: + """同一 PR で 2 round 分のエントリを seed する (oscillation 判定の前提)。""" + state = { + "current_pr": PR, + "rounds": [ + {"round": 1, "pr": PR, "started_at": "2026-05-23T00:00:00+00:00"}, + {"round": 2, "pr": PR, "started_at": "2026-05-23T00:10:00+00:00"}, + ], + "deferred_nits": [], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _make_args() -> argparse.Namespace: + return argparse.Namespace(pr=PR) + + +@pytest.fixture() +def patched_tmp_dir(monkeypatch, tmp_path, state_mod): + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def _write_payload( + tmp_dir: pathlib.Path, agent: str, round_no: int, data: object +) -> pathlib.Path: + p = tmp_dir / f"{agent}-review-pr{PR}-round{round_no}-payload.json" + p.write_text(json.dumps(data)) + return p + + +def test_non_dict_payload_dies(patched_tmp_dir, state_mod, capsys): + """payload.json が list 等の非 dict なら die(code=3)。 + + `payload.get(...)` を呼ぶ前に明示的に止めることで、launcher 出力バグや + 別実行の残骸を黙って見逃さない。 + """ + tmp_dir = patched_tmp_dir + _seed_state_two_rounds(tmp_dir) + + # round 1 / round 2 の codex payload を dict で置く (正常 = 比較ベース) + _write_payload(tmp_dir, "codex", 1, {"comments": [{"path": "a.py", "line": 1}]}) + _write_payload(tmp_dir, "codex", 2, {"comments": [{"path": "a.py", "line": 1}]}) + # round 2 の gemini payload は不正 (list) + _write_payload(tmp_dir, "gemini", 2, [{"comments": "should_be_ignored"}]) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_check_oscillation(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + + +def test_non_dict_comment_entry_dies(patched_tmp_dir, state_mod, capsys): + """payload.comments のエントリが dict ではない場合も die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state_two_rounds(tmp_dir) + + # round 1 / round 2 とも codex の payload は dict だが、 + # round 2 の comments エントリが str (本来 dict) + _write_payload(tmp_dir, "codex", 1, {"comments": [{"path": "a.py", "line": 1}]}) + _write_payload(tmp_dir, "codex", 2, {"comments": ["not-a-dict-entry"]}) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_check_oscillation(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + + +def test_valid_dict_payloads_dont_die(patched_tmp_dir, state_mod): + """正常な dict payload (regression guard): non-dict 検査が誤検知しないこと。""" + tmp_dir = patched_tmp_dir + _seed_state_two_rounds(tmp_dir) + + # 全 agent の payload を正規の dict で置く (overlap < 50% で continue 期待) + _write_payload(tmp_dir, "codex", 1, {"comments": [{"path": "a.py", "line": 1}]}) + _write_payload(tmp_dir, "codex", 2, {"comments": [{"path": "b.py", "line": 2}]}) + + # exit code 2 (continue) で正常終了 + with pytest.raises(SystemExit) as e: + state_mod.cmd_check_oscillation(_make_args()) + assert e.value.code == 2 diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_state_extra_review_instructions.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_extra_review_instructions.py new file mode 100644 index 0000000..b145b1c --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_state_extra_review_instructions.py @@ -0,0 +1,35 @@ +"""state.py の追加レビュー観点オプションのテスト。""" + +from __future__ import annotations + +import argparse + + +def test_extra_review_instructions_accepts_focus(state_mod): + args = argparse.Namespace( + focus="ドキュメントとコードの整合性を重点的に確認", + extra_instructions_file=None, + ) + + assert state_mod._extra_review_instructions(args) == "ドキュメントとコードの整合性を重点的に確認" + + +def test_extra_review_instructions_combines_focus_and_file(tmp_path, state_mod): + extra = tmp_path / "focus.md" + extra.write_text("公開 API の説明と実装差分も確認\n", encoding="utf-8") + args = argparse.Namespace( + focus="ドキュメント整合性", + extra_instructions_file=str(extra), + ) + + assert state_mod._extra_review_instructions(args) == ( + "ドキュメント整合性\n\n公開 API の説明と実装差分も確認" + ) + + +def test_extra_review_instructions_ignores_empty_values(tmp_path, state_mod): + extra = tmp_path / "empty.md" + extra.write_text(" \n", encoding="utf-8") + args = argparse.Namespace(focus=" ", extra_instructions_file=str(extra)) + + assert state_mod._extra_review_instructions(args) == "" diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_state_merge_fix.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_merge_fix.py new file mode 100644 index 0000000..63c31ba --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_state_merge_fix.py @@ -0,0 +1,776 @@ +"""state.py cmd_merge_fix の堅牢化テスト (PLAN21). + +カバー範囲: + 1. 正規パス + 正規 key (`fix_commit` / `fixed_count`) + 2. 正規パス不在 → `/tmp/` fallback で拾える + 3. 戻り値 key 別名 (`commit_sha` / `fixed`) も受理 + 4. 全候補不在 → exit 3 で die + 探索 path 一覧が stderr に出る + 5. (PLAN21 round 2) fallback の stale 検証 + - mtime が round 開始前 → skip + - JSON 内 `pr` 不一致 → skip + - 明示 `--file` は stale 検証スキップ + 6. (PLAN21 round 3) 正規パス JSON parse 失敗 → 即時 die(code=3) + 7. (PLAN21 round 3) `pr` フィールドが数値として解釈できない → skip + 8. (PLAN21 round 3) `_is_fresh_fix_result` 戻り値のタプル化追従 +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import pathlib + +import pytest + + +PR = 9919 + + +def _seed_state(tmp_dir: pathlib.Path) -> None: + state = { + "current_pr": PR, + "rounds": [ + {"round": 1, "pr": PR, "started_at": "2026-05-23T00:00:00+00:00"} + ], + "deferred_nits": [], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _make_args(file_path: pathlib.Path | None = None) -> argparse.Namespace: + return argparse.Namespace(pr=PR, file=str(file_path) if file_path else None) + + +def _read_state(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +def _canonical_fix() -> dict: + return { + "pr": PR, + "fix_commit": "abc1234", + "ci_status": "SUCCESS", + "ci_failed_checks": [], + "fixed_count": 5, + "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0}, + "resolved_threads": [{"thread_id": "T1"}], + "deferred": [], + "rejected": [], + } + + +@pytest.fixture() +def patched_tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向けて `_tmp_dir()` を決定的にする。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def test_canonical_path_and_key(patched_tmp_dir, state_mod): + """正規パス ($TMP_DIR/fix-prN-result.json) + 正規 key で state にマージされる。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + assert merged["commit"] == "abc1234" + assert merged["fixed"] == 5 + assert merged["ci"] == "SUCCESS" + assert merged["by_severity"]["major"] == 3 + + +def test_tmp_fallback_path(patched_tmp_dir, state_mod, tmp_path): + """正規パス不在で /tmp/fix-prN-result.json にある場合は fallback で拾う。 + + 旧プロンプトでサブエージェントが `/tmp/` を指定したケースの救済。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # 正規パスには書かず、/tmp/ のみに置く + fix = _canonical_fix() + fix["fix_commit"] = "tmp_path_commit" + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(fix)) + try: + state_mod.cmd_merge_fix(_make_args()) + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "tmp_path_commit" + finally: + legacy.unlink(missing_ok=True) + + +def test_key_alias_commit_sha_and_fixed(patched_tmp_dir, state_mod): + """サブエージェントが別名 (`commit_sha` / `fixed`) で書いても受理する。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = { + "pr": PR, + "commit_sha": "alias_sha", + "fixed": 3, + "ci_status": "SUCCESS", + "deferred": [], + "rejected": [], + "resolved_threads": [], + } + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + assert merged["commit"] == "alias_sha" + assert merged["fixed"] == 3 + + +def test_missing_all_candidates_dies_with_paths(patched_tmp_dir, state_mod, capsys): + """どの候補にもファイルが無い場合は exit 3 + 探索 path 一覧が stderr に出る。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # /tmp/ にも置かない (clean up to be safe) + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.unlink(missing_ok=True) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + + captured = capsys.readouterr() + # 探索 path 一覧 (どこを見たか) がメッセージに含まれる + assert str(tmp_dir / f"fix-pr{PR}-result.json") in captured.err + assert f"/tmp/fix-pr{PR}-result.json" in captured.err + + +def test_explicit_file_arg_wins(patched_tmp_dir, state_mod, tmp_path): + """`--file` 明示時はそれを最優先で読む (正規パスや /tmp/ より優先)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # 正規パスにも置くが、--file で別ファイルを指定する + canonical = _canonical_fix() + canonical["fix_commit"] = "canonical_should_not_be_used" + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(canonical)) + + explicit_fix = _canonical_fix() + explicit_fix["fix_commit"] = "explicit_wins" + explicit_path = tmp_path / "custom-fix.json" + explicit_path.write_text(json.dumps(explicit_fix)) + + state_mod.cmd_merge_fix(_make_args(explicit_path)) + + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "explicit_wins" + + +# ---------------- PLAN21 round 2: fallback stale 検証 ---------------- + + +def _seed_state_with_started_at(tmp_dir: pathlib.Path, started_at: str) -> None: + """`started_at` を任意のタイムスタンプで seed する。""" + state = { + "current_pr": PR, + "rounds": [{"round": 1, "pr": PR, "started_at": started_at}], + "deferred_nits": [], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def test_fallback_stale_mtime_is_ignored(patched_tmp_dir, state_mod, capsys): + """正規 fallback ファイルの mtime が round 開始前なら skip。 + + 別 round / 別実行の残骸ファイルを誤マージしないことを確認する。 + """ + tmp_dir = patched_tmp_dir + # round 開始時刻 = 現在 (これより古いファイルは skip 対象) + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + _seed_state_with_started_at(tmp_dir, now_dt.isoformat(timespec="seconds")) + + stale = tmp_dir / f"fix-pr{PR}-result.json" + stale.write_text(json.dumps(_canonical_fix())) + # mtime を 1 時間前に巻き戻す (round 開始より明確に古い) + stale_ts = now_dt.timestamp() - 3600 + os.utime(stale, (stale_ts, stale_ts)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + + captured = capsys.readouterr() + # 古いファイルとして skip された旨が stderr に出る + assert "round 開始前" in captured.err or "古いファイル" in captured.err + + +def test_fallback_tmp_stale_is_ignored(patched_tmp_dir, state_mod, capsys): + """`/tmp/fix-prN-result.json` の古い残骸 (別リポジトリの同番号 PR 想定) は skip。 + + codex review 指摘の本丸: PR 番号だけで命名された共有 namespace の + 古いファイルを無条件に拾わないこと。 + """ + tmp_dir = patched_tmp_dir + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + _seed_state_with_started_at(tmp_dir, now_dt.isoformat(timespec="seconds")) + + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(_canonical_fix())) + # mtime を 2 時間前に巻き戻す (= 過去の別実行で残った想定) + stale_ts = now_dt.timestamp() - 7200 + os.utime(legacy, (stale_ts, stale_ts)) + try: + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "round 開始前" in captured.err or "古いファイル" in captured.err + finally: + legacy.unlink(missing_ok=True) + + +def test_fallback_pr_mismatch_is_ignored(patched_tmp_dir, state_mod, capsys): + """fallback ファイル内の `pr` が対象 PR と一致しない場合は skip。 + + 別リポジトリの同番号 PR の戻り値が `/tmp` 共有 namespace に + 残っていたケースを想定する。 + """ + tmp_dir = patched_tmp_dir + # round 開始は十分過去にして mtime チェックでは弾かれないようにする + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + fix = _canonical_fix() + fix["pr"] = PR + 1 # 別 PR の戻り値 + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "pr 不一致" in captured.err or "別 PR" in captured.err + + +def test_fallback_fresh_mtime_and_matching_pr_is_accepted(patched_tmp_dir, state_mod): + """mtime が round 開始後 & `pr` 一致なら採用される (regression guard)。""" + tmp_dir = patched_tmp_dir + # round 開始を 1 時間前に置く → 直後に書いたファイルは "fresh" + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + _seed_state_with_started_at(tmp_dir, past_dt.isoformat(timespec="seconds")) + + fix = _canonical_fix() + fix["fix_commit"] = "fresh_ok" + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "fresh_ok" + + +def test_explicit_file_bypasses_stale_check(patched_tmp_dir, state_mod, tmp_path): + """`--file` 明示時は stale 検証をスキップする (ユーザー指定優先)。""" + tmp_dir = patched_tmp_dir + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + _seed_state_with_started_at(tmp_dir, now_dt.isoformat(timespec="seconds")) + + explicit_fix = _canonical_fix() + explicit_fix["fix_commit"] = "explicit_stale_ok" + explicit_path = tmp_path / "explicit.json" + explicit_path.write_text(json.dumps(explicit_fix)) + # mtime を round 開始前に巻き戻しても、--file 指定なら採用される + stale_ts = now_dt.timestamp() - 3600 + os.utime(explicit_path, (stale_ts, stale_ts)) + + state_mod.cmd_merge_fix(_make_args(explicit_path)) + + st = _read_state(tmp_dir) + assert st["rounds"][-1]["fix"]["commit"] == "explicit_stale_ok" + + +# ---------------- PLAN21 round 3: 正規パス parse 失敗 / pr 型不正 / 戻り値タプル化 ---------------- + + +def test_canonical_path_json_parse_failure_dies_immediately( + patched_tmp_dir, state_mod, capsys +): + """正規パス ($TMP_DIR/fix-prN-result.json) の JSON parse 失敗は即時 die(code=3)。 + + codex round 2 指摘: 壊れた正規パスをスキップして /tmp/ fallback に流れると + 別 PR の戻り値を誤マージする事故が起こるため、正規パスの parse 失敗は致命扱い。 + """ + tmp_dir = patched_tmp_dir + # round 開始を十分過去にして mtime チェックでは弾かれないようにする + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + # 正規パスに壊れた JSON を置く (size>0 だが parse 不能) + canonical = tmp_dir / f"fix-pr{PR}-result.json" + canonical.write_text("{ this is not valid json") + + # /tmp/ 側には別 PR を装う偽の正規 JSON を置く (これに流れ込んだら事故) + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(_canonical_fix())) + try: + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + # 正規パスの読み取り失敗である旨が stderr に出る + assert "正規パス" in captured.err + # /tmp 側の fix がマージされていないこと (= state.rounds[-1].fix が無い) + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + finally: + legacy.unlink(missing_ok=True) + + +def test_fallback_pr_field_non_numeric_is_skipped(patched_tmp_dir, state_mod, capsys): + """fallback ファイルの `pr` フィールドが int 変換不能なら skip (ValueError 防止)。 + + gemini round 2 指摘: `int(file_pr)` で ValueError が裸で上がるとプロセスごと落ちる。 + """ + tmp_dir = patched_tmp_dir + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + # 正規パスに `pr` が int 化できない値の JSON を置く + canonical = tmp_dir / f"fix-pr{PR}-result.json" + bad = _canonical_fix() + bad["pr"] = "not-a-number" + canonical.write_text(json.dumps(bad)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + # /tmp/ 側にも候補が無いので最終的には "戻り値ファイル無し" で die(3) + assert e.value.code == 3 + captured = capsys.readouterr() + # 数値として解釈できない旨が stderr に出る + assert "数値として解釈できない" in captured.err + # state にマージされていないこと + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +def test_is_fresh_fix_result_returns_tuple_with_parsed_payload( + patched_tmp_dir, state_mod +): + """`_is_fresh_fix_result` は (is_fresh, parsed_payload) を返す。 + + gemini round 2 指摘の性能改善: cmd_merge_fix 側で再パースしないよう、 + fresh な場合は parse 済み dict を返す。 + """ + tmp_dir = patched_tmp_dir + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + past_ts = past_dt.timestamp() + + fix = _canonical_fix() + fix["fix_commit"] = "tuple_return_ok" + p = tmp_dir / f"fix-pr{PR}-result.json" + p.write_text(json.dumps(fix)) + + is_fresh, parsed = state_mod._is_fresh_fix_result(p, PR, past_ts, is_canonical=True) + assert is_fresh is True + assert isinstance(parsed, dict) + assert parsed["fix_commit"] == "tuple_return_ok" + + +def test_is_fresh_fix_result_returns_none_when_stale(patched_tmp_dir, state_mod): + """stale な場合は (False, None) を返す。""" + tmp_dir = patched_tmp_dir + now_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() + + p = tmp_dir / f"fix-pr{PR}-result.json" + p.write_text(json.dumps(_canonical_fix())) + stale_ts = now_dt.timestamp() - 3600 + os.utime(p, (stale_ts, stale_ts)) + + is_fresh, parsed = state_mod._is_fresh_fix_result( + p, PR, now_dt.timestamp(), is_canonical=False + ) + assert is_fresh is False + assert parsed is None + + +# ---------------- PLAN21 round 4: non-dict JSON 防御 ---------------- + + +def test_is_fresh_fix_result_non_dict_json_is_skipped(patched_tmp_dir, state_mod, capsys): + """`_is_fresh_fix_result` は dict 以外 (list 等) の JSON を読んだら (False, None) を返す。 + + gemini round 3 指摘: `json.loads` は list / int / str も返しうるため、 + `payload.get(...)` 呼び出し前に `isinstance(payload, dict)` で防御する必要がある。 + """ + tmp_dir = patched_tmp_dir + # round 開始は十分過去にして mtime チェックを通す + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + past_ts = past_dt.timestamp() + + p = tmp_dir / f"fix-pr{PR}-result.json" + # JSON として valid だが dict ではない (list) + p.write_text(json.dumps([{"fix_commit": "should_be_ignored"}])) + + is_fresh, parsed = state_mod._is_fresh_fix_result( + p, PR, past_ts, is_canonical=False + ) + assert is_fresh is False + assert parsed is None + captured = capsys.readouterr() + # dict ではない旨が stderr に出る + assert "dict ではない" in captured.err + + +def test_is_fresh_fix_result_non_dict_json_canonical_dies_round5( + patched_tmp_dir, state_mod, capsys +): + """PLAN21 round 5 で挙動変更: 正規パス (is_canonical=True) で非 dict なら die(code=3)。 + + 旧 round 4 では skip (False, None) だったが、codex round 4 指摘により + `/tmp/` fallback への流れ込みを防ぐため parse 失敗と同様に即時中断する。 + """ + tmp_dir = patched_tmp_dir + past_dt = _dt.datetime.now(_dt.timezone.utc).astimezone() - _dt.timedelta(hours=1) + past_ts = past_dt.timestamp() + + p = tmp_dir / f"fix-pr{PR}-result.json" + p.write_text(json.dumps(["not", "a", "dict"])) + + with pytest.raises(SystemExit) as e: + state_mod._is_fresh_fix_result(p, PR, past_ts, is_canonical=True) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + assert "正規パス" in captured.err + + +def test_explicit_file_non_dict_json_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 明示時に non-dict JSON が渡されたら die(code=3) で即時中断。 + + gemini round 3 指摘: list 等が渡されると後続 `fix.get(...)` でクラッシュするため、 + 明示指定の場合も dict 検証を行ってから fix に代入する。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + + explicit_path = tmp_path / "bad-explicit.json" + # JSON として valid だが dict ではない (list) + explicit_path.write_text(json.dumps([_canonical_fix()])) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(explicit_path)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + # state は更新されていない + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +# ---------------- PLAN21 round 5: 正規パス non-dict 即時 die / --file 厳格化 ---------------- + + +def test_canonical_path_non_dict_json_dies_immediately( + patched_tmp_dir, state_mod, capsys +): + """正規パス ($TMP_DIR/fix-prN-result.json) の JSON が dict 以外なら即時 die(code=3)。 + + codex round 4 指摘: parse 失敗と同様、非 dict も「壊れた正規出力」として扱う。 + skip で /tmp/ fallback に流れると別実行の戻り値を誤マージする経路が残るため、 + list / str / int 等が返ったら fallback には進まず即時中断する。 + """ + tmp_dir = patched_tmp_dir + past = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc) + _seed_state_with_started_at(tmp_dir, past.isoformat(timespec="seconds")) + + # 正規パスに list (非 dict) を置く + canonical = tmp_dir / f"fix-pr{PR}-result.json" + canonical.write_text(json.dumps([{"fix_commit": "should_not_be_read"}])) + + # /tmp/ 側には別 PR を装う正規 JSON を置く (これに流れ込んだら事故) + legacy = pathlib.Path(f"/tmp/fix-pr{PR}-result.json") + legacy.write_text(json.dumps(_canonical_fix())) + try: + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args()) + assert e.value.code == 3 + captured = capsys.readouterr() + # 正規パスで非 dict であった旨が stderr に出る + assert "正規パス" in captured.err + assert "dict ではない" in captured.err + # /tmp 側の fix がマージされていないこと + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + finally: + legacy.unlink(missing_ok=True) + + +def test_explicit_file_nonexistent_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 指定パスが存在しなければ fallback に進まず die(code=3)。 + + codex round 4 指摘: ユーザー明示指定なのに無言で fallback に流れて + 別実行の戻り値を誤マージするのを防ぐ。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + # 正規パスにも置いて、fallback に流れたら事故と判別できるようにする + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(_canonical_fix())) + + nonexistent = tmp_path / "does-not-exist.json" + assert not nonexistent.exists() + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(nonexistent)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "存在しません" in captured.err + # fallback が読まれていない (state にマージされていない) + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +def test_explicit_file_empty_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 指定パスが空ファイルなら fallback に進まず die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(_canonical_fix())) + + empty = tmp_path / "empty.json" + empty.write_text("") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(empty)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "空です" in captured.err + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +def test_explicit_file_invalid_json_dies(patched_tmp_dir, state_mod, capsys, tmp_path): + """`--file` 指定パスが JSON 不正なら fallback に進まず die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(_canonical_fix())) + + bad = tmp_path / "bad.json" + bad.write_text("{ this is not valid json") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(_make_args(bad)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "parse に失敗" in captured.err or "読み取り" in captured.err + st = _read_state(tmp_dir) + assert "fix" not in st["rounds"][-1] + + +# ---------------- PLAN25: _count() 正規化 / int 混入時の堅牢化 ---------------- + + +@pytest.mark.parametrize( + "value, expected", + [ + (3, 3), # int(件数) はそのまま件数として扱う + (0, 0), # int の 0 + ([{"a": 1}, {"b": 2}], 2), # list は len() + ([], 0), # 空 list + ((1, 2, 3), 3), # tuple も len() + (None, 0), # None は 0 + (False, 0), # bool は int サブクラスだが件数扱いしない + (True, 0), # bool=True も 0 + ("xyz", 0), # 想定外の型 (str) は 0 + # PLAN25 round2 (gemini 指摘): LLM が件数を数値文字列で返すケースを許容 + ("3", 3), # 数値文字列 → int + ("0", 0), # "0" も 0 + ("12", 12), # 複数桁 + ("abc", 0), # 数値でない文字列は 0 + ("", 0), # 空文字は isdigit()=False → 0 + ("-1", 0), # 負号付きは strip 後も isdigit()=False → 0 (件数は非負) + ("1.5", 0), # 小数点付きは isdigit()=False → 0 + # PLAN25 round4 (gemini 指摘): 単一 dict を 1 件として扱う + ({"thread_id": "x"}, 1), # 単一 dict → 1 + ({}, 1), # 空 dict も 1 件 (構造化データ 1 要素扱い) + # PLAN25 round4 (gemini 指摘): 数値文字列の前後空白を許容 (strip) + (" 3 ", 3), # 前後空白付き数値文字列 → strip して int + (" 12", 12), # 先頭空白のみ + ("7 ", 7), # 末尾空白のみ + (" ", 0), # 空白のみは strip 後空文字 → 0 + ], +) +def test_count_normalizes_int_list_tuple_none_bool(state_mod, value, expected): + """`_count()` が int / dict / list / tuple / None / bool / 数値文字列 / 想定外型を件数(int)に正規化する。 + + codex review 指摘 (PLAN25): `_count()` の挙動がテストで固定されておらず、 + 同じ TypeError 経路 (`len(int)`) が再発しても検知できない。 + PLAN25 round2 (gemini 指摘): 数値文字列 ("3" 等) も件数として許容する。 + PLAN25 round4 (gemini 指摘): 単一 dict は 1 件、数値文字列は前後空白を strip して許容。 + """ + assert state_mod._count(value) == expected + + +def test_merge_fix_int_counts_do_not_crash_and_persist(patched_tmp_dir, state_mod): + """`resolved_threads` / `deferred` / `rejected` が int でも TypeError を出さず件数が保存される。 + + PLAN25 の本丸: fix サブエージェントが list の代わりに int(件数) を書いても + `len(int)` で落ちず完走すること。 + resolved_threads / rejected は _count() で int がそのまま件数として保存される。 + PLAN25 round4 (統一ルール): deferred が int (per-item を失った劣化表現) のときは + 件数を失わないよう _count() の値を保存する。展開はできないので deferred_nits は空。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # list ではなく int(件数) を書く (再発防止対象のケース) + fix["resolved_threads"] = 4 + fix["deferred"] = 2 + fix["rejected"] = 1 + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + # TypeError を出さずに完走すること + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + assert merged["resolved_threads"] == 4 + # 統一ルール: int は劣化表現なので _count() の件数 (2) を保存する + assert merged["deferred"] == 2 + assert merged["rejected"] == 1 + # int は展開できないため deferred_nits は空のまま (per-item データは復元不能) + assert st["deferred_nits"] == [] + + +def test_merge_fix_deferred_single_dict_is_wrapped(patched_tmp_dir, state_mod): + """`deferred` が単一 dict のとき 1 件として扱い、deferred_nits に 1 件展開される。 + + PLAN25 round4 (gemini #3): LLM が単一要素を list ではなく dict 単体で返した + 場合のフォールバック。保存件数 == 展開件数 == 1 を固定する。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # list ではなく単一 dict を書く (LLM 出力揺らぎ) + fix["deferred"] = {"comment_id": 7, "summary": "single-nit"} + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + # 構造化データ (dict) なので展開件数 (1) を保存 + assert merged["deferred"] == 1 + assert len(st["deferred_nits"]) == 1 + assert st["deferred_nits"][0]["summary"] == "single-nit" + assert st["deferred_nits"][0]["pr"] == PR + assert st["deferred_nits"][0]["round"] == 1 + + +def test_merge_fix_list_deferred_expands_nits(patched_tmp_dir, state_mod): + """`deferred` が list の場合は従来どおり件数が保存され deferred_nits が展開される。 + + int 対応で list の既存挙動 (件数集計 + deferred_nits 展開) が壊れていないことの regression guard。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + fix["resolved_threads"] = [{"thread_id": "T1"}, {"thread_id": "T2"}] + fix["deferred"] = [ + {"comment_id": 1, "summary": "nit-1"}, + {"comment_id": 2, "summary": "nit-2"}, + ] + fix["rejected"] = [{"comment_id": 3, "summary": "rej-1"}] + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + # list は len() で件数化 + assert merged["resolved_threads"] == 2 + assert merged["deferred"] == 2 + assert merged["rejected"] == 1 + # deferred_nits が展開され、pr / round が付与される + assert len(st["deferred_nits"]) == 2 + summaries = {n["summary"] for n in st["deferred_nits"]} + assert summaries == {"nit-1", "nit-2"} + for n in st["deferred_nits"]: + assert n["pr"] == PR + assert n["round"] == 1 + + +# ---------------- PLAN25 round2 (gemini): deferred 非 dict 要素ガード ---------------- + + +def test_merge_fix_deferred_non_dict_elements_are_skipped(patched_tmp_dir, state_mod): + """`deferred` に dict 以外の要素 (文字列等) が混じってもクラッシュせず、dict のみ展開される。 + + gemini round2 指摘 (major): LLM がスキーマを無視して `deferred` に + 文字列のリスト (例: ["nit: ..."]) を返すと `{**d}` で + `TypeError: 'str' object is not a mapping` が発生する。 + ループ内の `isinstance(d, dict)` ガードで非 dict 要素をスキップすること。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # dict / 文字列 / None / int が混在した deferred (LLM 出力揺らぎ) + fix["deferred"] = [ + {"comment_id": 1, "summary": "nit-dict"}, + "nit: please fix this string element", + None, + 42, + {"comment_id": 2, "summary": "nit-dict-2"}, + ] + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + # TypeError を出さずに完走すること + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + # codex round3 指摘 (major): 保存件数は「実際に deferred_nits へ展開される + # dict 要素数」と一致させる。非 dict 要素は除外されるので 2 になる。 + assert merged["deferred"] == 2 + # deferred_nits には dict 要素のみが展開される + assert len(st["deferred_nits"]) == 2 + summaries = {n["summary"] for n in st["deferred_nits"]} + assert summaries == {"nit-dict", "nit-dict-2"} + for n in st["deferred_nits"]: + assert n["pr"] == PR + assert n["round"] == 1 + + +# ---------- PLAN25 round3 (codex): 保存件数 == deferred_nits 展開件数 ---------- + + +def test_merge_fix_deferred_count_matches_expanded_nits(patched_tmp_dir, state_mod): + """codex round3 指摘 (major / 正確性): mixed list の `deferred` を渡したとき、 + `rounds[].fix["deferred"]` の保存件数が deferred_nits に実際に展開された + dict 要素数と一致することを検証する回帰テスト。 + + round2 の dict ガードは「展開件数 < 保存件数」という食い違いを生んでいた + (保存は list 全体の長さ、展開は dict のみ)。本テストで両者の一致を固定する。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + fix = _canonical_fix() + # dict 3 件 + 非 dict 4 件 の mixed list + fix["deferred"] = [ + {"comment_id": 1, "summary": "nit-a"}, + "str-element", + None, + {"comment_id": 2, "summary": "nit-b"}, + 123, + ["nested", "list"], + {"comment_id": 3, "summary": "nit-c"}, + ] + (tmp_dir / f"fix-pr{PR}-result.json").write_text(json.dumps(fix)) + + state_mod.cmd_merge_fix(_make_args()) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1]["fix"] + expanded = [n for n in st["deferred_nits"] if n.get("round") == 1] + # 保存件数 == 実展開件数 == dict 要素数 (3) + assert merged["deferred"] == len(expanded) == 3 + summaries = {n["summary"] for n in expanded} + assert summaries == {"nit-a", "nit-b", "nit-c"} diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_state_read_result.py b/plugins/ndf-codex/skills/cross-review/tests/test_state_read_result.py new file mode 100644 index 0000000..a243fc2 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_state_read_result.py @@ -0,0 +1,160 @@ +"""state.py cmd_read_result の result.json スキーマ揺れに対するテスト。 + +カバー範囲: + 1. 正規スキーマ (`event` / `comments_count`) → state にマージされる + 2. 変則スキーマ (`intent` / `comment_count`) → 同等にマージされる (フォールバック) + 3. event / intent いずれも欠落 → die(exit 1) で fail + state 不変 +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + + +PR = 4242 +AGENT = "gemini" + + +def _seed_state(tmp_dir: pathlib.Path) -> dict: + state = { + "current_pr": PR, + "rounds": [ + {"round": 1, "pr": PR, "started_at": "2026-05-21T00:00:00+00:00"} + ], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + return state + + +def _make_args(file_path: pathlib.Path) -> argparse.Namespace: + return argparse.Namespace(pr=PR, agent=AGENT, file=str(file_path)) + + +def _read_state(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +@pytest.fixture() +def patched_tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向ける。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def test_canonical_schema(patched_tmp_dir, state_mod): + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + result = { + "event": "APPROVE", + "posted_as": "APPROVE", + "comments_count": 0, + "review_url": "https://example/pr/1#1", + "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0}, + } + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps(result)) + + state_mod.cmd_read_result(_make_args(rfile)) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1][AGENT] + assert merged["intent"] == "APPROVE" + assert merged["posted_as"] == "APPROVE" + assert merged["comments"] == 0 + assert merged["review_url"] == "https://example/pr/1#1" + assert merged["by_severity"]["critical"] == 0 + + +def test_alias_schema_intent_and_comment_count(patched_tmp_dir, state_mod): + """gemini が `intent` / `comment_count` で書き出すパターンも受理する。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + result = { + "intent": "APPROVE", + "comment_count": 3, + "review_url": "https://example/pr/2#2", + "by_severity": {"critical": 0, "major": 0, "minor": 2, "nit": 1}, + } + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps(result)) + + state_mod.cmd_read_result(_make_args(rfile)) + + st = _read_state(tmp_dir) + merged = st["rounds"][-1][AGENT] + assert merged["intent"] == "APPROVE" + # posted_as は別名 result.json には存在しないので intent と同値にフォールバック + assert merged["posted_as"] == "APPROVE" + assert merged["comments"] == 3 + + +def test_missing_event_and_intent_dies(patched_tmp_dir, state_mod): + """event / intent いずれも無ければ exit 1 で fail し state は不変であること。""" + tmp_dir = patched_tmp_dir + seeded = _seed_state(tmp_dir) + result = {"comments_count": 0} + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps(result)) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 1 + + # state は更新されていない (rounds[-1] に agent エントリが追加されていない) + st = _read_state(tmp_dir) + assert AGENT not in st["rounds"][-1] + assert st["rounds"][-1]["round"] == seeded["rounds"][-1]["round"] + + +def test_empty_result_file_dies(patched_tmp_dir, state_mod): + """空 result.json → die (result 未生成扱い)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text("") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 1 + + +# ---------------- PLAN21 round 5: non-dict / 不正 JSON 防御 ---------------- + + +def test_non_dict_result_json_dies(patched_tmp_dir, state_mod, capsys): + """result.json が list 等の非 dict なら die(code=3)。 + + gemini round 4 指摘: `r.get(...)` で AttributeError になる前に明示的に止める。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + # JSON valid だが dict ではない + rfile.write_text(json.dumps([{"event": "APPROVE"}])) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "dict ではない" in captured.err + # state は更新されていない + st = _read_state(tmp_dir) + assert AGENT not in st["rounds"][-1] + + +def test_invalid_json_result_file_dies(patched_tmp_dir, state_mod, capsys): + """JSON parse 不能なら die(code=3)。""" + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text("{ this is not valid json") + + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(_make_args(rfile)) + assert e.value.code == 3 + captured = capsys.readouterr() + assert "parse" in captured.err.lower() or "parse" in captured.err diff --git a/plugins/ndf-codex/skills/deploy/SKILL.md b/plugins/ndf-codex/skills/deploy/SKILL.md new file mode 100644 index 0000000..769919d --- /dev/null +++ b/plugins/ndf-codex/skills/deploy/SKILL.md @@ -0,0 +1,114 @@ +--- +name: deploy +description: "Create deploy PRs from feature to environment branches." +argument-hint: " (例: qa/staging, release/v2)" +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# 環境デプロイPR作成コマンド + +現在のfeatureブランチを指定した環境ブランチへデプロイするためのPRを作成する。`{feature}_to_{env}` という命名のdeployブランチを作成し、最新 origin/main を取り込んでから環境ブランチへPRを出す。 + +## 使用方法 + +``` +/ndf:deploy qa/staging +/ndf:deploy release/v2 +``` + +## cherry-pick-pr との使い分け + +| 観点 | cherry-pick-pr | deploy | +|---|---|---| +| 適用範囲 | featureブランチの**一部コミット**を選択 | featureブランチ**全体**を適用 | +| ブランチ戦略 | 環境ブランチから短命ブランチ派生 | featureブランチから deploy ブランチ派生 | +| main取り込み | 必須 | 必須 | +| 用途 | 特定修正のみ検証環境に届けたい | feature機能全体を環境で検証したい | + +## 処理フロー + +### 1. バリデーション + +```bash +CURRENT_BRANCH=$(git branch --show-current) +[[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]] && \ + echo "❌ Error: デフォルトブランチからデプロイできません" && exit 1 +``` + +### 2. deployブランチ名の導出 + +```bash +FEATURE_BRANCH=$(git branch --show-current) +# 環境名を抽出: "qa/staging" → "staging", "release/v2" → "v2" +ENV_SUFFIX=$(echo "$ARGUMENTS" | sed 's|.*/||') +DEPLOY_BRANCH="${FEATURE_BRANCH}_to_${ENV_SUFFIX}" +``` + +### 3. 既存PRチェック + +```bash +EXISTING_PR=$(gh pr list --head "$DEPLOY_BRANCH" --base "$ARGUMENTS" \ + --json number,url --jq '.[0].url // empty') +if [[ -n "$EXISTING_PR" ]]; then + echo "✅ PR already exists: $EXISTING_PR" + exit 0 +fi +``` + +既存PRがあれば更新は「deployブランチにpushする」だけで済むため、再作成しない。 + +### 4. deployブランチ作成 + main取り込み + +```bash +git fetch origin main +git checkout -b "$DEPLOY_BRANCH" +git merge origin/main --no-edit || { + echo "❌ main とのmerge conflict。手動解決が必要です" + git merge --abort + git checkout "$FEATURE_BRANCH" + git branch -D "$DEPLOY_BRANCH" + exit 1 +} +``` + +### 5. push + PR作成 + +```bash +git push -u origin "$DEPLOY_BRANCH" +gh pr create --base "$ARGUMENTS" --head "$DEPLOY_BRANCH" \ + --title "$DEPLOY_BRANCH → $ARGUMENTS" \ + --body "$(cat <<'EOF' +## Summary +- 環境デプロイ用PR +- 元ブランチ: $FEATURE_BRANCH +- main取り込み済み + +## Test plan +- [ ] $ARGUMENTS 環境で動作確認 + + +EOF +)" +``` + +### 6. 元ブランチに復帰 + +```bash +git checkout "$FEATURE_BRANCH" +``` + +## 注意事項 + +- デフォルトブランチからの実行は禁止 +- main取り込みで conflict が出た場合、deployブランチを削除して戻る(featureブランチ側を先に同期すべき) +- deployブランチは PR マージ後に削除してよい +- 環境ブランチへの再デプロイは「同じ deployブランチに push」でPRが更新される + +## 関連 + +- `/ndf:cherry-pick-pr` — 一部コミットだけを環境に届ける場合 +- `/ndf:branch-fix-strategy` — ブランチ運用戦略の原則 +- `/ndf:sync-main` — featureブランチに main を取り込む diff --git a/plugins/ndf-codex/skills/docker-container-access/01-environment-detection.md b/plugins/ndf-codex/skills/docker-container-access/01-environment-detection.md new file mode 100644 index 0000000..51941bc --- /dev/null +++ b/plugins/ndf-codex/skills/docker-container-access/01-environment-detection.md @@ -0,0 +1,84 @@ +# 環境判定ガイド + +## Step 1: 自身の環境を確認 + +```bash +# 自分がコンテナ内で動作しているか確認 +cat /proc/1/cgroup 2>/dev/null | grep -q docker && echo "コンテナ内" || echo "ホスト環境" + +# または +[ -f /.dockerenv ] && echo "コンテナ内" || echo "ホスト環境" +``` + +## Step 2: Docker環境の種類を判定 + +自身がコンテナ内の場合、以下のいずれかの環境です: + +| 環境 | 説明 | 判定方法 | +|-----|------|---------| +| **DinD** (Docker in Docker) | コンテナ内に独立したDockerデーモン | `docker info`でDocker rootが`/var/lib/docker` | +| **DooD** (Docker outside of Docker) | ホストのDockerソケットを共有 | `/var/run/docker.sock`がマウントされている | + +```bash +# DooD判定: docker.sockがマウントされているか +ls -la /var/run/docker.sock 2>/dev/null && echo "DooD環境の可能性" || echo "DinDまたはホスト環境" + +# Docker rootディレクトリの確認 +docker info 2>/dev/null | grep "Docker Root Dir" +``` + +## DinD環境でのアクセス + +DinD環境では、**localhost**で他のコンテナにアクセスできます。 + +### 特徴 +- コンテナ内に独立したDockerデーモンが動作 +- ネットワークは通常のDocker環境と同じ +- `localhost:ポート`でアクセス可能 + +### アクセス例 + +```bash +# Webサーバーへのアクセス +curl http://localhost:8080 + +# データベースへの接続 +mysql -h localhost -P 3306 -u user -p + +# Playwright MCPでのアクセス +# URL: http://localhost:3000 +``` + +## 環境判定スクリプト + +```bash +#!/bin/bash +# Docker環境判定スクリプト + +echo "=== Docker環境判定 ===" + +# 自分がコンテナ内かチェック +if [ -f /.dockerenv ] || grep -q docker /proc/1/cgroup 2>/dev/null; then + echo "実行環境: Dockerコンテナ内" + + # DinD/DooD判定 + if [ -S /var/run/docker.sock ]; then + echo "Docker形式: DooD (Docker outside of Docker)" + echo "" + echo "→ 他のコンテナへのアクセスにはコンテナ名を使用してください" + echo "→ bind mountはホストのパスを参照するため注意が必要です" + else + echo "Docker形式: DinD (Docker in Docker)" + echo "" + echo "→ localhostで他のコンテナにアクセス可能です" + fi +else + echo "実行環境: ホストマシン" + echo "" + echo "→ 通常のDocker操作が可能です" +fi + +echo "" +echo "=== 利用可能なコンテナ ===" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo "Dockerが利用できません" +``` diff --git a/plugins/ndf-codex/skills/docker-container-access/02-dood-access.md b/plugins/ndf-codex/skills/docker-container-access/02-dood-access.md new file mode 100644 index 0000000..d19fc96 --- /dev/null +++ b/plugins/ndf-codex/skills/docker-container-access/02-dood-access.md @@ -0,0 +1,134 @@ +# DooD環境でのアクセスガイド + +## 特徴 + +- ホストのDockerデーモンを共有 +- localhostはホストマシンを指す(他のコンテナではない) +- コンテナ間通信はDockerネットワーク経由 + +## アクセス方法 + +### 1. コンテナ名でアクセス(同一ネットワーク内) + +```bash +# コンテナ名を確認 +docker ps --format "table {{.Names}}\t{{.Ports}}" + +# コンテナ名でアクセス +curl http://my-web-container:8080 + +# docker-composeの場合、サービス名でアクセス +curl http://web:8080 +``` + +### 2. Dockerネットワーク経由 + +```bash +# ネットワーク一覧を確認 +docker network ls + +# 特定ネットワークのコンテナを確認 +docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}' + +# IPアドレスでアクセス +curl http://172.17.0.2:8080 +``` + +### 3. 同一ネットワークへの参加 + +```bash +# 自分のコンテナを対象コンテナと同じネットワークに接続 +docker network connect my-network $(hostname) + +# その後、コンテナ名でアクセス可能 +curl http://target-container:8080 +``` + +## curlでのアクセス例 + +```bash +# NG: localhostは使えない +curl http://localhost:8080 # → Connection refused + +# OK: コンテナ名を使用 +curl http://my-app-container:8080 + +# OK: docker-composeのサービス名 +curl http://api:3000 + +# OK: コンテナのIPアドレス +CONTAINER_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' my-container) +curl http://${CONTAINER_IP}:8080 +``` + +## Playwright MCP / Chrome DevTools MCP + +```bash +# DooD環境では、コンテナ名またはIPを使用 +# URL: http://web-container:3000 (コンテナ名) +# URL: http://172.17.0.3:3000 (IP) +``` + +--- + +## bind mountの注意点 + +### 問題 + +DooD環境では、`docker run -v`や`docker-compose`のbind mountは**ホストマシンのパス**を参照します。開発コンテナ内のパスではありません。 + +```yaml +# NG: DooD環境では期待通りに動作しない +volumes: + - ./local-dir:/app/data # ホストの./local-dirを参照してしまう +``` + +### 解決策 + +#### 1. Dockerfileでコピー(推奨) + +```dockerfile +FROM node:18 +WORKDIR /app +COPY . . +RUN npm install +CMD ["npm", "start"] +``` + +#### 2. 名前付きボリュームを使用 + +```bash +# ボリュームを作成 +docker volume create my-data + +# ファイルをボリュームにコピー +docker run --rm -v my-data:/data -v $(pwd):/src alpine cp -r /src/. /data/ + +# ボリュームをマウントしてコンテナ起動 +docker run -v my-data:/app/data my-image +``` + +#### 3. docker cpを使用 + +```bash +# コンテナにファイルをコピー +docker cp ./local-file.txt my-container:/app/ + +# コンテナからファイルを取得 +docker cp my-container:/app/output.txt ./ +``` + +### docker-compose.yml での対応 + +```yaml +# DooD環境対応版 +version: '3.8' +services: + app: + build: . # Dockerfileでファイルをコピー + volumes: + - app-data:/app/data # 名前付きボリューム使用 + +volumes: + app-data: +``` diff --git a/plugins/ndf-codex/skills/docker-container-access/03-troubleshooting.md b/plugins/ndf-codex/skills/docker-container-access/03-troubleshooting.md new file mode 100644 index 0000000..0acac32 --- /dev/null +++ b/plugins/ndf-codex/skills/docker-container-access/03-troubleshooting.md @@ -0,0 +1,63 @@ +# トラブルシューティング + +## Q: `curl: (7) Failed to connect to localhost port 8080` + +**原因**: DooD環境でlocalhostを使用している + +**解決策**: +```bash +# コンテナ名またはIPを使用 +docker ps # コンテナ名を確認 +curl http://container-name:8080 +``` + +## Q: bind mountしたファイルが見えない + +**原因**: DooD環境ではホストのパスを参照している + +**解決策**: +```bash +# docker cpでコピー +docker cp ./file.txt container:/app/ + +# または名前付きボリュームを使用 +``` + +## Q: コンテナ間で通信できない + +**原因**: 異なるDockerネットワークに所属している + +**解決策**: +```bash +# 同じネットワークに接続 +docker network connect my-network container-a +docker network connect my-network container-b +``` + +## Q: docker.sockへのアクセス権限がない + +**解決策**: +```bash +# docker グループに追加(要再ログイン) +sudo usermod -aG docker $USER + +# または一時的に権限付与 +sudo chmod 666 /var/run/docker.sock +``` + +--- + +# ベストプラクティス + +## DO(推奨) + +- **コンテナアクセス前に環境を判定する** +- **DooD環境ではコンテナ名/サービス名を使用する** +- **ファイル共有はDockerfileのCOPYまたは名前付きボリュームを使用** +- **docker-composeではサービス名でアクセス** + +## DON'T(非推奨) + +- **環境を確認せずにlocalhostを使用する** +- **DooD環境でbind mountに依存する** +- **IPアドレスをハードコードする(変わる可能性がある)** diff --git a/plugins/ndf-codex/skills/docker-container-access/SKILL.md b/plugins/ndf-codex/skills/docker-container-access/SKILL.md new file mode 100644 index 0000000..eadd1ff --- /dev/null +++ b/plugins/ndf-codex/skills/docker-container-access/SKILL.md @@ -0,0 +1,76 @@ +--- +name: docker-container-access +description: "Diagnose Docker container access and localhost routing." +when_to_use: "Docker / コンテナへのアクセス・localhost 接続不可・DinD/DooD 環境判定が必要なとき。Triggers: 'docker access', 'container connect', 'localhost not working', 'DinD', 'DooD', 'Docker接続', 'コンテナアクセス', 'curl container'" +allowed-tools: + - Read + - Bash + - Glob +--- + +# Docker Container Access Skill + +## 概要 + +ローカル開発環境がDocker開発コンテナ上で動作している場合、他のDockerコンテナへのアクセス方法が通常と異なります。このスキルでは、環境を判定し、適切なアクセス方法を選択するためのガイドラインを提供します。 + +## クイックリファレンス + +``` +環境判定 → アクセス方法 +──────────────────────────────── +ホスト環境 → localhost:port +DinD環境 → localhost:port +DooD環境 → container-name:port または IP:port + +ファイル共有(DooD環境) +──────────────────────────────── +Dockerfile COPY → 推奨(ビルド時にコピー) +名前付きボリューム → 推奨(永続化が必要な場合) +docker cp → OK(一時的なコピー) +bind mount → NG(ホストのパスを参照) +``` + +## 環境判定(最初に実行) + +```bash +# 自分がコンテナ内か確認 +[ -f /.dockerenv ] && echo "コンテナ内" || echo "ホスト環境" + +# DooD判定 +ls -la /var/run/docker.sock 2>/dev/null && echo "DooD環境" || echo "DinDまたはホスト" +``` + +| 環境 | 説明 | コンテナへのアクセス | +|-----|------|-------------------| +| **ホスト** | 通常のDocker環境 | `localhost:port` | +| **DinD** | コンテナ内に独立したDockerデーモン | `localhost:port` | +| **DooD** | ホストのDockerソケットを共有 | `container-name:port` | + +## 詳細ガイド + +詳細は以下のファイルを参照してください: + +| ファイル | 内容 | +|---------|------| +| `01-environment-detection.md` | 環境判定の詳細、判定スクリプト | +| `02-dood-access.md` | DooD環境でのアクセス方法、bind mount注意点 | +| `03-troubleshooting.md` | トラブルシューティング、ベストプラクティス | + +## よくある問題(簡易版) + +| 症状 | 原因 | 解決策 | +|-----|------|--------| +| `localhost`で接続できない | DooD環境 | コンテナ名を使用 | +| bind mountしたファイルが見えない | DooD環境 | `docker cp`または名前付きボリューム | +| コンテナ間で通信できない | 別ネットワーク | 同じネットワークに接続 | + +## 関連Skill + +- **python-execution**: Python実行環境の判定 +- **corder-code-templates**: Dockerfileテンプレート + +## 関連リソース + +- [Docker Networking](https://docs.docker.com/network/) +- [Docker in Docker](https://hub.docker.com/_/docker) diff --git a/plugins/ndf-codex/skills/fix/SKILL.md b/plugins/ndf-codex/skills/fix/SKILL.md new file mode 100644 index 0000000..3b001bb --- /dev/null +++ b/plugins/ndf-codex/skills/fix/SKILL.md @@ -0,0 +1,303 @@ +--- +name: fix +description: "Fix actionable PR review comments." +when_to_use: "PRレビューコメント (codex/gemini/人間) の指摘を実際にコード修正で対応したいとき。review-pr-comments で分類した後の修正フェーズに使う。Triggers: 'PRコメント対応', 'PRレビュー修正', 'PR fix', 'review feedback fix', 'コメントに対応して修正'" +argument-hint: "[PR番号] [--defer-nit] [--severity-min critical|major|minor]" +allowed-tools: + - Bash + - Read + - Edit + - Write + - Glob + - Grep +--- + +# PR修正コマンド + +直前PR、または引数で指定されたPRのreview comment確認・修正対応実行。 + +## 起動モード + +このスキルは **メインセッション直接実行** と **サブエージェント (`general-purpose`) 起動** の両方に対応する。 +長丁場のクロスレビューループ(`/ndf:cross-review`)からは **必ずサブエージェント経由で起動** されることを想定: + +```python +# メインからの起動例(cross-review が内部でこれを行う) +Agent( + subagent_type="general-purpose", + description="Fix PR review comments (sub-agent)", + prompt=""" +/ndf:fix --defer-nit を実行してください。 + +PR: +リポジトリ: +重要度ポリシー: critical/major/minor は修正、nit は deferred として残す +完了後の戻り値: 件数サマリ + 修正コミット SHA + 残 nit リスト +""" +) +``` + +サブエージェント側ではこの SKILL.md を読み込んで、自己完結で +**修正 → コミット → push → reply → Resolve Conversation** まで実行する。 +メインへの戻り値は最小限のサマリのみ。 + +## 引数 + +| 引数 | 意味 | 既定 | +|---|---|---| +| `[PR番号]` | 対象 PR | 直前 PR | +| `--defer-nit` | nit 指摘は修正せず deferred としてリスト出力 | OFF | +| `--severity-min LEVEL` | 指定重要度未満は無視(`critical` / `major` / `minor`) | `minor` (= minor 以上を修正) | + +## 重要度ベースの自動修正ポリシー + +`[重要度 / カテゴリ]` プレフィックス(`/ndf:review` 出力規約)で分類。 +**ただし重要度ラベルを鵜呑みにしない** — 各指摘ごとにコード/仕様を独自に調査し、 +本来の重要度を判定し直してから下表の動作を適用する(bot のラベリングは参考値に過ぎない)。 + +| 重要度 | 動作 | ユーザ問い合わせ | +|---|---|---| +| `critical` | **必ず自動修正** | なし | +| `major` | **必ず自動修正** | なし | +| `minor` / `nit` (パフォーマンス・可読性・重複コード排除) | **このPRで修正対応**。特にトータル行数が減る方向の修正は積極的に実施 | なし | +| `minor` / `nit` (上記カテゴリ、修正範囲が +30 行を超えそう) | ユーザ問い合わせ | あり | +| `minor` (その他) | 自動修正(明らかな改善のみ)。判断が割れるなら `nit` として deferred 扱い | なし | +| `nit` (その他) | `--defer-nit` 指定時は **修正せず deferred リスト** に追加。最後にまとめてユーザ問い合わせ | あり(最後に1回) | + +**重要度の独自判定**: +- AI agent (CodeRabbit / Copilot 等) が `nit` と付けていても、実体がパフォーマンス改善や重複排除なら **minor/nit カテゴリ修正対象** として扱う +- 逆に AI agent が `critical` と付けていても、実害がないスタイル指摘なら `nit` 相当に格下げして deferred 化してよい +- 重要度はカテゴリ(performance/readability/duplication/security/style/etc)と合わせて、コード本体を読んだ上で判定する + +**指摘の正否判断**: +- ロジック・仕様逸脱・セキュリティ: コード/仕様を確認してから修正可否判断 +- bot 指摘で **明らかに誤読** している場合(例: 意図的な変数展開を「クオート不足」と指摘する等): 修正しない、reply で理由説明 +- 仕様判断が必要な指摘(API 変更、互換性破壊など): ユーザ問い合わせ対象(critical でもエスカレーション) + +**自動判断できない場合の取り扱い** (context 節約のため安易に user に投げない): +- 仕様文書(docs/, README)を読んで判断する +- 既存テストを読んで挙動を確認する +- 関連する他コードの慣例を確認する +- それでも不明なら deferred リストに「要ユーザ判断」として記録、最後にまとめて問い合わせ + +## 手順 + +1. review comment取得 + 重要度を**独自に再判定**(AI agent のラベルは参考値) +2. **CIエラー確認**(`gh pr checks ` で **現時点の** 失敗ジョブを検出) + - **完了待ちはしない**。実行中(PENDING/IN_PROGRESS)のチェックは無視して次ステップへ進む + - 直近で失敗(FAILURE)状態のジョブのみを修正対象に取り込む +3. 修正対象を確定: + - `critical` / `major` → 全件修正対象 + - `minor` / `nit` (パフォーマンス・可読性・重複排除) → 修正対象。+30行超なら **deferred + ユーザ問い合わせ** + - `minor` (その他) → 修正対象(明らかでないものは `deferred[]` へ) + - `nit` (その他、`--defer-nit` 時) → `deferred[]` のみ、修正しない + - CIエラー → 全件修正対象(PRテスト範囲外の **flaky テストも見つけ次第修正**) +4. 問題点修正 + - **コード行数が減る方向の修正は積極的に実施**(重複排除、不要分岐除去 等) +5. **コミット前の再確認**(修正作業中に状況が変わっている可能性への対応) + - **review comment再取得**: 作業中に新しいコメントが追加されていないか確認 + - **CI状態再確認**: 現時点の状態だけ確認(完了待ちはしない)。新しい失敗が出ていれば対象に取り込む + - 新しい指摘/失敗があれば手順3に戻る +6. コミット・プッシュ +7. PRにSummaryコメントを追加(対応した件数 + deferred 件数を明記) +8. 対応したインラインコメントに個別に返信 +9. **deferred スレッドには `[deferred / nit]` のラベル付き返信** を投稿(resolve はしない) +10. reviewerに再レビューを依頼 +11. 対応完了したインラインコメントを「Resolve Conversation」にする(`resolveReviewThread` mutation) + - resolve した thread_id / comment_id / path / line を `resolved_threads[]` に記録 + - `deferred` / `rejected` の thread は Resolve しない(次ラウンドで再評価するため) +12. **戻り値ファイルを書き出す**: `/tmp/fix-pr<番号>-result.json` (後述「戻り値フォーマット」参照) + - `ci_failed_checks` には `gh pr checks --json name,state` から `state=FAILURE` の name を抽出して列挙 + - push 直後の CI 再実行結果は**待たない**ため、戻り値の `ci_status` は push 時点での既知失敗のみを反映する + +- 4〜6はgit、1〜2/5と7以降はgithub mcpまたはghを利用 + +**flakyテストの扱い**: PR の変更範囲外で発生している flaky テストも、見つけ次第このPRで修正する。 +flaky を放置するとリポジトリ全体のコード品質が下がり、後続 PR の CI 信頼性も損なわれるため。 + +## CIエラーチェック + +### 失敗ジョブの検出 + +```bash +# PRの全チェック状態を確認(FAIL/PASS/PENDING) +gh pr checks + +# JSON形式で詳細取得 +gh pr checks --json name,state,link,completedAt + +# 失敗ジョブのみ抽出 +gh pr checks --json name,state | \ + python3 -c "import json,sys; [print(c['name']) for c in json.load(sys.stdin) if c['state']=='FAILURE']" + +# 実行中ジョブのみ抽出(状態スナップショット用。完了は待たない) +gh pr checks --json name,state | \ + python3 -c "import json,sys; [print(c['name']) for c in json.load(sys.stdin) if c['state'] in ('PENDING','IN_PROGRESS','QUEUED')]" +``` + +### CI完了待ちはしない + +このスキルでは **CI 完了待ちは行わない**(`gh pr checks --watch` 等は使わない)。 +- 各チェックポイントでは「現時点で FAILURE のジョブ」のみを取り込んで修正する +- push 後の CI 再実行結果も待たない(待機中に context を消費しないため) +- ただし `gh pr checks --json name,state` での **状態スナップショット取得は実施** + し、戻り値の `ci_status` / `ci_failed_checks` に反映する + +### 失敗ログの取得 + +```bash +# ワークフロー実行ID取得 +RUN_ID=$(gh run list --branch --limit 1 --json databaseId --jq '.[0].databaseId // empty') +[ -z "$RUN_ID" ] && { echo "No CI run found for this branch"; exit 0; } + +# 失敗ステップのログだけ表示(効率的) +gh run view $RUN_ID --log-failed + +# 特定ジョブのログ +gh run view $RUN_ID --job --log +``` + +### CIエラーの分類と対応方針 + +| エラー種別 | 対応方針 | +|---|---| +| **lint/format** | 自動修正ツール実行(`ruff`, `prettier`, `eslint --fix` 等)→ コミット | +| **型チェック** | 型定義・アノテーションを修正。無視コメントは原則禁止(根本対応) | +| **テスト失敗** | 失敗テストを読み、実装/テストどちらが正しいか判断してから修正。テスト側の問題なら仕様確認 | +| **ビルドエラー** | 依存関係・構文・設定ファイルを確認 | +| **依存脆弱性** | 可能ならバージョン更新、無理なら除外ルール追加(理由明記) | +| **タイムアウト/flaky** | retry設定、テスト分割、リトライ追加。**PR範囲外の flaky も見つけ次第修正**(放置でリポジトリ全体の品質劣化を招くため) | +| **インフラ一時障害** | 再実行で解消することがあるため `gh run rerun $RUN_ID` を先に試す | + +### review指摘との統合 + +review指摘とCIエラーは**同じPRで一緒に修正**する: +- 同じファイル・機能に関する指摘とCIエラーは1コミットにまとめる +- 独立しているなら別コミットに分離(git log で追いやすい) + +## ghコマンド例 + +### PR コメント一括取得 (3 ソース) + +```bash +# インラインコメント / レビュー body / PR レベルコメントを一括取得 +FETCH_SCRIPT="${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}/skills/fix/scripts/fetch-pr-comments.sh" +"$FETCH_SCRIPT" +``` + +### コメントへの返信 + +```bash +# PRのレビューコメント一覧を取得 (インラインコメントのみ) +gh api repos/{owner}/{repo}/pulls/{pr_number}/comments + +# 特定のコメントに返信(in_reply_to にコメントIDを指定) +gh api repos/{owner}/{repo}/pulls/{pr_number}/comments \ + -f body="修正しました。" \ + -F in_reply_to={comment_id} +``` + +### Resolve Conversation + +```bash +# GraphQL APIでスレッドをresolveする +gh api graphql -f query=' + mutation { + resolveReviewThread(input: {threadId: "{thread_node_id}"}) { + thread { isResolved } + } + } +' +``` + +### thread_node_idの取得方法 + +```bash +# PRのレビュースレッド一覧を取得(node_id含む) +gh api graphql -f query=' + query { + repository(owner: "{owner}", name: "{repo}") { + pullRequest(number: {pr_number}) { + reviewThreads(first: 100) { + nodes { + id + isResolved + comments(first: 1) { + nodes { body } + } + } + } + } + } + } +' +``` + +**方針**: +- 品質・可読性・セキュリティ向上、既存機能影響なし +- 指摘がすべて正しいとは限らない。修正前に仕様を調査し、実施の可否を判断すること +- 未対応の場合はその理由をコメントに書き込む + +## 戻り値フォーマット(必須) + +サブエージェント呼び出し時の context 節約のため、**実行結果は `/tmp/fix-pr<番号>-result.json` に書き出す**: + +```json +{ + "pr": 67, + "fix_commit": "abc1234", + "ci_status": "SUCCESS" | "FAILURE" | "PENDING" | "NONE", + "ci_failed_checks": [], + "ci_note": null, + "fixed_count": 5, + "by_severity": {"critical": 1, "major": 2, "minor": 2, "nit": 0}, + "resolved_threads": [ + { + "thread_id": "PRRT_...", + "comment_id": 3222849090, + "path": "src/foo.py", + "line": 42 + } + ], + "deferred": [ + { + "comment_id": 3222849090, + "thread_id": "PRRT_...", + "path": "src/foo.py", + "line": 42, + "severity": "nit", + "category": "style", + "summary": "末尾セミコロンの有無", + "reason_for_deferral": "好みの範囲。プロジェクト規約と齟齬なし" + } + ], + "rejected": [ + { + "comment_id": 3222849090, + "summary": "heredoc を <<'JSON' にせよ", + "reason_for_rejection": "$SHA を意図的に展開する必要があり、クオート化すると逆に壊れる" + } + ], + "summary_comment_url": "https://github.com/.../pull/67#issuecomment-..." +} +``` + +**フィールド説明**: + +- `ci_failed_checks` — `ci_status = FAILURE` のとき、失敗した check 名の配列。`/ndf:cross-review` 側で code-related (`pint/larastan/test/build/lint/type`) と meta-only (`check_pr_requirements/assignees/reviewers/labels`) を分類し、メタチェックのみ失敗ならループ継続する +- `ci_note` — code-related ではない CI 失敗の補足。例: `"メタチェックのみ失敗: check_pr_requirements — Assignees 未設定"` +- `resolved_threads` — 手順 11 で `resolveReviewThread` mutation を実行したスレッド一覧。`deferred` / `rejected` の thread は **Resolve しない**(再評価のため) + +サブエージェントとして起動された場合は、この JSON をメインに返すサマリの基礎とする。 + +## 作業完了報告(必須) + +メイン or PR への報告内容(戻り値ファイルから抽出): +- 対応した指摘の件数(重要度別: critical/major/minor/nit) +- **deferred 件数**(主に nit、最後にユーザ問い合わせ予定) +- **rejected 件数**(bot 指摘が不適切で修正しなかった件、各々理由付き) +- **対応したCIエラーの一覧**(ジョブ名、エラー内容、修正方法) +- **対応した flaky テストの一覧**(PR範囲外も含む) +- 修正コミット SHA / 修正ファイル一覧 +- 戻り値ファイルパス: `/tmp/fix-pr<番号>-result.json` +- **PR URL を最後に必ず記載**(例: `https://github.com///pull/<番号>`) diff --git a/plugins/ndf-codex/skills/fix/scripts/fetch-pr-comments.sh b/plugins/ndf-codex/skills/fix/scripts/fetch-pr-comments.sh new file mode 100755 index 0000000..aa7f97f --- /dev/null +++ b/plugins/ndf-codex/skills/fix/scripts/fetch-pr-comments.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Usage: fetch-pr-comments.sh +# 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を一括取得し、 +# タグ付き行単位で stdout に出力する。 +# 全ソース取得失敗時は非 0 で終了する(0件取得と取得失敗を区別)。 +set -uo pipefail + +if [[ $# -lt 2 ]] || [[ -z "${1:-}" ]] || [[ -z "${2:-}" ]]; then + echo "ERROR: 引数が不足しています。Usage: $0 " >&2 + exit 1 +fi + +REPO="$1" +PR="$2" + +FAIL_COUNT=0 + +# 1. インラインコメント (diff の特定行に紐づく) +# 本文全体を保持する。改行は \n エスケープして 1 行に収める。 +# gh api --jq は内部で jq -r 相当だが、環境差を吸収するため明示的に jq -r へパイプする。 +if ! gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \ + | jq -r '.[] | "\(.path // "?"):\(.line // .original_line // "?") [\(.user.login)] \(.body // "" | gsub("\n"; "\\n") | gsub("```"; "` ` `"))"'; then + echo "WARNING: インラインコメントの取得に失敗しました (repos/${REPO}/pulls/${PR}/comments)" >&2 + (( FAIL_COUNT += 1 )) || true +fi + +# 2. レビュー body (CHANGES_REQUESTED / COMMENTED 等の総評) +# 本文全体を保持する。改行は \n エスケープして 1 行に収める。 +if ! gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate \ + | jq -r '.[] | select(.body != null and .body != "") | "[REVIEW-BODY] [\(.user.login)] state=\(.state) \(.body | gsub("\n"; "\\n") | gsub("```"; "` ` `"))"'; then + echo "WARNING: レビュー body の取得に失敗しました (repos/${REPO}/pulls/${PR}/reviews)" >&2 + (( FAIL_COUNT += 1 )) || true +fi + +# 3. PR レベルコメント (Conversation タブの通常コメント) +# 本文全体を保持する。改行は \n エスケープして 1 行に収める。 +if ! gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ + | jq -r '.[] | "[PR-COMMENT] [\(.user.login)] \(.body // "" | gsub("\n"; "\\n") | gsub("```"; "` ` `"))"'; then + echo "WARNING: PR レベルコメントの取得に失敗しました (repos/${REPO}/issues/${PR}/comments)" >&2 + (( FAIL_COUNT += 1 )) || true +fi + +# 全ソース失敗時のみ非 0 で終了(認証切れ等の検出) +if (( FAIL_COUNT >= 3 )); then + echo "ERROR: 全 3 ソースの取得に失敗しました" >&2 + exit 1 +fi diff --git a/plugins/ndf-codex/skills/git-gh-operations/01-common-errors.md b/plugins/ndf-codex/skills/git-gh-operations/01-common-errors.md new file mode 100644 index 0000000..a7afa44 --- /dev/null +++ b/plugins/ndf-codex/skills/git-gh-operations/01-common-errors.md @@ -0,0 +1,145 @@ +# Git / gh 共通エラー事例集 + +## 1. git add pathspec エラー + +### 事象 +``` +fatal: pathspec 'lambda-batch/CarImageProcessingPipeline/src/foo.py' did not match any files +``` + +### 原因 +CWD が `/work/repo/lambda-batch/CarImageProcessingPipeline/` なのに、 +リポジトリルートからの相対パスで `git add` した。 + +`git status` はリポジトリルートからの相対パスで表示するが、 +`git add` は CWD からの相対パスで解決する。 + +### 予防策 +```bash +# Step 1: CWD確認 +pwd +# => /work/repo/lambda-batch/CarImageProcessingPipeline/ + +# Step 2: git status の出力を確認 +git status +# modified: lambda-batch/CarImageProcessingPipeline/src/foo.py +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# これはリポジトリルートからの相対パス + +# Step 3: CWD からの相対パスに変換 +git add src/foo.py +# または +git add . # CWD以下のすべての変更 +``` + +## 2. gh api 404 エラー + +### 事象 +``` +gh api repos/owner/repo/pulls/comments/123/replies -f body='message' +# => 404 Not Found +``` + +### 原因 +POST メソッドが必要な API エンドポイントに GET でアクセスした。 +`gh api` はデフォルトで GET を使用する。 + +### 修正 +```bash +gh api -X POST repos/owner/repo/pulls/comments/123/replies -f body='message' +``` + +## 3. GitHub 自己 Approve エラー + +### 事象 +``` +Could not approve for pull request review. Can not approve your own pull request +``` + +### 原因 +GitHub はセキュリティ上、自分で作成した PR を APPROVE できない。 + +### 対策 +```bash +# pending review を削除してから COMMENT として再送信 +# method: "delete_pending" → method: "create" + event: "COMMENT" +``` + +## 4. AWS CLI [$LATEST] パースエラー + +### 事象 +``` +Unknown options: , , , +``` + +### 原因 +CloudWatch ログストリーム名に含まれる `[$LATEST]` が +`--query` JMESPath パーサーや shell の glob として解釈される。 + +### 対策 +```bash +# シングルクォートで囲んでも --query との組み合わせで問題が出る +# --output json + python パースが最も安全 +aws logs get-log-events \ + --log-group-name "/aws/lambda/func-name" \ + --log-stream-name '2026/02/18/[$LATEST]abc123' \ + --output json | python3 -c " +import sys, json +data = json.loads(sys.stdin.read()) +for e in data['events']: + print(e['message'].strip()) +" +``` + +## 5. git commit メッセージの特殊文字 + +### 事象 +コミットメッセージに日本語や改行が含まれるとエスケープ問題が発生。 + +### 対策 +常に HEREDOC 形式を使用: +```bash +git commit -m "$(cat <<'EOF' +日本語メッセージ + +詳細説明 + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +注意: `<<'EOF'` (シングルクォート付き)で変数展開を抑制する。 + +## 6. gh pr checks が exit code 1 で止まる + +### 事象 +``` +gh pr checks 11765 2>&1 +# => チェック結果は表示されるが、1つでもfailがあると exit code 1 で終了 +# => Claude Code が「コマンド失敗」と判定して処理を中断 +``` + +### 原因 +`gh pr checks` は CI チェックに失敗があると非0の exit code を返す仕様。 +Claude Code の Bash ツールはコマンドの exit code が 0 以外だとエラーとして扱う。 + +### 対策 +常に `|| true` を付けて exit code を 0 にする: +```bash +# チェック一覧を取得(failがあっても止まらない) +gh pr checks 11765 2>&1 || true + +# --watch で完了待ちする場合も同様 +gh pr checks 11765 --watch 2>&1 || true + +# 失敗のみフィルタする場合 +gh pr checks 11765 2>&1 | grep -i fail || true +``` + +### 補足 +同様の問題が発生する gh コマンド: +- `gh run view RUN_ID --log-failed` (失敗ログ取得時) +- `gh pr diff` (差分が大きい場合にパイプ破損) + +いずれも `2>&1 || true` を付けることで安全に実行できる。 diff --git a/plugins/ndf-codex/skills/git-gh-operations/SKILL.md b/plugins/ndf-codex/skills/git-gh-operations/SKILL.md new file mode 100644 index 0000000..68b8a7a --- /dev/null +++ b/plugins/ndf-codex/skills/git-gh-operations/SKILL.md @@ -0,0 +1,228 @@ +--- +name: git-gh-operations +description: "Resolve git and GitHub CLI operation errors." +when_to_use: "git / gh コマンドでエラーが出た or 操作方法に迷うとき。Triggers: 'git add', 'git commit', 'git push', 'gh pr', 'gh api', 'GitHub操作', 'gitエラー', 'fatal:', 'pathspec'" +allowed-tools: + - Bash + - Read +--- + +# Git / gh 操作スキル + +## 最重要ルール: CWD とパスの整合性 + +git コマンドはすべて **CWD からの相対パス** で解決される。 +操作前に必ず `pwd` で CWD を確認すること。 + +### パターン1: CWDがサブディレクトリの場合 + +``` +# CWD: /work/repo/lambda-batch/MyProject/ +# リポジトリルート: /work/repo/ + +# NG: リポジトリルートからのパスを指定 +git add lambda-batch/MyProject/src/foo.py +# => fatal: pathspec did not match any files + +# OK: CWDからの相対パスを指定 +git add src/foo.py + +# OK: 絶対パスを指定 +git add /work/repo/lambda-batch/MyProject/src/foo.py +``` + +### パターン2: 安全な方法 + +```bash +# 方法A: git -C でリポジトリルートを指定 +git -C /work/repo add lambda-batch/MyProject/src/foo.py + +# 方法B: CWD を変更せずに絶対パスを使用 +git add "$(git rev-parse --show-toplevel)/lambda-batch/MyProject/src/foo.py" + +# 方法C(推奨): CWDからの相対パスを使用 +# まず pwd で確認してからパスを組み立てる +``` + +## git 操作チェックリスト + +### git add の前に + +1. `pwd` で CWD を確認 +2. `git status` で変更ファイルのパスを確認(表示されるパスはリポジトリルートからの相対パス) +3. `git status` の出力パスと CWD の関係を計算してから `git add` する + +### git commit の前に + +1. `git diff --cached` でステージング内容を確認 +2. HEREDOC形式でメッセージを渡す(改行・特殊文字の問題回避) + +```bash +git commit -m "$(cat <<'EOF' +コミットメッセージ + +Co-Authored-By: Claude Opus 4.6 +EOF +)" +``` + +## gh CLI / GitHub API の注意点 + +### パラメータ: `-f` vs `-F` + +```bash +# -f: 文字列パラメータ +gh api repos/OWNER/REPO/pulls/PR/comments -f body="テキスト" + +# -F: 非文字列パラメータ(数値、boolean、null、ファイル) +gh api repos/OWNER/REPO/pulls/PR/comments -F in_reply_to=2826074026 + +# 混在OK +gh api repos/OWNER/REPO/pulls/PR/comments -f body="返信テキスト" -F in_reply_to=2826074026 +``` + +### PRレビューコメントの取得 + +```bash +# コメント一覧を取得(id, path, body の先頭を表示) +gh api repos/OWNER/REPO/pulls/PR/comments \ + --jq '.[] | {id: .id, path: .path, body: (.body | split("\n")[0][:80])}' +``` + +### PRレビューコメントへの返信 + +```bash +# NG: /replies エンドポイントは存在しない(404になる) +gh api repos/OWNER/REPO/pulls/comments/{id}/replies -f body='...' +# => 404 Not Found + +# NG: -X POST を付けても同じ(エンドポイント自体が存在しない) +gh api -X POST repos/OWNER/REPO/pulls/comments/{id}/replies -f body='...' +# => 404 Not Found + +# OK: in_reply_to パラメータを使って新規コメントとして投稿 +gh api repos/OWNER/REPO/pulls/PR/comments \ + -f body="返信テキスト" \ + -F in_reply_to=COMMENT_ID +``` + +### レビュースレッドの Resolve(GraphQL) + +```bash +# 1. 未解決スレッドのID一覧を取得 +gh api graphql -f query=' +query { + repository(owner: "OWNER", name: "REPO") { + pullRequest(number: PR) { + reviewThreads(first: 50) { + nodes { + id + isResolved + comments(first: 1) { + nodes { path body } + } + } + } + } + } +}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {id, path: .comments.nodes[0].path}' + +# 2. スレッドを Resolve +gh api graphql -f query=' +mutation { + resolveReviewThread(input: {threadId: "PRRT_xxx"}) { + thread { isResolved } + } +}' +``` + +### PR の CI チェック結果 + +`gh pr checks` は1つでもfailがあると **exit code 1** で終了する。 +Claude Codeではコマンド失敗と判定されて処理が止まるため、必ず `|| true` を付ける。 + +```bash +# NG: failがあるとexit code 1で止まる +gh pr checks PR --repo OWNER/REPO + +# OK: exit codeを常に0にして出力を取得 +gh pr checks PR --repo OWNER/REPO 2>&1 || true + +# OK: 失敗のみフィルタ +gh pr checks PR --repo OWNER/REPO 2>&1 | grep -i fail || true +``` + +#### 重要: CIの完了を待ってはいけない + +- `--watch` や完了までのポーリングは **禁止**。現在のステータスを一度スナップショットするだけでよい。 +- チェックが `in_progress` / `queued` / `pending` の場合は **完了を待たず次のステップへ進む**。 +- 対応対象は **コード修正で直せるfailのみ**。以下のような「ステータス確認系」チェックは無視する: + - `check_pr_requirements` 等、PR要件・メタ情報のみ検証するもの + - Lint/テストに非依存なラベル/タイトル/説明チェック + - 外部サービス起因で自己修復するトランジェントなfail(再実行で直るもの) +- 対応する: ビルド失敗・テスト失敗・型エラー・lint違反など、**リポジトリ内コードの修正で解消可能なもの**。 + +```bash +# 失敗ジョブのログ(エラー行のみ抽出) +gh run view RUN_ID --repo OWNER/REPO --log-failed 2>&1 \ + | grep -E '(FAIL|Error|Tests:)' | head -20 || true +``` + +### 自分のPRは Approve できない + +``` +# GitHub の制約: 自分で作成した PR に APPROVE レビューは不可 +# => "Can not approve your own pull request" +# 対策: event を "COMMENT" に変更して送信 +``` + +### PR作成時の body は HEREDOC + +```bash +# NG: \n がリテラルで混入する可能性 +gh pr create --title "タイトル" --body "行1\n行2" + +# OK: HEREDOC形式 +gh pr create --title "タイトル" --body "$(cat <<'EOF' +## Summary +- 変更内容 + +## Test plan +- [ ] テスト項目 +EOF +)" +``` + +## AWS CLI の注意点 + +### CloudWatch ログストリーム名の [$LATEST] + +```bash +# NG: --query で [$LATEST] を含む文字列がパースエラー +aws logs get-log-events --query 'events[*].message' --output text + +# OK: --output json にして python でパース +aws logs get-log-events --output json | python3 -c " +import sys,json +data = json.loads(sys.stdin.read()) +for e in data['events']: + print(e['message'].strip()) +" +``` + +## エラー事例集 + +| エラーメッセージ | 原因 | 対策 | +|----------------|------|------| +| `fatal: pathspec '...' did not match any files` | CWD とパスの不一致 | `pwd` 確認後、CWD相対パスで指定 | +| `404 Not Found` (gh api replies) | `/comments/{id}/replies` は存在しない | `in_reply_to` パラメータで投稿 | +| `422 Unprocessable` (gh api) | `-f` で数値を渡した | 数値は `-F` を使う | +| `Can not approve your own pull request` | 自己 Approve 不可 | `COMMENT` イベントに変更 | +| `gh pr checks` が exit code 1 | 1つでもfailがあると非0終了 | `gh pr checks ... 2>&1 \|\| true` | +| `Unknown options: , , ,` (aws cli) | `[$LATEST]` のシェルエスケープ | `--output json` + python パース | + +## 詳細ガイド + +| ファイル | 内容 | 参照タイミング | +|---------|------|--------------| +| `01-common-errors.md` | 詳細なエラー事例と再現手順 | エラー発生時 | diff --git a/plugins/ndf-codex/skills/implementation-plan/SKILL.md b/plugins/ndf-codex/skills/implementation-plan/SKILL.md new file mode 100644 index 0000000..0e0a130 --- /dev/null +++ b/plugins/ndf-codex/skills/implementation-plan/SKILL.md @@ -0,0 +1,98 @@ +--- +name: implementation-plan +description: "Create or update implementation plan files." +when_to_use: "実装開始時 / PR作成時に実装プランの作成・更新が必要なとき。複数ファイル変更・新機能追加・DBマイグレーションを含む変更で自動参照。Triggers: '実装プラン', '実装を開始', 'PR作成', 'implementation plan', 'plan first', '設計書を作成', 'issues/に追加'" +--- + +# 実装プランガイド + +## 基本方針 + +実装の開始時およびPR作成時に、`issues/` 配下に実装プランファイルが存在するか確認し、なければ作成する。プランを残すことで後任エンジニアや将来の自分が変更意図を追跡できる。 + +## 実装プランが必要なケース + +以下のいずれかに該当する場合は作成する: + +- 複数ファイルにまたがる変更 +- 新規機能の追加 +- 既存ロジックの大幅な変更 +- DBマイグレーションを伴う変更 +- 複数のタスクに分解できる作業 + +## 実装プランが不要なケース + +以下のような軽微な変更では不要: + +- typo修正、文言変更 +- 設定値の変更のみ +- 1ファイルで完結する軽微な修正 +- フォーマッター適用のみ +- ドキュメントのみの更新 + +判断に迷う場合はユーザーに確認する。 + +## ファイル配置・命名 + +- パス: `issues/` +- ファイル名に日本語は含めないこと(Git/CI/検索ツール互換性のため) +- タスクIDがある場合: `issues/TASK-1234_concise-description.md` +- タスクIDがない場合: `issues/{feature-name}.md` + +## PR作成時のプランファイル生成 + +PR作成時に `issues/` にプランファイルが存在しない場合、以下の情報源からプランファイルを生成する: + +1. **会話履歴** - それまでのやりとりから要件・背景・方針を抽出 +2. **git log** - コミット履歴からタスクの流れと変更概要を把握 +3. **git diff** - 実際の変更内容から修正対象ファイルと変更内容を特定 + +これらを組み合わせて、下記フォーマットに沿ったプランファイルを作成してからPRを作成する。 + +## プランのフォーマット + +```markdown +# {タスクID}: {機能名/修正内容} + +## 関連リンク +(Issue/チケット/設計ドキュメントがあれば記載) + +## 概要 +- 何を実装・修正するのか + +## 問題・背景 +- なぜこの変更が必要なのか(該当する場合) + +## 修正対象 +- 変更対象のファイルパス一覧 + +## タスク分解 + +### Task 1: {タスク名} +- **対象ファイル:** 変更対象のファイルパス +- **変更内容:** 具体的な変更内容 + +### Task 2: {タスク名} +- **対象ファイル:** 変更対象のファイルパス +- **変更内容:** 具体的な変更内容 + +## 影響範囲 +- 変更による影響を受ける機能やファイル + +## テスト計画 +- [ ] {実装した機能が正しく動作することの確認} +- [ ] {既存機能にリグレッションがないことの確認} +``` + +## ワークフロー + +1. 実装の依頼を受けたら、まずプランが必要か判断する +2. 必要な場合は `issues/` にプランファイルを作成してから実装を開始する +3. PR作成時にプランファイルが存在しない場合、必要であれば会話履歴・git log・git diffからプランファイルを生成してからPRを作成する + +## プランと PR Body の関係 + +- プランファイル = 「なぜ」「どう分解するか」を残す永続的な記録 +- PR body = 「何をやったか」「どうテストするか」のレビュー用サマリ + +同じ内容をコピーせず、PR bodyでは「詳細は `issues/xxx.md` 参照」と誘導してもよい。 diff --git a/plugins/ndf-codex/skills/investigation-rules/SKILL.md b/plugins/ndf-codex/skills/investigation-rules/SKILL.md new file mode 100644 index 0000000..a4757a8 --- /dev/null +++ b/plugins/ndf-codex/skills/investigation-rules/SKILL.md @@ -0,0 +1,105 @@ +--- +name: investigation-rules +description: "Write evidence-backed investigation and debug reports." +when_to_use: "調査・デバッグ・不具合レポートを作成するとき。「ない」「該当なし」等の否定的結論を出すときは必ず参照。Triggers: '調査', 'デバッグ', '不具合レポート', '原因調査', 'investigation', 'root cause', 'カラムがない', '該当コードがない', 'データがない'" +--- + +# 調査レポート作成ルール + +不具合調査・データ調査・仕様調査でレポートを作成する際のルール。コード読解だけに頼らず、必ず実行結果・出力・実データで裏取りする。 + +## 否定的結論にはエビデンス必須 + +「カラムがない」「データがない」「関数が呼ばれていない」「該当コードがない」等の **否定的な結論** を書く場合、**必ず実行結果をエビデンスとして添付すること**。 + +### なぜこのルールが必要か + +AIは「もっともらしいが間違った推論」をしがちで、コード読解だけで「ない」と断定して誤判断を招きやすい。事例として、外部テーブルの一部カラムだけを見て「該当カラムなし」と結論づけたが、実際には別名のカラムにデータが存在していた、という判断ミスが典型。 + +### 具体的な裏取り方法 + +| 主張の種類 | 必須エビデンス | +|-----------|--------------| +| DB: カラムが存在しない | `SHOW COLUMNS FROM table_name` / `DESCRIBE` の結果 | +| DB: データが存在しない | `SELECT COUNT(*) FROM table WHERE ...` の結果 | +| DB: テーブルが存在しない | `SHOW TABLES LIKE '%keyword%'` の結果 | +| コード: 関数/シンボルが存在しない | `grep -rn 'name' .` / LSP検索 / Serena `find_symbol` の結果 | +| コード: 呼び出し箇所がない | `find_referencing_symbols` / `grep` の結果 | +| 設定: 値が存在しない | 設定ファイルのdiff / `env` / `config` コマンド出力 | +| ログ: エラーが出ていない | `grep` / 検索ツールのクエリと結果期間 | + +### レポートへの記載例 + +```markdown +### 残課題 + +| 課題 | 概要 | エビデンス | 優先度 | +|------|------|-----------|--------| +| 外部API の retry 未実装 | Xクライアントで retry ハンドリングが無い | `grep -rn "retry\|Retry" src/client/x/` → 0件 | 中 | +| status=deleted の件数 | 論理削除レコードが残存 | `SELECT COUNT(*) FROM ... WHERE status='deleted'` → 2,341件 | 低 | +``` + +### やってはいけないこと + +- コードを読んだだけで「このカラムは存在しない」と断定する +- 1つのテーブル/ファイルだけ見て「データに問題はない」と結論づける +- 外部テーブルの一部のカラムだけ見て「他にはない」と判断する(全カラムを確認する) +- エビデンスなしで残課題の優先度を「低」にする(誤判断の典型) + +## 外部データ調査の原則 + +外部API/外部テーブル/サードパーティデータソースを調査する際は、**全体構造を必ず確認する**。 + +```sql +-- まず全体像を把握する +SHOW COLUMNS FROM external_source_table; + +-- 次に対象カラムのデータ分布を確認する +SELECT column_name, COUNT(*) FROM table GROUP BY column_name; +``` + +外部データは外部システム由来でカラム名・値域が予測しづらいため、コードから逆引きするだけでは見落とす。 + +## ハルシネーション防止チェックリスト + +推論で埋めず、必ず以下を実行して裏取りする: + +| チェック項目 | 方法 | +|------------|------| +| カラム/フィールドが存在するか | `SHOW COLUMNS` / スキーマ定義ファイルを開く | +| データが存在するか | `SELECT COUNT(*) WHERE ...` / サンプルレコード取得 | +| 型が一致するか | DB定義とアプリコード両方を確認(Eloquent `$casts`、dataclass型等) | +| FK/制約が存在するか | マイグレーション履歴を追跡(追加→削除→再追加の変遷を確認) | +| 論理削除ポリシーは何か | `SoftDeletes` / `deleted_at` の有無を確認 | +| 環境差異がないか | dev/staging/prod で同じクエリを実行して比較 | + +## 調査結果の書き方テンプレート + +```markdown +## 症状 +何がどう間違っているか(定量的に、エビデンス付きで) + +## 調査経緯 +1. 仮説1: xxx → クエリ/コマンドで確認 → 否定/肯定 +2. 仮説2: yyy → ... + +## 根本原因 +コードレベルでどこが問題か(ファイル名:行番号で特定) + +## エビデンス +``` +SQL/コマンド実行結果をそのまま貼る +``` + +## 修正方針 +どのフェーズで何を直すか(多層防御の観点) + +## 検証手順 +修正後にどう確認するか(回帰テスト含む) +``` + +SQLクエリ結果・コマンド出力をそのまま貼り、「コードを読んだ推測」と「実行して確認した事実」を明確に区別する。 + +## 関連スキル + +- `/ndf:problem-solving` — 根本原因分析と多層防御の原則 diff --git a/plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md b/plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md new file mode 100644 index 0000000..4275a37 --- /dev/null +++ b/plugins/ndf-codex/skills/issue-plan-strategy/SKILL.md @@ -0,0 +1,335 @@ +--- +name: issue-plan-strategy +description: "Turn issues into plans and implementation workflows." +when_to_use: "issue → plan 作成 / 既存 plan の実装 (実行) を依頼されたとき。複数 PR に分割される設計や、release branch + 個別 PR + worktree 運用が必要なときに参照する。Triggers: 'issueのplanを作って', 'PLANxxの設計', '設計書を起こして', 'このplanを実装して', 'PLANxxを実装', 'planを実行', 'release branch 作って実装開始', 'multi-PR で進めて'" +argument-hint: "[issue-path-or-url] (例: issues/i16.md, https://github.com/org/repo/issues/123)" +allowed-tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep +--- + +# issue → plan → multi-PR ワークフロー + +1 つの issue から plan を作る際、推奨される PR が複数に分かれることは日常的に発生する。本 skill はその際の **release ブランチ + 個別 PR ブランチ + Draft PR 先行作成 + git worktree 並行開発 + レビュー運用** の標準フローを規定する。 + +本 skill は **plan の作成フェーズと plan の実行(実装)フェーズの両方** をカバーする。同じワークフローが「設計を起こす段階」と「設計に従って実装する段階」を貫通することで、作成者と実装者(あるいは将来の自分)が同じ手順を共有できる。 + +## 発動条件 + +| トリガ | 例 | 入る Step | +|---|---|---| +| スラッシュコマンド (引数あり) | `/ndf:issue-plan-strategy issues/foo.md`、`/ndf:issue-plan-strategy https://github.com/org/repo/issues/123` | Step 0 から | +| スラッシュコマンド (引数なし) | `/ndf:issue-plan-strategy` (現在ブランチで作業中の issue/plan を解析) | Step 0 から | +| 自動発動 (作成系) | 「この issue の plan を作って」「設計書を起こして」「PLAN42 の設計を起こして」 | Step 1〜2 | +| 自動発動 (実行系) | 「この plan を実装して」「PLAN42 を実行して」「multi-PR で進めて」「release branch を切って実装開始」 | Step 0 → 既存 plan を読み → Step 3 以降 | + +引数で渡された issue / plan は **ファイルパス / URL / 番号** いずれでも受け付ける: + +- ファイルパス (`issues/PLANxx_*.md`): 直接 Read +- GitHub Issue URL / `#番号`: `gh issue view --json title,body,labels` で取得 +- それ以外の文字列: そのまま issue 本文として扱う + +## Step 0: 作成フェーズか実行フェーズか判定 + +最初に **既に plan ファイルが存在するか** で判定する。skill 内で `Glob` を使うのが第一選択 (例: `Glob('issues/*PLAN42*')`)。shell で確認する場合は: + +```bash +# issues/ 配下に該当 plan があるか (PLAN42 / feature-name 部分は実値に置換) +find issues/ -maxdepth 1 -iname '*PLAN42*' -o -iname '*feature-name*' +``` + +| 状況 | 進むフェーズ | +|---|---| +| plan ファイルがない / issue しかない | **作成フェーズ** (Step 1〜2 へ) | +| plan ファイルがあり、release branch がない | **実行フェーズ・初期化** (Step 3 へ) | +| release branch も Draft PR も既にある | **実行フェーズ・継続** (Step 5 以降。worktree / 並行開発 / レビュー / merge を進める) | + +実行フェーズで入った場合、既存 plan の **「PR 分割計画」セクション**を必ず Read してから Step 3 以降の自動化判断に使う。 + +## 全体フロー + +``` + ┌─ 作成フェーズ ──────────────────────────────────────┐ +issue 取得 ─┤ │ + │ plan 作成 (必要なら plan モード) ─ 単一PR? ─ YES ─▶ implementation-plan + /ndf:pr で完了 + │ │ + │ NO + └──────────────────────────────────────────┼──────────┘ + ▼ + ┌─ 実行フェーズ ──────────────────────────────────────┐ +既存 plan ─▶│ Step 3: release branch 作成 + Draft release PR │ + │ Step 4: 個別 PR ブランチ作成 + 各 Draft PR (release base) + │ Step 5: git worktree で並行開発 (依存関係を考慮) │ + │ Step 6: 個別 PR ごとに /ndf:review or /ndf:cross-review + │ → /ndf:fix → merge into release │ + │ Step 7: release ブランチで結合テスト相当のレビュー │ + │ Step 8: release PR body 最終化 → Ready & merge │ + └─────────────────────────────────────────────────────┘ +``` + +QA / staging 等の検証環境向けには、個別 PR or release PR 単位で `/ndf:cherry-pick-pr` を別途実行する (Step 9)。 + +実行フェーズに途中から入った場合は、対応する Step の途中再開で構わない。各 Step の冒頭で **既に存在するブランチ / PR / worktree を `git branch -a` / `gh pr list` / `git worktree list` で確認**してから作業に入る。 + +## Step 1: issue 取得と plan 作成 (作成フェーズ専用) + +> 実行フェーズで入った場合はこの Step をスキップし、既存 plan を Read して Step 3 へ進む。 + +1. 引数を解釈して issue 本文を取得する +2. `issues/` 配下に plan ファイルが既に存在するか `Glob` で確認する +3. なければ `/ndf:implementation-plan` の **プランフォーマット**に従って plan ファイルを作成する + - ファイル名は英数 (例: `issues/PLAN42_multi-pr-refactor.md`) + - 内容に「複数 PR に分割する根拠」「PR 単位と依存関係」を必ず含める +4. 設計判断が重い場合は **Claude Code の plan モード** (ExitPlanMode を用いる読み取り専用フェーズ) に切り替えて十分検討してから実装へ進む + +plan の構造は `/ndf:implementation-plan` を参照。本 skill では multi-PR を前提に **以下のセクションを追加**する: + +```markdown +## PR 分割計画 + +| PR # | branch 名 | 概要 | 依存 | 並行可否 | +|---|---|---|---|---| +| 1 | feature/PLAN42-schema | スキーマ追加 | なし | ○ | +| 2 | feature/PLAN42-api | API 実装 | PR1 | × (PR1 merge 後) | +| 3 | feature/PLAN42-ui | UI 実装 | PR1 | ○ (mock で開始可) | + +release branch: `release/PLAN42` +base branch: `main` +``` + +## Step 2: 単一 PR で足りるか判定 + +plan を書いた結果が以下のいずれかなら **release ブランチを作らず**、`/ndf:implementation-plan` + `/ndf:pr` の通常フローに切り替える: + +- 変更ファイルが 1〜2 個で結合度が低い +- 1 PR で安全に review 可能 (差分 ~500 行以内が目安) +- 依存関係のある複数タスクが存在しない + +複数 PR が妥当な場合 (スキーマ + API + UI、機能追加 + マイグレーション、複数モジュール横断 等) のみ Step 3 に進む。 + +## Step 3: release ブランチ + Draft PR 先行作成 (実行フェーズの開始点) + +> 実行フェーズで自動発動した場合の最初の自動化対象。既に `release/` ブランチや Draft PR が存在する場合は作成をスキップし、Step 4 へ進む。 + +### release ブランチ作成 + +```bash +git fetch origin +git checkout -b release/ origin/ +git push -u origin release/ +``` + +### レビュアー視点の原則 (release PR body の大前提) + +個別 PR はセルフレビュー (`/ndf:cross-review` 等) で merge される。**人間のレビュアーが見るのは release PR だけ**であり、個別 PR の存在をレビュアーに意識させてはならない。したがって: + +- release PR の body は **self-contained 必須**: 「何のために」(背景・解決したい課題) と「何を」(release ブランチ全体としての変更内容) を、**個別 PR を一切参照せずに**理解できる粒度で書く +- 個別 PR リンクの列挙を body の本文にしない。開発中の進捗管理に使う場合は `
` 折りたたみ内の補足情報に格下げする +- `/ndf:cross-review` の light rotation と同じ原則を適用する: 現状の差分・実装を反映し、内部用語 (PLAN-ID 運用、round、rotated 等) をレビュアー向け本文に漏らさない + +### release → default の Draft PR を先行作成 + +```bash +gh pr create \ + --base \ + --head release/ \ + --draft \ + --title "release: <概要>" \ + --body "$(cat <<'EOF' +## Summary +- (背景) なぜこの変更が必要か / 解決したい課題 +- (変更内容) release ブランチ全体として何をするか +- plan: issues/_xxx.md + +## Test plan (結合観点のみ) +- [ ] 個別 PR では検出できない結合テスト項目 + +
+開発用: 個別 PR 進捗 (レビュー対象外) + +- [ ] # PR1: ... +- [ ] # PR2: ... +- [ ] # PR3: ... + +
+ + +EOF +)" +``` + +Draft 作成時点では実装が進んでいないため body は plan ベースの暫定でよいが、Ready for review 前に **実装の最終形を反映した body へ最終化**する (Step 8 参照)。 + +release PR を **先に作る理由**: PR 番号が確定し、個別 PR の説明から参照できるため。 + +## Step 4: 個別 PR ブランチ + Draft PR 先行作成 + +> 既存ブランチは `git branch -a | grep "feature/-"` で確認し、未作成のものだけ作る。Draft PR の存在は `gh pr list --base release/ --state all` で確認。 + +各 PR について **同じパターンで先に Draft PR まで作る**: + +```bash +# release ブランチを base に個別ブランチを切る +git fetch origin release/ +git checkout -b feature/- origin/release/ + +# 空コミットで push して Draft PR を作る (base=release と HEAD が同一だと +# gh pr create が "No commits between ..." で失敗するため、差分ゼロのまま PR +# 作成のトリガにする目的で `--allow-empty` を使う) +git commit --allow-empty -m "chore: - Draft PR 作成" +git push -u origin feature/- + +gh pr create \ + --base release/ \ + --head feature/- \ + --draft \ + --title "feat: - <概要>" \ + --body "$(cat <<'EOF' +## Summary +- plan: issues/_xxx.md +- release PR: # +- 担当範囲: + +## Test plan +- [ ] ... + + +EOF +)" +``` + +完了後 release PR の本文を `gh pr edit` で更新し、`
` 内の開発用チェックリストに個別 PR 番号を埋める (body 本文には書かない)。 + +## Step 5: git worktree で並行開発 + +並行可能 (依存なし or mock で先行可) な PR は **git worktree** で同時に開く: + +```bash +# repo ルート (default branch のまま) で +git worktree add ../--schema feature/-schema +git worktree add ../--ui feature/-ui + +# それぞれの worktree で別ターミナル / 別エージェントを起動 +``` + +ガイドライン: + +- **依存のある PR は順次着手**する (PR1 merge → PR2 開始) +- 並行 PR 間で同じファイルを触る場合は事前にレビュー観点で分担を明確化する +- 終わった worktree は `git worktree remove ` で片付ける +- Claude Code から並行開発を指示する場合、Agent tool の `isolation: "worktree"` も検討する + +## Step 6: 個別 PR のレビュー + +**レビューは原則個別 PR 単位**で行う: + +| 用途 | コマンド | +|---|---| +| PR 作成前のセルフレビュー | `/ndf:review-branch` | +| GitHub 上の単体レビュー | `/ndf:review ` | +| codex + gemini 両方の収束ループ | `/ndf:cross-review ` | +| 指摘の修正 | `/ndf:fix ` | + +個別 PR が APPROVE → Draft 解除 → release ブランチへ merge (squash 推奨)。 + +## Step 7: release ブランチのレビュー (結合テスト相当のみ) + +release ブランチへの merge が一通り進んだ段階で: + +- **個別 PR で見た観点を再レビューしない** +- **結合テスト相当**の観点のみレビューする: + - PR 間の API / 型 / スキーマ整合 + - 設定値の重複・矛盾 + - migration の順序依存 + - E2E シナリオ (`/ndf:playwright-scenario-test` の活用) +- ここで個別 PR 範囲のバグが見つかった場合は、**release PR にコメントせず**、該当の個別 PR (既に merge 済みなら新しい修正 PR を release 配下に作成) 側に指摘を書き込み、修正ループを回す +- release PR には integration 観点の指摘のみ残す + +## Step 8: release PR body の最終化と release → default の merge + +### body の最終化 (Ready for review の前に必須) + +個別 PR が全て merge されたら、**Draft 解除の前に** release PR の body を実装の最終形を反映した self-contained な内容へ更新する: + +```bash +# release ブランチ全体の差分を確認して body を書き直す +git fetch origin +git diff origin/...origin/release/ --stat +gh pr edit --title "..." --body "..." +``` + +最終化のチェック観点 (Step 3 のレビュアー視点の原則を満たすこと): + +- [ ] 「何のために」「何を」が個別 PR や plan ファイルを辿らずに理解できる +- [ ] 実装中の方針変更・スコープ増減が body に反映されている +- [ ] 個別 PR への参照が本文に残っていない (`
` 内の開発用情報は残してよい) +- [ ] 内部用語 (round、rotated 等) が漏れていない + +### Draft 解除と merge + +release PR が APPROVE されたら: + +```bash +# Draft 解除 +gh pr ready +# merge: 個別 PR が既に squash 済みで release ブランチに並んでいるため、 +# main 側でも個別 PR 単位の commit を追跡できる `--merge` (merge commit 保持) +# が既定として推奨。プロジェクト規約で線形履歴必須なら `--rebase`、 +# それ以外で commit 数を 1 本にしたい場合のみ `--squash`。 +gh pr merge --merge --delete-branch +``` + +merge 後は plan ファイル末尾に「完了サマリ」(マージ済み PR 番号 / 検証結果) を追記してクローズ化する。 + +## Step 9: 検証環境 (qa/staging 等) への適用 + +QA / staging 検証は **個別 PR 単位** or **release ブランチ単位** のどちらでも OK。 +`/ndf:cherry-pick-pr` は Claude Code 内の slash command なので、shell ではなく +Claude Code セッション上で実行する点に注意。 + +個別 PR 単位で qa に反映する場合: + +```text +# (Claude Code 内で実行する slash command) +/ndf:cherry-pick-pr qa/staging +``` + +release ブランチごと qa に反映する場合 (まとまった検証が必要な場合): + +```bash +# 1. shell で release ブランチに切り替え +git checkout release/ +``` + +```text +# 2. (Claude Code 内で実行する slash command) +/ndf:cherry-pick-pr qa/staging +``` + +詳細は `/ndf:cherry-pick-pr` と `/ndf:branch-fix-strategy` を参照。`feature → main` 系 PR を汚染しないため、検証ブランチ向けは必ず短命ブランチ経由で扱う。 + +## アンチパターン + +| ❌ やってはいけないこと | 理由 | +|---|---| +| release ブランチを作らず巨大な 1 PR で出す | レビュー困難・revert 困難・並行開発不可 | +| 個別 PR の base を default にする | release で統合する意味が失われ、partial merge が default を汚染する | +| 個別 PR Draft 作成を実装後に回す | PR 番号が未確定でクロス参照や CI 待機の段取りが組めない | +| release PR で個別 PR 範囲の指摘を解決しようとする | 該当 PR が既に閉じている場合、コミット意図がずれる | +| release PR の body を個別 PR リンクの列挙だけにする | レビュアーは release PR 単体で変更を把握できず、個別 PR や plan を辿ることになる。body は self-contained 必須 (Step 3 / Step 8) | +| body 最終化せずに Ready for review にする | Draft 作成時の plan ベースの暫定 body のままだと実装の最終形と乖離する | +| 検証ブランチを feature/release に merge する | `feature → main` PR への汚染 (詳細: `/ndf:branch-fix-strategy`) | + +## 関連 skill + +- `/ndf:implementation-plan` — plan ファイルのフォーマット (本 skill が依存) +- `/ndf:branch-fix-strategy` — ブランチ汚染を避ける原則 +- `/ndf:pr` — 通常の PR 作成 / 更新 +- `/ndf:cherry-pick-pr` — 検証ブランチへの cherry-pick PR +- `/ndf:review` / `/ndf:review-branch` / `/ndf:cross-review` — レビュー +- `/ndf:fix` / `/ndf:resolve-pr-comments` — コメント対応 +- `/ndf:playwright-scenario-test` — release ブランチでの E2E 結合テスト diff --git a/plugins/ndf-codex/skills/logging-guidelines/SKILL.md b/plugins/ndf-codex/skills/logging-guidelines/SKILL.md new file mode 100644 index 0000000..007b969 --- /dev/null +++ b/plugins/ndf-codex/skills/logging-guidelines/SKILL.md @@ -0,0 +1,112 @@ +--- +name: logging-guidelines +description: "Design safe and useful application logging." +when_to_use: "コードにログを追加・修正・整理するとき。Triggers: 'ログ追加', 'log追加', 'logger', 'logging', 'ログレベル', 'log level', 'デバッグログ', 'エラーログ', 'logger.info', 'logger.error', 'print文をログに'" +--- + +# ログ運用ガイドライン + +コードにログを追加・修正する際は、以下のルールに従うこと。言語/フレームワークに依存しない原則として記述している。 + +## ログレベルの選択基準 + +| レベル | 用途 | 本番出力(推奨) | +|--------|------|---------------| +| `error` | 例外発生、処理失敗 | o | +| `warning` | データ不備でスキップ、処理継続可能な異常 | o | +| `info` | バッチ開始/完了、重要なビジネスイベント | 環境による(本番off推奨) | +| `debug` | 開発向けデバッグ情報 | x | + +**推奨**: 本番は `LOG_LEVEL=warning` 以上。info/debug は開発・ステージングのみで出力する。 + +## 使用を避けるログレベル + +以下は用途が曖昧または過剰なため、明示的な運用規則がない限り使わない: + +- `notice` — error/warning/info と区別が曖昧 +- `critical`, `alert`, `emergency` — 通常のアプリには過剰。運用規則として「PagerDuty起動基準」などが定義されていない限り使わない + +## ループ内ログのルール + +### 原則: ループ内では info/warning を出力しない + +ループ内で1件ずつログを出力すると、大量データ処理時にログが爆発する。ループ後にサマリーとしてまとめて出力すること。 + +### サマリーログ化パターン(擬似コード) + +``` +# NG: ループ内で1件ずつ出力 +for item in items: + log.info("処理完了", id=item.id) + +# OK: ループ後にまとめて出力 +processed_count = 0 +for item in items: + # 処理... + processed_count += 1 +log.info("バッチ処理完了", processed_count=processed_count) +``` + +### エラー蓄積パターン + +ループ内で例外が発生し処理を継続する場合は、エラー情報を蓄積してループ後にまとめて報告する。先頭N件のみ含めることで、ログサイズ爆発を防ぐ。 + +``` +errors = [] +for item in items: + try: + process(item) + except Exception as e: + errors.append({"id": item.id, "error": str(e)}) + +if errors: + log.error( + "処理で一部失敗", + total_count=len(items), + failed_count=len(errors), + sample_errors=errors[:10], # 先頭10件のみ + ) +``` + +### ループ内 debug も必要最小限 + +ループ内での debug 出力は、他に代替手段がなく調査に不可欠な場合のみ許容。デフォルトは「ループ外で件数サマリ」を基本とする。 + +## 例外処理のルール + +1. **例外は最上位でログ出力** — エントリポイント(コマンド/コントローラー/ジョブ)で catch してログ出力 +2. **再スロー時はログ不要** — 上位で出力されるため二重出力を避ける +3. **例外を握りつぶさない** — catch後に何も報告せず続行するのは禁止 +4. **広めの例外型で捕捉** — 言語の最上位例外型(Python `Exception`、PHP `Throwable`、Java `Throwable` 等)でトップレベル catch する + +## 必須ルール + +1. **コンテキスト情報を含める** — 調査に必要なID等を構造化ログとして渡す +2. **機密情報を含めない** — パスワード、トークン、クレジットカード番号、個人特定情報は禁止 +3. **メッセージは明確に** — 何が起きたか分かる言葉で記述(プロジェクトの言語ポリシーに従う) +4. **ロガー呼び出しを統一** — プロジェクトで統一ファサード/クライアントを使う(例: Laravel は `Log::`, Python は `logging.getLogger(__name__)`) +5. **グローバル/暗黙の名前空間を使わない** — 明示的にimport/useする + +## ログとメトリクスの使い分け + +- **ログ**: 個別のイベント、エラー、コンテキスト情報(構造化ログ) +- **メトリクス**: 件数、レイテンシ、成功/失敗率の集計(Prometheus/DataDog等) +- **トレース**: リクエスト横断の実行フロー(OpenTelemetry等) + +ループ件数カウントなどは、ログではなくメトリクスに寄せるのが望ましい場合が多い。 + +## アンチパターン一覧 + +| アンチパターン | 問題 | +|--------------|------| +| `log.info("")` / 空メッセージ | 意図が伝わらない | +| `log.error(e)` のみ | スタックトレース/contextが欠ける | +| 機密情報をそのままログに入れる | 情報漏洩リスク | +| ループ内で毎回 info 出力 | ログ爆発 | +| try/except で握りつぶし、何も報告しない | 障害の気配を消す | +| 複数行の ASCII ART をログに含める | grep/集計が困難 | + +## 関連スキル + +- `/ndf:problem-solving` — ログから根本原因を特定する手順 +- `/ndf:investigation-rules` — ログをエビデンスとして扱う際の注意点 diff --git a/plugins/ndf-codex/skills/markdown-writing/01-diagram-guide.md b/plugins/ndf-codex/skills/markdown-writing/01-diagram-guide.md new file mode 100644 index 0000000..db40f52 --- /dev/null +++ b/plugins/ndf-codex/skills/markdown-writing/01-diagram-guide.md @@ -0,0 +1,144 @@ +# 図表作成ガイド + +## mermaid 記法 + +### フローチャート + +```mermaid +graph TD + A[開始] --> B{条件判定} + B -->|Yes| C[処理A] + B -->|No| D[処理B] + C --> E[終了] + D --> E +``` + +### シーケンス図 + +```mermaid +sequenceDiagram + User->>API: リクエスト + API->>DB: クエリ + DB-->>API: 結果 + API-->>User: レスポンス +``` + +### クラス図 + +```mermaid +classDiagram + class User { + +int id + +string name + +login() + +logout() + } + class Order { + +int id + +float total + } + User "1" --> "*" Order +``` + +### ER図 + +```mermaid +erDiagram + USER ||--o{ ORDER : places + ORDER ||--|{ LINE_ITEM : contains + PRODUCT ||--o{ LINE_ITEM : "ordered in" +``` + +## plantUML 記法 + +### コンポーネント図 + +```plantuml +@startuml +package "Frontend" { + [React App] +} +package "Backend" { + [API Server] + [Database] +} +[React App] --> [API Server] +[API Server] --> [Database] +@enduml +``` + +### アクティビティ図 + +```plantuml +@startuml +start +:ユーザー入力; +if (有効?) then (yes) + :処理実行; +else (no) + :エラー表示; +endif +stop +@enduml +``` + +## ASCII 許可例(ツリーのみ) + +ディレクトリ構造はASCIIで表現可能: + +``` +project/ +├── src/ +│ ├── components/ +│ └── utils/ +├── tests/ +└── docs/ +``` + +## よくある間違い + +### 避けるべき: ASCII ARTで図を描く + +``` + ┌─────────┐ + │ User │ + └────┬────┘ + │ + ┌────▼────┐ + │ API │ + └─────────┘ +``` + +上記のような図は **mermaid** で描いてください: + +```mermaid +graph TD + User --> API +``` + +### 避けるべき: 順序prefixなしで分割 + +``` +docs/ +├── introduction.md ← NG: prefixがない +├── setup.md +└── usage.md +``` + +正しい方法: + +``` +docs/ +├── 01-introduction.md ← OK +├── 02-setup.md +└── 03-usage.md +``` + +## ベストプラクティス + +| DO | DON'T | +|----|-------| +| mermaid/plantUMLで図を描く | ASCII ARTで図を描く | +| 300行以内に収める | 1000行超の巨大ファイル | +| 順序prefixで分割 | prefixなしで分割 | +| 2桁パディング(01-, 02-) | 1桁(1-, 2-) | diff --git a/plugins/ndf-codex/skills/markdown-writing/SKILL.md b/plugins/ndf-codex/skills/markdown-writing/SKILL.md new file mode 100644 index 0000000..56b3583 --- /dev/null +++ b/plugins/ndf-codex/skills/markdown-writing/SKILL.md @@ -0,0 +1,58 @@ +--- +name: markdown-writing +description: "Write Markdown docs, diagrams, and split files." +when_to_use: "Markdown 文書 / 図表を作成 / 編集するとき。Triggers: 'Markdown作成', 'ドキュメント作成', '文書作成', '図を描く', 'mermaid', 'create document', 'write docs'" +allowed-tools: + - Read + - Write + - Edit +--- + +# Markdown Writing Skill + +## 重要ルール + +### 1. 図表作成ルール + +**mermaid または plantUML を使用**(ASCII ART禁止、ツリー除く) + +```mermaid +graph TD + A[開始] --> B{条件判定} + B -->|Yes| C[処理A] + B -->|No| D[処理B] +``` + +### 2. 文書の長さと分割ルール + +| ページ数 | 対応 | +|---------|-----| +| ~300行 | そのまま | +| 301~600行 | 2ファイルに分割 | +| 600行以上 | セクションごとに分割 | + +**分割時のファイル名**: 順序prefix(01-, 02-, ...)+ ケバブケース + +``` +docs/feature-guide/ +├── 01-introduction.md +├── 02-installation.md +└── 03-usage.md +``` + +## チェックリスト + +- [ ] 図表はmermaid/plantUML使用(ツリー除く) +- [ ] ファイル長は300行以内(超える場合は分割) +- [ ] 分割時は順序prefix使用(01-, 02-, ...) + +## 詳細ガイド + +| ファイル | 内容 | +|---------|------| +| `01-diagram-guide.md` | mermaid/plantUML記法、よくある間違い | + +## 関連リソース + +- [Mermaid公式ドキュメント](https://mermaid.js.org/) +- [PlantUML公式ドキュメント](https://plantuml.com/) diff --git a/plugins/ndf-codex/skills/merged/SKILL.md b/plugins/ndf-codex/skills/merged/SKILL.md new file mode 100644 index 0000000..06af6f2 --- /dev/null +++ b/plugins/ndf-codex/skills/merged/SKILL.md @@ -0,0 +1,29 @@ +--- +name: merged +description: "Clean up after a PR is merged." +argument-hint: "[PR番号]" +disable-model-invocation: true +allowed-tools: + - Bash + - Read +--- + +# マージ後クリーンアップコマンド + +PRマージ後のクリーンアップを実行。 + +## 手順 + +0. **事前確認**: github mcpで引数の(引数が無ければ自身が作成した最新の)PRがmainにmergeされていることを確認。mergeされていなければ終了 +1. **事前確認**: `git status`→変更あればstash +2. **main更新**: `git checkout main`→`git pull` +3. **worktreeクリーンアップ**: `git worktree list` で当該PR番号に対応する worktree (`pr`) を探し、あれば `git worktree remove ` で削除(worktree 内の `.cross_review/` も一緒に消える) +4. **ブランチ削除**: `git branch -d ` → stash復元 + +**注意**: 冪等性保証・エラー時中断・削除済み無視 + +## 作業完了報告(必須) + +- 実行サマリー(PRタイトル、マージコミット、削除したブランチ、現在のブランチ) +- mainブランチの状態 +- PR URL diff --git a/plugins/ndf-codex/skills/ndf-policies/SKILL.md b/plugins/ndf-codex/skills/ndf-policies/SKILL.md new file mode 100644 index 0000000..eb25c33 --- /dev/null +++ b/plugins/ndf-codex/skills/ndf-policies/SKILL.md @@ -0,0 +1,10 @@ +--- +name: ndf-policies +description: "Apply core NDF project policies." +user-invocable: false +--- + +# NDFポリシー + +このスキルはNDFプラグインの基本ポリシーを定義します。 +descriptionフィールドが常時コンテキストに注入されるため、本文の参照は不要です。 diff --git a/plugins/ndf-codex/skills/plan-to-spec/SKILL.md b/plugins/ndf-codex/skills/plan-to-spec/SKILL.md new file mode 100644 index 0000000..eaea6f9 --- /dev/null +++ b/plugins/ndf-codex/skills/plan-to-spec/SKILL.md @@ -0,0 +1,182 @@ +--- +name: plan-to-spec +description: "Finalize an implemented plan into a permanent specification document. Use after implementation is complete and an issues/ plan, PLAN file, design note, or implementation plan should become the final as-is specification under docs/ or another authoritative specification location. Triggers: 'planを仕様書にして', '確定仕様書に移動', '実装完了後にplanを整理', 'planをdocsへ移動', '仕様書としてリライト', 'plan-to-spec', 'finalize plan spec'." +allowed-tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep +--- + +# Plan to Spec + +実装完了後の plan を、開発履歴ではなく **現在のコードと一致する確定仕様書**として保存する。plan は作業中の意思決定記録であり、完了後は読者が実装経緯を追わなくても仕様を理解できる形に変換する。 + +## 基本方針 + +- plan の内容をそのまま移動せず、最終実装の as-is 仕様として書き直す +- 開発中の履歴、TODO、PR 分割、作業チェックリスト、途中変更、未採用案は削除する +- 仕様書の置き場は既存 docs 構造に合わせ、なければ `docs/specifications/` を作成する +- 仕様書はコードと照合し、実装と矛盾する記述を残さない +- 完了報告は本 skill のテンプレートに従う + +## 入力 + +`$ARGUMENTS` + +引数は plan ファイルパス、issue 番号、PR 番号、または関連キーワードを受け付ける。引数がない場合は、現在ブランチの差分、`issues/`、`docs/`、`git log` から直近の plan を特定する。 + +## 手順 + +### 1. 対象 plan と実装範囲を特定する + +1. 引数がファイルパスならその plan を読む +2. 引数が PR / issue 番号なら `gh pr view` / `gh issue view` とローカルファイル検索で関連 plan を探す +3. 引数がない場合は以下を確認する: + - `git status --short` + - `git branch --show-current` + - `git log --oneline --decorate -20` + - `find issues docs -maxdepth 3 -type f \( -iname '*plan*' -o -iname '*PLAN*' \)` +4. plan が複数候補ある場合は、現在ブランチ・PR・変更ファイルと最も関連が強いものを選ぶ。不明ならユーザーに確認する +5. 実装範囲を特定する: + - PR がある場合: `gh pr diff` / `gh pr view --json files,title,body` + - ローカル変更の場合: `git diff --stat` / `git diff` + - merge 済みの場合: 関連コミット範囲の `git show` / `git diff` + +### 2. 仕様書の配置先を決める + +既存 docs の分類に合わせて配置する。優先順位: + +1. 同種の仕様書がある既存ディレクトリ (`docs/specifications/`, `docs/specs/`, `docs/features/`, `docs/architecture/`, `docs/modules/` など) +2. 対象機能に対応する既存 docs 配下 +3. 適切な場所がなければ `docs/specifications/` を作成 + +ファイル名は英数字・ハイフン中心にし、内容が分かる名前にする。例: + +```text +docs/specifications/auth-session-management.md +docs/features/review-workflow.md +docs/architecture/plugin-skill-loading.md +``` + +移動は履歴が追えるように、可能なら `git mv ` を使う。plan を残す必要がある運用の場合は、ユーザーに確認してからコピーに切り替える。 + +### 3. 仕様書としてリライトする + +他の仕様書の章立て・表記・粒度を先に確認し、同じ体裁へ合わせる。標準章立ては以下を使う。既存 docs に明確な型がある場合はそちらを優先する。 + +```markdown +# {仕様名} + +## 概要 + +## 背景 + +## 対象範囲 + +## 仕様 + +## データ・設定 + +## 外部連携 + +## エラー処理 + +## セキュリティ + +## 運用 + +## テスト観点 + +## 関連リンク +``` + +該当しない章は削除してよい。小さな仕様では `概要`、`仕様`、`運用`、`テスト観点`、`関連リンク` 程度に圧縮する。 + +### 4. 削除・変換ルール + +削除するもの: + +- 実装タスクのチェックリスト +- PR 分割計画、worktree 運用、作業担当、レビュー進捗 +- 「これから実装する」「予定」「案」「未定」など完了前提の表現 +- 開発中に破棄された方針、調査メモ、試行錯誤 +- AI エージェント向けの作業指示 + +変換するもの: + +- 「実装する」→「提供する」「使用する」「保持する」 +- 「修正対象」→「構成」「関連ファイル」 +- 「テスト計画」→「テスト観点」または「検証方法」 +- 「背景・問題」→ 現在の仕様を理解するために必要な背景だけ残す + +### 5. リンクを再調査して修正する + +仕様書内のリンクは移動後の位置から有効になるように直す。 + +- 相対リンクは新しい配置先基準で更新する +- 存在しないファイルリンクは `find` / `rg --files` で移動先を探す +- GitHub issue / PR / 外部ドキュメントのリンクは必要最小限にする +- 開発中の一時リンク、ローカル絶対パス、エージェント固有の transcript リンクは削除する + +リンク先が確認できない場合は、推測で残さず削除するか、確実な上位ドキュメントへ差し替える。 + +### 6. コードと仕様を照合する + +仕様書を書いた後、実装と一致しているかレビューする。 + +確認観点: + +- 仕様書に書いた機能・制約・設定名・ファイルパスが実コードに存在する +- 実コードに存在する重要な挙動が仕様書から抜けていない +- エラー処理、権限、環境変数、永続化、外部連携の記述が実装と一致する +- テスト観点が実装されたテストや手動確認に対応している +- 廃止された名前、過剰な略語、未承認の用語を使っていない + +用語確認では、`AGENTS.md`、`CLAUDE.md`、`docs/`、README、既存仕様書の表記を優先し、プロジェクトで authorize された名称に合わせる。略語は初出で正式名称を併記し、ローカルな作業略称は使わない。 + +### 7. 仕様書レビューを行い修正する + +セルフレビューを 1 回行い、必要に応じて修正する。レビュー観点: + +- 他の仕様書と章立て・見出し粒度・表記が揃っているか +- plan 由来の作業履歴が残っていないか +- as-is 仕様として読めるか +- コードと矛盾していないか +- リンクが移動後のパスで正しいか +- 用語がプロジェクト標準に合っているか +- 仕様書として過不足がないか + +レビュー結果で修正した内容は、完了報告に要約する。 + +### 8. 完了報告 + +完了時は以下のテンプレートで報告する。 + +```markdown +## Plan to Spec 完了報告 + +### 対象 +- 元 plan: `{元planパス}` +- 確定仕様書: `{仕様書パス}` + +### 実施内容 +- plan を `{配置先ディレクトリ}` に移動し、確定仕様書としてリライト +- 開発履歴・TODO・PR分割などの作業情報を削除 +- リンクを移動後のパス基準で修正 + +### レビュー結果 +- 体裁: `{他仕様書との整合結果}` +- コード一致: `{照合した主な実装ファイルと結果}` +- 用語: `{標準用語への修正有無}` + +### 検証 +- `{実行したコマンドや確認内容}` + +### 補足 +- `{残課題がなければ「なし」}` +``` + +残課題がある場合は、仕様書に曖昧な記述を残さず、完了報告の補足に明確に分離する。 diff --git a/plugins/ndf-codex/skills/playwright-execution/SKILL.md b/plugins/ndf-codex/skills/playwright-execution/SKILL.md new file mode 100644 index 0000000..f99970c --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-execution/SKILL.md @@ -0,0 +1,101 @@ +--- +name: playwright-execution +description: "Run Playwright E2E tests with evidence and metrics." +when_to_use: "E2E テストの実行 / エビデンス収集 / 動画エビデンス / accessibility チェック / Core Web Vitals 計測が必要なとき。テストスクリプト作成済みであることが前提。Triggers: 'E2E テスト実行', 'テスト実行', '動画エビデンス', 'エビデンス収集', 'テスト証跡', 'a11y テスト', 'accessibility テスト', 'axe-core', 'WCAG', 'Core Web Vitals', 'Web Vitals', 'LCP', 'CLS', 'body_check', 'overlay', '字幕', 'カーソル'" +allowed-tools: + - Read + - Bash(uv *) + - Bash(pytest *) + - Bash(npx *) + - Bash(playwright *) + - Bash(python *) +--- + +# Playwright Execution (テスト実行 + エビデンス収集) + +テストスクリプト作成済みの状態で E2E テストを実行し、エビデンスを収集する。 + +## 前提条件 + +- テストスクリプトが `tests/` に作成済みであること (`/ndf:playwright-script-creation` で作成) +- `scenario.config.yaml` が設定済みであること + +## 大原則 + +**エビデンス動画はデフォルト ON**。全テストで常に動画を取得する。 +明示的にスキップする場合のみ `--pwk-no-video` を指定する。 + +## 実行コマンド + +```bash +./scenario-test/run.sh # 全テスト (動画 ON) +./scenario-test/run.sh -k test_admin # フィルタ +./scenario-test/run.sh --pwk-overlay # 字幕 + カーソル付き動画 +./scenario-test/run.sh --pwk-no-video # 動画のみ OFF +./scenario-test/run.sh --pwk-no-evidence # 全エビデンス OFF (HAR/trace/動画) +``` + +## エビデンス種別 + +| 種別 | デフォルト | OFF フラグ | 説明 | +|---|---|---|---| +| video | **ON** | `--pwk-no-video` | 全テストの動画を取得 | +| trace | ON (retain-on-failure) | `--pwk-no-evidence` | Playwright Trace (DOM + 操作ログ) | +| HAR | ON (minimal) | `--pwk-har-mode none` | ネットワーク通信ログ | +| screenshot | ON (only-on-failure) | `--pwk-no-evidence` | 失敗時スクリーンショット | + +## overlay (赤丸カーソル + 字幕) + +`--pwk-overlay` フラグで全テストの動画にオーバーレイが適用される。 + +API 詳細・使用例は `playwright_kit/overlay.py` を参照。主要関数: `set_caption()`, `flash_click()`, `hide_cursor()`。 + +## 品質計測 + +### accessibility (axe-core) + +`@pytest.mark.page_role` marker が付いたテストで auto_roles にマッチする場合に自動実行。 +設定は `scenario.config.yaml` の `accessibility:` セクションで制御。→ 設定例は `templates/scenario.config.yaml` を参照。 + +### Core Web Vitals + +`@pytest.mark.page_role` marker + auto_roles マッチで LCP/CLS/TTFB/longest_task を自動計測。 +設定は `scenario.config.yaml` の `web_vitals:` セクションで制御。→ 設定例は `templates/scenario.config.yaml` を参照。 + +### body_check (PHP/SSR エラー検出) + +`page.on("response")` で全 HTML レスポンスを監視し、`Fatal error` 等を検出。デフォルト有効。 +`@pytest.mark.no_body_check` で個別 opt-out 可能。→ 設定例は `templates/scenario.config.yaml` の `body_check:` セクションを参照。 + +## 成果物 + +``` +reports// +├── report.md # テスト結果サマリ +├── / +│ ├── video.mp4 # テスト動画 (デフォルト ON) +│ ├── trace.zip # Playwright Trace +│ ├── request.har # ネットワーク通信ログ +│ ├── body_check.jsonl # body_check 違反詳細 +│ └── screenshot-*.png # スクリーンショット +``` + +## CLI options + +| option | 役割 | +|---|---| +| `--pwk-config ` | `scenario.config.yaml` のパス | +| `--pwk-out-dir ` | 成果物出力先 (default: `reports//`) | +| `--pwk-no-video` | 動画収集を OFF (デフォルトは ON) | +| `--pwk-no-evidence` | HAR / trace / video の収集を全て OFF | +| `--pwk-har-mode {minimal,full,none}` | HAR 録画モード (default: minimal) | +| `--pwk-overlay` | overlay (赤丸カーソル + 字幕) を ON | + +## 関連 Skill + +- `/ndf:playwright-script-creation` — テストスクリプト作成 (実行の前段) +- `/ndf:playwright-report` — Markdown レポート生成 +- `/ndf:playwright-kit-ops` — スクリプト実行 (init_project / スキャン) +- `/ndf:playwright-browser-connect` — ブラウザ接続構成 (local / CDP remote) +- `/ndf:playwright-evidence-drive` — エビデンス Google Drive 保管 +- `/ndf:playwright-scenario-test` — 全機能統括 diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/.gitignore b/plugins/ndf-codex/skills/playwright-kit-ops/.gitignore new file mode 100644 index 0000000..f9cf1ad --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ + +# pytest 実行時に生成される NDF report 出力 (run-id 別) +reports/ +.pytest_cache/ + +# uv lock は共有可 +!uv.lock diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/SKILL.md b/plugins/ndf-codex/skills/playwright-kit-ops/SKILL.md new file mode 100644 index 0000000..991e993 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/SKILL.md @@ -0,0 +1,119 @@ +--- +name: playwright-kit-ops +description: "Operate playwright_kit setup, scans, and evidence tools." +when_to_use: "playwright_kit のスクリプトを実行するとき / E2E テストプロジェクトの初期化 / page role 自動分類 / 単発 a11y・CWV スキャン / Google Drive エビデンスアップロードが必要なとき。Triggers: 'init_project', 'プロジェクト初期化', 'classify_page_role', 'run_a11y_scan', 'check_cwv', 'upload_evidence', 'record_scenario', 'playwright_kit 実行'" +allowed-tools: + - Read + - Bash(python *) + - Bash(uv *) + - Bash(pytest *) + - Bash(playwright *) + - Bash(./scripts/*) + - Bash(bash *) + - Bash(chmod *) +--- + +# playwright_kit 操作エージェント + +playwright_kit のスクリプト群を実行してテスト環境のセットアップ・テスト実行・エビデンス管理を行う。 + +## スクリプト一覧 + +| スクリプト | 用途 | カテゴリ | +|---|---|---| +| `scripts/init_project.sh` | 利用者プロジェクトに scenario-test ランタイムを埋め込む | セットアップ | +| `scripts/init_project.bat` | 同 (Windows) | セットアップ | +| `scripts/classify_page_role.py` | URL の a11y tree + パターンから page role を自動推定 | テスト計画 | +| `scripts/record_scenario.py` | Playwright codegen で操作を記録しテストコード化 | テスト計画 | +| `scripts/run_a11y_scan.py` | axe-core による単発 accessibility スキャン | 品質 | +| `scripts/check_cwv.py` | Core Web Vitals (LCP/CLS/TTFB) 単発計測 | 品質 | +| `scripts/upload_evidence.py` | エビデンスファイルを Google Drive にアップロード | レポート | +| `scripts/gdrive_upload_dir.py` | ディレクトリごと Drive にバッチアップロード | レポート | +| `scripts/upload_md_as_gdoc.py` | Markdown を Google Doc に変換・アップロード | レポート | +| `scripts/build_gdoc_with_drive_links.py` | Google Doc にエビデンスの Drive リンクを埋め込み | レポート | + +## セットアップ + +### プロジェクト初期化 + +```bash +# SKILL_DIR はこの skill のパス +./scripts/init_project.sh /path/to/your-app + +# ディレクトリ名をカスタマイズ +./scripts/init_project.sh /path/to/your-app --runtime-dir e2e + +# Windows +scripts\init_project.bat C:\path\to\your-app +``` + +→ `your-app/scenario-test/` に all-in-one ランタイムが作成され、Skill 非依存で動作する。 + +### テスト実行 + +```bash +cd /path/to/your-app +./scenario-test/run.sh # 全テスト +./scenario-test/run.sh -k test_admin # フィルタ +./scenario-test/run.sh --pwk-overlay # 字幕 + カーソル付き動画 +./scenario-test/run.sh --pwk-drive-folder= # Drive 自動アップロード +``` + +Drive 連携は optional dependency として扱う。Codex 公開セットには `google-auth` +skill を同梱しないため、Drive 系コマンドや `--pwk-drive-folder` を使う場合は +`GOOGLE_AUTH_SCRIPTS` を `google-auth/scripts` の実パスへ設定する。 + +```bash +export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts +cd scenario-test +uv sync --extra drive +``` + +## テスト計画ツール + +```bash +# page role を自動推定 +python scripts/classify_page_role.py --url https://example.com/products + +# Playwright codegen で操作を記録 +python scripts/record_scenario.py https://example.com/login +``` + +## 品質スキャンツール + +```bash +# axe-core 単発スキャン +python scripts/run_a11y_scan.py --url https://example.com + +# Core Web Vitals 単発計測 +python scripts/check_cwv.py --url https://example.com +``` + +## エビデンスアップロードツール + +```bash +# 単一ファイルを Drive にアップロード +python scripts/upload_evidence.py reports/run-001/test_login/trace.zip --kind trace + +# ディレクトリごとアップロード +python scripts/gdrive_upload_dir.py reports/run-001/ --folder-id + +# Markdown → Google Doc 変換 +python scripts/upload_md_as_gdoc.py reports/run-001/report.md + +# Google Doc にエビデンス Drive リンクを埋め込み +python scripts/build_gdoc_with_drive_links.py reports/run-001/ +``` + +## パッケージ参照 + +playwright_kit Python パッケージ本体・templates・tests はこの skill ディレクトリ内に配置されている。 + +## 関連 Skill + +- `/ndf:playwright-test-planning` — テスト計画 (方法論 + チェックリスト) +- `/ndf:playwright-script-creation` — テストスクリプト作成 +- `/ndf:playwright-execution` — テスト実行 + エビデンス収集 (video/trace/overlay/quality) +- `/ndf:playwright-browser-connect` — ブラウザ接続構成 (local / CDP remote) +- `/ndf:playwright-report` — レポート生成 +- `/ndf:playwright-scenario-test` — 全機能統括 diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/__init__.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/__init__.py new file mode 100644 index 0000000..bd275e6 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/__init__.py @@ -0,0 +1,11 @@ +"""pytest-playwright 上で動く Web E2E シナリオテストパッケージ。 + +利用方法: +- pytest plugin として ``--pwk-config=...`` で読み込む (entry-point 経由で auto-load) +- ``pwk_config`` / ``pwk_role_`` / ``pwk_evidence`` 等の fixture を test に注入 +- ``@pytest.mark.page_role(...)`` で accessibility / web vitals を autouse + +詳細は SKILL.md を参照。 +""" + +__version__ = "0.5.0" diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/accessibility.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/accessibility.py new file mode 100644 index 0000000..588e3cd --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/accessibility.py @@ -0,0 +1,90 @@ +"""runner 内蔵の Web アクセシビリティ (axe-core) スキャンモジュール。 + +Web アクセシビリティ (旧称 a11y) は WCAG 準拠の機械検査を指し、本モジュールは +axe-core (axe-playwright-python) で WCAG 2.0/2.1/2.2 AA レベルの違反を検出する。 + +`scripts/run_a11y_scan.py` (CLI) はこのモジュールの薄いラッパで、 +runner からは `scan_page(page, ...)` を直接呼び出して `EvidenceCollectors` +の `axe_violations` に格納する。 + +page_role が `lp / list / form / dashboard / cart / checkout / settings / auth` +のとき runner が自動実行する (config.accessibility.auto_roles で上書き可能)。 +""" + +from __future__ import annotations + +from typing import Any + +from playwright.sync_api import Page + + +# WCAG 2.0/2.1/2.2 AA 準拠を最低基準として走査する。 +# WCAG 2.0 AAA は適合義務がない (一般的に過剰) ため除外。 +DEFAULT_TAGS: tuple[str, ...] = ("wcag2a", "wcag2aa", "wcag21aa", "wcag22aa") + +# page_role × a11y 自動実行のデフォルト対象。フォーム / 商取引 / 認証系は a11y 影響大。 +DEFAULT_AUTO_ROLES: frozenset[str] = frozenset({ + "lp", "list", "form", "dashboard", "cart", "checkout", "settings", "auth", +}) + + +def is_available() -> bool: + """axe-playwright-python がインストール済かを確認する (Maj-9: silent fail 対策)。""" + try: + import axe_playwright_python.sync_playwright # noqa: F401 + except ImportError: + return False + return True + + +def scan_page( + page: Page, + *, + tags: tuple[str, ...] | list[str] = DEFAULT_TAGS, +) -> list[dict[str, Any]]: + """既にロード済みの Page に対し axe-core を実行し violations の list を返す。 + + axe-playwright-python が未インストールなら空 list を返す。呼出側は事前に + `is_available()` で判定し、未インストールならスキップを明示すること。 + """ + try: + from axe_playwright_python.sync_playwright import Axe + except ImportError: + return [] + + axe = Axe() + try: + results = axe.run( + page, options={"runOnly": {"type": "tag", "values": list(tags)}}, + ) + except TypeError: + # axe-playwright-python の旧版は options 非対応 + results = axe.run(page) + + violations: list[dict[str, Any]] = [] + for v in results.response.get("violations", []): + violations.append({ + "id": v.get("id"), + "impact": v.get("impact"), + "tags": v.get("tags", []), + "help": v.get("help"), + "helpUrl": v.get("helpUrl"), + "nodes": [ + { + "html": n.get("html", "")[:200], + "target": n.get("target", []), + "failureSummary": n.get("failureSummary", "")[:300], + } + for n in v.get("nodes", []) + ], + }) + return violations + + +def should_auto_scan( + page_roles: list[str], + *, + auto_roles: frozenset[str] = DEFAULT_AUTO_ROLES, +) -> bool: + """testcase の page_role に基づき axe-core を自動実行すべきか判定する。""" + return any(r in auto_roles for r in page_roles) diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/body_check.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/body_check.py new file mode 100644 index 0000000..37a8c4c --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/body_check.py @@ -0,0 +1,118 @@ +"""ページ本文の文字列マッチ検出 (PHP / SSR エラー検出, v0.4.0)。 + +JavaScript ランタイム由来の console.error / pageerror では拾えない、 +サーバ側で HTML 本文に直接出力された "Fatal error" / "Warning:" 等の +エラー文字列を、Playwright の ``page.on("response", ...)`` で拾った +HTML 本文に対して substring match で検出する純粋関数群。 + +旧 v0.2.x の自前 YAML runner にあった ``body_check`` 機能の再実装で、 +PHP プロジェクトのフロント漏れ ``Fatal error`` / ``STRICT:`` 等を +テスト失敗として捕捉する。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable + + +@dataclass(frozen=True) +class BodyViolation: + """1 件の body_check ヒット。""" + + url: str + category: str # "fatal" / "warning" / "not_found" + pattern: str + snippet: str + + def to_dict(self) -> dict[str, Any]: + return { + "url": self.url, + "category": self.category, + "pattern": self.pattern, + "snippet": self.snippet, + } + + +def _snippet(body: str, pattern: str, *, around: int = 60) -> str: + """body 中の pattern 周辺 ``around`` 文字を取り出して表示用に整形する。""" + idx = body.find(pattern) + if idx < 0: + return pattern + start = max(0, idx - around) + end = min(len(body), idx + len(pattern) + around) + snippet = body[start:end].replace("\n", " ").replace("\r", " ").replace("\t", " ") + prefix = "..." if start > 0 else "" + suffix = "..." if end < len(body) else "" + return prefix + snippet + suffix + + +def scan_body( + body: str, + url: str, + *, + fatal_patterns: Iterable[str] = (), + warning_patterns: Iterable[str] = (), + warning_head_chars: int = 300, + not_found_patterns: Iterable[str] = (), +) -> list[BodyViolation]: + """``body`` に該当パターンが出現すれば ``BodyViolation`` のリストで返す。 + + - ``fatal_patterns`` / ``not_found_patterns`` は body 全体を substring で走査 + - ``warning_patterns`` は ``body[:warning_head_chars]`` (= 先頭 N 文字 / + code points) のみを走査。本文中の説明文や入力例の "Notice:" 等を許容する + ための head 検索。bytes ではなく code points で切るのは、日本語ページで + 300 bytes ≒ 100 字相当となり実用にならないため。 + + パターン文字列は **substring 比較** (正規表現ではない)。空文字列は無視する。 + """ + violations: list[BodyViolation] = [] + + if not body: + return violations + + head_size = max(0, int(warning_head_chars)) + head = body[:head_size] if head_size else "" + + for pat in fatal_patterns: + if pat and pat in body: + violations.append( + BodyViolation( + url=url, + category="fatal", + pattern=pat, + snippet=_snippet(body, pat), + ) + ) + + if head: + for pat in warning_patterns: + if pat and pat in head: + violations.append( + BodyViolation( + url=url, + category="warning", + pattern=pat, + snippet=_snippet(head, pat), + ) + ) + + for pat in not_found_patterns: + if pat and pat in body: + violations.append( + BodyViolation( + url=url, + category="not_found", + pattern=pat, + snippet=_snippet(body, pat), + ) + ) + + return violations + + +def is_html_response(content_type: str | None) -> bool: + """``Content-Type`` ヘッダから HTML レスポンスか判定する。""" + if not content_type: + return False + return "html" in content_type.lower() diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/config.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/config.py new file mode 100644 index 0000000..18436b4 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/config.py @@ -0,0 +1,461 @@ +"""共通設定 (config.yaml) のロードとデータクラス。 + +テストケース YAML ではなく、対象環境・ロール別ログイン・Playwright/Runner 設定、 +およびページ検査・スラッグ正規化・レポート生成のプロジェクト固有パラメータを保持する。 +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +import yaml + + +# --------------------------------------------------------------------------- +# 環境変数展開 (Codex Major 4) +# --------------------------------------------------------------------------- + +_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}") + + +def _expand_env_in_str(s: str) -> str: + """文字列中の ${VAR} / ${VAR:-default} を環境変数で展開する。""" + def repl(m: re.Match) -> str: + name, default = m.group(1), m.group(2) + val = os.environ.get(name) + if val is None: + if default is None: + raise ValueError( + f"環境変数 ${{{name}}} が未定義です " + "(default 指定 ${VAR:-default} または env を設定してください)" + ) + return default + return val + return _ENV_RE.sub(repl, s) + + +def _expand_env(value: Any) -> Any: + """dict / list / str を再帰的に走査して ${VAR} を展開する。""" + if isinstance(value, str): + return _expand_env_in_str(value) + if isinstance(value, list): + return [_expand_env(v) for v in value] + if isinstance(value, dict): + return {k: _expand_env(v) for k, v in value.items()} + return value + + +# --- ブラウザ接続 --------------------------------------------------- + +BrowserMode = Literal["local", "cdp-remote"] +BROWSER_MODES: tuple[BrowserMode, ...] = ("local", "cdp-remote") + + +@dataclass +class BrowserConfig: + """ブラウザ接続設定。 + + cdp_endpoint が空文字列や空白のみの場合はデフォルト値 + ``http://localhost:9222`` にフォールバックする。 + """ + + mode: BrowserMode = "local" + cdp_endpoint: str = "http://localhost:9222" + + @classmethod + def from_raw(cls, raw: dict[str, Any]) -> "BrowserConfig": + base = cls() + mode_raw = str(raw.get("mode") or base.mode).lower() + if mode_raw not in BROWSER_MODES: + raise ValueError( + f"browser.mode は {BROWSER_MODES} のいずれかを指定してください " + f"(指定値: {mode_raw!r})" + ) + mode: BrowserMode = mode_raw # type: ignore[assignment] + # cdp_endpoint: 空文字列・空白のみの場合はデフォルト値にフォールバック + cdp_raw = raw.get("cdp_endpoint") + cdp_endpoint = str(cdp_raw).strip() if cdp_raw else "" + if not cdp_endpoint: + cdp_endpoint = base.cdp_endpoint + return cls( + mode=mode, + cdp_endpoint=cdp_endpoint, + ) + + +# --- 接続/認証 ------------------------------------------------------- + +@dataclass +class BasicAuth: + user: str + password: str + + +@dataclass +class Login: + path: str + requires_basic_auth: bool + fields: dict[str, str] + fail_if_url_contains: str + # ログイン送信ボタンを特定するためのプロジェクト固有セレクタ (CSS / role / text)。 + # auth fixture の _submit_login_form が「これ → role/type=submit フォールバック + # → Password で Enter」の順で試す。空のままでも汎用フォールバックで通常はログインできる。 + submit_selectors: list[str] = field(default_factory=list) + + +@dataclass +class Role: + id: str + label: str + login: Login + + +# --- レポート設定 --------------------------------------------------- + +@dataclass +class ReportConfig: + title: str = "シナリオ E2E テスト 実施報告書" + test_plan_link: str = "./test-plan.md" + phase_labels: dict[int, str] = field(default_factory=dict) + + +# --- Playwright / Runner ------------------------------------------- + +# Playwright ``record_har_mode`` に直接渡す値。"minimal" は request/response の +# メタデータのみ記録し、Basic 認証 + redirect が連続するページで navigation を +# abort させる race を回避する (Issue #62)。"full" は body も含めた完全な HAR、 +# "none" は HAR を出力しない (= ``record_har_path`` を inject しない)。 +HarMode = Literal["minimal", "full", "none"] +HAR_MODES: tuple[HarMode, ...] = ("minimal", "full", "none") + + +@dataclass +class PlaywrightConfig: + headless: bool = True + viewport_width: int = 1280 + viewport_height: int = 720 + slow_mo_ms: int = 0 + video_width: int = 1280 + video_height: int = 720 + navigation_timeout_ms: int = 30000 + # 各ステップ遷移後の表示維持時間 (動画でじっくり見せるため) + step_delay_ms: int = 1800 + # 動画にカーソル+字幕オーバーレイを焼き込む (true 推奨) + enable_overlay: bool = True + # Playwright Trace (trace.zip) を出力する。クリック箇所のハイライト・ + # DOM スナップショット・コンソール・ネットワークなどを `playwright show-trace` + # で対話的に確認できる。生成物が大きく (数MB〜) なるので必要時のみ。 + enable_trace: bool = True + # 録画後の動画フォーマット: "webm" (Playwright 既定) | "mp4" (H.264 変換) + # mp4 は Google Drive プレビュアで再生互換性が高い。 + video_format: str = "mp4" + # HAR 録画モード (Issue #62)。Playwright >= 1.30 で導入された + # ``record_har_mode`` に対応する。 + # - "minimal" (default): メタデータのみ記録。Basic 認証 + redirect が混在 + # するページで ``record_har_path`` 起因の ERR_ABORTED race を回避する。 + # - "full": Playwright 既定の full HAR (body + content)。 + # - "none": HAR を一切出力しない (= ``record_har_path`` を inject しない)。 + har_mode: HarMode = "minimal" + + @classmethod + def from_raw(cls, raw: dict[str, Any]) -> "PlaywrightConfig": + # dataclass の default を真実の源 (single source of truth) とする。 + # fallback 値を base = cls() から参照することで、dataclass default と + # from_raw() の fallback が乖離するバグを防ぐ (Codex Minor 6)。 + base = cls() + viewport = raw.get("viewport") or {} + video_size = raw.get("video_size") or {} + har_mode_raw = str(raw.get("har_mode", base.har_mode)).lower() + if har_mode_raw not in HAR_MODES: + raise ValueError( + f"playwright.har_mode は {HAR_MODES} のいずれかを指定してください " + f"(指定値: {har_mode_raw!r})" + ) + har_mode: HarMode = har_mode_raw # type: ignore[assignment] + return cls( + headless=bool(raw.get("headless", base.headless)), + viewport_width=int(viewport.get("width", base.viewport_width)), + viewport_height=int(viewport.get("height", base.viewport_height)), + slow_mo_ms=int(raw.get("slow_mo_ms", base.slow_mo_ms)), + video_width=int(video_size.get("width", base.video_width)), + video_height=int(video_size.get("height", base.video_height)), + navigation_timeout_ms=int(raw.get("navigation_timeout_ms", base.navigation_timeout_ms)), + step_delay_ms=int(raw.get("step_delay_ms", base.step_delay_ms)), + enable_overlay=bool(raw.get("enable_overlay", base.enable_overlay)), + enable_trace=bool(raw.get("enable_trace", base.enable_trace)), + video_format=str(raw.get("video_format", base.video_format)).lower(), + har_mode=har_mode, + ) + + @classmethod + def defaults(cls) -> "PlaywrightConfig": + """設定が完全に省略された場合の defaults。viewport=video_size=1280x720 で揃える。""" + return cls() + + +@dataclass +class RunnerConfig: + workers: int = 4 + testcases_dir: str = "./testcases" + + @classmethod + def from_raw(cls, raw: dict[str, Any]) -> "RunnerConfig": + return cls( + workers=int(raw.get("workers", 4)), + testcases_dir=str(raw.get("testcases_dir", "./testcases")), + ) + + +# --- accessibility / web vitals (v0.3.0) ----------------------------- + +@dataclass +class AccessibilityConfig: + """axe-core 自動スキャンの設定 (page_role に応じて runner が自動実行)。""" + enabled: bool = True + auto_roles: list[str] = field(default_factory=lambda: [ + "lp", "list", "form", "dashboard", "cart", "checkout", "settings", "auth", + ]) + tags: list[str] = field(default_factory=lambda: [ + "wcag2a", "wcag2aa", "wcag21aa", "wcag22aa", + ]) + # 検出した violations を testcase の FAIL 要因として扱うか (false なら情報出力のみ) + fail_on_violations: bool = True + + +@dataclass +class WebVitalsConfig: + """Core Web Vitals 自動計測の設定 (page_role に応じて runner が自動実行)。""" + enabled: bool = True + auto_roles: list[str] = field(default_factory=lambda: [ + "lp", "list", "dashboard", "search", + ]) + observe_ms: int = 5000 + # poor 判定が 1 件でもあれば testcase を FAIL とするか + fail_on_poor: bool = True + + +# --- body_check (PHP / SSR エラー検出, v0.4.0) ---------------------- + +@dataclass +class BodyCheckConfig: + """ページ本文の文字列マッチ検出 (PHP / SSR プロジェクト向け)。 + + JavaScript ランタイム由来の console.error / pageerror では拾えない、 + サーバ側で HTML 本文に直接出力された "Fatal error" / "Warning:" 等の + エラー文字列を、Playwright の ``page.on("response", ...)`` を介して + 検出する。 + + - ``fatal_patterns``: HTML 本文全体に対する substring match。1 つでも + 含まれれば violation。 + - ``warning_patterns``: 本文の **先頭 ``warning_head_chars`` 文字** に + 対する substring match。本文中の説明文に含まれる "Notice:" 等は許容し、 + ページ最上段への漏れだけを拾う。 + (PLAN18 の説明文は「先頭 300 文字」、code point ベース。日本語ページで + 300 bytes だと先頭 1〜2 行しか見えず実用にならないため、文字数を採用。) + - ``not_found_patterns``: 本文全体への substring match。 + - ``fail_on_match``: True なら violation 検出時に ``pytest.fail``。 + False なら情報収集のみ (report.md / body_check.jsonl には記録)。 + + default は ``enabled=True`` + PHP 系のフロント漏れ検出パターンを内蔵。 + config.yaml を書かなくてもまず PHP プロジェクトで素直に動く。 + """ + + enabled: bool = True + fatal_patterns: list[str] = field(default_factory=lambda: [ + "Fatal error", + "Uncaught", + "Parse error", + ]) + warning_patterns: list[str] = field(default_factory=lambda: [ + "STRICT:", + "Warning:", + "Notice:", + "Deprecated:", + ]) + # 文字数ベースの head 切り出し閾値 (code points)。PLAN18 のフィールド名は + # ``warning_head_bytes`` だったが、説明文は「先頭 300 文字」と書かれており + # 矛盾していた。実用上は文字数の方が日本語ページで安定するため採用。 + warning_head_chars: int = 300 + not_found_patterns: list[str] = field(default_factory=lambda: [ + "File not found", + ]) + fail_on_match: bool = True + + +# --- ルート --------------------------------------------------------- + +@dataclass +class Config: + base_url: str + basic_auth: BasicAuth + verify_tls: bool + roles: dict[str, Role] + playwright: PlaywrightConfig + runner: RunnerConfig + report: ReportConfig + config_path: Path # 設定ファイルの絶対パス(testcases_dir の解決基点) + browser: BrowserConfig = field(default_factory=BrowserConfig) + # docs/checklists/checklist-common.md C8/C9 の境界曖昧さに対応する「除外」設定。 + # console.error / pageerror の本文がいずれかの正規表現にマッチした場合は + # 集計から除外し FAIL を抑制する。3rd party の既知 warning などを許容するための + # 抜け穴。空 (デフォルト) なら従来どおり 1 件で FAIL。 + tolerated_console_errors: list[str] = field(default_factory=list) + tolerated_page_errors: list[str] = field(default_factory=list) + # accessibility / web_vitals 自動実行 (page_role に応じて runner が判定) + accessibility: AccessibilityConfig = field(default_factory=AccessibilityConfig) + web_vitals: WebVitalsConfig = field(default_factory=WebVitalsConfig) + # PHP / SSR ページ本文エラー検出 (v0.4.0, opt-in) + body_check: BodyCheckConfig = field(default_factory=BodyCheckConfig) + + @property + def testcases_dir(self) -> Path: + d = Path(self.runner.testcases_dir) + if not d.is_absolute(): + d = self.config_path.parent / d + return d.resolve() + + def role(self, role_id: str) -> Role: + if role_id not in self.roles: + raise KeyError(f"未定義のロール: {role_id}. roles 設定を確認してください。") + return self.roles[role_id] + + @classmethod + def load(cls, path: Path) -> "Config": + if not path.exists(): + raise FileNotFoundError( + f"設定ファイルが見つかりません: {path}\n" + "templates/scenario.config.yaml をコピーして作成してください。" + ) + with path.open("r", encoding="utf-8") as fp: + raw = yaml.safe_load(fp) + if not isinstance(raw, dict): + raise ValueError( + f"scenario.config.yaml の中身が空または辞書ではありません: {path}\n" + "templates/scenario.config.yaml をコピーして必要項目を埋めてください。" + ) + raw = _expand_env(raw) + return cls._from_dict(raw, config_path=path.resolve()) + + @classmethod + def _from_dict(cls, raw: dict[str, Any], *, config_path: Path) -> "Config": + target = raw["target"] + # basic_auth は省略可能 (サイトに Basic 認証が掛かっていない場合)。 + # 省略時は空 BasicAuth を使い、role 側で `requires_basic_auth: true` を + # 指定したテストケースだけが basic_auth ヘッダを要求する設計。 + ba_raw = target.get("basic_auth") or {} + basic_auth = BasicAuth( + user=str(ba_raw.get("user", "")), + password=str(ba_raw.get("password", "")), + ) + roles = {rid: _role_from_raw(rid, r) for rid, r in (raw.get("roles") or {}).items()} + + cfg = cls( + base_url=target["base_url"].rstrip("/"), + basic_auth=basic_auth, + verify_tls=bool(raw.get("verify_tls", False)), + roles=roles, + playwright=PlaywrightConfig.from_raw(raw.get("playwright") or {}), + runner=RunnerConfig.from_raw(raw.get("runner") or {}), + report=_report_from_raw(raw.get("report") or {}), + config_path=config_path, + browser=BrowserConfig.from_raw(raw.get("browser") or {}), + tolerated_console_errors=list(raw.get("tolerated_console_errors") or []), + tolerated_page_errors=list(raw.get("tolerated_page_errors") or []), + accessibility=_accessibility_from_raw(raw.get("accessibility") or {}), + web_vitals=_web_vitals_from_raw(raw.get("web_vitals") or {}), + body_check=_body_check_from_raw(raw.get("body_check") or {}), + ) + + # fail-fast: requires_basic_auth=True なロールが宣言されているのに + # basic_auth.user が空ならば実行時に HTTP 401 で必ず落ちる。先に検出して + # 設定不備として ValueError を投げる (Maj-4)。 + for role in cfg.roles.values(): + if role.login.requires_basic_auth and not basic_auth.user: + raise ValueError( + f"role '{role.id}' は requires_basic_auth=True だが、" + f"target.basic_auth.user が空 (config.yaml を確認してください)" + ) + + return cfg + + +def _role_from_raw(rid: str, raw: dict[str, Any]) -> Role: + login = raw["login"] + return Role( + id=rid, + label=str(raw.get("label", rid)), + login=Login( + path=login["path"], + requires_basic_auth=bool(login.get("requires_basic_auth", False)), + fields=dict(login["fields"]), + fail_if_url_contains=login["fail_if_url_contains"], + submit_selectors=list(login.get("submit_selectors") or []), + ), + ) + + +def _report_from_raw(raw: dict[str, Any]) -> ReportConfig: + labels_raw = raw.get("phase_labels") or {} + return ReportConfig( + title=str(raw.get("title", "シナリオ E2E テスト 実施報告書")), + test_plan_link=str(raw.get("test_plan_link", "./test-plan.md")), + phase_labels={int(k): str(v) for k, v in labels_raw.items()}, + ) + + +def _accessibility_from_raw(raw: dict[str, Any]) -> AccessibilityConfig: + base = AccessibilityConfig() + return AccessibilityConfig( + enabled=bool(raw.get("enabled", base.enabled)), + auto_roles=list(raw.get("auto_roles") or base.auto_roles), + tags=list(raw.get("tags") or base.tags), + fail_on_violations=bool(raw.get("fail_on_violations", base.fail_on_violations)), + ) + + +def _web_vitals_from_raw(raw: dict[str, Any]) -> WebVitalsConfig: + base = WebVitalsConfig() + return WebVitalsConfig( + enabled=bool(raw.get("enabled", base.enabled)), + auto_roles=list(raw.get("auto_roles") or base.auto_roles), + observe_ms=int(raw.get("observe_ms", base.observe_ms)), + fail_on_poor=bool(raw.get("fail_on_poor", base.fail_on_poor)), + ) + + +def _body_check_from_raw(raw: dict[str, Any]) -> BodyCheckConfig: + """``body_check`` セクションを ``BodyCheckConfig`` に変換する。 + + - キーが **省略** されている場合は dataclass の default 値を採用する + (config を書かなくても PHP 系のデフォルトパターンが効くようにするため)。 + - キーが **明示的に空リスト** で書かれている場合はそのまま空リストにする + (default を上書きしてカテゴリを無効化したい場合の挙動)。 + """ + base = BodyCheckConfig() + + def _patterns(key: str, default: list[str]) -> list[str]: + if key not in raw: + return list(default) + value = raw.get(key) + if value is None: + return list(default) + return [str(s) for s in value] + + # ``warning_head_chars`` を新フィールド名として採用。旧名 ``warning_head_bytes`` + # も alias として受理する (PLAN18 がフィールド名と説明文で矛盾していた経緯)。 + head_chars = raw.get("warning_head_chars") + if head_chars is None: + head_chars = raw.get("warning_head_bytes", base.warning_head_chars) + + return BodyCheckConfig( + enabled=bool(raw.get("enabled", base.enabled)), + fatal_patterns=_patterns("fatal_patterns", base.fatal_patterns), + warning_patterns=_patterns("warning_patterns", base.warning_patterns), + warning_head_chars=int(head_chars), + not_found_patterns=_patterns("not_found_patterns", base.not_found_patterns), + fail_on_match=bool(raw.get("fail_on_match", base.fail_on_match)), + ) diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py new file mode 100644 index 0000000..b9eb500 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/__init__.py @@ -0,0 +1,19 @@ +"""playwright-scenario-test pytest fixtures。 + +利用者は通常の pytest テストを書き、`pwk_config` / `pwk_role_` 等の +fixture をパラメタ宣言するだけで NDF の機能 (config / 認証 / evidence / +accessibility / web vitals / overlay / Drive) を享受できる。 + +各 fixture の実体はサブモジュールに分離する: +- ``auth`` : ``pwk_config`` / ``pwk_role_`` (login 済 storage_state) +- ``evidence`` : ``pwk_evidence`` (HAR / trace / console listeners) +- ``accessibility`` : autouse hook で page_role marker に応じ axe-core を実行 +- ``web_vitals`` : autouse hook で page_role marker に応じ Core Web Vitals 計測 + +pytest plugin (``playwright_kit.pytest_plugin``) から ``pytest_plugins`` で +読み込まれる想定。利用者プロジェクトの ``conftest.py`` で個別 import する必要は無い。 +""" + +from __future__ import annotations + +__all__ = ["auth"] diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py new file mode 100644 index 0000000..eafc0a7 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/accessibility.py @@ -0,0 +1,143 @@ +"""accessibility fixture: ``page_role`` marker に応じた axe-core 自動スキャン。 + +Web アクセシビリティ (WCAG 準拠) を ``axe-core`` で機械検査する fixture。 +``@pytest.mark.page_role("form")`` 等が付与された test 関数の終了直前に +axe-core を自動実行する。 + +利用方法: +- ``@pytest.mark.page_role("form")`` を test に付与すれば autouse 経由で + axe-core が走る (config.accessibility.auto_roles に該当する場合のみ) +- 違反があれば ``config.accessibility.fail_on_violations`` (default True) に従い + ``pytest.fail`` する +- 明示的に scan したい場合は ``pwk_accessibility_scan`` fixture を直接呼ぶ +""" + +from __future__ import annotations + +from collections import Counter +from typing import Iterator + +import pytest + +from playwright_kit import accessibility as accessibility_mod +from playwright_kit.config import Config +from playwright_kit.fixtures.evidence import PwkEvidence + + +def _page_roles_from_marker(item) -> list[str]: + """test item から ``page_role`` marker の引数 (役割名 list) を集約する。""" + roles: list[str] = [] + for marker in item.iter_markers(name="page_role"): + for arg in marker.args: + if isinstance(arg, str): + roles.append(arg) + elif isinstance(arg, (list, tuple)): + roles.extend(str(a) for a in arg) + return roles + + +@pytest.fixture() +def pwk_accessibility_scan(page, pwk_evidence: PwkEvidence, _pwk_config_optional): + """明示呼び出し用: ``violations = pwk_accessibility_scan()`` で 1 度スキャン。""" + config: Config | None = _pwk_config_optional + + def _scan(*, tags: tuple[str, ...] | None = None) -> list[dict]: + if not accessibility_mod.is_available(): + pwk_evidence.log_lines.append( + "[accessibility] axe-playwright-python 未インストール — SKIP " + "(`uv sync --extra a11y` で有効化)" + ) + return [] + actual_tags = ( + tuple(tags) + if tags is not None + else ( + tuple(config.accessibility.tags) + if config is not None + else accessibility_mod.DEFAULT_TAGS + ) + ) + violations = accessibility_mod.scan_page(page, tags=actual_tags) + pwk_evidence.axe_violations.extend(violations) + return violations + + return _scan + + +@pytest.fixture(autouse=True) +def _pwk_accessibility_autouse(request) -> Iterator[None]: + """``page_role`` marker が付いた test の終了直前に axe-core を実行する。 + + ``page`` fixture を **要求している test のみ** 対象。autouse fixture が + 無条件に ``page`` を要求すると、pytest-playwright が全 test を browser + parametrize してしまうため、ここでは ``request.fixturenames`` を見て + 必要な test だけ取得する。 + + Issue #60 fix: 旧版の ``"pwk_evidence" not in request.fixturenames`` ガードを + 廃止。test 引数に ``pwk_evidence`` を書いていなくても ``getfixturevalue`` + 経由で lazy 取得し、accessibility autouse が走るようにする。 + + teardown order 対策 (Issue #61): pytest fixture の teardown は LIFO のため、 + ``yield`` 後に ``getfixturevalue("pwk_evidence")`` を呼ぶと「既に解放済」 + AssertionError が発生する。setup phase で ``ev`` / ``page`` を取得して + closure に保持し、teardown phase はその参照のみを使う。 + """ + + # ``page`` を要求していない (= browser を使わない) test では何もしない。 + # これにより pure pytest test の挙動に影響を与えない。 + if "page" not in request.fixturenames: + yield + return + + config: Config | None = request.getfixturevalue("_pwk_config_optional") + if config is None or not config.accessibility.enabled: + yield + return + page_roles = _page_roles_from_marker(request.node) + if not page_roles: + yield + return + if not accessibility_mod.should_auto_scan( + page_roles, auto_roles=frozenset(config.accessibility.auto_roles) + ): + yield + return + + # setup phase: closure に必要なオブジェクトを束ねる。 + pwk_evidence: PwkEvidence = request.getfixturevalue("pwk_evidence") + page = request.getfixturevalue("page") + + yield + + # teardown phase: closure に保持した ev / page のみを参照する。 + if not accessibility_mod.is_available(): + pwk_evidence.log_lines.append( + "[accessibility autouse] axe-playwright-python 未インストール — SKIP" + ) + return + + try: + if page.is_closed(): + return + except Exception: + return + + violations = accessibility_mod.scan_page(page, tags=tuple(config.accessibility.tags)) + pwk_evidence.axe_violations.extend(violations) + if not violations: + return + + impacts = Counter(v.get("impact") or "unknown" for v in violations) + impact_summary = ", ".join(f"{k}={n}" for k, n in impacts.most_common()) + pwk_evidence.log_lines.append( + f"[accessibility autouse] {len(violations)} violations: {impact_summary}" + ) + + if config.accessibility.fail_on_violations: + pytest.fail( + f"[accessibility] {len(violations)} 件の axe-core 違反 " + f"[{impact_summary}]: " + + ", ".join( + f"{v.get('id')}({v.get('impact', '?')})" for v in violations[:5] + ) + ) diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py new file mode 100644 index 0000000..964f1e1 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/auth.py @@ -0,0 +1,318 @@ +"""認証 fixture: ``pwk_config`` と動的に生成する ``pwk_role_``。 + +pytest-playwright が提供する ``page`` / ``context`` / ``browser_context_args`` +fixture と協調して動作する。 + +設計方針: +- ``pwk_config`` は session scope。``--pwk-config`` で指定された YAML を + 1 度だけ読み込む。利用者プロジェクトの ``conftest.py`` から override 可能。 +- 各 role に対し ``pwk_role_`` fixture を *動的* に生成する。 + 実体は ``_login_and_get_storage_state`` で session 内 1 回だけ login し、 + storage_state を session-scoped cache (`_StorageStateCache`) に保管。 + 以降の test では同じ role の cache を ``context.add_cookies`` 等で再利用する + ことで login の再実行を避ける。 +- function scope で ``page.context.storage_state(...)`` を inject し、 + ``page`` は既に該当 role でログイン済みの状態で test 関数に渡される。 + +fail_if_url_contains による失敗判定もここで行い、test 開始前に明示的に +``pytest.fail`` する。 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlsplit + +import pytest + +from playwright_kit.config import Config, Login, Role + + +def _same_origin(origin_url: str, base_url: str) -> bool: + """``origin_url`` が ``base_url`` と同一 origin (scheme + host + port) かを返す。 + + storage_state には認証対象以外のサードパーティ origin (広告 / 計測タグ等) が + 含まれることがある。restore 時にそれらへ ``page.goto(url)`` するのは意図しない + 外部送信になるため、本関数で base_url の origin に厳格一致するもののみ許可する。 + """ + try: + a = urlsplit(origin_url) + b = urlsplit(base_url) + except ValueError: + return False + return ( + bool(a.scheme) + and bool(a.hostname) + and a.scheme == b.scheme + and a.hostname == b.hostname + and (a.port or None) == (b.port or None) + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +@dataclass +class _StorageStateCache: + """session 内で role ごとの storage_state を 1 回だけ作る簡易 cache。""" + + states: dict[str, dict[str, Any]] + + @classmethod + def empty(cls) -> "_StorageStateCache": + return cls(states={}) + + def get(self, role_id: str) -> dict[str, Any] | None: + return self.states.get(role_id) + + def put(self, role_id: str, state: dict[str, Any]) -> None: + self.states[role_id] = state + + +def _submit_login_form(page, login: Login) -> None: + """ログインフォームの submit を行う。 + + 優先順位は ``login.submit_selectors`` → role/type=submit → password Enter。 + """ + for sel in login.submit_selectors: + try: + page.locator(sel).first.click(timeout=2000) + return + except Exception: + continue + for fallback in ( + "role=button[name=/login|sign.?in|ログイン/i]", + 'button[type="submit"]', + 'input[type="submit"]', + ): + try: + page.locator(fallback).first.click(timeout=2000) + return + except Exception: + continue + pw_field = next( + (n for n in login.fields if "pass" in n.lower() or "pwd" in n.lower()), + None, + ) + if pw_field: + page.locator(f'input[name="{pw_field}"]').press("Enter") + return + raise RuntimeError( + "ログイン送信ボタンが見つかりません (submit_selectors を設定してください)" + ) + + +def _login_and_get_storage_state( + *, + playwright, + base_url: str, + role: Role, + basic_auth_user: str, + basic_auth_password: str, + verify_tls: bool, + nav_timeout_ms: int = 30_000, +) -> dict[str, Any]: + """role の login flow を実行し storage_state を返す。 + + 1 度だけ呼ばれることを想定。失敗時は ``pytest.fail`` を投げる。 + + AQ Critical-2 完遂: 関数全体を browser try/finally で囲み、 + page.goto() / fill() / expect_navigation() / fail_if_url_contains で + pytest.fail() が発生した場合も含め、全ての failure path で + browser.close() が必ず呼ばれることを保証する。 + pytest.fail() は内部的に例外を raise するため finally は確実に動く。 + """ + browser = playwright.chromium.launch(headless=True) + try: + ctx_kwargs: dict[str, Any] = { + "ignore_https_errors": not verify_tls, + } + if role.login.requires_basic_auth: + ctx_kwargs["http_credentials"] = { + "username": basic_auth_user, + "password": basic_auth_password, + } + context = browser.new_context(**ctx_kwargs) + context.set_default_navigation_timeout(nav_timeout_ms) + context.set_default_timeout(nav_timeout_ms) + + try: + page = context.new_page() + url = f"{base_url}{role.login.path}" + try: + page.goto(url, wait_until="domcontentloaded", timeout=nav_timeout_ms) + except Exception as exc: # pragma: no cover - depends on remote target + pytest.fail( + f"[pwk_role_{role.id}] login page open failed: {url} ({exc})" + ) + + for name, value in role.login.fields.items(): + try: + page.locator(f'input[name="{name}"]').fill( + value, timeout=nav_timeout_ms + ) + except Exception as exc: # pragma: no cover + pytest.fail( + f"[pwk_role_{role.id}] fill {name!r} failed: {exc}" + ) + + try: + with page.expect_navigation( + wait_until="domcontentloaded", timeout=nav_timeout_ms + ): + _submit_login_form(page, role.login) + except Exception as exc: # pragma: no cover + pytest.fail( + f"[pwk_role_{role.id}] navigation 失敗: " + f"{type(exc).__name__}: {exc}" + ) + + final_url = page.url + # Amazon Q Critical-1: fail_if_url_contains が空文字列の場合、空文字列は + # あらゆる文字列に含まれるため常に True になり全 login が失敗する。 + # 空文字列 (= 未設定) の場合はチェックをスキップする。 + if role.login.fail_if_url_contains and role.login.fail_if_url_contains in final_url: + pytest.fail( + f"[pwk_role_{role.id}] login 失敗: " + f"final_url={final_url} に '{role.login.fail_if_url_contains}' を含む" + ) + + state = context.storage_state() + return state + finally: + try: + context.close() + except Exception: + pass + finally: + try: + browser.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Public fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def pwk_config(pytestconfig) -> Config: + """``--pwk-config`` で指定された YAML をロードして ``Config`` を返す。 + + 指定が無い場合は CWD 直下の ``scenario.config.yaml`` を試し、 + それも無ければ ``pytest.skip`` する (NDF 機能が要らない test と共存可能にする)。 + """ + raw_path: str | None = pytestconfig.getoption("pwk_config", default=None) + if not raw_path: + env = os.environ.get("PWK_CONFIG") + if env: + raw_path = env + if not raw_path: + candidate = Path.cwd() / "scenario.config.yaml" + if candidate.exists(): + raw_path = str(candidate) + if not raw_path: + pytest.skip( + "pwk_config 未指定: --pwk-config もしくは PWK_CONFIG env、" + "または ./scenario.config.yaml を用意してください。" + ) + + path = Path(raw_path).resolve() + return Config.load(path) + + +@pytest.fixture(scope="session") +def _pwk_storage_state_cache() -> _StorageStateCache: + return _StorageStateCache.empty() + + +def _make_role_fixture(role_id: str) -> Callable: + """role_id ごとに ``pwk_role_`` fixture の実装関数を生成する。""" + + def _fixture( + pwk_config: Config, + playwright, + context, + _pwk_storage_state_cache: _StorageStateCache, + ) -> Role: + """login 済の storage_state を ``context`` に注入し、Role を返す。 + + - ``playwright`` / ``context`` は ``pytest-playwright`` 提供 + - 既に同 role の storage_state が cache 済なら login をスキップ + """ + role = pwk_config.role(role_id) + + state = _pwk_storage_state_cache.get(role_id) + if state is None: + state = _login_and_get_storage_state( + playwright=playwright, + base_url=pwk_config.base_url, + role=role, + basic_auth_user=pwk_config.basic_auth.user, + basic_auth_password=pwk_config.basic_auth.password, + verify_tls=pwk_config.verify_tls, + nav_timeout_ms=pwk_config.playwright.navigation_timeout_ms, + ) + _pwk_storage_state_cache.put(role_id, state) + + # cookies / origins (localStorage 等) を新しい context に注入する。 + cookies = state.get("cookies") or [] + if cookies: + context.add_cookies(cookies) + # storage_state に含まれる広告/計測タグ等の third-party origin に対して + # 不用意に goto するのを避けるため、base_url と同一 origin のみ復元する。 + for origin in state.get("origins") or []: + url = origin.get("origin") + items = origin.get("localStorage") or [] + if not url or not items: + continue + if not _same_origin(url, pwk_config.base_url): + continue + try: + page = context.new_page() + page.goto(url, wait_until="domcontentloaded") + for it in items: + page.evaluate( + "([k, v]) => window.localStorage.setItem(k, v)", + [it.get("name"), it.get("value")], + ) + page.close() + except Exception: + # localStorage 注入失敗は致命的ではない (cookie ベースの認証なら OK)。 + pass + + return role + + _fixture.__name__ = f"pwk_role_{role_id}" + _fixture.__doc__ = ( + f"role={role_id!r} で login 済の storage_state を context に注入する。" + ) + return _fixture + + +def register_role_fixtures(plugin_module, config: Config) -> list[str]: + """plugin module に ``pwk_role_`` fixture を動的登録する。 + + ``pytest_configure`` から呼ばれる。pytest は modules の attribute を + fixture として discover するため、setattr で十分。 + + Returns: + 登録した fixture 名のリスト。 + """ + registered: list[str] = [] + for role_id in config.roles: + name = f"pwk_role_{role_id}" + if hasattr(plugin_module, name): + continue + impl = _make_role_fixture(role_id) + # function scope (default) で wrap してから plugin module に attach + wrapped = pytest.fixture(name=name)(impl) + setattr(plugin_module, name, wrapped) + registered.append(name) + return registered diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py new file mode 100644 index 0000000..d6437c2 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/body_check.py @@ -0,0 +1,194 @@ +"""body_check fixture: ``page.on("response", ...)`` でレスポンス本文を監視し、 +PHP / SSR が HTML 本文に出力したエラー文字列を検出する (v0.4.0)。 + +config.yaml の ``body_check.enabled`` の default は ``True`` (PHP 系パターン +内蔵)。``page`` fixture を要求している test に限り autouse で listener を +attach する (accessibility autouse と同じ guard 戦略)。 + +opt-out: +- 全体無効化: ``body_check.enabled: false`` を config.yaml に明示 +- カテゴリ単位: ``fatal_patterns: []`` などで明示空指定 +- 個別 test: ``@pytest.mark.no_body_check`` を付与 + +注意: +- 同じ page を ``pwk_body_check_scan`` helper で手動スキャンしつつ autouse + listener も走らせると、同一 violation を重複加算する。helper は autouse の + listener が拾えないタイミング (例: SPA 内のローカル DOM 更新後) でのみ使う。 +""" + +from __future__ import annotations + +import json +from typing import Any, Iterator + +import pytest + +from playwright_kit.body_check import ( + is_html_response, + scan_body, +) +from playwright_kit.config import BodyCheckConfig, Config +from playwright_kit.fixtures.evidence import PwkEvidence + + +def _build_response_handler(cfg: BodyCheckConfig, ev: PwkEvidence): + """``page.on("response", ...)`` 用の handler を closure として作る。 + + listener 内で発生する例外は test 失敗には伝播させず ``ev.log_lines`` に + 記録するに留める (accessibility / pageerror listener と同じ防御方針)。 + """ + fatal = tuple(cfg.fatal_patterns) + warn = tuple(cfg.warning_patterns) + not_found = tuple(cfg.not_found_patterns) + head_chars = int(cfg.warning_head_chars) + + def _on_response(response) -> None: + try: + headers = response.headers or {} + if not is_html_response(headers.get("content-type") or headers.get("Content-Type")): + return + try: + body = response.text() + except Exception: + # navigation 中の中断 / closed context などで text() が失敗するケース + return + violations = scan_body( + body, + response.url, + fatal_patterns=fatal, + warning_patterns=warn, + warning_head_chars=head_chars, + not_found_patterns=not_found, + ) + for v in violations: + ev.body_check_violations.append(v.to_dict()) + except Exception as exc: # pragma: no cover - listener 内で test を落とさない + ev.log_lines.append(f"[body_check listener] {exc}") + + return _on_response + + +def _write_jsonl(ev: PwkEvidence) -> None: + """1 violation = 1 行で ``case_dir/body_check.jsonl`` に書き出す。""" + if not ev.body_check_violations: + return + try: + path = ev.case_dir / "body_check.jsonl" + with path.open("w", encoding="utf-8") as fp: + for v in ev.body_check_violations: + fp.write(json.dumps(v, ensure_ascii=False) + "\n") + except Exception as exc: # pragma: no cover + ev.log_lines.append(f"[body_check] jsonl write failed: {exc}") + + +def _format_violation_summary(violations: list[dict[str, Any]], limit: int = 5) -> str: + """``pytest.fail`` メッセージ用の短いサマリ文字列。""" + parts: list[str] = [] + for v in violations[:limit]: + url = v.get("url", "?") + cat = v.get("category", "?") + pat = v.get("pattern", "?") + parts.append(f"{cat}:{pat!r}@{url}") + if len(violations) > limit: + parts.append(f"... (+{len(violations) - limit} more)") + return "; ".join(parts) + + +@pytest.fixture() +def pwk_body_check_scan(page, pwk_evidence: PwkEvidence, _pwk_config_optional): + """明示呼び出し用: ``violations = pwk_body_check_scan()`` で現在の page 本文を 1 度スキャン。 + + autouse 経路を使わず、特定タイミング (例: フォーム送信後の 200 応答) で + 本文を再評価したい場合の helper。 + """ + config: Config | None = _pwk_config_optional + + def _scan() -> list[dict[str, Any]]: + if config is None or not config.body_check.enabled: + return [] + try: + body = page.content() + except Exception as exc: + pwk_evidence.log_lines.append(f"[body_check] page.content() failed: {exc}") + return [] + violations = scan_body( + body, + page.url, + fatal_patterns=config.body_check.fatal_patterns, + warning_patterns=config.body_check.warning_patterns, + warning_head_chars=config.body_check.warning_head_chars, + not_found_patterns=config.body_check.not_found_patterns, + ) + as_dicts = [v.to_dict() for v in violations] + pwk_evidence.body_check_violations.extend(as_dicts) + return as_dicts + + return _scan + + +@pytest.fixture(autouse=True) +def _pwk_body_check_autouse(request) -> Iterator[None]: + """``page`` を要求する test に限り、HTML response への body_check を自動実行する。 + + ガード戦略: + - ``page`` を fixturename に持たない test は対象外 (browser を起動させない) + - config.body_check.enabled が False なら何もしない + - ``@pytest.mark.no_body_check`` が付いている test は skip + + ``pwk_evidence`` は ``getfixturevalue`` で setup phase に lazy resolve する。 + test 関数の引数に ``pwk_evidence`` を書いていなくても listener が attach される + (Issue #60)。 + + teardown 時に違反があれば ``case_dir/body_check.jsonl`` に書き出し、 + ``fail_on_match`` が True なら ``pytest.fail`` で test を失敗させる。 + """ + if "page" not in request.fixturenames: + yield + return + + config: Config | None = request.getfixturevalue("_pwk_config_optional") + if config is None or not config.body_check.enabled: + yield + return + + if request.node.get_closest_marker("no_body_check") is not None: + yield + return + + page = request.getfixturevalue("page") + # setup phase で fetch して closure に保持する (Issue #61 と同じ teardown + # order 問題を防ぐ)。 + ev: PwkEvidence = request.getfixturevalue("pwk_evidence") + handler = _build_response_handler(config.body_check, ev) + + try: + page.on("response", handler) + except Exception as exc: + ev.log_lines.append(f"[body_check] page.on attach failed: {exc}") + yield + return + + try: + yield + finally: + try: + page.remove_listener("response", handler) + except Exception: + # page が既に閉じられている場合などは黙殺 + pass + + if not ev.body_check_violations: + return + + _write_jsonl(ev) + + ev.log_lines.append( + f"[body_check] {len(ev.body_check_violations)} 件の違反: " + + _format_violation_summary(ev.body_check_violations) + ) + + if config.body_check.fail_on_match: + pytest.fail( + f"[body_check] {len(ev.body_check_violations)} 件の本文エラーを検出: " + + _format_violation_summary(ev.body_check_violations) + ) diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py new file mode 100644 index 0000000..c5ab484 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/evidence.py @@ -0,0 +1,353 @@ +"""evidence fixture: HAR / trace / console / pageerror の収集。 + +pytest-playwright が提供する ``browser_context_args`` / ``context`` / ``page`` +fixture と組み合わせて、test 関数ごとに以下を自動収集する: + +- HAR: ``browser_context_args`` に ``record_har_path`` を inject +- trace: ``context.tracing.start`` / ``stop`` (``--pwk-no-evidence`` で無効化) +- console.error / pageerror: page listener として attach + (``tolerated_console_errors`` / ``tolerated_page_errors`` でフィルタ) + +artifact の出力先は ``--pwk-out-dir`` (default: ``./reports//``)。 +test 関数 ID から sub-dir を作って 1 test = 1 dir で隔離する。 +""" + +from __future__ import annotations + +import datetime as _dt +import hashlib +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterator + +import pytest + +from playwright_kit.config import Config, PlaywrightConfig + + +# --------------------------------------------------------------------------- +# Output directory resolver (session scope) +# --------------------------------------------------------------------------- + + +def _resolve_out_dir(pytestconfig) -> Path: + """``--pwk-out-dir`` が指定されればそれを、なければ ``reports//``。 + + run_id は session 開始時に 1 度だけ決定し、``pytestconfig._pwk_out_dir`` に + キャッシュする。これにより ``pwk_out_dir`` fixture と + ``pytest_terminal_summary`` が別々に ``datetime.now()`` を呼んで + 秒またぎでディレクトリがズレる問題を防ぐ (新規 Major 対応)。 + + ``--pwk-out-dir`` が明示指定されている場合はキャッシュ不要のため + 常にその値を返す(複数回呼ばれても同じ値)。 + """ + raw: str | None = pytestconfig.getoption("pwk_out_dir", default=None) + if raw: + return Path(raw).resolve() + + # --pwk-out-dir 未指定時のみキャッシュで run_id の秒またぎを防ぐ。 + # hasattr で厳密にチェックし、MagicMock 等が偽の属性を返さないようにする。 + if "_pwk_out_dir" in vars(pytestconfig): + return pytestconfig._pwk_out_dir # type: ignore[attr-defined] + + run_id = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") + out = (Path.cwd() / "reports" / run_id).resolve() + # session-scoped キャッシュとして保存 + pytestconfig._pwk_out_dir = out # type: ignore[attr-defined] + return out + + +@pytest.fixture(scope="session") +def pwk_out_dir(pytestconfig) -> Path: + """session 全体で共有する成果物ルート。session 開始時に作成する。 + + ``_resolve_out_dir`` を通じて ``pytestconfig._pwk_out_dir`` にキャッシュし、 + ``pytest_terminal_summary`` と同じ out_dir を参照する。 + """ + out = _resolve_out_dir(pytestconfig) + out.mkdir(parents=True, exist_ok=True) + return out + + +# --------------------------------------------------------------------------- +# Per-test evidence (function scope) +# --------------------------------------------------------------------------- + + +_FILENAME_SAFE_RE = re.compile(r"[^\w\-]+") + + +def _safe_slug(name: str, fallback: str = "test") -> str: + """文字列から安全なファイル名スラグを生成する (後方互換維持)。""" + s = _FILENAME_SAFE_RE.sub("-", name).strip("-").lower() + return s[:80] or fallback + + +def _safe_case_slug(node: Any) -> str: + """nodeid + xdist worker + sha1[:6] suffix で衝突しない slug を生成する (Codex Major 2)。 + + - parametrize / 同名関数 / xdist 並列で trace.zip / request.har の上書きを防止。 + - 既存の _safe_slug(name, fallback) 仕様は変えず、evidence fixture 内のみ本関数を使う。 + """ + nodeid = getattr(node, "nodeid", getattr(node, "name", "test")) + worker = os.environ.get("PYTEST_XDIST_WORKER", "") + raw = f"{nodeid}@{worker}" if worker else nodeid + slug = _FILENAME_SAFE_RE.sub("-", raw).strip("-").lower() + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:6] + # 60 文字 + "-" + sha1[:6] = 最大 67 文字程度に圧縮 + return f"{slug[:60]}-{digest}".strip("-") or "test" + + +@dataclass +class PwkEvidence: + """1 test 関数分の証跡コレクタ。""" + + case_dir: Path + config: Config | None + enabled: bool + + har_path: Path | None = None + trace_path: Path | None = None + har_relpath: str | None = None + trace_relpath: str | None = None + + console_errors: list[str] = field(default_factory=list) + page_errors: list[str] = field(default_factory=list) + axe_violations: list[dict[str, Any]] = field(default_factory=list) + web_vitals_metrics: dict[str, float] = field(default_factory=dict) + web_vitals_passed: bool = True + # PHP / SSR ページ本文エラー (body_check) 違反 (v0.4.0)。1 件 = 1 dict + # ({url, category, pattern, snippet})。 + body_check_violations: list[dict[str, Any]] = field(default_factory=list) + + log_lines: list[str] = field(default_factory=list) + + _trace_started: bool = field(default=False, init=False, repr=False) + _tolerated_console_re: list[re.Pattern[str]] = field( + default_factory=list, init=False, repr=False + ) + _tolerated_page_re: list[re.Pattern[str]] = field( + default_factory=list, init=False, repr=False + ) + + def __post_init__(self) -> None: + if self.config is not None: + self._tolerated_console_re = [ + re.compile(p) for p in self.config.tolerated_console_errors + ] + self._tolerated_page_re = [ + re.compile(p) for p in self.config.tolerated_page_errors + ] + + # --- listener ------------------------------------------------------ + + def attach_listeners(self, page) -> None: + page.on("console", self._on_console) + page.on("pageerror", self._on_pageerror) + + def _on_console(self, msg) -> None: + try: + if msg.type != "error": + return + loc = getattr(msg, "location", None) or {} + text = msg.text[:500] + for rx in self._tolerated_console_re: + if rx.search(text): + return + self.console_errors.append(f"{loc.get('url', '?')}: {text}") + except Exception as exc: # pragma: no cover + self.log_lines.append(f"[console listener] {exc}") + + def _on_pageerror(self, exc) -> None: + try: + text = str(exc)[:1000] + for rx in self._tolerated_page_re: + if rx.search(text): + return + self.page_errors.append(text) + except Exception as listener_exc: # pragma: no cover + self.log_lines.append(f"[pageerror listener] {listener_exc}") + + # --- trace lifecycle (context scope) ------------------------------- + + def start_tracing(self, context) -> None: + if not self.enabled or self.trace_path is None: + return + try: + context.tracing.start( + name=self.case_dir.name, + title=self.case_dir.name, + snapshots=True, + screenshots=True, + sources=False, + ) + self._trace_started = True + except Exception as exc: # pragma: no cover + self.log_lines.append(f"[trace] start 失敗: {exc}") + + def stop_tracing(self, context) -> None: + if not self._trace_started or self.trace_path is None: + return + try: + context.tracing.stop(path=str(self.trace_path)) + self.trace_relpath = self.trace_path.name + except Exception as exc: # pragma: no cover + self.log_lines.append(f"[trace] stop 失敗: {exc}") + + def confirm_har(self) -> None: + if self.har_path and self.har_path.exists(): + self.har_relpath = self.har_path.name + + # --- summary ------------------------------------------------------- + + @property + def has_runtime_errors(self) -> bool: + return bool(self.console_errors or self.page_errors) + + def runtime_error_summary(self) -> str: + parts: list[str] = [] + if self.page_errors: + parts.append(f"pageerror {len(self.page_errors)} 件") + if self.console_errors: + parts.append(f"console.error {len(self.console_errors)} 件") + return "Runtime errors detected: " + ", ".join(parts) if parts else "" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def _pwk_config_optional(pytestconfig) -> Config | None: + """``pwk_config`` を session に 1 度だけ load する (失敗時は None)。 + + evidence fixture は pwk_config が無くても動くように optional にしてある。 + """ + cached = getattr(pytestconfig, "_pwk_config", None) + if cached is not None: + return cached # type: ignore[no-any-return] + return None + + +def _resolve_har_mode(pytestconfig, pwk_config) -> str: + """``--pwk-har-mode`` CLI > ``playwright.har_mode`` config > ``PlaywrightConfig`` default。 + + Issue #62 対策で default は ``minimal``。``--pwk-no-evidence`` が True の場合 + 呼び出し側で HAR を一切 inject しない (本関数の戻り値は使われない)。 + + default 値は ``PlaywrightConfig().har_mode`` から引くことで dataclass 側との + 二重管理を防ぐ。 + """ + cli = pytestconfig.getoption("pwk_har_mode", default=None) + if cli: + # ``pytest_plugin.py`` の argparse ``choices`` で値は担保済だが、 + # API 経由 (``--config`` 等) で大文字が来た場合に備える defensive normalize。 + return str(cli).lower() + if pwk_config is not None: + return str(pwk_config.playwright.har_mode).lower() + return PlaywrightConfig().har_mode + + +@pytest.fixture() +def browser_context_args( + browser_context_args, request, pytestconfig, pwk_out_dir, _pwk_config_optional +) -> dict[str, Any]: + """pytest-playwright の ``browser_context_args`` を function scope で override し、 + 1 test = 1 HAR を実現する (Codex Major 1)。 + + - scope を function に変更し、``request.node`` ごとに ``case_dir/request.har`` + を ``record_har_path`` に inject する。 + - session 共通 HAR (``session.har``) は廃止。これにより + ``PwkEvidence.confirm_har()`` が常に None を返す不整合を解消。 + - ``--pwk-no-evidence`` が True なら HAR 収集を OFF。 + - HAR mode (Issue #62): + - ``--pwk-har-mode none`` (または config ``playwright.har_mode: none``): + ``record_har_path`` を inject しない。 + - ``minimal`` (default): ``record_har_mode="minimal"`` でメタデータのみ + 記録。Basic 認証 + redirect 連続時の ``ERR_ABORTED`` race を回避する。 + - ``full``: Playwright 既定の full HAR (body + content) を記録。 + """ + no_evidence = bool(pytestconfig.getoption("pwk_no_evidence", default=False)) + args = dict(browser_context_args or {}) + if no_evidence: + return args + + har_mode = _resolve_har_mode(pytestconfig, _pwk_config_optional) + if har_mode == "none": + return args + + case_dir = pwk_out_dir / _safe_case_slug(request.node) + case_dir.mkdir(parents=True, exist_ok=True) + args.setdefault("record_har_path", str(case_dir / "request.har")) + if har_mode == "minimal": + # Playwright の record_har_mode="minimal" (Playwright >= 1.30) は + # request/response の主要メタデータのみ。content は記録されないので + # omit 指定は不要。 + args.setdefault("record_har_mode", "minimal") + else: # "full" + # 既存挙動 (body 含む) を維持しつつ content だけは省略する。 + args.setdefault("record_har_content", "omit") + return args + + +@pytest.fixture() +def pwk_evidence( + request, + pytestconfig, + pwk_out_dir: Path, + _pwk_config_optional, + context, + page, +) -> Iterator[PwkEvidence]: + """1 test 関数分の evidence collector を返す。 + + - ``--pwk-no-evidence`` が True なら trace/HAR を OFF にし、listener のみ動かす + - ``page`` fixture から console / pageerror listener を attach + - ``context.tracing.start/stop`` を裏で実行 (有効時) + - ``--pwk-overlay`` 指定時は ``overlay.OVERLAY_INIT_SCRIPT`` を ``context.add_init_script`` + で全 page に inject する + - ``pytest_runtest_makereport`` から FAIL 時に ``pwk_evidence`` の状態を確認可能 + """ + enabled = not bool(pytestconfig.getoption("pwk_no_evidence", default=False)) + overlay_enabled = bool(pytestconfig.getoption("pwk_overlay", default=False)) + # _safe_case_slug で nodeid + xdist worker + sha1[:6] の衝突しない slug を使用 (Codex Major 2) + case_dir = pwk_out_dir / _safe_case_slug(request.node) + case_dir.mkdir(parents=True, exist_ok=True) + + # HAR mode が "none" のときは ``request.har`` を期待しないようにする + # (browser_context_args で record_har_path 自体を inject していない: Issue #62)。 + har_mode = _resolve_har_mode(pytestconfig, _pwk_config_optional) + har_enabled = enabled and har_mode != "none" + + ev = PwkEvidence( + case_dir=case_dir, + config=_pwk_config_optional, + enabled=enabled, + har_path=(case_dir / "request.har") if har_enabled else None, + trace_path=(case_dir / "trace.zip") if enabled else None, + ) + ev.attach_listeners(page) + ev.start_tracing(context) + + # overlay (赤丸カーソル + 字幕、旧名 HUD) を init_script で inject。 + if overlay_enabled: + try: + from playwright_kit.overlay import OVERLAY_INIT_SCRIPT + + context.add_init_script(OVERLAY_INIT_SCRIPT) + except Exception as exc: # pragma: no cover + ev.log_lines.append(f"[overlay] add_init_script 失敗: {exc}") + + # request.node に ev を保持して makereport hook から参照可能にする + request.node._pwk_evidence = ev # type: ignore[attr-defined] + + try: + yield ev + finally: + try: + ev.stop_tracing(context) + finally: + ev.confirm_har() diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py new file mode 100644 index 0000000..924e382 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py @@ -0,0 +1,109 @@ +"""Web Vitals fixture: ``page_role`` marker に応じた Core Web Vitals 自動計測。 + +``@pytest.mark.page_role("dashboard")`` 等が付与された test の終了直前に +LCP (Largest Contentful Paint) / CLS (Cumulative Layout Shift) / +TTFB (Time To First Byte) / longest_task (Long Tasks API) を計測する。 +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest + +from playwright_kit import web_vitals as web_vitals_mod +from playwright_kit.config import Config +from playwright_kit.fixtures.evidence import PwkEvidence + + +def _page_roles_from_marker(item) -> list[str]: + roles: list[str] = [] + for marker in item.iter_markers(name="page_role"): + for arg in marker.args: + if isinstance(arg, str): + roles.append(arg) + elif isinstance(arg, (list, tuple)): + roles.extend(str(a) for a in arg) + return roles + + +@pytest.fixture() +def pwk_web_vitals_measure(page, pwk_evidence: PwkEvidence, _pwk_config_optional): + """明示呼び出し用: ``metrics = pwk_web_vitals_measure()`` で 1 度計測。""" + config: Config | None = _pwk_config_optional + + def _measure(*, observe_ms: int | None = None) -> dict[str, float]: + ms = ( + int(observe_ms) + if observe_ms is not None + else ( + int(config.web_vitals.observe_ms) + if config is not None + else 5000 + ) + ) + metrics = web_vitals_mod.measure_page(page, observe_ms=ms) + pwk_evidence.web_vitals_metrics.update(metrics) + pwk_evidence.web_vitals_passed = web_vitals_mod.passed(pwk_evidence.web_vitals_metrics) + return metrics + + return _measure + + +@pytest.fixture(autouse=True) +def _pwk_web_vitals_autouse(request) -> Iterator[None]: + """``page_role`` marker が付いた test の終了直前に Web Vitals 計測を行う。 + + accessibility autouse と同じく ``page`` fixture を要求している test のみ対象。 + + Issue #60 fix: 旧版の ``"pwk_evidence" not in request.fixturenames`` ガードを + 廃止。test 引数に ``pwk_evidence`` を書いていなくても ``getfixturevalue`` + 経由で lazy 取得し、Web Vitals autouse が走るようにする。 + + teardown order 対策 (Issue #61): ``yield`` 後に ``pwk_evidence`` を fetch + しようとすると LIFO 解放済の AssertionError になるため、setup phase で + ``ev`` / ``page`` を取得して closure に保持する。 + """ + + if "page" not in request.fixturenames: + yield + return + + config: Config | None = request.getfixturevalue("_pwk_config_optional") + if config is None or not config.web_vitals.enabled: + yield + return + page_roles = _page_roles_from_marker(request.node) + if not page_roles: + yield + return + if not web_vitals_mod.should_auto_measure( + page_roles, auto_roles=frozenset(config.web_vitals.auto_roles) + ): + yield + return + + # setup phase: closure に保持。 + pwk_evidence: PwkEvidence = request.getfixturevalue("pwk_evidence") + page = request.getfixturevalue("page") + + yield + + # teardown phase: closure 経由でアクセス。 + try: + if page.is_closed(): + return + except Exception: + return + + metrics = web_vitals_mod.measure_page(page, observe_ms=int(config.web_vitals.observe_ms)) + pwk_evidence.web_vitals_metrics.update(metrics) + pwk_evidence.web_vitals_passed = web_vitals_mod.passed(pwk_evidence.web_vitals_metrics) + + detail = ", ".join( + f"{k}={v:.1f}({web_vitals_mod.judge(k, v)})" for k, v in metrics.items() + ) or "no metrics collected" + pwk_evidence.log_lines.append(f"[web_vitals autouse] {detail}") + + if not pwk_evidence.web_vitals_passed and config.web_vitals.fail_on_poor: + pytest.fail(f"[web_vitals] poor metric を検出: {detail}") diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/overlay.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/overlay.py new file mode 100644 index 0000000..21e9844 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/overlay.py @@ -0,0 +1,220 @@ +"""動画録画用 HUD オーバーレイ (赤丸カーソル + 2 行字幕) の制御。 + +ブラウザ標準のカーソルは録画に焼き付かないため、JS で `
` を 2 つ inject する: + - `#__hud_cursor` — mousemove/down/up を追う赤丸(クリックで黄色リップル) + - `#__hud_caption` — 画面上部の 2 行字幕(`直前 │ … / 次へ │ …`) + +OVERLAY_INIT_SCRIPT は context.add_init_script() に渡す。 +他の関数は Page を受け取り、HUD の状態を JS evaluate 越しに更新する。 +""" + +from __future__ import annotations + +from playwright.sync_api import Page + + +OVERLAY_INIT_SCRIPT = r""" +(() => { + if (window.__hudInited) return; + window.__hudInited = true; + function setup() { + if (!document.body) return false; + const cursor = document.createElement('div'); + cursor.id = '__hud_cursor'; + cursor.style.cssText = + 'position:fixed;width:24px;height:24px;border-radius:50%;' + + 'background:rgba(255,80,80,0.55);border:2px solid #f33;' + + 'pointer-events:none;z-index:2147483647;' + + 'transform:translate(-50%,-50%);' + + 'box-shadow:0 0 10px rgba(255,0,0,0.7);transition:background 0.1s;'; + document.documentElement.appendChild(cursor); + // 前のページから引き継いだ最終カーソル位置と表示状態を復元 + try { + const cx = sessionStorage.getItem('__hudCursorX'); + const cy = sessionStorage.getItem('__hudCursorY'); + const cv = sessionStorage.getItem('__hudCursorVisible'); + if (cx !== null) cursor.style.left = cx + 'px'; + if (cy !== null) cursor.style.top = cy + 'px'; + // 既定は「非表示」 (擬似クリック対象がないステップは消す方針) + if (cv === '1') { + cursor.style.opacity = '1'; + cursor.style.visibility = 'visible'; + } else { + cursor.style.opacity = '0'; + cursor.style.visibility = 'hidden'; + } + } catch (e) {} + document.addEventListener('mousemove', (e) => { + cursor.style.left = e.clientX + 'px'; + cursor.style.top = e.clientY + 'px'; + try { + sessionStorage.setItem('__hudCursorX', String(e.clientX)); + sessionStorage.setItem('__hudCursorY', String(e.clientY)); + } catch (err) {} + }, true); + // 任意座標でクリックリップルを発火させる外部 API + window.__hudFlash = function(x, y) { + cursor.style.left = x + 'px'; + cursor.style.top = y + 'px'; + cursor.style.opacity = '1'; + cursor.style.visibility = 'visible'; + try { sessionStorage.setItem('__hudCursorVisible', '1'); } catch (e) {} + spawnRipple(x, y); + cursor.style.background = 'rgba(0,255,200,0.95)'; + cursor.style.transform = 'translate(-50%,-50%) scale(1.6)'; + cursor.style.boxShadow = '0 0 22px rgba(255,224,0,0.95)'; + cursor.style.borderColor = '#ffe000'; + setTimeout(() => { + cursor.style.background = 'rgba(255,80,80,0.55)'; + cursor.style.transform = 'translate(-50%,-50%) scale(1)'; + cursor.style.boxShadow = '0 0 10px rgba(255,0,0,0.7)'; + cursor.style.borderColor = '#f33'; + }, 400); + }; + + // 擬似クリック対象が見つからないステップで非表示にする + window.__hudHideCursor = function() { + cursor.style.opacity = '0'; + cursor.style.visibility = 'hidden'; + try { sessionStorage.setItem('__hudCursorVisible', '0'); } catch (e) {} + }; + + function spawnRipple(x, y) { + // 3 重リングのリップルでクリック箇所を強調 + for (let i = 0; i < 3; i++) { + const ring = document.createElement('div'); + ring.style.cssText = + 'position:fixed;pointer-events:none;z-index:2147483645;' + + 'left:' + x + 'px;top:' + y + 'px;' + + 'width:0;height:0;border:4px solid #ffe000;' + + 'border-radius:50%;transform:translate(-50%,-50%);' + + 'box-shadow:0 0 12px rgba(255,224,0,0.8);'; + document.documentElement.appendChild(ring); + ring.animate( + [ + { width: '24px', height: '24px', opacity: 1, borderWidth: '5px', + borderColor: '#ffe000' }, + { width: '110px', height: '110px', opacity: 0, borderWidth: '2px', + borderColor: '#ff6600' } + ], + { duration: 800, delay: i * 130, easing: 'ease-out', + fill: 'forwards' } + ); + setTimeout(() => { try { ring.remove(); } catch(e) {} }, + 850 + i * 130); + } + const cross = document.createElement('div'); + cross.style.cssText = + 'position:fixed;pointer-events:none;z-index:2147483647;' + + 'left:' + x + 'px;top:' + y + 'px;' + + 'width:48px;height:48px;transform:translate(-50%,-50%);' + + 'background:radial-gradient(circle,rgba(255,224,0,0.55) 0%,rgba(255,224,0,0) 70%);'; + document.documentElement.appendChild(cross); + cross.animate( + [{ opacity: 1 }, { opacity: 0 }], + { duration: 600, easing: 'ease-out', fill: 'forwards' } + ); + setTimeout(() => { try { cross.remove(); } catch(e) {} }, 650); + } + + document.addEventListener('mousedown', (e) => { + cursor.style.background = 'rgba(0,255,200,0.95)'; + cursor.style.transform = 'translate(-50%,-50%) scale(1.6)'; + cursor.style.boxShadow = '0 0 22px rgba(255,224,0,0.95)'; + cursor.style.borderColor = '#ffe000'; + spawnRipple(e.clientX, e.clientY); + }, true); + document.addEventListener('mouseup', () => { + cursor.style.background = 'rgba(255,80,80,0.55)'; + cursor.style.transform = 'translate(-50%,-50%) scale(1)'; + cursor.style.boxShadow = '0 0 10px rgba(255,0,0,0.7)'; + cursor.style.borderColor = '#f33'; + }, true); + + const HUD_HEIGHT = 60; // 字幕 2 行分の固定高 + const cap = document.createElement('div'); + cap.id = '__hud_caption'; + cap.style.cssText = + 'position:fixed;top:0;left:0;right:0;' + + 'height:' + HUD_HEIGHT + 'px;' + + 'background:rgba(0,0,0,0.88);color:#fff;' + + 'font:14px/1.5 "Noto Sans CJK JP","Noto Sans JP",' + + '"Hiragino Sans","Yu Gothic","Meiryo",IPAGothic,sans-serif;' + + 'font-feature-settings:"palt";' + + 'padding:8px 16px;pointer-events:none;z-index:2147483646;' + + 'box-sizing:border-box;border-bottom:3px solid #fa0;' + + 'white-space:pre-wrap;word-break:break-all;'; + document.documentElement.appendChild(cap); + + // 本文が字幕で隠れないよう body を下にずらす (border-bottom 3px を含めて +3) + try { + const padTop = (HUD_HEIGHT + 3) + 'px'; + document.body.style.paddingTop = padTop; + document.documentElement.style.scrollPaddingTop = padTop; + } catch (e) {} + let stored = ''; + try { stored = sessionStorage.getItem('__hudCaption') || ''; } catch (e) {} + cap.textContent = window.__pendingCaption || stored || ''; + return true; + } + if (!setup()) { + document.addEventListener('DOMContentLoaded', setup); + } +})(); +""" + + +_SET_CAPTION_JS = """(text) => { + window.__pendingCaption = text; + try { sessionStorage.setItem('__hudCaption', text); } catch (e) {} + const cap = document.getElementById('__hud_caption'); + if (cap) cap.textContent = text; +}""" + + +def set_caption(page: Page, *, previous: str = "", next_action: str = "") -> None: + """現ページの HUD 字幕を「直前 / 次へ」の 2 行で更新する。 + + sessionStorage にも書き込むので、次の navigation 後に init script が拾い直す。 + """ + parts = [ + f"直前 │ {previous}" if previous else None, + f"次へ │ {next_action}" if next_action else None, + ] + text = "\n".join(p for p in parts if p) + try: + page.evaluate(_SET_CAPTION_JS, text) + except Exception: + pass + + +def flash_click(page: Page, x: int, y: int, *, settle_ms: int = 250) -> None: + """指定座標 (viewport 内) にカーソルを移動し、HUD リップルを発火させる。 + + DOM 要素はクリックしない (`page.mouse.click` は呼ばない)。HUD オーバーレイの + `__hudFlash(x, y)` を JS evaluate で呼び出すだけ。 + """ + try: + page.mouse.move(x, y, steps=10) + except Exception: + pass + try: + page.evaluate( + "(c) => { if (window.__hudFlash) window.__hudFlash(c[0], c[1]); }", + [x, y], + ) + except Exception: + return + if settle_ms > 0: + try: + page.wait_for_timeout(settle_ms) + except Exception: + pass + + +def hide_cursor(page: Page) -> None: + """擬似クリック対象が見つからないとき、HUD カーソルを非表示にする。""" + try: + page.evaluate("() => { if (window.__hudHideCursor) window.__hudHideCursor(); }") + except Exception: + pass diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py new file mode 100644 index 0000000..81d7dff --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_plugin.py @@ -0,0 +1,491 @@ +"""playwright-scenario-test の pytest plugin。 + +CLI options: +- ``--pwk-config ``: scenario.config.yaml を指定 +- ``--pwk-out-dir ``: 成果物 (HAR / trace / 動画 / report) の出力先 +- ``--pwk-no-evidence``: evidence 収集を OFF +- ``--pwk-overlay``: overlay (赤丸カーソル + 字幕、旧名 HUD) を ON +- ``--pwk-drive-folder ``: Drive 連携 + +markers: +- ``page_role(*roles)``: accessibility / web vitals autouse の判定材料 +- ``role(role_id)``: login する role を明示 (`pwk_role_` fixture と並用可) +- ``phase(num)``: report.md のフェーズ集計用 +- ``priority(level)``: report.md のソート用 +""" + +from __future__ import annotations + +import datetime as _dt +from pathlib import Path +from typing import Any + +import pytest + +from playwright_kit.pytest_report import PwkTestEntry, write_report + +# 配下の fixture モジュールを pytest_plugins として読み込む +# (こうすると entry-point 経由で plugin がロードされた瞬間に fixture が +# 全 test に対して discover される)。 +pytest_plugins = [ + "playwright_kit.fixtures.auth", + "playwright_kit.fixtures.evidence", + "playwright_kit.fixtures.accessibility", + "playwright_kit.fixtures.web_vitals", + "playwright_kit.fixtures.body_check", +] + + +# --------------------------------------------------------------------------- +# CLI options +# --------------------------------------------------------------------------- + + +def pytest_addoption(parser: pytest.Parser) -> None: + group = parser.getgroup("pwk", "playwright-scenario-test (playwright_kit)") + group.addoption( + "--pwk-config", + action="store", + default=None, + help="scenario.config.yaml へのパス (env PWK_CONFIG, または ./scenario.config.yaml も可)", + ) + group.addoption( + "--pwk-out-dir", + action="store", + default=None, + help="成果物出力先ディレクトリ (default: ./reports//)", + ) + group.addoption( + "--pwk-no-evidence", + action="store_true", + default=False, + help="HAR / trace / video の収集を OFF にする", + ) + group.addoption( + "--pwk-har-mode", + action="store", + default=None, + choices=["minimal", "full", "none"], + help=( + "HAR 録画モード (Issue #62)。" + "minimal=メタデータのみ (default; Basic 認証 + redirect race を回避), " + "full=Playwright 既定の full HAR, " + "none=HAR を出力しない。" + "config の playwright.har_mode より優先。" + ), + ) + group.addoption( + "--pwk-no-video", + action="store_true", + default=False, + help="動画収集を明示的に OFF にする (デフォルトは全テストで動画 ON)", + ) + group.addoption( + "--pwk-overlay", + action="store_true", + default=False, + help="overlay (赤丸カーソル + 字幕、旧名 HUD) を全 page に inject する", + ) + group.addoption( + "--pwk-drive-folder", + action="store", + default=None, + help=( + "Drive アップロード先フォルダ ID (terminal_summary 後に upload 実行)。" + "trace.zip / *.har / 動画には機微情報 (URL / Cookie / localStorage / 操作履歴) " + "が含まれる可能性があります。private folder + 信頼できる共有相手のみに限定してください。" + " (Codex Minor 8)" + ), + ) + + +# --------------------------------------------------------------------------- +# Markers / Config +# --------------------------------------------------------------------------- + + +_PWK_MARKERS: list[tuple[str, str]] = [ + ("page_role", "page_role(*roles): accessibility / web vitals autouse の判定 (例: form, list, dashboard)"), + ("role", "role(role_id): test がどの login role を要求するか (`pwk_role_` 経由でも可)"), + ("phase", "phase(num): report.md のフェーズ集計用 (1〜N の整数)"), + ("priority", "priority(level): report.md のソート用 (high/mid/low など任意文字列)"), + ("no_body_check", "no_body_check: body_check autouse をこの test では skip する"), +] + + +def pytest_configure(config: pytest.Config) -> None: + """marker 登録 + config の早期 load を試みる。 + + config 読み込みは ``pwk_config`` fixture でも遅延ロードされるが、 + ``pwk_role_`` fixture を *動的登録* するためには + ``pytest_configure`` で 1 度 Config をロードしておく必要がある。 + failure は警告にとどめ、利用者が playwright_kit 機能を使わない場合に test 全体を + 潰さないようにする。 + """ + for name, doc in _PWK_MARKERS: + config.addinivalue_line("markers", f"{name}: {doc}") + + # 動的 fixture 登録のため、可能なら Config を early load する。 + cfg = _try_load_config_silently(config) + if cfg is not None: + from playwright_kit.fixtures import auth as auth_module + + registered = auth_module.register_role_fixtures(auth_module, cfg) + if registered: + # plugin 自体にも公開しておく (ユーザが import 元を調整しなくて良いように)。 + import playwright_kit.pytest_plugin as plugin_self + + for name in registered: + fn = getattr(auth_module, name, None) + if fn is not None: + setattr(plugin_self, name, fn) + # session 中で再利用するためにキャッシュする。 + config._pwk_config = cfg # type: ignore[attr-defined] + + # 動画デフォルト ON (大原則: エビデンス動画を常に取得) + # ユーザーが --video を CLI で明示指定した場合はそちらを優先する。 + # --pwk-no-video 指定時は video='off' に設定する。 + # --pwk-no-evidence 指定時も video='off' に設定する (全エビデンス OFF)。 + # pytest-playwright の --video デフォルト値は 'off' であるため、 + # getoption() の返り値では明示指定の有無を判別できない。 + # invocation_params.args を走査して明示指定を検出する。 + try: + cli_args = list(config.invocation_params.args) + video_explicitly_set = any( + a == "--video" or a.startswith("--video=") for a in cli_args + ) + no_video = config.getoption("pwk_no_video", default=False) + no_evidence = config.getoption("pwk_no_evidence", default=False) + if not video_explicitly_set: + if no_video or no_evidence: + config.option.video = "off" + else: + config.option.video = "on" + except (ValueError, AttributeError): + pass + + +# --------------------------------------------------------------------------- +# Reports / hooks +# --------------------------------------------------------------------------- + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """test の各 phase 終了時に ``pwk_evidence`` の状態をレポートに紐付ける。 + + FAIL 時には evidence の trace/HAR path を log に追記し、 + 成果物 path / marker を rep.user_properties に保存して + ``pytest_terminal_summary`` で report.md に集約する。 + + HAR lifecycle 修正 (Codex Major-1 完遂 / 3回目): + Playwright は HAR を ``context.close()`` 時に flush する。 + ``pwk_evidence`` の finalizer は ``context`` の finalizer より先に動くため、 + call phase の時点では ``har_relpath`` / ``trace_relpath`` がまだ未確定の場合がある。 + + teardown phase の makereport は pytest-playwright の ``context`` finalizer が + teardown 中に完了した後に走るため、ここで再度 ``confirm_har()`` を呼んで + HAR の存在を確認し直し、確定した path を teardown report の user_properties に + 積む。``_collect_entries()`` が teardown report の pwk_har/pwk_trace を call + entry に merge することで、report.md に artifact path が反映される。 + """ + outcome = yield + rep = outcome.get_result() + + ev = getattr(item, "_pwk_evidence", None) + + # teardown phase: context.close() 後に HAR が flush されるため confirm_har() 再呼び出し。 + # 確定した har_relpath / trace_relpath を teardown report の user_properties に積む。 + # body_check_violations もこの phase で確定する (autouse fixture finalizer が + # pytest.fail 直前まで populate してから走る)。 + # _collect_entries() がこれらの値を call entry に merge する。 + if rep.when == "teardown" and ev is not None: + ev.confirm_har() + if ev.har_relpath: + rep.user_properties.append(("pwk_har", str(ev.case_dir / ev.har_relpath))) + if ev.trace_relpath: + rep.user_properties.append( + ("pwk_trace", str(ev.case_dir / ev.trace_relpath)) + ) + rep.user_properties.append( + ("pwk_body_check_violations", len(ev.body_check_violations)) + ) + if ev.body_check_violations: + rep.user_properties.append( + ("pwk_body_check_detail", list(ev.body_check_violations)) + ) + return + + if rep.when != "call": + return + + # pwk_evidence fixture が attach した状態を直接参照 + if ev is not None: + if ev.har_relpath: + rep.user_properties.append(("pwk_har", str(ev.case_dir / ev.har_relpath))) + if ev.trace_relpath: + rep.user_properties.append( + ("pwk_trace", str(ev.case_dir / ev.trace_relpath)) + ) + rep.user_properties.append(("pwk_console_errors", len(ev.console_errors))) + rep.user_properties.append(("pwk_page_errors", len(ev.page_errors))) + + # markers を user_properties に転写 + page_roles: list[str] = [] + for marker in item.iter_markers(name="page_role"): + for arg in marker.args: + if isinstance(arg, str): + page_roles.append(arg) + elif isinstance(arg, (list, tuple)): + page_roles.extend(str(a) for a in arg) + if page_roles: + rep.user_properties.append(("pwk_page_role", page_roles)) + + role_marker = item.get_closest_marker("role") + if role_marker is not None and role_marker.args: + rep.user_properties.append(("pwk_role", str(role_marker.args[0]))) + + phase_marker = item.get_closest_marker("phase") + if phase_marker is not None and phase_marker.args: + try: + rep.user_properties.append(("pwk_phase", int(phase_marker.args[0]))) + except (TypeError, ValueError): + pass + + priority_marker = item.get_closest_marker("priority") + if priority_marker is not None and priority_marker.args: + rep.user_properties.append(("pwk_priority", str(priority_marker.args[0]))) + + +# --------------------------------------------------------------------------- +# Terminal summary / session finish +# --------------------------------------------------------------------------- + + +def _collect_entries(terminalreporter) -> list[PwkTestEntry]: + """terminalreporter から ``PwkTestEntry`` のリストを構築する。 + + xfailed / xpassed も集約する (Codex Major 3)。 + pytest 内部では xfailed の rep は stats["xfailed"] に直接入るため、 + "xfailed" / "xpassed" キーを明示的に走査する。 + + artifact 伝搬 (Codex Major-1 / 3回目): + HAR は context.close() 時に flush されるため、call phase 時点では + har_relpath / trace_relpath が未確定の場合がある。 + teardown phase の makereport で確定した pwk_har / pwk_trace を + call entry に merge することで、report.md に artifact path を反映する。 + """ + # Step 1: call/setup phase の entry を nodeid でインデックス化 + call_entries: dict[str, PwkTestEntry] = {} + for outcome_key in ("passed", "failed", "skipped", "error", "xfailed", "xpassed"): + for rep in terminalreporter.stats.get(outcome_key, []): + if getattr(rep, "when", "call") not in ("call", "setup"): + continue + props = dict(rep.user_properties or []) + nodeid = getattr(rep, "nodeid", "?") + entry = PwkTestEntry( + nodeid=nodeid, + name=getattr(rep, "head_line", nodeid), + outcome=outcome_key, + duration_s=float(getattr(rep, "duration", 0.0) or 0.0), + page_role=list(props.get("pwk_page_role") or []), + role=props.get("pwk_role"), + phase=int(props.get("pwk_phase") or 0), + priority=props.get("pwk_priority"), + har_path=props.get("pwk_har"), + trace_path=props.get("pwk_trace"), + console_errors=int(props.get("pwk_console_errors") or 0), + page_errors=int(props.get("pwk_page_errors") or 0), + # Amazon Q Critical-3: skipped 時の longrepr は tuple 形式のため + # failed / error のときのみ str() 化する。他 outcome は None のまま。 + error_message=( + str(rep.longrepr) + if outcome_key in ("failed", "error") and rep.longrepr + else None + ), + ) + call_entries[nodeid] = entry + + # Step 2: teardown report の pwk_har / pwk_trace / body_check を call entry に merge する。 + # teardown 時点で context.close() 後の確定値や body_check の violation 集計が + # 積まれているため、call phase で未確定だった値をここで埋める。 + # pytest は setup/teardown の rep を stats[""] (空文字キー) に格納するため、 + # "" キーも含めて全キーを走査する。 + for outcome_key in terminalreporter.stats: + for rep in terminalreporter.stats[outcome_key]: + if getattr(rep, "when", None) != "teardown": + continue + nodeid = getattr(rep, "nodeid", "?") + if nodeid not in call_entries: + continue + entry = call_entries[nodeid] + props = dict(rep.user_properties or []) + if not entry.har_path and props.get("pwk_har"): + entry.har_path = props["pwk_har"] + if not entry.trace_path and props.get("pwk_trace"): + entry.trace_path = props["pwk_trace"] + if "pwk_body_check_violations" in props: + entry.body_check_violations = int( + props.get("pwk_body_check_violations") or 0 + ) + detail = props.get("pwk_body_check_detail") + if detail: + entry.body_check_detail = list(detail) + # body_check が teardown で pytest.fail を起こした場合、call phase + # は passed / xfailed / xpassed / skipped のまま teardown report + # のみ failed/error になる。call phase の本物の failure は上書き + # しないが、それ以外の outcome は teardown 失敗を反映させる + # (xfail テストでも teardown の body_check fail は実バグ扱い)。 + teardown_outcome = getattr(rep, "outcome", None) + if ( + teardown_outcome in ("failed", "error") + and entry.outcome not in ("failed", "error") + ): + entry.outcome = ( + "error" if teardown_outcome == "error" else "failed" + ) + if rep.longrepr and not entry.error_message: + entry.error_message = str(rep.longrepr) + + return list(call_entries.values()) + + +def pytest_terminal_summary(terminalreporter, exitstatus, config): + """``reports//report.md`` を生成する。 + + ``--pwk-out-dir`` 指定があればそこに、なければ ``pwk_out_dir`` fixture と + 同一の ``pytestconfig._pwk_out_dir`` キャッシュを参照する。 + キャッシュが無い場合 (pwk_out_dir fixture が一度も呼ばれていない) は + ``_resolve_out_dir`` 経由でセットする。これにより evidence と report.md の + 出力先が秒またぎでズレる問題を防ぐ (新規 Major 対応)。 + """ + # session 中で 1 件も test を回していない (collect-only など) は先に entries で判断。 + # xfailed / xpassed のみの session でも report を生成するため、 + # early return は _collect_entries() の結果で判断する (新規 Minor 対応)。 + entries = _collect_entries(terminalreporter) + if not entries: + return + + cached_cfg = getattr(config, "_pwk_config", None) + base_url = cached_cfg.base_url if cached_cfg is not None else None + title = ( + cached_cfg.report.title + if cached_cfg is not None + else "シナリオ E2E テスト 実施報告書" + ) + + # evidence.py の _resolve_out_dir と同一キャッシュ (_pwk_out_dir) を参照する。 + # これにより両者が独立に datetime.now() を呼んで別ディレクトリを作る問題を解消。 + from playwright_kit.fixtures.evidence import _resolve_out_dir + + out_dir = _resolve_out_dir(config) + out_dir.mkdir(parents=True, exist_ok=True) + + # Amazon Q Critical-4: xdist 並列実行時の session 開始時刻計算が不正確な問題を修正。 + # terminalreporter._sessionstarttime (pytest 内部 float) を優先利用し、 + # 無ければ従来の逐次実行前提の計算にフォールバックする。 + session_start_ts = getattr(terminalreporter, "_sessionstarttime", None) + if session_start_ts is not None: + started = _dt.datetime.fromtimestamp(session_start_ts) + else: + started = _dt.datetime.now() - _dt.timedelta( + seconds=sum(e.duration_s for e in entries) + ) + finished = _dt.datetime.now() + path = write_report( + entries, + out_dir=out_dir, + started_at=started, + finished_at=finished, + title=title, + base_url=base_url, + ) + terminalreporter.write_sep("-", "pwk report") + terminalreporter.write_line(f"report.md generated: {path}") + + # session 後の Drive アップロードに使うため pickle 不要な情報を保存 + config._pwk_report_path = path # type: ignore[attr-defined] + config._pwk_out_dir = out_dir # type: ignore[attr-defined] + + +def pytest_sessionfinish(session, exitstatus): + """``--pwk-drive-folder`` 指定時、生成済 report.md と evidence を Drive アップ。 + + ``upload_evidence.upload`` を直接呼ぶ。失敗時は警告のみで test 結果には影響しない。 + """ + folder_id: str | None = session.config.getoption( + "pwk_drive_folder", default=None + ) + if not folder_id: + return + + report_path: Path | None = getattr(session.config, "_pwk_report_path", None) + out_dir: Path | None = getattr(session.config, "_pwk_out_dir", None) + if report_path is None or out_dir is None: + return + + try: + # Amazon Q Critical-5: sys.path への動的 inject を廃止し、 + # playwright_kit.uploaders パッケージ経由で安全に import する。 + # scripts/upload_evidence.py は CLI スタンドアロン用途として残す。 + from playwright_kit.uploaders import upload, detect_kind + + # report.md は kind=any でアップ + if report_path.exists(): + upload( + report_path, kind="any", parent_folder_id=folder_id, public=False + ) + + # trace.zip / *.har / *.mp4 / body_check.jsonl を 1 階層下から拾い上げる + for sub in out_dir.iterdir(): + if not sub.is_dir(): + continue + for f in sub.iterdir(): + suffix = f.suffix + if suffix not in (".zip", ".har", ".mp4", ".webm", ".jsonl"): + continue + # detect_kind は body_check.jsonl 等の任意ファイルを未知の kind + # と扱うため、jsonl は ``any`` に固定する。 + kind = "any" if suffix == ".jsonl" else detect_kind(f) + upload( + f, kind=kind, parent_folder_id=folder_id, public=False + ) + except Exception as exc: # pragma: no cover - depends on Drive auth + import warnings + + warnings.warn( + f"[pwk] Drive upload 失敗 (session continues): {exc}", + stacklevel=1, + ) + + +def _try_load_config_silently(config: pytest.Config) -> Any | None: + """``--pwk-config`` 等から Config を試行ロードする。失敗時は None。""" + import os + from pathlib import Path + + raw_path: str | None = config.getoption("pwk_config", default=None) + if not raw_path: + env = os.environ.get("PWK_CONFIG") + if env: + raw_path = env + if not raw_path: + candidate = Path.cwd() / "scenario.config.yaml" + if candidate.exists(): + raw_path = str(candidate) + if not raw_path: + return None + + try: + from playwright_kit.config import Config + + return Config.load(Path(raw_path).resolve()) + except Exception as exc: # pragma: no cover - depends on user config + import warnings + + warnings.warn( + f"[pwk] config load 失敗 ({raw_path}): {exc}. " + "pwk_role_ fixture は動的登録されません。", + stacklevel=2, + ) + return None diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_report.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_report.py new file mode 100644 index 0000000..2d67a89 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/pytest_report.py @@ -0,0 +1,212 @@ +"""pytest hook で集めた test result から Markdown レポートを生成する。 + +``pytest_terminal_summary`` から呼ばれ、``reports//report.md`` を生成する。 +``--pwk-drive-folder`` 指定時は Drive アップロードと URL 差し込みも担当。 +""" + +from __future__ import annotations + +import datetime as _dt +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable + + +@dataclass +class PwkTestEntry: + """1 test 関数分のレポート用エントリ。 + + pytest の ``TestReport`` から要点だけを抽出して保持する + (``user_properties`` 経由で ``pwk_evidence`` の状態が紐付く)。 + """ + + nodeid: str + name: str + outcome: str # passed / failed / skipped / xfailed / xpassed / error + duration_s: float + page_role: list[str] = field(default_factory=list) + role: str | None = None + phase: int = 0 + priority: str | None = None + har_path: str | None = None + trace_path: str | None = None + console_errors: int = 0 + page_errors: int = 0 + error_message: str | None = None + # body_check (PHP / SSR エラー検出, v0.4.0) + body_check_violations: int = 0 + body_check_detail: list[dict] = field(default_factory=list) + + @property + def ok(self) -> bool: + return self.outcome in ("passed", "xfailed") + + @property + def status_label(self) -> str: + return { + "passed": "OK", + "failed": "FAIL", + "skipped": "SKIP", + "xfailed": "XFAIL", + "xpassed": "XPASS", + "error": "ERROR", + }.get(self.outcome, self.outcome.upper()) + + +def render_markdown( + entries: Iterable[PwkTestEntry], + *, + started_at: _dt.datetime, + finished_at: _dt.datetime, + title: str = "シナリオ E2E テスト 実施報告書", + base_url: str | None = None, +) -> str: + """test entries から Markdown 文字列を生成する。""" + entries_list = list(entries) + total = len(entries_list) + passed = sum(1 for e in entries_list if e.outcome == "passed") + failed = sum(1 for e in entries_list if e.outcome == "failed") + skipped = sum(1 for e in entries_list if e.outcome == "skipped") + errors = sum(1 for e in entries_list if e.outcome == "error") + xfailed = sum(1 for e in entries_list if e.outcome == "xfailed") + xpassed = sum(1 for e in entries_list if e.outcome == "xpassed") + duration = (finished_at - started_at).total_seconds() + # xfailed は期待通りの失敗なので OK 扱い (PwkTestEntry.ok と同じ方針) + # xpassed は意図せず pass したため注意喚起 (全PASS とはしない) + all_pass = total > 0 and (passed + xfailed) == total and xpassed == 0 + + lines: list[str] = [ + f"# {title}", + "", + f"- 実行開始: {started_at:%Y-%m-%d %H:%M:%S}", + f"- 実行終了: {finished_at:%Y-%m-%d %H:%M:%S}", + f"- 所要時間: {duration:.1f} 秒", + ] + if base_url: + lines.append(f"- 対象URL : {base_url}") + + # 集計サマリ行を構築 + extra_parts: list[str] = [] + if failed: + extra_parts.append(f"FAIL {failed}") + if skipped: + extra_parts.append(f"SKIP {skipped}") + if errors: + extra_parts.append(f"ERROR {errors}") + if xfailed: + extra_parts.append(f"XFAIL {xfailed}") + if xpassed: + extra_parts.append(f"XPASS {xpassed}") + # 全PASS でも xfailed / xpassed があれば内訳を明示する + if all_pass: + if extra_parts: + result_suffix = " (全PASS) / " + " / ".join(extra_parts) + else: + result_suffix = " (全PASS)" + else: + result_suffix = " / " + " / ".join(extra_parts) if extra_parts else "" + + lines.extend([ + f"- **結果: {passed}/{total} test PASS{result_suffix}**", + "", + "## サマリ", + "", + "| nodeid | role | page_role | status | duration | console.error | pageerror | body_check |", + "|---|---|---|---|---|---|---|---|", + ]) + + # phase / priority / nodeid の順でソート + sorted_entries = sorted( + entries_list, + key=lambda e: (e.phase, e.priority or "", e.nodeid), + ) + for e in sorted_entries: + page_role = ",".join(e.page_role) if e.page_role else "-" + lines.append( + f"| `{e.nodeid}` | {e.role or '-'} | {page_role} | " + f"{e.status_label} | {e.duration_s:.2f}s | " + f"{e.console_errors} | {e.page_errors} | " + f"{e.body_check_violations} |" + ) + + failures = [e for e in sorted_entries if e.outcome in ("failed", "error")] + if failures: + lines.extend(["", "## FAIL / ERROR の詳細", ""]) + for e in failures: + lines.append(f"### `{e.nodeid}` — {e.status_label}") + lines.append("") + if e.error_message: + lines.append("```") + lines.append(e.error_message[:2000]) + lines.append("```") + if e.trace_path: + lines.append(f"- trace: `{e.trace_path}`") + if e.har_path: + lines.append(f"- HAR: `{e.har_path}`") + lines.append("") + + body_check_hits = [e for e in sorted_entries if e.body_check_violations > 0] + if body_check_hits: + lines.extend(["", "## body_check 違反の詳細", ""]) + for e in body_check_hits: + lines.append( + f"### `{e.nodeid}` — body_check {e.body_check_violations} 件 " + f"({e.status_label})" + ) + lines.append("") + lines.append("| # | URL | category | pattern | snippet |") + lines.append("|---:|---|---|---|---|") + for i, v in enumerate(e.body_check_detail[:20], start=1): + url = _escape_table_cell(str(v.get("url", "?"))) + cat = _escape_table_cell(str(v.get("category", "?"))) + pat = _escape_table_cell(str(v.get("pattern", "?"))) + snippet = _escape_table_cell(str(v.get("snippet", ""))) + if len(snippet) > 200: + snippet = snippet[:200] + "..." + lines.append(f"| {i} | `{url}` | {cat} | `{pat}` | {snippet} |") + if len(e.body_check_detail) > 20: + lines.append( + f"\n_(表示は先頭 20 件のみ。詳細は ``body_check.jsonl`` を参照)_" + ) + lines.append("") + + return "\n".join(lines) + "\n" + + +def _escape_table_cell(text: str) -> str: + """Markdown 表のセル値を 1 行に潰してエスケープする。 + + 改行・タブが残ると行が分割されて表が崩れるので空白に置換する。 + ``|`` と backtick もエスケープして表構造とコード span を破壊しないようにする。 + """ + return ( + text.replace("\r\n", " ") + .replace("\r", " ") + .replace("\n", " ") + .replace("\t", " ") + .replace("|", "\\|") + .replace("`", "\\`") + ) + + +def write_report( + entries: Iterable[PwkTestEntry], + *, + out_dir: Path, + started_at: _dt.datetime, + finished_at: _dt.datetime, + title: str = "シナリオ E2E テスト 実施報告書", + base_url: str | None = None, +) -> Path: + """``out_dir/report.md`` を書き出してそのパスを返す。""" + out_dir.mkdir(parents=True, exist_ok=True) + md = render_markdown( + entries, + started_at=started_at, + finished_at=finished_at, + title=title, + base_url=base_url, + ) + path = out_dir / "report.md" + path.write_text(md, encoding="utf-8") + return path diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py new file mode 100644 index 0000000..283d2f0 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/uploaders/__init__.py @@ -0,0 +1,151 @@ +"""Drive アップロード機能を playwright_kit パッケージから直接 import するためのラッパー。 + +scripts/upload_evidence.py の CLI スタンドアロン用途 (利用者が +``python upload_evidence.py ...`` で叩く) を壊さずに、pytest_sessionfinish から +安全に import できるようにする (Amazon Q Critical-5: sys.path 廃止)。 + +使い方 (pytest_plugin.py から): + from playwright_kit.uploaders import upload, detect_kind + +この module は google-auth スキルが存在しない環境でも import できる。 +実際のアップロード時のみ google-auth を必要とする (遅延 import)。 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from urllib.parse import quote + + +_HERE = Path(__file__).resolve() +_CANDIDATES: tuple[Path, ...] = tuple( + Path(p).expanduser() + for p in ( + os.environ.get("GOOGLE_AUTH_SCRIPTS"), + "~/.claude/skills/google-auth/scripts", + "~/.codex/skills/google-auth/scripts", + str(_HERE.parent.parent.parent / "scripts"), + str(_HERE.parent.parent.parent.parent / "google-auth" / "scripts"), + ) + if p +) + + +def _ensure_google_auth_on_path() -> None: + for p in _CANDIDATES: + if p.is_dir(): + path = str(p) + if path not in sys.path: + sys.path.insert(0, path) + return + searched = "\n - ".join(str(p) for p in _CANDIDATES) + raise RuntimeError( + "Google Drive 連携には optional skill `google-auth` が必要です。\n" + "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " + "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "検索した候補:\n - " + f"{searched}" + ) + + +# 拡張子 → kind の自動判定 +_EXT_KIND: dict[str, str] = { + ".zip": "trace", + ".har": "har", + ".mp4": "video", + ".webm": "video", +} + +_MIME_BY_KIND: dict[str, str] = { + "trace": "application/zip", + "har": "application/json", + "video": "video/mp4", + "any": "application/octet-stream", +} + +_MIME_BY_EXT: dict[str, str] = { + ".webm": "video/webm", + ".mp4": "video/mp4", + ".har": "application/json", + ".zip": "application/zip", +} + +ALLOWED_KINDS: frozenset[str] = frozenset(_MIME_BY_KIND) + + +def detect_kind(path: Path) -> str: + """拡張子から evidence kind を自動判定する。""" + return _EXT_KIND.get(path.suffix.lower(), "any") + + +def detect_mime(path: Path, kind: str) -> str: + """拡張子優先で MIME を決定し、未知拡張子は kind の既定値にフォールバック。""" + return _MIME_BY_EXT.get( + path.suffix.lower(), + _MIME_BY_KIND.get(kind, "application/octet-stream"), + ) + + +def upload( + file_path: Path, + *, + kind: str = "any", + parent_folder_id: str | None = None, + public: bool = False, +) -> dict: + """ファイルを Drive にアップして metadata + 補助 URL を返す。 + + ⚠️ trace.zip / HAR / video には DOM snapshot や入力痕跡・HTTP request body が含まれる。 + 既定では非公開アップロード。``public=True`` のときだけ anyone/read を付与する。 + ``parent_folder_id`` には **private folder** の ID を指定し、 + 共有相手を信頼できるメンバーに限定してください (Amazon Q Critical-5 / Codex Minor 8)。 + """ + if kind not in ALLOWED_KINDS: + raise ValueError( + f"未対応の kind: {kind!r} (allowed: {sorted(ALLOWED_KINDS)})" + ) + + _ensure_google_auth_on_path() + from google_auth import get_credentials # type: ignore # noqa: E402 + from googleapiclient.discovery import build # noqa: E402 + from googleapiclient.http import MediaFileUpload # noqa: E402 + + creds = get_credentials(["drive.file"]) + service = build("drive", "v3", credentials=creds) + + metadata: dict = {"name": file_path.name} + if parent_folder_id: + metadata["parents"] = [parent_folder_id] + media = MediaFileUpload( + str(file_path), mimetype=detect_mime(file_path, kind), + ) + f = service.files().create( + body=metadata, media_body=media, fields="id,webViewLink", + ).execute() + file_id = f["id"] + + if public: + service.permissions().create( + fileId=file_id, body={"type": "anyone", "role": "reader"}, + ).execute() + + direct_url: str | None = None + viewer_url: str | None = None + if public: + direct_url = f"https://drive.google.com/uc?export=download&id={file_id}" + if kind == "trace": + viewer_url = ( + f"https://trace.playwright.dev/?trace={quote(direct_url, safe='')}" + ) + + return { + "file_id": file_id, + "drive_view": f.get("webViewLink"), + "direct_download": direct_url, + "playwright_trace_viewer": viewer_url, + "is_public": public, + "kind": kind, + } diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/video.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/video.py new file mode 100644 index 0000000..e1a018e --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/video.py @@ -0,0 +1,71 @@ +"""Playwright 録画 webm → Google Drive 互換 mp4 への変換。 + +Drive のストリーミング再生で「処理中」になりにくい設定: + - H.264 High profile / Level 4.0 / yuv420p / bt709 + - 30 fps CFR (Constant Frame Rate) + - 60 frames keyframe interval (2 sec at 30fps) + - AAC LC stereo 48kHz 128kbps (無音でも音声トラックは必須) + - +faststart で moov atom を先頭配置 → プログレッシブ再生 + +依存: imageio-ffmpeg (静的 ffmpeg バイナリ同梱の Python パッケージ) +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def _ffmpeg_args(webm: Path, mp4: Path) -> list[str]: + """Drive 互換 mp4 への変換用 ffmpeg 引数を組み立てる。""" + return [ + "-y", + "-i", str(webm), + # 無音 AAC を映像と並行して生成 (一部プレイヤは音声トラック必須) + "-f", "lavfi", "-i", + "anullsrc=channel_layout=stereo:sample_rate=48000", + # --- 映像 --- + "-c:v", "libx264", + "-profile:v", "high", + "-level", "4.0", + "-pix_fmt", "yuv420p", + "-preset", "medium", + "-crf", "23", + "-r", "30", # 固定フレームレート + "-fps_mode", "cfr", # 旧 -vsync の後継 + "-g", "60", # キーフレーム間隔 2秒 + "-keyint_min", "30", + "-sc_threshold", "0", # シーン検出キーフレーム無効 + "-color_primaries", "bt709", + "-color_trc", "bt709", + "-colorspace", "bt709", + # --- 音声 (無音) --- + "-c:a", "aac", + "-b:a", "128k", + "-ar", "48000", + "-ac", "2", + "-shortest", # 映像終了で打ち切り + # --- ストリーミング向け --- + "-movflags", "+faststart", + str(mp4), + ] + + +def convert_webm_to_mp4(webm: Path, mp4: Path, *, timeout_sec: int = 300) -> Path | None: + """webm を Drive 互換 mp4 に変換。成功時は mp4 のパス、失敗時は None。""" + try: + import imageio_ffmpeg + ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() + except Exception: + return None + + try: + subprocess.run( + [ffmpeg, *_ffmpeg_args(webm, mp4)], + check=True, capture_output=True, timeout=timeout_sec, + ) + except Exception: + return None + if not mp4.exists() or mp4.stat().st_size == 0: + return None + return mp4 diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/web_vitals.py b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/web_vitals.py new file mode 100644 index 0000000..034247a --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/playwright_kit/web_vitals.py @@ -0,0 +1,142 @@ +"""runner 内蔵の Core Web Vitals (Web Vitals) 計測モジュール。 + +Core Web Vitals は Google が定義する「ユーザ体感パフォーマンス指標」群で、 +本モジュールは以下の 4 指標を計測する: + +- **LCP** (Largest Contentful Paint): 最大コンテンツ描画時間 (体感ロード速度) +- **CLS** (Cumulative Layout Shift): 累積レイアウトずれ量 (視覚的安定性) +- **TTFB** (Time To First Byte): 初バイト到達時間 (サーバ応答速さ) +- **longest_task** (Long Tasks API): 観測した最長タスクのミリ秒値 (応答性代理指標) + +`scripts/check_cwv.py` (CLI) はこのモジュールの薄いラッパで、runner からは +`measure_page(page)` を直接呼び出して `EvidenceCollectors.web_vitals_metrics` に +格納する。 + +page_role が `lp / list / dashboard` のとき runner が自動実行する +(config.web_vitals.auto_roles で上書き可能)。 + +注意: +- INP は実 user 入力ベースの指標であり Playwright で完全再現は不可能。 + `longest_task_ms` (50ms 超を 1 件以上検出) を「応答性低下の代理指標」として記録 + するが、INP の代わりにはならない。 +- 計測は page.evaluate で 5 秒間 PerformanceObserver を回す ため、testcase の + 最後 (全 step 実行後) に呼ぶこと。 +""" + +from __future__ import annotations + +from typing import Any + +from playwright.sync_api import Page + + +# web.dev 公式閾値 (75 percentile 基準) +THRESHOLDS: dict[str, dict[str, float]] = { + "lcp_ms": {"good": 2500, "poor": 4000}, + "cls": {"good": 0.1, "poor": 0.25}, + "ttfb_ms": {"good": 800, "poor": 1800}, + "longest_task_ms": {"good": 50, "poor": 200}, +} + +# page_role × Web Vitals 自動実行のデフォルト対象。インタラクション主体 (form / cart) は +# 過度な負荷になるため除外し、初回表示性能が UX に直結する role に限定する。 +DEFAULT_AUTO_ROLES: frozenset[str] = frozenset({"lp", "list", "dashboard", "search"}) + + +# Crit-1 (amazon-q-developer 指摘): JS 内の "5000" を `str.replace` する旧実装は +# JS 中に同じ数値が他出現すると壊れる fragile な置換だった。`page.evaluate` の +# arg 経由で `observeMs` を渡し、JS 関数引数として明示的に bind する。 +_PERF_JS = r""" +(observeMs) => new Promise((resolve) => { + const result = {lcp: null, cls: 0, longest_task: 0, ttfb: null}; + + try { + const lcpObs = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const last = entries.at(-1); + if (last) result.lcp = last.startTime; + }); + lcpObs.observe({type: 'largest-contentful-paint', buffered: true}); + } catch (e) {} + + try { + const clsObs = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!entry.hadRecentInput) { + result.cls += entry.value; + } + } + }); + clsObs.observe({type: 'layout-shift', buffered: true}); + } catch (e) {} + + try { + const ltObs = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.duration > result.longest_task) { + result.longest_task = entry.duration; + } + } + }); + ltObs.observe({type: 'longtask', buffered: true}); + } catch (e) {} + + try { + const nav = performance.getEntriesByType('navigation')[0]; + if (nav) result.ttfb = nav.responseStart - nav.requestStart; + } catch (e) {} + + setTimeout(() => resolve(result), observeMs); +}); +""" + + +def measure_page(page: Page, *, observe_ms: int = 5000) -> dict[str, float]: + """既にロード済みの Page で Core Web Vitals を `observe_ms` ミリ秒観察し、metrics dict を返す。 + + Returns: `{"lcp_ms": float, "cls": float, "ttfb_ms": float, "longest_task_ms": float}` + 計測失敗した metric は dict から除外される。 + """ + try: + raw: dict[str, Any] = page.evaluate(_PERF_JS, int(observe_ms)) + except Exception: + return {} + + out: dict[str, float] = {} + if raw.get("lcp") is not None: + out["lcp_ms"] = float(raw["lcp"]) + if raw.get("cls") is not None: + out["cls"] = float(raw["cls"]) + if raw.get("ttfb") is not None: + out["ttfb_ms"] = float(raw["ttfb"]) + if raw.get("longest_task") is not None: + out["longest_task_ms"] = float(raw["longest_task"]) + return out + + +def judge(metric: str, value: float | None) -> str: + """値を `good` / `needs-improvement` / `poor` / `unknown` に分類する。""" + if value is None: + return "unknown" + th = THRESHOLDS.get(metric) + if not th: + return "unknown" + if value <= th["good"]: + return "good" + if value <= th["poor"]: + return "needs-improvement" + return "poor" + + +def passed(metrics: dict[str, float]) -> bool: + """すべての metric が good または needs-improvement なら True (poor が 1 件でも あれば False)。""" + return all(judge(k, v) != "poor" for k, v in metrics.items()) + + +def should_auto_measure( + page_roles: list[str], + *, + auto_roles: frozenset[str] = DEFAULT_AUTO_ROLES, +) -> bool: + """testcase の page_role に基づき Web Vitals を自動計測すべきか判定する。""" + return any(r in auto_roles for r in page_roles) diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/pyproject.toml b/plugins/ndf-codex/skills/playwright-kit-ops/pyproject.toml new file mode 100644 index 0000000..14e535b --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "playwright_kit" +version = "0.5.0" +description = "pytest-playwright 上で動く Web シナリオ E2E テストの理論ベース実施フレームワーク (Claude Code skill)。v0.3.0: 自前 YAML DSL を全廃し、pytest plugin / fixture / marker / accessibility autouse / web vitals autouse / overlay / report.md / Google Drive 連携を提供。v0.4.0: PHP / SSR がフロント漏れさせる Fatal error / STRICT: 等を検出する body_check autouse を default 有効で復活 (PHP 系パターン内蔵)。v0.5.0: パッケージ名を playwright_kit に rename し、利用者プロジェクト直下で自己完結 (Skill 非依存) に動作させる構成へ移行。" +requires-python = ">=3.11" +dependencies = [ + "playwright>=1.50,<2.0", + "pyyaml>=6.0", + # webm → mp4 変換用。Playwright 録画は webm のみ生成するが、 + # Google Drive のプレビュアでは mp4 (H.264) の方が安定するため変換する。 + "imageio-ffmpeg>=0.4", + # v0.3.0: pure pytest-playwright 移行 + # pytest 本体と pytest-playwright fixture (page / context / browser_context_args)、 + # pytest-xdist による並列実行を main 依存として要求する。 + "pytest>=8.0", + "pytest-playwright>=0.5", + "pytest-xdist>=3.0", + # accessibility autouse は scenario.config.yaml で default 有効。 + # runtime テンプレート (templates/pyproject.toml.runtime) と揃えて main dep に含める。 + "axe-playwright-python>=0.1.4", +] + +[project.optional-dependencies] +# Drive 連携: scripts/trace_link.py / build_gdoc_with_drive_links.py 等が使用 +drive = [ + "google-api-python-client>=2.0", + "google-auth>=2.0", + "google-auth-oauthlib>=1.0", +] +# 開発用: pure function の単体テスト (uv run --extra dev pytest) +# pytest 本体は main deps に移行済 (v0.3.0)。dev には開発時のみの拡張を入れる余地。 +dev = [ + "pytest>=8.0", +] + +[project.entry-points."pytest11"] +# pytest が auto-discover する plugin entry-point。 +# ``uv pip install -e .`` 後は ``pytest`` 起動だけで playwright_kit plugin が有効になる。 +playwright-kit = "playwright_kit.pytest_plugin" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["playwright_kit"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/scripts/_drive_auth.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/_drive_auth.py new file mode 100644 index 0000000..647ab52 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/_drive_auth.py @@ -0,0 +1,57 @@ +"""google-auth スキル経由で Drive API クレデンシャルを取得する共通ヘルパ。 + +3 つの uploader スクリプト (gdrive_upload_dir / build_gdoc_with_drive_links / +upload_md_as_gdoc) はいずれも同じ手順で `google_auth.get_credentials()` を +sys.path から発見する。本モジュールにロジックを集約する。 + +Drive 連携は optional dependency。`GOOGLE_AUTH_SCRIPTS` 環境変数が設定されて +いればそれを使い、それ以外は標準インストール先と sibling の google-auth +スキルを探す。Codex 公開セットには google-auth を含めないため、Codex で +Drive 系コマンドを使う場合は `GOOGLE_AUTH_SCRIPTS` を明示する。 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +_HERE = Path(__file__).resolve() +_CANDIDATES: tuple[Path, ...] = tuple( + Path(p).expanduser() + for p in ( + os.environ.get("GOOGLE_AUTH_SCRIPTS"), + "~/.claude/skills/google-auth/scripts", + "~/.codex/skills/google-auth/scripts", + str(_HERE.parent.parent.parent / "google-auth" / "scripts"), + ) + if p +) + + +def _ensure_google_auth_on_path() -> None: + """`from google_auth import get_credentials` できるよう sys.path を整える。""" + for p in _CANDIDATES: + if p.is_dir(): + path = str(p) + if path not in sys.path: + sys.path.insert(0, path) + return + searched = "\n - ".join(str(p) for p in _CANDIDATES) + raise RuntimeError( + "Google Drive 連携には optional skill `google-auth` が必要です。\n" + "Codex 公開セットには同梱していないため、Drive 系コマンドを使う前に " + "`GOOGLE_AUTH_SCRIPTS` を google-auth/scripts へ設定してください。\n" + "例: export GOOGLE_AUTH_SCRIPTS=/path/to/plugins/ndf/skills/google-auth/scripts\n" + "検索した候補:\n - " + f"{searched}" + ) + + +def drive_service(scopes: list[str]): + """認証済み Drive API v3 service を返す。""" + _ensure_google_auth_on_path() + from google_auth import get_credentials # type: ignore + from googleapiclient.discovery import build + return build("drive", "v3", credentials=get_credentials(scopes)) diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py new file mode 100644 index 0000000..47990c6 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py @@ -0,0 +1,126 @@ +"""report.md の相対リンクを Google Drive URL に置換し、Google Docs として再アップロードする。 + +事前に対象ディレクトリを Drive にアップロード済みである前提。 +このスクリプトは: + 1. Drive 上の フォルダから {相対パス: file_id} mapping を構築 + 2. report.md 中の `(./TC-XX/foo.ext)` 形式リンクを Drive URL に書き換え + 3. text/markdown としてアップロードし mimeType=Google Docs 指定で自動変換 +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _drive_auth import drive_service # noqa: E402 + +from googleapiclient.http import MediaFileUpload # noqa: E402 + +SCOPES = ["drive.file", "drive.readonly"] +FOLDER_MIME = "application/vnd.google-apps.folder" +DOC_MIME = "application/vnd.google-apps.document" +LINK_PATTERN = re.compile(r"\(\.?\/?(TC-[\w-]+/[^\)\s]+)\)") + + +def list_folder_files(service, folder_id: str, prefix: str = "") -> dict[str, str]: + """folder_id 配下のファイルを再帰的に列挙し、{相対パス: file_id} を返す。""" + out: dict[str, str] = {} + page_token: str | None = None + while True: + resp = service.files().list( + q=f"'{folder_id}' in parents and trashed=false", + fields="nextPageToken, files(id,name,mimeType)", + pageSize=200, pageToken=page_token, + supportsAllDrives=True, includeItemsFromAllDrives=True, + ).execute() + for f in resp.get("files", []): + rel = f"{prefix}/{f['name']}".lstrip("/") + if f["mimeType"] == FOLDER_MIME: + out.update(list_folder_files(service, f["id"], rel)) + else: + out[rel] = f["id"] + page_token = resp.get("nextPageToken") + if not page_token: + return out + + +def find_run_folder_id(service, parent_id: str, run_id: str) -> str: + """parent 配下の run_id 名フォルダの ID を返す。なければ例外。""" + files = service.files().list( + q=( + f"'{parent_id}' in parents and name='{run_id}' " + f"and mimeType='{FOLDER_MIME}' and trashed=false" + ), + fields="files(id,name)", + supportsAllDrives=True, includeItemsFromAllDrives=True, + ).execute().get("files", []) + if not files: + raise SystemExit(f"ERROR: run-id folder '{run_id}' not found under {parent_id}") + return files[0]["id"] + + +def _drive_url_for(rel: str, fid: str) -> str: + # PNG は uc?id (画像直接表示)、その他 (動画/zip/etc) は file/d//view + if rel.endswith(".png"): + return f"https://drive.google.com/uc?id={fid}" + return f"https://drive.google.com/file/d/{fid}/view" + + +def rewrite_links(md: str, mapping: dict[str, str]) -> tuple[str, int]: + """`(./TC-XX/foo.ext)` 形式リンクを Drive URL に置換し、(新md, 置換件数) を返す。""" + replaced = 0 + + def rep(m: re.Match[str]) -> str: + nonlocal replaced + rel = m.group(1) + fid = mapping.get(rel) + if fid is None: + return m.group(0) # 未マップは原文のまま + replaced += 1 + return f"({_drive_url_for(rel, fid)})" + + return LINK_PATTERN.sub(rep, md), replaced + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--md", required=True, type=Path) + p.add_argument("--folder", required=True, + help="Drive folder containing the run-id subfolder") + p.add_argument("--run-id", required=True, + help="Run id subfolder name (= local report dir name)") + p.add_argument("--name", required=True) + args = p.parse_args() + + service = drive_service(SCOPES) + run_folder_id = find_run_folder_id(service, args.folder, args.run_id) + print(f"run folder: {run_folder_id}") + + mapping = list_folder_files(service, run_folder_id) + print(f"Indexed {len(mapping)} files") + + md_new, replaced = rewrite_links(args.md.read_text(encoding="utf-8"), mapping) + print(f"Replaced links: {replaced} matches") + + tmp_md = Path("/tmp/report_with_drive_links.md") + tmp_md.write_text(md_new, encoding="utf-8") + + media = MediaFileUpload(str(tmp_md), mimetype="text/markdown", resumable=True) + file = service.files().create( + body={"name": args.name, "mimeType": DOC_MIME, "parents": [args.folder]}, + media_body=media, + fields="id,name,webViewLink,mimeType", + supportsAllDrives=True, + ).execute() + print(f"OK: created {file['name']} ({file['mimeType']})") + print(f" id: {file['id']}") + print(f" url: {file['webViewLink']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/scripts/check_cwv.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/check_cwv.py new file mode 100644 index 0000000..679554d --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/check_cwv.py @@ -0,0 +1,113 @@ +"""Core Web Vitals (LCP/CLS/TTFB/longest_task) を 1 URL に対して計測する CLI。 + +`playwright_kit.web_vitals` モジュールの薄いラッパ。runner は testcase 内蔵で同 module +を呼ぶため、本 CLI は外部 URL の単発計測専用。 + +Usage: + python check_cwv.py --url https://example.com + python check_cwv.py --url-list urls.txt --output web_vitals.json + python check_cwv.py --url https://example.com --device "Pixel 5" +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from playwright.sync_api import sync_playwright + +_SKILL_ROOT = Path(__file__).resolve().parent.parent +if str(_SKILL_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILL_ROOT)) + +from playwright_kit.web_vitals import THRESHOLDS, judge, measure_page # noqa: E402 + + +def measure( + url: str, + *, + storage_state: str | None = None, + device_name: str | None = None, + timeout_ms: int = 30_000, + headless: bool = True, + observe_ms: int = 5000, +) -> dict[str, Any]: + with sync_playwright() as p: + browser = p.chromium.launch(headless=headless) + ctx_kwargs: dict[str, Any] = {"ignore_https_errors": True} + if storage_state: + ctx_kwargs["storage_state"] = storage_state + if device_name: + device = p.devices.get(device_name) + if not device: + browser.close() + return {"url": url, "error": f"unknown device: {device_name}"} + ctx_kwargs.update(device) + + context = browser.new_context(**ctx_kwargs) + page = context.new_page() + try: + page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded") + except Exception as exc: + browser.close() + return {"url": url, "error": str(exc)} + + metrics = measure_page(page, observe_ms=observe_ms) + browser.close() + return { + "url": url, + "device": device_name or "desktop", + "metrics": metrics, + "judgement": {k: judge(k, v) for k, v in metrics.items()}, + "thresholds": THRESHOLDS, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Core Web Vitals を計測") + parser.add_argument("--url", help="計測対象 URL") + parser.add_argument("--url-list", type=Path, help="URL を 1 行 1 件で書いたファイル") + parser.add_argument("--storage-state", default=None) + parser.add_argument("--device", default=None, + help="Playwright device 名 (例: 'Pixel 5')") + parser.add_argument("--observe-ms", type=int, default=5000, + help="PerformanceObserver 観測時間 (ms)") + parser.add_argument("--output", type=Path, default=None) + parser.add_argument("--fail-on-poor", action="store_true", + help="poor 判定 1 件以上で exit 1") + parser.add_argument("--headed", action="store_true") + args = parser.parse_args() + + if not args.url and not args.url_list: + parser.error("--url または --url-list が必要です") + + urls = ( + [line.strip() for line in args.url_list.read_text().splitlines() if line.strip()] + if args.url_list else [args.url] + ) + + results = [ + measure(u, storage_state=args.storage_state, device_name=args.device, + headless=not args.headed, observe_ms=args.observe_ms) + for u in urls + ] + + text = json.dumps(results, indent=2, ensure_ascii=False) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text, encoding="utf-8") + print(f"OK: web_vitals → {args.output}", file=sys.stderr) + else: + sys.stdout.write(text + "\n") + + has_poor = any( + any(v == "poor" for v in r.get("judgement", {}).values()) for r in results + ) + return 1 if args.fail_on_poor and has_poor else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/ndf-codex/skills/playwright-kit-ops/scripts/classify_page_role.py b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/classify_page_role.py new file mode 100644 index 0000000..2943843 --- /dev/null +++ b/plugins/ndf-codex/skills/playwright-kit-ops/scripts/classify_page_role.py @@ -0,0 +1,238 @@ +"""URL の page role を DOM の (implicit + explicit) role 集計から判定する。 + +実装はブラウザ DOM クエリで `