diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..959f663c --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "ai-plugins", + "plugins": [ + { + "name": "ndf", + "source": { + "source": "local", + "path": "./plugins/ndf" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "NDF" + } + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e977e319..9725b28d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "All-in-one plugin (v4.16.1): 8 specialized agents (model-tiered), 48 skills including pytest-playwright E2E testing (7 focused skills: test-planning, script-creation, execution, report, kit-ops, browser-connect, evidence-drive), CDP remote browser support, Google Drive evidence archival, ML model structure standard, PR/review workflows, principles, data analysis, Codex/Gemini CLI integration, skill usage stats, statusline switcher (/ndf:statusline). SessionStart hook (transcript retention >= 90 days, default statusline when none is configured), Stop hook (AI-summarized Slack notifications)." + "description": "All-in-one plugin (v4.17.0): Claude Code/Kiro and Codex skill exposure split, 8 specialized agents (model-tiered), 48 source skills including pytest-playwright E2E testing (7 focused skills: test-planning, script-creation, execution, report, kit-ops, browser-connect, evidence-drive), CDP remote browser support, Google Drive evidence archival, ML model structure standard, PR/review workflows, principles, data analysis, Codex/Gemini CLI integration, skill usage stats, statusline switcher (/ndf:statusline). SessionStart hook (transcript retention >= 90 days, default statusline when none is configured), Stop hook (AI-summarized Slack notifications)." }, { "name": "affaan-m", diff --git a/AGENTS.md b/AGENTS.md index 7d1fe2c8..5536782d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ ai-plugins/ ## NDFプラグインについて -**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v4.16.1): +**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v4.17.0): - 8個の専門サブエージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer) - 48個のSkills(PR/コードレビューワークフロー、AIクロスレビュー (codex/gemini)、原則・ガイドライン、MLモデル構造標準 (ml-model-structure)、issue→multi-PR ワークフロー戦略、SQL最適化、データエクスポート、skill利用統計、statusline切替 (/ndf:statusline)、Codex CLI連携、Playwrightシナリオテスト (CDPリモート接続・Google Driveエビデンス保管含む)、Google Drive/Chat連携 等) - SessionStartフック(transcript保持期間自動管理 `cleanupPeriodDays >= 90`、statusLine未設定時のNDF標準statusline自動設定) diff --git a/README.md b/README.md index 81e77bf0..be73ffb7 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # AI Plugins -Claude CodeプラグインおよびKiro CLI向けのスキル・MCP設定を共有するための内部マーケットプレイスです。 +Claude Code / Codex / Kiro CLI向けのスキル・MCP設定を共有するための内部マーケットプレイスです。 ## 概要 -このマーケットプレイスは、チーム全体でAI開発ツール(Claude Code / Kiro CLI)の導入を加速するための事前設定されたプラグインを提供します。 +このマーケットプレイスは、チーム全体でAI開発ツール(Claude Code / Codex / Kiro CLI)の導入を加速するための事前設定されたプラグインを提供します。 -**NDFプラグイン v4.13.0** は、以下の機能を**オールインワン**で提供する統合プラグインです: +**NDFプラグイン v4.17.0** は、以下の機能を**オールインワン**で提供する統合プラグインです: -- **47個のSkills**: +- **公開Skills**: Claude Code/Kiro向け core 26個、Codex向け core 28個に分離。`skills-optional/` にランタイム別の除外候補を整理。 +- **元Skills(48個)**: - PR/レビューワークフロー (13): pr, pr-tests, fix, review, review-branch, review-pr-comments, resolve-pr-comments, cherry-pick-pr, deploy, sync-main, merged, clean, browser-test - 原則・ガイドライン (9): ndf-policies, branch-fix-strategy, implementation-plan, investigation-rules, problem-solving, logging-guidelines, markdown-writing, issue-plan-strategy, ml-model-structure - データ分析・品質・環境 (12): data-analyst-sql-optimization, data-analyst-export, qa-security-scan, python-execution, docker-container-access, git-gh-operations, google-auth, codex, deepwiki-transfer, knowledge-reorg, mcp-builder, official-skills-autoloader @@ -38,6 +39,20 @@ Claude CodeプラグインおよびKiro CLI向けのスキル・MCP設定を共 /plugin install ndf@ai-plugins ``` +### Codex + +```bash +codex plugin marketplace add https://github.com/devbasex/ai-plugins +codex plugin add ndf@ai-plugins +``` + +ローカルで検証する場合: + +```bash +codex plugin marketplace add ./local/path/to/ai-plugins +codex plugin add ndf@ai-plugins +``` + ### Kiro CLI #### 1. リポジトリをクローン @@ -83,7 +98,7 @@ kiro-cli chat | プラグイン名 | バージョン | 説明 | 詳細 | |------------|----------|------|------| -| **ndf** | 4.13.0 | Claude Code / Kiro CLI開発環境を**オールインワン**で強化する統合プラグイン。8個の専門エージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer)、47個のSkills(PR/レビューワークフロー、原則・ガイドライン、MLモデル構造標準、データ分析、品質、Playwright E2E(CDPリモート接続・Google Driveエビデンス保管含む)、Google連携、AIクロスレビュー、Codex CLI連携、skill利用統計など)、SessionStartフック(transcript保持期間自動管理)、Stopフック(AI要約生成+Slack通知)を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:codex` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | +| **ndf** | 4.17.0 | Claude Code / Codex / Kiro CLI開発環境を**オールインワン**で強化する統合プラグイン。8個の専門エージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer)、公開Skills(Claude Code/Kiro向け core 26個、Codex向け core 28個)、SessionStartフック(transcript保持期間自動管理)、Stopフック(AI要約生成+Slack通知)を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:codex` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | ## 開発ガイドライン @@ -93,15 +108,21 @@ kiro-cli chat ``` ai-plugins/ +├── .agents/ +│ └── plugins/ +│ └── marketplace.json # Codexマーケットプレイスメタデータ ├── .claude-plugin/ -│ └── marketplace.json # マーケットプレイスメタデータ +│ └── marketplace.json # Claude Codeマーケットプレイスメタデータ ├── plugins/ │ └── {plugin-name}/ +│ ├── .codex-plugin/ +│ │ └── plugin.json # Codexプラグインメタデータ │ ├── .claude-plugin/ -│ │ └── plugin.json # プラグインメタデータ(必須) -│ ├── commands/ # スラッシュコマンド (*.md) +│ │ └── plugin.json # Claude Codeプラグインメタデータ │ ├── agents/ # サブエージェント (*.md) -│ └── skills/ # プロジェクトスキル +│ ├── skills/ # 全Skillの実体(Claude Code/Kiroはmanifest配列で公開対象を指定) +│ ├── skills-codex/ # Codex向け公開Skill(marketplace cache対応の実ディレクトリ) +│ └── skills-optional/ # ランタイム別除外候補リスト │ └── {skill-name}/ │ └── SKILL.md # エントリポイント(必須) ├── README.md diff --git a/docs/ndf-plugin-reference.md b/docs/ndf-plugin-reference.md index c9bf22da..f94a6dbf 100644 --- a/docs/ndf-plugin-reference.md +++ b/docs/ndf-plugin-reference.md @@ -2,9 +2,9 @@ ## 概要 -NDF プラグインは、Claude Code / Kiro CLI 向けのオールインワン開発支援プラグイン。エージェント、Skills、フックを統合して提供する。 +NDF プラグインは、Claude Code / Codex / Kiro CLI 向けのオールインワン開発支援プラグイン。エージェント、Skills、フックを統合して提供する。 -**現行バージョン**: **v4.16.1** — statusline: NDF 由来の旧コピー(マーカー付き / レガシー `statusline-command.sh`)を `settings.json` が指す場合、SessionStart で正規パス(`~/.claude/ndf-statusline.sh`)へ自動移行しバージョンアップ追従を回復(ユーザー独自 statusline は誤検出ガードで保護)。**v4.16.0** で statusline の `[ctx:` 固定ラベルを利用モデル表示名(例 `Opus 4.8`)に置換(取得不可時は `ctx` にフォールバック)。**v4.15.0** で cross-review の worktree 生成先を非永続領域 `<システム tmpdir>/ndf-worktrees/--/pr` に変更(永続 volume 消費・他リポジトリの PR 番号衝突・残骸流用事故を解消。`NDF_WORKTREE_BASE` env で明示オーバーライド可)。**v4.14.0** で `statusline` skill とデフォルト statusline 設定 hook を追加(47→48個)。statusLine 未設定時のみ NDF 標準 statusline(コンテナ名/ホスト名 + project_dir + コンテキスト使用率)を自動設定し、`/ndf:statusline set|restore|status` で切り替え・復元できる。**v4.13.0** で `issue-plan-strategy` の release PR body を self-contained 必須化。**v4.12.0** で Playwright E2E に `/ndf:playwright-browser-connect`(CDP リモートブラウザ接続)と `/ndf:playwright-evidence-drive`(Google Drive エビデンスアーカイブ)の 2 skill を追加(45→47個)。直前の **v4.11.0** で `/ndf:cross-review` の堅牢性改善(monitor.py の EARLY_ERROR 誤検知を解消: テスト用文字列リテラル / grep 形式ソース引用行を benign 自動判定し、ループ終了時の最終スイープで残 open review thread を全 Resolve)を実施。**v4.10.0** で `ml-model-structure` skill(MLモデル構築・推論API開発の標準ディレクトリ構造: 版内feature SSoT / train↔serve契約)を追加。`/ndf:fix` の修正ポリシー刷新(minor/nit のうち performance/readability/duplication は積極修正、+30 行超は要問い合わせ)、CI 完了待ち廃止、PR範囲外 flaky テストも修正対象。`/ndf:cross-review` 内のサブエージェントプロンプトも同期。重要度ラベルは AI agent の付与を鵜呑みにせず独自再判定。完了報告には PR URL 必須。詳細は [CHANGELOG.md](../plugins/ndf/CHANGELOG.md)。`/ndf:codex` skill + `corder` エージェント経由の Codex CLI 直接実行に一本化、Serena MCP は別プラグイン `mcp-serena` に分離済み、Playwright シナリオ E2E、Google Drive / Chat 連携 skill を提供。 +**現行バージョン**: **v4.17.0** — Codex plugin 対応として Claude Code / Kiro と Codex の公開 Skill セットを分離し、Codex では `skills-codex/` の core 28 個のみを公開する。Skill 実体は `skills/` に集約し、Claude Code / Kiro は manifest 配列指定で公開対象を制御する。Codex 向け公開ディレクトリは marketplace cache で欠落しないよう実ディレクトリとして同梱する。**v4.16.1** で statusline: NDF 由来の旧コピー(マーカー付き / レガシー `statusline-command.sh`)を `settings.json` が指す場合、SessionStart で正規パス(`~/.claude/ndf-statusline.sh`)へ自動移行しバージョンアップ追従を回復(ユーザー独自 statusline は誤検出ガードで保護)。**v4.16.0** で statusline の `[ctx:` 固定ラベルを利用モデル表示名(例 `Opus 4.8`)に置換(取得不可時は `ctx` にフォールバック)。**v4.15.0** で cross-review の worktree 生成先を非永続領域 `<システム tmpdir>/ndf-worktrees/--/pr` に変更(永続 volume 消費・他リポジトリの PR 番号衝突・残骸流用事故を解消。`NDF_WORKTREE_BASE` env で明示オーバーライド可)。**v4.14.0** で `statusline` skill とデフォルト statusline 設定 hook を追加(47→48個)。statusLine 未設定時のみ NDF 標準 statusline(コンテナ名/ホスト名 + project_dir + コンテキスト使用率)を自動設定し、`/ndf:statusline set|restore|status` で切り替え・復元できる。**v4.13.0** で `issue-plan-strategy` の release PR body を self-contained 必須化。**v4.12.0** で Playwright E2E に `/ndf:playwright-browser-connect`(CDP リモートブラウザ接続)と `/ndf:playwright-evidence-drive`(Google Drive エビデンスアーカイブ)の 2 skill を追加(45→47個)。直前の **v4.11.0** で `/ndf:cross-review` の堅牢性改善(monitor.py の EARLY_ERROR 誤検知を解消: テスト用文字列リテラル / grep 形式ソース引用行を benign 自動判定し、ループ終了時の最終スイープで残 open review thread を全 Resolve)を実施。**v4.10.0** で `ml-model-structure` skill(MLモデル構築・推論API開発の標準ディレクトリ構造: 版内feature SSoT / train↔serve契約)を追加。`/ndf:fix` の修正ポリシー刷新(minor/nit のうち performance/readability/duplication は積極修正、+30 行超は要問い合わせ)、CI 完了待ち廃止、PR範囲外 flaky テストも修正対象。`/ndf:cross-review` 内のサブエージェントプロンプトも同期。重要度ラベルは AI agent の付与を鵜呑みにせず独自再判定。完了報告には PR URL 必須。詳細は [CHANGELOG.md](../plugins/ndf/CHANGELOG.md)。`/ndf:codex` skill + `corder` エージェント経由の Codex CLI 直接実行に一本化、Serena MCP は別プラグイン `mcp-serena` に分離済み、Playwright シナリオ E2E、Google Drive / Chat 連携 skill を提供。 ## ディレクトリ構造 @@ -20,7 +20,9 @@ plugins/ndf/ │ ├── statusline-switch.sh # statusline の導入・切替・復元 (ensure/set/restore/status) │ └── slack-notify.js # Slack通知スクリプト ├── agents/ # 専門エージェント(8個) -├── skills/ # Skills(48個) +├── skills/ # 全Skill実体(48個。Claude Code/Kiroはmanifest配列で公開対象を指定) +├── skills-codex/ # Codex向け公開Skill(core 28個。marketplace cache対応の実ディレクトリ) +├── skills-optional/ # ランタイム別除外候補リスト ├── CLAUDE.md # プラグイン開発者向けガイド └── README.md # 利用者向けドキュメント ``` @@ -209,3 +211,4 @@ claude -p --settings '{"disableAllHooks": true, "disableAllPlugins": true}' --ou | **v4.15.0** | cross-review: worktree 生成先を `<システム tmpdir>/ndf-worktrees/--/pr` に変更 (非永続化 + リポジトリ別分離)。未登録パスの残骸は `.stale-` に退避して作り直すガード追加 | | **v4.16.0** | statusline: `[ctx:` の固定ラベルを利用モデル表示名 (例 `Opus 4.8`) に置換。モデル名が取れない場合は `ctx` にフォールバック | | **v4.16.1** | statusline: NDF 由来の旧コピー (マーカー付き / レガシー `statusline-command.sh`) を `settings.json` が指す場合、SessionStart で正規パス (`~/.claude/ndf-statusline.sh`) へ自動移行しバージョンアップ追従を回復。ユーザー独自 statusline は誤検出ガードで保護。`skills/statusline/tests/` 新設 | +| **v4.17.0** | Codex plugin 対応: Claude Code / Kiro と Codex の公開 Skill セットを分離。Claude Code / Kiro は manifest 配列指定、Codex は marketplace cache 対応の `skills-codex/` 実ディレクトリで core 28 個を公開 | diff --git a/docs/project-overview.md b/docs/project-overview.md index 06923deb..46f17642 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -2,7 +2,7 @@ ## プロジェクトの目的 -Claude Codeプラグインマーケットプレイス(内部用)として、チーム全体でClaude Codeの導入を加速するための事前設定されたプラグインを提供する。 +Claude Code / Codex プラグインマーケットプレイス(内部用)として、チーム全体でAI開発ツールの導入を加速するための事前設定されたプラグインを提供する。 ## リポジトリ情報 @@ -22,10 +22,16 @@ Claude Codeプラグインマーケットプレイス(内部用)として、 ``` ai-plugins/ +├── .agents/ +│ └── plugins/ +│ └── marketplace.json # Codexマーケットプレイスメタデータ ├── .claude-plugin/ -│ └── marketplace.json # マーケットプレイスメタデータ +│ └── marketplace.json # Claude Codeマーケットプレイスメタデータ ├── plugins/ │ ├── ndf/ # NDFプラグイン(メイン) +│ │ ├── skills/ # 全Skill実体(Claude Code/Kiroはmanifest配列で公開対象を指定) +│ │ ├── skills-codex/ # Codex向け公開Skill(実ディレクトリ) +│ │ └── skills-optional/ # ランタイム別除外候補リスト │ ├── mcp-serena/ # Serena MCPプラグイン │ └── {plugin-name}/ # その他のプラグイン ├── docs/ # リポジトリ知識 @@ -37,12 +43,18 @@ ai-plugins/ ## インストール方法 -### マーケットプレイスの追加 +### Codex +```bash +codex plugin marketplace add https://github.com/devbasex/ai-plugins +codex plugin add ndf@ai-plugins +``` + +### Claude Code: マーケットプレイスの追加 ```bash /plugin marketplace add https://github.com/devbasex/ai-plugins ``` -### プラグインのインストール +### Claude Code: プラグインのインストール ```bash /plugin install ndf@ai-plugins ``` diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 032af723..2cb74ea8 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "4.16.1", - "description": "Integrated plugin with 8 specialized agents (model-tiered: opus/sonnet/haiku), 48 skills including official mcp-builder, on-demand loader for Anthropic official skills, generic workflow/principle skills, ML model structure standard (ml-model-structure), skill usage statistics, pytest-playwright E2E testing split into 7 focused skills (test-planning, script-creation, execution, report, kit-ops, browser-connect, evidence-drive) + orchestrator with video-by-default evidence, CDP remote browser support, and Google Drive evidence archival, Google Drive/Chat integration, and Codex CLI integration via /ndf:codex skill. Transcript retention is automatically kept at >= 90 days. Default statusline (container/host name + project dir + context usage) is set when none is configured, switchable via /ndf:statusline. Serena MCP is a separate plugin (mcp-serena).", + "version": "4.17.0", + "description": "Integrated plugin with 8 specialized agents and a focused core skill set for PR/review workflows, implementation planning, debugging principles, statusline, browser smoke testing, and Codex CLI delegation. Additional niche skills remain under skills-optional for maintainers. Transcript retention is automatically kept at >= 90 days. Default statusline is set when none is configured.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" @@ -35,18 +35,10 @@ "./skills/merged", "./skills/clean", "./skills/ndf-policies", - "./skills/data-analyst-sql-optimization", - "./skills/data-analyst-export", - "./skills/qa-security-scan", "./skills/markdown-writing", "./skills/python-execution", "./skills/docker-container-access", - "./skills/deepwiki-transfer", - "./skills/knowledge-reorg", "./skills/git-gh-operations", - "./skills/google-auth", - "./skills/mcp-builder", - "./skills/official-skills-autoloader", "./skills/branch-fix-strategy", "./skills/implementation-plan", "./skills/investigation-rules", @@ -60,21 +52,7 @@ "./skills/resolve-pr-comments", "./skills/browser-test", "./skills/codex", - "./skills/skill-stats", "./skills/statusline", - "./skills/playwright-test-planning", - "./skills/playwright-script-creation", - "./skills/playwright-execution", - "./skills/playwright-report", - "./skills/playwright-kit-ops", - "./skills/playwright-browser-connect", - "./skills/playwright-evidence-drive", - "./skills/playwright-scenario-test", - "./skills/google-drive", - "./skills/google-chat", - "./skills/gemini", - "./skills/cross-review", - "./skills/issue-plan-strategy", - "./skills/ml-model-structure" + "./skills/issue-plan-strategy" ] } diff --git a/plugins/ndf/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json new file mode 100644 index 00000000..62da2479 --- /dev/null +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "ndf", + "version": "4.17.0", + "description": "NDF workflows for PRs, reviews, testing, data analysis, Google integrations, and external AI delegation.", + "skills": "./skills-codex/", + "hooks": "./hooks/codex-hooks.json" +} diff --git a/plugins/ndf/AGENTS.md b/plugins/ndf/AGENTS.md index aa32aa2a..de383358 100644 --- a/plugins/ndf/AGENTS.md +++ b/plugins/ndf/AGENTS.md @@ -7,7 +7,7 @@ ## プラグイン情報 - **名前**: ndf -- **現在バージョン**: 4.4.0 +- **現在バージョン**: 4.17.0 - **種類**: 統合プラグイン(Skills + Agents + Hooks / v4.0.0 で Codex MCP 廃止) - **リポジトリ**: https://github.com/devbasex/ai-plugins @@ -25,9 +25,11 @@ plugins/ndf/ ├── .claude-plugin/ │ └── plugin.json # プラグインメタデータ -├── .mcp.json # MCPサーバー定義(Codex CLI) +├── .codex-plugin/ +│ └── plugin.json # Codexプラグインメタデータ ├── hooks/ -│ └── hooks.json # プロジェクトフック定義 +│ ├── hooks.json # Claude Codeプロジェクトフック定義 +│ └── codex-hooks.json # Codex hook定義 ├── scripts/ │ └── slack-notify.js # Slack通知スクリプト ├── agents/ # サブエージェント(8個、モデル階層化) @@ -39,48 +41,9 @@ plugins/ndf/ │ ├── debugger.md # sonnet: 根本原因分析 │ ├── devops-engineer.md # sonnet: Docker/CI/K8s │ └── code-reviewer.md # sonnet: diff/PRレビュー -├── skills/ # スキル(39個) -│ # PRワークフロー系 -│ ├── pr/ # commit+push+PR作成/更新 -│ ├── pr-tests/ # Test Plan自動実行 -│ ├── fix/ # PRコメント修正対応 -│ ├── review/ # PR単位レビュー(Approve/RC判定) -│ ├── review-branch/ # ローカル差分レビュー(PR前) -│ ├── review-pr-comments/ # PRコメント分類(READ-ONLY) -│ ├── resolve-pr-comments/ # 対応済みコメント返信+Resolve -│ ├── cherry-pick-pr/ # 環境ブランチへのcherry-pick PR -│ ├── deploy/ # 環境ブランチへのデプロイPR -│ ├── sync-main/ # main取り込み -│ ├── merged/ # マージ後クリーンアップ -│ ├── clean/ # マージ済みブランチ一括削除 -│ # 原則・ガイドライン系 -│ ├── ndf-policies/ # ポリシー常時注入 -│ ├── branch-fix-strategy/ # ブランチ修正適用戦略 -│ ├── issue-plan-strategy/ # issue→plan→multi-PR ワークフロー (release branch + draft PR + worktree) -│ ├── implementation-plan/ # 実装プラン管理(issues/) -│ ├── investigation-rules/ # 調査時のエビデンス主義 -│ ├── problem-solving/ # 根本原因分析・多層防御 -│ ├── logging-guidelines/ # ログ運用ガイドライン(言語非依存) -│ # データ分析・品質 -│ ├── data-analyst-sql-optimization/ -│ ├── data-analyst-export/ -│ ├── qa-security-scan/ -│ # ドキュメント・環境 -│ ├── markdown-writing/ -│ ├── python-execution/ -│ ├── docker-container-access/ -│ ├── deepwiki-transfer/ -│ ├── knowledge-reorg/ -│ ├── git-gh-operations/ -│ ├── google-auth/ -│ ├── browser-test/ # ブラウザ動作確認(Playwright/Chrome DevTools) -│ ├── codex/ # Codex CLI直接実行(MCP版との使い分け) -│ ├── playwright-scenario-test/ # Playwright+curl Web シナリオE2E並列ランナー -│ ├── google-drive/ # Google Drive エクスポート/DL/UP(google-auth依存) -│ ├── google-chat/ # Google Chat メッセージ取得(google-auth依存) -│ # Anthropic公式連携 -│ ├── mcp-builder/ # Anthropic公式(Apache-2.0) -│ └── official-skills-autoloader/ # 公式Skill自動ロード +├── skills/ # 全Skill実体(48個、Claude Code/Kiroはmanifest配列で公開対象を指定) +├── skills-codex/ # Codex向け公開Skill(core 28個、marketplace cache向け実ディレクトリ) +├── skills-optional/ # ランタイム別除外候補リスト ├── AGENTS.md # このファイル(開発者向け) └── README.md # プラグイン説明書 ``` @@ -90,9 +53,11 @@ plugins/ndf/ ### 新しいスキルの追加 1. `skills/{skill-name}/SKILL.md` を作成(YAMLフロントマター必須) -2. `plugin.json` の `skills` 配列に `"./skills/{skill-name}"` を追加 -3. `plugin.json` のバージョンをMINOR上げ -4. テスト・コミット +2. Claude Code/Kiroで初期公開する場合は `.claude-plugin/plugin.json` の `skills` 配列に `"./skills/{skill-name}"` を追加 +3. Codexで初期公開する場合は `skills-codex/{skill-name}` に実ディレクトリとしてコピーする(`.codex-plugin/plugin.json` は `./skills-codex/` ディレクトリを参照) +4. 低頻度・保守用に留める場合は `skills-optional/README.md` の候補リストへ追加 +5. plugin.json のバージョンをMINOR上げ +6. テスト・コミット ### 新しいサブエージェントの追加 @@ -100,19 +65,12 @@ plugins/ndf/ 2. `plugin.json` の `agents` 配列に追加 3. バージョンMINOR上げ → テスト・コミット -### MCPサーバーの追加・更新 - -1. `.mcp.json` の `mcpServers` に追加 -2. README.mdに説明追加 -3. バージョン更新 → テスト・コミット - ## 検証チェックリスト - [ ] plugin.jsonが有効なJSON - [ ] バージョン番号が適切にインクリメント - [ ] すべてのスキル/エージェントファイルが存在 - [ ] YAMLフロントマターが正しい -- [ ] .mcp.jsonが有効なJSON - [ ] README.md が最新 ## トラブルシューティング @@ -121,7 +79,6 @@ plugins/ndf/ |------|------| | エージェントが認識されない | plugin.jsonのagents配列、ファイルパス、YAMLフロントマターを確認 | | スキルが表示されない | plugin.jsonのskills配列、SKILL.mdのフロントマターを確認、`/plugin reload ndf` | -| MCPサーバーが起動しない | .mcp.jsonの構文、コマンドパス、環境変数を確認 | | フックが動作しない | hooks.jsonの構文、スクリプト実行権限を確認 | ## 開発履歴 diff --git a/plugins/ndf/CHANGELOG.md b/plugins/ndf/CHANGELOG.md index fac869db..6ac9616f 100644 --- a/plugins/ndf/CHANGELOG.md +++ b/plugins/ndf/CHANGELOG.md @@ -1,5 +1,22 @@ # NDF Plugin CHANGELOG +### v4.17.0 (Codex plugin 対応: ランタイム別 Skill 公開構成) + +Claude Code / Kiro と Codex で公開する Skill セットを分離し、Codex plugin から +不要な Claude Code 専用 Skill を読み込まない構成にした。 + +- **`.codex-plugin/plugin.json`**: Codex 向け manifest を追加し、`skills-codex/` + を Codex の公開 Skill ディレクトリとして指定 +- **`.claude-plugin/plugin.json`**: Claude Code / Kiro 向け公開 Skill を core 26 個に整理 +- **`skills-codex/`**: Codex 向け core 28 個を marketplace cache で欠落しない + 実ディレクトリとして同梱 + (`playwright-kit-ops` を含め、Playwright 系 skill から参照するスクリプト群も同梱) +- **`skills-optional/README.md`**: ランタイム別の除外候補を整理 +- **`scripts/install-kiro.sh`**: manifest-selected skill set を読むように更新し、 + Codex CLI 連携表示を MCP 前提から CLI 直接実行前提へ修正 +- **plugin.json: version 4.16.1 → 4.17.0**。marketplace.json / README / AGENTS.md / + ndf-plugin-reference の version 表記を整合 + ### v4.16.1 (statusline: NDF 由来の旧コピーをバージョンアップ追従させる) `settings.json` の `statusLine` が NDF の過去配置コピー (マーカー付き / diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index a88f709e..78145487 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -1,13 +1,13 @@ # NDF Plugin -Claude Code開発環境を**オールインワン**で強化する統合プラグインです。 +Claude Code / Codex開発環境を**オールインワン**で強化する統合プラグインです。 ## 概要 -このプラグイン1つで、以下の**すべて**の機能を利用できます: +このプラグイン1つで、以下の機能を利用できます: 1. **コアMCP**: なし (v4.0.0 で Codex MCP 廃止 / Serena MCP は `mcp-serena` プラグインに分離) -2. **Skills**: 48個(PR/コードレビュー系ワークフロー13個 + 原則・ガイドライン9個 (issue→multi-PR 戦略・MLモデル構造標準含む) + データ分析/品質/環境系12個 + Playwright E2E 8個 (CDPリモート接続・Google Driveエビデンス保管含む) + Google Drive/Chat 連携2個 + AI クロスレビュー2個 (cross-review / gemini) + 運用系2個 (skill-stats / statusline)) +2. **Skills**: Claude Code/Kiro向け core 26個、Codex向け core 28個を公開。元の48個は `skills/` に保持し、ランタイム別の除外候補は `skills-optional/README.md` で管理。 3. **専門エージェント**: 8つの特化型AIエージェント(director、data-analyst、corder、researcher、qa、debugger、devops-engineer、code-reviewer) 4. **自動フック**: Slack通知、デフォルトstatusline設定(未設定時のみ) @@ -67,6 +67,28 @@ GitHub、Context7 MCPは公式プラグインとして提供されています /plugin install ndf@ai-plugins ``` +### Codexでのインストール + +```bash +codex plugin marketplace add https://github.com/devbasex/ai-plugins +codex plugin add ndf@ai-plugins +``` + +Codex版ではSkillsに加えて、Codex向けSlack終了通知hookを同梱します。通知は明示的に `NDF_CODEX_SLACK_NOTIFY=true` を設定した場合のみ送信されます。Claude Code向けのstatusline設定、transcript保持期間設定、Claude CLIによるSlack要約通知hookはCodexでは自動有効化しません。 + +Claude Code/Kiro版で初期表示するSkillsは、PR運用・レビュー・調査・実装計画・browser smoke test・statusline・Codex CLI委譲などの core 26個に絞っています。Codex版は、Codex自体からの再委譲・Claude専用statusline・Claude transcript統計を外し、代わりにPlaywrightの計画・作成・実行・レポート・ツール操作の5個を含めた core 28個にしています。Google連携、DeepWiki転送、MLモデル構造、AIクロスレビュー、高度なPlaywright連携などは通常利用時のskills context budgetを圧迫しないよう初期公開から外し、`skills-optional/README.md` に整理しています。 + +Codex向けSlack通知を使う場合は、Claude Code向けSlack通知と同じ環境変数を使います。プロジェクトの `.env` などに以下を設定してください。 + +```bash +NDF_CODEX_SLACK_NOTIFY=true +SLACK_BOT_TOKEN=xoxb-... +SLACK_CHANNEL_ID=C0123456789 +SLACK_USER_MENTION=<@U0123456789> # オプション +``` + +Codexのhookは初回実行前に `/hooks` で信頼設定が必要です。 + ### ステップ3: .envファイルの作成 プロジェクトルートに `.env` ファイルを作成し、必要な認証情報を設定します。 @@ -771,7 +793,7 @@ NDFプラグインと併用することで、以下の機能が追加されま | プラグイン | 役割 | |-----------|------| -| **NDFプラグイン** | MCP統合、スキル(48個)、専門エージェント | +| **NDFプラグイン** | MCP統合、公開スキル(Claude/Kiro core 26個、Codex core 28個)、専門エージェント | | **affaan-mプラグイン** | コンテキスト管理、品質保証、TDDワークフロー | 詳細は[affaan-mプラグインREADME](../affaan-m/README.md)を参照してください。 diff --git a/plugins/ndf/hooks/codex-hooks.json b/plugins/ndf/hooks/codex-hooks.json new file mode 100644 index 00000000..282d0e18 --- /dev/null +++ b/plugins/ndf/hooks/codex-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/scripts/codex-slack-notify.js", + "timeout": 15, + "statusMessage": "Sending Codex completion notification" + } + ] + } + ] + } +} diff --git a/plugins/ndf/hooks/hooks.json b/plugins/ndf/hooks/hooks.json index 2433473d..9f5c2b0c 100644 --- a/plugins/ndf/hooks/hooks.json +++ b/plugins/ndf/hooks/hooks.json @@ -1,5 +1,4 @@ { - "description": "NDF Plugin hooks: transcript retention guard, default statusline, and Slack notifications", "hooks": { "SessionStart": [ { @@ -8,14 +7,14 @@ "hooks": [ { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/ensure-retention.sh", + "command": "bash ${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/scripts/ensure-retention.sh", "description": "NDF: ensure transcript retention >= 90 days", "continueOnError": true, "suppressOutput": false }, { "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/statusline-switch.sh ensure", + "command": "bash ${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/scripts/statusline-switch.sh ensure", "description": "NDF: set default statusline when none is configured", "continueOnError": true, "suppressOutput": false @@ -28,7 +27,7 @@ "hooks": [ { "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/slack-notify.js session_end", + "command": "node ${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/scripts/slack-notify.js session_end", "description": "Send Slack notification when Claude Code exits" } ] diff --git a/plugins/ndf/scripts/codex-slack-notify.js b/plugins/ndf/scripts/codex-slack-notify.js new file mode 100755 index 00000000..c1d5d9fe --- /dev/null +++ b/plugins/ndf/scripts/codex-slack-notify.js @@ -0,0 +1,295 @@ +#!/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, + 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) { + const mention = 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 postSlack(text) { + const token = process.env.SLACK_BOT_TOKEN; + const channel = process.env.SLACK_CHANNEL_ID; + if (!token || !channel) return Promise.resolve(false); + + return new Promise((resolve) => { + const body = JSON.stringify({ channel, text }); + const req = https.request({ + hostname: 'slack.com', + port: 443, + path: '/api/chat.postMessage', + 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 ok:', json?.ok, 'error:', json?.error || ''); + resolve(json?.ok === true); + }); + }); + req.on('error', (error) => { + log('slack error:', error.message); + resolve(false); + }); + req.write(body); + req.end(); + }); +} + +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); + const text = formatMessage(repo, session); + await postSlack(text); + } finally { + releaseLock(); + } +} + +main().catch((error) => { + log('fatal:', error.stack || error.message); + releaseLock(); + process.exit(0); +}); diff --git a/plugins/ndf/skills-codex/branch-fix-strategy/SKILL.md b/plugins/ndf/skills-codex/branch-fix-strategy/SKILL.md new file mode 100644 index 00000000..a4714a34 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/cherry-pick-pr/SKILL.md b/plugins/ndf/skills-codex/cherry-pick-pr/SKILL.md new file mode 100644 index 00000000..b5dc1341 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/clean/SKILL.md b/plugins/ndf/skills-codex/clean/SKILL.md new file mode 100644 index 00000000..2f75e54e --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/deploy/SKILL.md b/plugins/ndf/skills-codex/deploy/SKILL.md new file mode 100644 index 00000000..769919d7 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/docker-container-access/01-environment-detection.md b/plugins/ndf/skills-codex/docker-container-access/01-environment-detection.md new file mode 100644 index 00000000..51941bcc --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/docker-container-access/02-dood-access.md b/plugins/ndf/skills-codex/docker-container-access/02-dood-access.md new file mode 100644 index 00000000..d19fc967 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/docker-container-access/03-troubleshooting.md b/plugins/ndf/skills-codex/docker-container-access/03-troubleshooting.md new file mode 100644 index 00000000..0acac32c --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/docker-container-access/SKILL.md b/plugins/ndf/skills-codex/docker-container-access/SKILL.md new file mode 100644 index 00000000..eadd1ffe --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/fix/SKILL.md b/plugins/ndf/skills-codex/fix/SKILL.md new file mode 100644 index 00000000..f7ce9318 --- /dev/null +++ b/plugins/ndf/skills-codex/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-codex/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/skills-codex/fix/scripts/fetch-pr-comments.sh b/plugins/ndf/skills-codex/fix/scripts/fetch-pr-comments.sh new file mode 100755 index 00000000..aa7f97fe --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/git-gh-operations/01-common-errors.md b/plugins/ndf/skills-codex/git-gh-operations/01-common-errors.md new file mode 100644 index 00000000..a7afa445 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/git-gh-operations/SKILL.md b/plugins/ndf/skills-codex/git-gh-operations/SKILL.md new file mode 100644 index 00000000..68b8a7a0 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/implementation-plan/SKILL.md b/plugins/ndf/skills-codex/implementation-plan/SKILL.md new file mode 100644 index 00000000..0e0a1307 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/investigation-rules/SKILL.md b/plugins/ndf/skills-codex/investigation-rules/SKILL.md new file mode 100644 index 00000000..a4757a83 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/issue-plan-strategy/SKILL.md b/plugins/ndf/skills-codex/issue-plan-strategy/SKILL.md new file mode 100644 index 00000000..4275a379 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/logging-guidelines/SKILL.md b/plugins/ndf/skills-codex/logging-guidelines/SKILL.md new file mode 100644 index 00000000..007b9691 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/markdown-writing/01-diagram-guide.md b/plugins/ndf/skills-codex/markdown-writing/01-diagram-guide.md new file mode 100644 index 00000000..db40f52b --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/markdown-writing/SKILL.md b/plugins/ndf/skills-codex/markdown-writing/SKILL.md new file mode 100644 index 00000000..56b35839 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/merged/SKILL.md b/plugins/ndf/skills-codex/merged/SKILL.md new file mode 100644 index 00000000..06af6f2a --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/ndf-policies/SKILL.md b/plugins/ndf/skills-codex/ndf-policies/SKILL.md new file mode 100644 index 00000000..eb25c338 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-execution/SKILL.md b/plugins/ndf/skills-codex/playwright-execution/SKILL.md new file mode 100644 index 00000000..f99970cd --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/.gitignore b/plugins/ndf/skills-codex/playwright-kit-ops/.gitignore new file mode 100644 index 00000000..f9cf1ad0 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/SKILL.md b/plugins/ndf/skills-codex/playwright-kit-ops/SKILL.md new file mode 100644 index 00000000..991e993f --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/__init__.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/__init__.py new file mode 100644 index 00000000..bd275e61 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/accessibility.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/accessibility.py new file mode 100644 index 00000000..588e3cd5 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/body_check.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/body_check.py new file mode 100644 index 00000000..37a8c4c0 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/config.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/config.py new file mode 100644 index 00000000..18436b4d --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/fixtures/__init__.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/__init__.py new file mode 100644 index 00000000..b9eb5000 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/fixtures/accessibility.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/accessibility.py new file mode 100644 index 00000000..eafc0a7c --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/fixtures/auth.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/auth.py new file mode 100644 index 00000000..964f1e19 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/fixtures/body_check.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/body_check.py new file mode 100644 index 00000000..d6437c2e --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/fixtures/evidence.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/evidence.py new file mode 100644 index 00000000..c5ab4846 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/fixtures/web_vitals.py new file mode 100644 index 00000000..924e382a --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/overlay.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/overlay.py new file mode 100644 index 00000000..21e9844a --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/pytest_plugin.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/pytest_plugin.py new file mode 100644 index 00000000..81d7dffa --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/pytest_report.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/pytest_report.py new file mode 100644 index 00000000..2d67a89f --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/uploaders/__init__.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/uploaders/__init__.py new file mode 100644 index 00000000..283d2f07 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/video.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/video.py new file mode 100644 index 00000000..e1a018ea --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/playwright_kit/web_vitals.py b/plugins/ndf/skills-codex/playwright-kit-ops/playwright_kit/web_vitals.py new file mode 100644 index 00000000..034247ab --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/pyproject.toml b/plugins/ndf/skills-codex/playwright-kit-ops/pyproject.toml new file mode 100644 index 00000000..14e535b1 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/scripts/_drive_auth.py b/plugins/ndf/skills-codex/playwright-kit-ops/scripts/_drive_auth.py new file mode 100644 index 00000000..647ab526 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py b/plugins/ndf/skills-codex/playwright-kit-ops/scripts/build_gdoc_with_drive_links.py new file mode 100644 index 00000000..47990c64 --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/scripts/check_cwv.py b/plugins/ndf/skills-codex/playwright-kit-ops/scripts/check_cwv.py new file mode 100644 index 00000000..679554de --- /dev/null +++ b/plugins/ndf/skills-codex/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/skills-codex/playwright-kit-ops/scripts/classify_page_role.py b/plugins/ndf/skills-codex/playwright-kit-ops/scripts/classify_page_role.py new file mode 100644 index 00000000..2943843a --- /dev/null +++ b/plugins/ndf/skills-codex/playwright-kit-ops/scripts/classify_page_role.py @@ -0,0 +1,238 @@ +"""URL の page role を DOM の (implicit + explicit) role 集計から判定する。 + +実装はブラウザ DOM クエリで `