From 3ef22f2ea3fd38249d50235fdc947753c3c08b91 Mon Sep 17 00:00:00 2001 From: kewton Date: Wed, 25 Mar 2026 17:10:13 +0900 Subject: [PATCH] feat(snippet): add --with-snippet to issue and before-change commands (#168) Add inline snippet display for issue and before-change commands, enabling users to see document summaries without reading each file individually. Changes: - Add snippet: Option to BeforeChangeFinding and IssueDocumentEntry - Add --with-snippet, --snippet-lines, --snippet-chars CLI options - Add enrich_before_change_with_snippets() and enrich_issue_documents_with_snippets() - Unify existing enrich functions to convert empty strings to None - Update human/llm/json formatters for both commands - issue JSON: --with-snippet off = string[] (backward compat), on = object[] - Tantivy reader failure falls back to snippet: None (non-fatal) - Add 14 new tests (formatter + CLI args) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../issue-168-snippet-inline-design-policy.md | 424 ++++++++++++++++++ .../issue-review/hypothesis-verification.md | 46 ++ .../168/issue-review/original-issue.json | 1 + .../issue-review/stage1-review-context.json | 75 ++++ .../168/issue-review/stage2-apply-result.json | 16 + .../issue-review/stage3-review-context.json | 105 +++++ .../168/issue-review/stage4-apply-result.json | 15 + .../issue-review/stage5-review-context.json | 64 +++ .../168/issue-review/stage6-apply-result.json | 15 + .../issue-review/stage7-review-context.json | 64 +++ .../168/issue-review/stage8-apply-result.json | 15 + .../issue/168/issue-review/summary-report.md | 57 +++ .../stage1-apply-result.json | 1 + .../stage1-review-context.json | 19 + .../stage2-review-context.json | 21 + .../stage3-review-context.json | 21 + .../stage4-review-context.json | 17 + .../stage5-review-context.json | 5 + .../summary-report.md | 47 ++ .../iteration-1/codex-review-result.json | 39 ++ .../pm-auto-dev/iteration-1/tdd-context.json | 15 + .../pm-auto-dev/iteration-1/tdd-result.json | 108 +++++ dev-reports/issue/168/work-plan.md | 179 ++++++++ src/cli/before_change.rs | 129 +++++- src/cli/help_llm.rs | 14 +- src/cli/issue.rs | 182 +++++++- src/cli/snippet_helper.rs | 54 ++- src/indexer/knowledge.rs | 1 + src/indexer/symbol_store.rs | 1 + src/main.rs | 70 ++- src/output/human.rs | 5 + src/output/json.rs | 5 + src/output/llm.rs | 3 + src/output/mod.rs | 1 + tests/cli_args.rs | 74 +++ 35 files changed, 1880 insertions(+), 28 deletions(-) create mode 100644 dev-reports/design/issue-168-snippet-inline-design-policy.md create mode 100644 dev-reports/issue/168/issue-review/hypothesis-verification.md create mode 100644 dev-reports/issue/168/issue-review/original-issue.json create mode 100644 dev-reports/issue/168/issue-review/stage1-review-context.json create mode 100644 dev-reports/issue/168/issue-review/stage2-apply-result.json create mode 100644 dev-reports/issue/168/issue-review/stage3-review-context.json create mode 100644 dev-reports/issue/168/issue-review/stage4-apply-result.json create mode 100644 dev-reports/issue/168/issue-review/stage5-review-context.json create mode 100644 dev-reports/issue/168/issue-review/stage6-apply-result.json create mode 100644 dev-reports/issue/168/issue-review/stage7-review-context.json create mode 100644 dev-reports/issue/168/issue-review/stage8-apply-result.json create mode 100644 dev-reports/issue/168/issue-review/summary-report.md create mode 100644 dev-reports/issue/168/multi-stage-design-review/stage1-apply-result.json create mode 100644 dev-reports/issue/168/multi-stage-design-review/stage1-review-context.json create mode 100644 dev-reports/issue/168/multi-stage-design-review/stage2-review-context.json create mode 100644 dev-reports/issue/168/multi-stage-design-review/stage3-review-context.json create mode 100644 dev-reports/issue/168/multi-stage-design-review/stage4-review-context.json create mode 100644 dev-reports/issue/168/multi-stage-design-review/stage5-review-context.json create mode 100644 dev-reports/issue/168/multi-stage-design-review/summary-report.md create mode 100644 dev-reports/issue/168/pm-auto-dev/iteration-1/codex-review-result.json create mode 100644 dev-reports/issue/168/pm-auto-dev/iteration-1/tdd-context.json create mode 100644 dev-reports/issue/168/pm-auto-dev/iteration-1/tdd-result.json create mode 100644 dev-reports/issue/168/work-plan.md diff --git a/dev-reports/design/issue-168-snippet-inline-design-policy.md b/dev-reports/design/issue-168-snippet-inline-design-policy.md new file mode 100644 index 0000000..1711ede --- /dev/null +++ b/dev-reports/design/issue-168-snippet-inline-design-policy.md @@ -0,0 +1,424 @@ +# 設計方針書: Issue #168 - issue/before-changeの出力に判断理由のスニペットを付与する + +## 1. 概要 + +### 目的 +`issue` と `before-change` コマンドの出力に、各文書の判断理由のスニペットをインライン表示する機能を追加する。 + +### 背景 +現状、両コマンドはファイルパスのリストのみを返す。「過去の判断を取り出す」というプロダクトコアを実現するには、判断理由が直接読めて初めて「次の意思決定に接続」できる。 + +### スコープ +- Phase 1(本Issue): 既存 `snippet_helper::fetch_snippet()` を活用した基本スニペット付与 +- Phase 2(別Issue): セクション優先抽出(heading ベースのフィルタリング) + +## 2. アーキテクチャ設計 + +### レイヤー構成と責務 + +``` +┌─────────────────────────────────────────────────┐ +│ CLI Layer (main.rs) │ +│ --with-snippet / --snippet-lines / --snippet-chars │ +│ → SnippetOptions 構築 → コマンド関数に渡す │ +├─────────────────────────────────────────────────┤ +│ Command Layer (cli/issue.rs, cli/before_change.rs)│ +│ 1. 既存ロジック(SQLite/Git)で結果取得 │ +│ 2. enrich_*_with_snippets() でスニペット付与 │ +│ 3. format 関数で出力 │ +├─────────────────────────────────────────────────┤ +│ Snippet Helper (cli/snippet_helper.rs) │ +│ enrich_issue_documents_with_snippets() │ +│ enrich_before_change_with_snippets() │ +│ → fetch_snippet() → IndexReaderWrapper │ +├─────────────────────────────────────────────────┤ +│ Output Layer (output/human.rs, llm.rs, json.rs) │ +│ snippet フィールドの条件付き表示 │ +├─────────────────────────────────────────────────┤ +│ Index Layer (indexer/reader.rs) │ +│ tantivy IndexReaderWrapper │ +│ search_by_exact_path() → body フィールド取得 │ +└─────────────────────────────────────────────────┘ +``` + +### データフロー + +``` +CLI引数 → SnippetOptions + ↓ +issue/before-change の結果取得(SQLite/Git) + ↓ +enrich_*_with_snippets(results, reader, options, format) + ├── enabled=false → スキップ(snippet=None のまま) + ├── format=Path → スキップ + └── enabled=true → fetch_snippet(reader, doc_path, config) + ├── 成功 & 非空 → Some(text) + └── 失敗/空 → None + ↓ +format_*() で出力(snippet の有無で条件分岐) +``` + +## 3. 設計判断とトレードオフ + +### 判断1: snippet 未取得時の契約 + +**採用方針**: `Option` に統一。`Some(non-empty)` / `None` のみ。`Some("")` は禁止。 + +**理由**: +- JSON 出力で `null` と `""` の区別が消費者にとって曖昧 +- 既存 `fetch_snippet()` は空文字列を返すが、enrich 関数内で空→None に変換 +- 一貫したAPI契約により、フォーマッタ側の条件分岐がシンプルに + +**トレードオフ**: fetch_snippet() の戻り値を直接使えず変換が必要だが、enrich 関数内で吸収可能。 + +**既存関数との統一**: 既存の `enrich_impact_with_snippets()` / `enrich_related_with_snippets()` は `Some(fetch_snippet(...))` で空文字列を `Some("")` として設定している。本Issue のスコープ内で、既存の enrich 関数も空→None 変換に統一するリファクタリングを行う。既存フォーマッタは `!snippet.is_empty()` チェックを既に行っているため影響は軽微。 + +### 判断2: --with-snippet フラグ(デフォルトオフ) + +**採用方針**: 既存の impact/search と同じパターンで `--with-snippet` フラグを追加。デフォルトオフ。 + +**理由**: +- 後方互換性の維持(既存の出力に影響なし) +- tantivy IndexReader のオープンコストを必要時のみ発生させる +- issue JSON の条件付きスキーマ問題を回避(--with-snippet 未指定時は現行 string[] 維持) + +### 判断3: issue JSON のスキーマ変更 + +**採用方針**: `--with-snippet` 未指定時は現行 `string[]` を維持。指定時のみオブジェクト配列に拡張。 + +```rust +// --with-snippet 未指定時: 現行互換 +{ "documents": { "設計": ["path/to/design.md"] } } + +// --with-snippet 指定時: オブジェクト配列 +{ "documents": { "設計": [{"file_path": "path/to/design.md", "snippet": "..."}] } } +``` + +**理由**: JSON 出力を常時変更すると breaking change になり、既存の CI/CD パイプラインや LLM 連携が壊れる。 + +### 判断4: before-change JSON の snippet フィールド + +**採用方針**: `--with-snippet` 指定時のみ `snippet` フィールドを出力。None 時は `null` で出力。 + +**理由**: +- 既存の impact JSON パターンに準拠 +- JSON consumer が snippet フィールドの有無で --with-snippet 指定を判別可能 + +### 判断5: SnippetConfig のデフォルト値 + +**採用方針**: `lines=3, chars=200`(既存の `lines=2, chars=120` とは異なる) + +**理由**: +- issue/before-change は判断理由の要約が主目的で、コードスニペットより長めの文脈が必要 +- 150-200文字の要件を満たすデフォルト +- `--snippet-lines` / `--snippet-chars` で調整可能 + +**デフォルト値の注入箇所**: main.rs の CLI 引数処理で `snippet_lines.unwrap_or(3)`, `snippet_chars.unwrap_or(200)` とする。定数は `const KNOWLEDGE_SNIPPET_LINES: usize = 3; const KNOWLEDGE_SNIPPET_CHARS: usize = 200;` として main.rs に定義。 + +### 判断6: tantivy 未存在時のフォールバック + +**採用方針**: IndexReaderWrapper のオープンに失敗した場合は snippet: None でフォールバック(エラーにしない) + +**理由**: +- `commandindex index` 未実行でも issue/before-change は SQLite ベースで動作する +- スニペットは付加情報であり、取得失敗でコマンド全体が失敗すべきではない + +### 判断7: IssueDocumentEntry への snippet 追加 + +**採用方針**: `IssueDocumentEntry` に直接 `snippet: Option` を追加。別DTOは作成しない。 + +**理由**: +- 既存の ImpactFileResult, RelatedSearchResult と同じパターン +- 今回のスコープでは表示専用DTOを分離するほどの複雑性はない +- YAGNI: 将来必要になった時にリファクタリングすれば良い + +## 4. 型定義の変更 + +### BeforeChangeFinding + +```rust +#[derive(Debug, Clone, Serialize)] +pub struct BeforeChangeFinding { + pub issue_number: String, + pub relation: String, + pub doc_path: String, + pub doc_title: Option, + pub similarity: Option, + pub snippet: Option, // 追加 +} +``` + +### IssueDocumentEntry + +```rust +#[derive(Debug, Clone, Serialize)] +pub struct IssueDocumentEntry { + pub file_path: String, + pub relation: KnowledgeRelation, + pub doc_subtype: DocSubtype, + pub snippet: Option, // 追加 +} +``` + +## 5. CLI引数の追加 + +### before-change コマンド + +```rust +BeforeChange { + // ... existing fields ... + + /// Enable snippet output for findings + #[arg(long)] + with_snippet: bool, + + /// Number of snippet lines (default: 3) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=100))] + snippet_lines: Option, + + /// Number of snippet characters for single-line body (default: 200) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=10000))] + snippet_chars: Option, +} +``` + +### issue コマンド + +```rust +Issue { + // ... existing fields ... + + /// Enable snippet output for documents + #[arg(long)] + with_snippet: bool, + + /// Number of snippet lines (default: 3) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=100))] + snippet_lines: Option, + + /// Number of snippet characters for single-line body (default: 200) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=10000))] + snippet_chars: Option, +} +``` + +## 6. snippet_helper.rs の追加関数 + +```rust +pub(crate) fn enrich_before_change_with_snippets( + findings: &mut [crate::output::BeforeChangeFinding], + reader: &IndexReaderWrapper, + snippet_options: &SnippetOptions, + format: crate::output::OutputFormat, +) { + if !snippet_options.enabled || matches!(format, crate::output::OutputFormat::Path) { + return; + } + for finding in findings.iter_mut() { + let snippet = fetch_snippet(reader, &finding.doc_path, snippet_options.config); + finding.snippet = if snippet.is_empty() { None } else { Some(snippet) }; + } +} + +pub(crate) fn enrich_issue_documents_with_snippets( + documents: &mut [IssueDocumentEntry], + reader: &IndexReaderWrapper, + snippet_options: &SnippetOptions, + format: crate::output::OutputFormat, +) { + if !snippet_options.enabled || matches!(format, crate::output::OutputFormat::Path) { + return; + } + for doc in documents.iter_mut() { + let snippet = fetch_snippet(reader, &doc.file_path, snippet_options.config); + doc.snippet = if snippet.is_empty() { None } else { Some(snippet) }; + } +} +``` + +## 7. 出力フォーマット仕様 + +### before-change human形式 + +``` +path/to/design.md (similarity: 0.85) [#299, has_design] + 設計方針書タイトル + > z-index指定方式をinline style方式で統一。Z_INDEX定数を直接参照... +``` + +### before-change llm形式 + +``` +- path/to/design.md [0.85] (#299, has_design) - 設計方針書タイトル + > z-index指定方式をinline style方式で統一。Z_INDEX定数を直接参照... +``` + +### before-change json形式(--with-snippet指定時) + +```json +{ + "findings": [ + { + "issue_number": "299", + "relation": "has_design", + "doc_path": "path/to/design.md", + "doc_title": "設計方針書タイトル", + "similarity": 0.85, + "snippet": "z-index指定方式をinline style方式で統一..." + } + ] +} +``` + +### issue human形式 + +``` +# Issue #299 関連ドキュメント + +## 設計 + path/to/design.md + > z-index指定方式をinline style方式で統一... +``` + +### issue llm形式 + +```markdown +# Issue #299 関連ドキュメント + +## 設計 +- path/to/design.md + > z-index指定方式をinline style方式で統一... +``` + +### issue json形式(--with-snippet指定時) + +```json +{ + "issue_number": "299", + "documents": { + "設計": [{"file_path": "path/to/design.md", "snippet": "z-index指定方式を..."}] + } +} +``` + +### issue json形式(--with-snippet未指定時 = 現行互換) + +```json +{ + "issue_number": "299", + "documents": { + "設計": ["path/to/design.md"] + } +} +``` + +## 8. run_before_change() の変更 + +### 関数シグネチャ変更 + +```rust +pub fn run_before_change( + file: &str, + format: OutputFormat, + index_path: Option<&Path>, + limit: usize, + max_commits: usize, + snippet_options: SnippetOptions, // 追加 +) -> Result<(), BeforeChangeError> +``` + +### スニペット付与タイミング + +```rust +// group_and_limit_by_issue() 後、format 出力前 +let limited = group_and_limit_by_issue(findings, limit); + +// tantivy reader のオープン(snippet 有効時のみ) +if snippet_options.enabled { + if let Ok(reader) = IndexReaderWrapper::open(&commandindex_dir) { + enrich_before_change_with_snippets( + &mut limited, + &reader, + &snippet_options, + format, + ); + } + // reader オープン失敗時は snippet: None のまま継続 +} +``` + +## 9. issue::run() の変更 + +### 関数シグネチャ変更 + +```rust +pub fn run( + issue_number: u64, + format: OutputFormat, + commandindex_dir: &Path, + snippet_options: SnippetOptions, // 追加 +) -> Result<(), IssueCommandError> +``` + +### スニペット付与タイミング + +```rust +let mut result = IssueDocumentsResult { ... }; + +// tantivy reader のオープン(snippet 有効時のみ) +if snippet_options.enabled { + if let Ok(reader) = IndexReaderWrapper::open(commandindex_dir) { + enrich_issue_documents_with_snippets( + &mut result.documents, + &reader, + &snippet_options, + format, + ); + } +} +``` + +## 10. セキュリティ設計 + +| 脅威 | 対策 | 優先度 | +|------|------|--------| +| パストラバーサル | doc_path は tantivy インデックスから取得済みの正規化パス。strip_control_chars() で制御文字除去 | 中 | +| 大量データ | SnippetConfig の lines/chars で出力量制限(上限: lines=100, chars=10000)。issue は LIMIT 100、before-change は limit で制限 | 低 | +| unsafe | 使用しない | 高 | + +## 11. 影響範囲 + +### 変更対象ファイル(15件) + +| ファイル | 変更種別 | 変更内容 | +|---|---|---| +| `src/output/mod.rs` | 型追加 | BeforeChangeFinding.snippet | +| `src/indexer/knowledge.rs` | 型追加 | IssueDocumentEntry.snippet(定義は knowledge.rs L179) | +| `src/indexer/symbol_store.rs` | 初期値 | find_documents_by_issue() で snippet: None 設定 | +| `src/cli/snippet_helper.rs` | リファクタリング | 既存 enrich_impact/enrich_related の空→None 変換統一 | +| `src/cli/before_change.rs` | 機能追加 | enrich 呼び出し + テスト更新 | +| `src/cli/issue.rs` | 機能追加 | enrich 呼び出し + フォーマッタ更新 + テスト更新 | +| `src/cli/snippet_helper.rs` | 機能追加 | enrich 関数2つ追加 | +| `src/output/human.rs` | 表示追加 | before-change snippet 表示 | +| `src/output/llm.rs` | 表示追加 | before-change snippet 表示 | +| `src/output/json.rs` | フィールド追加 | before-change snippet | +| `src/main.rs` | CLI引数追加 | --with-snippet 等 3引数 × 2コマンド | +| `src/cli/help_llm.rs` | ドキュメント | コマンド説明更新 | +| `tests/cli_args.rs` | テスト追加 | 新オプション検証 | +| `tests/e2e_issue.rs` | テスト更新 | JSON スキーマ + snippet | +| `tests/e2e_before_change.rs` | テスト追加 | snippet 検証 | +| `tests/output_format.rs` | テスト追加 | フォーマッタ snippet テスト | + +### 影響なし + +- search, impact, why, suggest 等の他コマンド +- parser, embedding モジュール + +## 12. 品質基準 + +| チェック項目 | コマンド | 基準 | +|---|---|---| +| ビルド | `cargo build` | エラー0件 | +| Clippy | `cargo clippy --all-targets -- -D warnings` | 警告0件 | +| テスト | `cargo test --all` | 全テストパス | +| フォーマット | `cargo fmt --all -- --check` | 差分なし | diff --git a/dev-reports/issue/168/issue-review/hypothesis-verification.md b/dev-reports/issue/168/issue-review/hypothesis-verification.md new file mode 100644 index 0000000..be66e8a --- /dev/null +++ b/dev-reports/issue/168/issue-review/hypothesis-verification.md @@ -0,0 +1,46 @@ +# 仮説検証レポート - Issue #168 + +## 総合判定表 + +| 仮説 | 判定 | 根拠 | +|---|---|---| +| 1. `issue --format llm` はパスのみ | ✅ Confirmed | issue.rs の format_llm がパスのみ出力 | +| 2. `before-change` の snippet は null | ✅ Confirmed | BeforeChangeFinding に snippet フィールドなし | +| 3. 先頭N文字をスニペットとして付与 | ⚠️ Partially Confirmed | snippet_helper 基盤は存在するが before-change に未適用 | +| 4. has_design で採用方針/結論セクション優先抽出 | ❌ Unverifiable | セクション優先抽出ロジック未実装 | +| 5. has_review で Executive Summary 優先抽出 | ❌ Unverifiable | セクション優先抽出ロジック未実装 | +| 6. has_workplan で Phase一覧要約を抽出 | ❌ Unverifiable | セクション優先抽出ロジック未実装 | +| 7. human/llm でインライン、json で snippet フィールド | ⚠️ Partially Confirmed | フォーマット分岐は存在するが snippet フィールド未定義 | + +## 詳細 + +### 仮説1: issue --format llm はパスのみ (Confirmed) +- `src/cli/issue.rs` の `format_llm()` はファイルパスのみ出力 +- `IssueDocumentEntry` は file_path, relation, doc_subtype のみ + +### 仮説2: before-change snippet は null (Confirmed) +- `BeforeChangeFinding` (output/mod.rs) に snippet フィールドが存在しない +- human/json/llm どのフォーマットでも snippet は出力されない + +### 仮説3: 先頭N文字スニペット (Partially Confirmed) +- `src/cli/snippet_helper.rs` に `fetch_snippet()` が実装済み +- impact コマンドや related 検索で既に使用されている +- tantivy インデックスの body フィールドは STORED で保存済み +- **ただし** before-change/issue コマンドではこのメカニズムが未適用 + +### 仮説4-6: セクション優先抽出 (Unverifiable) +- 現在のコードにはセクション指定抽出ロジックが存在しない +- fetch_snippet() はパスベースで最初の非空ドキュメントを返すのみ +- 将来の拡張として実装が必要 + +### 仮説7: フォーマット別表示 (Partially Confirmed) +- human/json/llm/path のフォーマット分岐は実装済み +- snippet フィールド追加後は比較的容易に対応可能 + +## 実装に必要な主要変更箇所 + +1. **BeforeChangeFinding** に `snippet: Option` 追加 (output/mod.rs) +2. **IssueDocumentEntry** に `snippet: Option` 追加 +3. **before-change** コマンドで snippet_helper を使用して snippet 付与 +4. **issue** コマンドで snippet 付与 +5. **各フォーマッタ** (human.rs, llm.rs, json.rs) で snippet 表示 diff --git a/dev-reports/issue/168/issue-review/original-issue.json b/dev-reports/issue/168/issue-review/original-issue.json new file mode 100644 index 0000000..c280458 --- /dev/null +++ b/dev-reports/issue/168/issue-review/original-issue.json @@ -0,0 +1 @@ +{"body":"## 概要\n\n`issue --format llm` と `before-change --format llm` はファイルパスのリストのみを返す。「過去の判断を取り出す」というプロダクトコアを実現するには、各文書の**判断理由の要約(スニペット)**をインライン表示する必要がある。\n\n## 現状\n\n```bash\n# issue --format llm: パスのみ\ncommandindexdev issue 299 --format llm\n# → - dev-reports/design/issue-299-ipad-layout-fix-design-policy.md\n# (中身は不明。読みに行く必要がある)\n\n# before-change --format json: snippet が NONE\ncommandindexdev before-change src/config/z-index.ts --format json\n# → findings[].snippet: null(全件)\n```\n\n## 期待される結果\n\n```markdown\n# Issue #299 関連ドキュメント\n\n## 設計\n- dev-reports/design/issue-299-ipad-layout-fix-design-policy.md\n > z-index指定方式をinline style方式で統一。Z_INDEX定数を直接参照し、Tailwindのz-[60]を廃止。DRY違反を解消。\n\n## レビュー\n- dev-reports/review/2026-02-18-issue299-consistency-review-stage2.md\n > Must Fix 1件: Toast.tsxがZ_INDEX.TOASTではなくz-50ハードコード。設計書の前提と不整合。\n```\n\n```bash\n# before-change --format llm: 判断理由がインライン\ncommandindexdev before-change src/config/z-index.ts --format llm\n# → - issue-299-ipad-layout-fix-design-policy.md (#299, has_design)\n# > inline style方式で統一。Z_INDEX定数を直接参照。\n```\n\n## 対象バリュー\n\n- **判断再利用**: パスのリストでは判断を再利用できない。判断理由が直接読めて初めて「次の意思決定に接続」できる\n- **文脈先回り**: AIエージェントがbefore-changeの結果だけで設計制約を把握できる。各ファイルをfile.readする多段ステップが不要になる\n\n## 実装案\n\n- インデックス済みセクションの先頭N文字(150-200文字程度)をスニペットとして付与\n- `has_design`: 設計ポリシーの「採用方針」または「結論」セクションを優先抽出\n- `has_review`: summary-reportの「Executive Summary」セクションを優先抽出\n- `has_workplan`: Phase一覧の要約を抽出\n- `--format human`/`llm` でインライン表示、`--format json` で`snippet`フィールド\n\n## テスト環境\n\n- commandindex 0.1.0\n- CommandMateリポジトリ(2910ファイル、124690セクション)","title":"issue/before-changeの出力に判断理由のスニペットを付与する"} diff --git a/dev-reports/issue/168/issue-review/stage1-review-context.json b/dev-reports/issue/168/issue-review/stage1-review-context.json new file mode 100644 index 0000000..bf57404 --- /dev/null +++ b/dev-reports/issue/168/issue-review/stage1-review-context.json @@ -0,0 +1,75 @@ +{ + "must_fix": [ + { + "id": "M1", + "title": "issue コマンドには tantivy IndexReader へのアクセスパスがない", + "description": "issue.rs の run() 関数は SymbolStore (SQLite) のみを使用しており、tantivy の IndexReaderWrapper を受け取っていない。snippet_helper::fetch_snippet() は IndexReaderWrapper を必要とする。issue コマンドに tantivy インデックスへのパスを渡す引数追加が必要であり、main.rs のサブコマンド呼び出し側にも変更が必要。", + "suggestion": "受け入れ基準に「issue コマンドが commandindex_dir から tantivy インデックスを開き、snippet_helper::fetch_snippet() を呼び出してスニペットを付与する」ことを明記する。run() の引数変更と main.rs 側の呼び出し変更を実装方針に含める。" + }, + { + "id": "M2", + "title": "BeforeChangeFinding 型に snippet フィールドが不在", + "description": "現在の BeforeChangeFinding (output/mod.rs:318-325) には snippet フィールドが存在しない。before-change コマンドでスニペットを表示するには型定義の変更が必要。BeforeChangeFinding のコンストラクタ全箇所(5箇所)とテスト全箇所(7箇所)に影響する。", + "suggestion": "影響範囲として BeforeChangeFinding コンストラクタ全箇所(before_change.rs 内 5箇所)とテスト全箇所(7箇所)の変更を実装方針に明記する。snippet フィールドは Option として追加し、デフォルト None で既存コードへの影響を最小化する。" + }, + { + "id": "M3", + "title": "IssueDocumentEntry 型にも snippet フィールドが不在", + "description": "IssueDocumentEntry (knowledge.rs:178-183) には file_path, relation, doc_subtype のみ。issue コマンドでスニペット付き表示をするには snippet: Option を追加する必要がある。テスト8箇所とフォーマッタ4関数に影響する。", + "suggestion": "IssueDocumentEntry に snippet: Option フィールドを追加するか、IssueDocumentsResult レベルでスニペットを付与するパターン(enrich関数)を採用する。" + }, + { + "id": "M4", + "title": "受け入れ基準が未定義", + "description": "テスト可能な受け入れ基準が定義されていない。文字数制限、空スニペット時の挙動、--format path 時の挙動等の境界条件が不明確。", + "suggestion": "受け入れ基準を追加:(1) issue --format human/llm でスニペット表示、(2) issue --format json で snippet フィールド、(3) before-change --format json で snippet フィールド、(4) --format path ではスニペット非出力、(5) 取得不可時は空/null、(6) 最大150-200文字。" + } + ], + "should_fix": [ + { + "id": "S1", + "title": "セクション優先抽出ロジックの実現可能性と既存基盤との不整合", + "description": "fetch_snippet() は先頭N文字を返すだけで、特定セクション選択機能がない。セクション優先抽出には新関数か大幅なインターフェース変更が必要。", + "suggestion": "Phase 1 では fetch_snippet() の既存ロジックを利用し、セクション優先抽出は Phase 2 以降に分離する。" + }, + { + "id": "S2", + "title": "before-change での tantivy IndexReader 追加が必要", + "description": "before_change.rs は SymbolStore と EmbeddingStore のみ使用。スニペット取得には IndexReaderWrapper が追加で必要。", + "suggestion": "run_before_change() の引数に IndexReaderWrapper を追加するか、commandindex_dir から遅延初期化する。findings数は少ないためパフォーマンス問題なし。" + }, + { + "id": "S3", + "title": "issue コマンドの JSON 出力形式が breaking change", + "description": "format_json() は現在パスの配列を返す。スニペット追加で {path, snippet} オブジェクトに変更する必要があり、後方互換性が壊れる。", + "suggestion": "JSON出力形式の変更を明確に定義し、breaking change であることをIssueに明記する。" + }, + { + "id": "S4", + "title": "snippet_helper パターンの一貫性", + "description": "既存コードでは enrich_*_with_snippets() パターンが確立されている。", + "suggestion": "snippet_helper.rs に enrich_issue_documents_with_snippets() と enrich_before_change_with_snippets() を追加。" + } + ], + "nice_to_have": [ + { + "id": "N1", + "title": "スニペット文字数設定のハードコード回避", + "description": "Issueでは150-200文字だが、既存のSnippetConfigデフォルトはlines=2, chars=120。", + "suggestion": "issue/before-change用のデフォルトSnippetConfigを別途定義するか、CLIオプションを追加。" + }, + { + "id": "N2", + "title": "tantivy未インデックスファイルのフォールバック", + "description": "dev-reports/ がインデックスされていない場合、スニペットが空になる。", + "suggestion": "取得できない場合は空文字列を返すことを受け入れ基準に含める。" + }, + { + "id": "N3", + "title": "統合テストの網羅性", + "description": "スニペット付き出力の統合テストが必要。", + "suggestion": "テスト用tantivy インデックスを構築し、各フォーマットのスニペット出力を検証する統合テストを追加。" + } + ], + "summary": "Issue #168 はプロダクトコアに合致する重要な機能追加だが、(M1-M3) issue/before-change両コマンドで tantivy IndexReaderWrapper への新依存が必要なこと、(M4) テスト可能な受け入れ基準が未定義であることが主要課題。型変更の影響範囲(BeforeChangeFinding 5箇所、IssueDocumentEntry 8箇所)の明記、セクション優先抽出の段階的実装、JSON出力のbreaking change明示が必要。" +} diff --git a/dev-reports/issue/168/issue-review/stage2-apply-result.json b/dev-reports/issue/168/issue-review/stage2-apply-result.json new file mode 100644 index 0000000..2d59878 --- /dev/null +++ b/dev-reports/issue/168/issue-review/stage2-apply-result.json @@ -0,0 +1,16 @@ +{ + "stage": 2, + "action": "apply_review", + "applied_items": [ + "M1: issue コマンドの tantivy IndexReader 依存を実装方針に追加", + "M2: BeforeChangeFinding 型変更の影響範囲を明記", + "M3: IssueDocumentEntry 型変更の影響範囲を明記", + "M4: テスト可能な受け入れ基準を11項目追加", + "S1: セクション優先抽出をPhase 2(スコープ外)に分離", + "S2: before-change での IndexReader 追加方針を明記", + "S3: JSON出力のbreaking changeを明記", + "S4: snippet_helper パターンの一貫性(enrich関数追加)を明記" + ], + "issue_updated": true, + "url": "https://github.com/Kewton/CommandIndex/issues/168" +} diff --git a/dev-reports/issue/168/issue-review/stage3-review-context.json b/dev-reports/issue/168/issue-review/stage3-review-context.json new file mode 100644 index 0000000..bba859e --- /dev/null +++ b/dev-reports/issue/168/issue-review/stage3-review-context.json @@ -0,0 +1,105 @@ +{ + "must_fix": [ + { + "id": "M1", + "title": "BeforeChangeFinding 全コンストラクタ更新(5箇所+テスト10箇所以上)", + "description": "snippet: Option 追加で全コンストラクタがコンパイルエラーになる。", + "suggestion": "全コンストラクタで snippet: None を初期値設定。enrich 関数で後から上書き。" + }, + { + "id": "M2", + "title": "IssueDocumentEntry 全コンストラクタ・テスト更新", + "description": "symbol_store.rs:909 の find_documents_by_issue() 内構造体生成、issue.rs テスト6箇所の更新が必要。", + "suggestion": "snippet: None を全箇所に追加。" + }, + { + "id": "M3", + "title": "issue JSON出力 breaking change のテスト更新", + "description": "tests/e2e_issue.rs:94-99 のアサーションとユニットテストの全面更新が必要。", + "suggestion": "新スキーマ(オブジェクト配列)に合わせてテスト更新。" + }, + { + "id": "M4", + "title": "before-change テスト内の構造体リテラル全更新", + "description": "before_change.rs テストモジュール内に10箇所以上のリテラルあり。", + "suggestion": "全リテラルに snippet: None を追加。" + }, + { + "id": "M5", + "title": "run_before_change 関数に IndexReaderWrapper 追加", + "description": "main.rs:968-974 のコール箇所も更新必要。tantivy未存在時のフォールバック必要。", + "suggestion": "オプショナルパラメータとして追加、未存在時はスニペットなしでフォールバック。" + } + ], + "should_fix": [ + { + "id": "S1", + "title": "issue コマンドに IndexReaderWrapper 導入", + "description": "commandindex_dir は既に受け取っているが tantivy 用 IndexReaderWrapper が未導入。", + "suggestion": "snippet_helper.rs に enrich_issue_documents_with_snippets() を追加。" + }, + { + "id": "S2", + "title": "output フォーマッタ before-change スニペット表示対応(human/llm/json)", + "description": "3つのフォーマッタで snippet 表示ロジック追加が必要。", + "suggestion": "impact の実装パターンを参考に追加。" + }, + { + "id": "S3", + "title": "output フォーマッタ issue スニペット表示対応(human/llm/json)", + "description": "4つの出力関数全てで snippet 表示対応が必要。format_json は大幅書き換え。", + "suggestion": "format_json をオブジェクト配列に変更。format_human/llm はインデント付き表示追加。" + }, + { + "id": "S4", + "title": "snippet_helper.rs に enrich_before_change_with_snippets() 追加", + "description": "既存の enrich パターンに従い追加。", + "suggestion": "doc_path をキーに fetch_snippet() を呼ぶ。format=Path の場合はスキップ。" + }, + { + "id": "S5", + "title": "tests/output_format.rs にスニペット出力テスト追加", + "description": "フォーマッタの単体テストにスニペット表示テストがない。", + "suggestion": "human/json/llm 各フォーマットのスニペット出力テストを追加。" + }, + { + "id": "S6", + "title": "e2e テストの更新(e2e_before_change.rs, e2e_issue.rs)", + "description": "JSON出力テストで snippet フィールドの存在を検証すべき。", + "suggestion": "新スキーマに合わせてアサーション更新。" + } + ], + "nice_to_have": [ + { + "id": "N1", + "title": "tantivy IndexReaderWrapper のオープンコスト: 問題なし", + "description": "コマンド実行ごとに1回のオープンで軽微。", + "suggestion": "tantivy 未存在時のフォールバック(snippet: None)は必須。" + }, + { + "id": "N2", + "title": "fetch_snippet() 呼び出し回数: 最大20回程度で軽微", + "description": "TermQuery(完全一致)なので O(1) に近い。", + "suggestion": "enrich 関数は limit 適用後に呼ぶ設計を維持。" + }, + { + "id": "N3", + "title": "help-llm 出力のスキーマ更新", + "description": "issue/before-change の出力フォーマット説明を更新。", + "suggestion": "snippet 関連の情報を help-llm に追加。" + }, + { + "id": "N4", + "title": "--with-snippet フラグの追加検討", + "description": "他コマンド(impact/search)との一貫性のため検討。", + "suggestion": "issue/before-change にも --with-snippet を追加、デフォルトオフ。" + }, + { + "id": "N5", + "title": "why コマンドへの波及: 今回スコープ外", + "description": "類似構造だが今回は変更不要。", + "suggestion": "将来のスニペット拡張時に同パターンを適用。" + } + ], + "summary": "変更の影響範囲: (1) BeforeChangeFinding/IssueDocumentEntry の型変更(構造体リテラル15箇所以上)、(2) snippet_helper.rs に enrich 関数2つ追加、(3) output フォーマッタ6関数更新、(4) main.rs の2コマンドハンドラ更新。issue JSON の breaking change が最大の影響。issue/before-change 以外への波及なし。パフォーマンス影響は軽微。" +} diff --git a/dev-reports/issue/168/issue-review/stage4-apply-result.json b/dev-reports/issue/168/issue-review/stage4-apply-result.json new file mode 100644 index 0000000..818ff8c --- /dev/null +++ b/dev-reports/issue/168/issue-review/stage4-apply-result.json @@ -0,0 +1,15 @@ +{ + "stage": 4, + "action": "apply_review", + "applied_items": [ + "M1-M5: 全コンストラクタ更新箇所の詳細を影響範囲テーブルに追加", + "S1: --with-snippet フラグ追加(他コマンドとの一貫性)", + "S2-S4: output フォーマッタ更新箇所を影響範囲テーブルに追加", + "S5-S6: テスト更新箇所を影響範囲テーブルに追加", + "N1: tantivy未存在時のフォールバックを受け入れ基準に追加", + "N3: help-llm 更新を実装方針に追加", + "N4: --with-snippet フラグを受け入れ基準に反映(デフォルトオフ→後方互換性維持)" + ], + "issue_updated": true, + "url": "https://github.com/Kewton/CommandIndex/issues/168" +} diff --git a/dev-reports/issue/168/issue-review/stage5-review-context.json b/dev-reports/issue/168/issue-review/stage5-review-context.json new file mode 100644 index 0000000..97c678b --- /dev/null +++ b/dev-reports/issue/168/issue-review/stage5-review-context.json @@ -0,0 +1,64 @@ +{ + "reviewer": "Codex (gpt-5.4)", + "must_fix": [ + { + "id": "M1", + "title": "snippet 未取得時の契約が Option と空文字列で矛盾している", + "description": "Issue本文では snippet: Option を追加する方針だが、受け入れ基準では「取得できない場合は空文字列を返す」とされ、実装方針では tantivy オープン失敗時は snippet: None フォールバック。現行の fetch_snippet() は空文字列を返すAPIだが、型は Option なので Some(\"\") と None が混在し挙動が不統一になる。", + "suggestion": "未取得時の契約を1つに統一。Option とし、取得成功時のみ Some(non-empty)、tantivy未存在・未インデックス・空本文・検索失敗はすべて None に寄せる。" + }, + { + "id": "M2", + "title": "issue --format json の後方互換性方針が受け入れ基準と衝突", + "description": "Issue本文では JSON 出力を「パス配列からオブジェクト配列に変更」(breaking change)と明記しているが、受け入れ基準では「--with-snippet 未指定時はスニペットを出力しない(後方互換性維持)」とも書かれている。常時スキーマ変更すると --with-snippet 未指定でも既存利用者が壊れる。", + "suggestion": "--with-snippet 未指定時は現行の string[] を維持し、指定時のみオブジェクト配列へ拡張するか、常時 breaking change にするなら「後方互換性維持」は削除し移行方針を明記。" + }, + { + "id": "M3", + "title": "スニペット長の扱いが既存CLI/configと整合していない", + "description": "Issueでは150-200文字だが、現行 SnippetConfig の既定値は lines=2, chars=120。既存コマンドは --snippet-lines / --snippet-chars と設定ファイル値を使える設計。今回は --with-snippet しか追加しておらず、150-200文字をどこで決めるか不明確。", + "suggestion": "issue / before-change でも --snippet-lines / --snippet-chars を採用するか、「固定 SnippetConfig を使う」と明記。受け入れ基準の150-200文字も固定か設定可能か明確にする。" + } + ], + "should_fix": [ + { + "id": "S1", + "title": "issue の JSON スキーマが具体化されておらずテストしにくい", + "description": "Issue本文は「オブジェクト配列に変更」としか書いておらず、カテゴリ構造を維持するのか、各要素に relation/doc_subtype を含めるのかが未定義。", + "suggestion": "issue --format json の最終スキーマ例を JSON サンプルで明示する。" + }, + { + "id": "S2", + "title": "before-change の出力仕様が既存の title/similarity 表示とどう共存するか未定義", + "description": "既存の doc_title と similarity の表示を残すのか、snippet との行順をどうするかが未記載。", + "suggestion": "doc_path・similarity・doc_title・snippet の表示順を明記する。" + }, + { + "id": "S3", + "title": "パフォーマンス見積もりが issue コマンドの実際の上限と不一致", + "description": "Issue本文では fetch_snippet() 最大20回とあるが、issue の find_documents_by_issue() は SQL上 LIMIT 100 で最大100件。", + "suggestion": "before-change と issue のパフォーマンス見積もりを分けて記述する。" + }, + { + "id": "S4", + "title": "テスト方針に tantivy フィクスチャ構築要件の明記が必要", + "description": "既存 e2e_issue.rs は SQLite のみで tantivy 本文を持っていない。snippet 機能の E2E 検証には tantivy フィクスチャが必要。", + "suggestion": "受け入れ基準に「snippet付きE2Eでは tantivy index を含むフィクスチャを使う」ことを明記。" + } + ], + "nice_to_have": [ + { + "id": "N1", + "title": "help-llm に snippet 付き実例を追加", + "description": "LLM向けに snippet 付きの出力例があると使い方の理解が早くなる。", + "suggestion": "key_options への追記に加えて、実例を追加。" + }, + { + "id": "N2", + "title": "IssueDocumentEntry に snippet を持たせるか別DTOで包むか", + "description": "IssueDocumentEntry は knowledge graph のメタデータ表現として純粋。表示専用の enrichment 結果を別DTOにする選択肢もある。", + "suggestion": "今回のスコープ優先なら型追加でもよいが、一度判断しておくとよい。" + } + ], + "summary": "1回目レビューの主要論点は概ね反映されているが、snippet 未取得時の契約(Option vs 空文字列)、issue --format json の breaking change と後方互換性の関係、スニペット長を既存 SnippetConfig/CLI設定とどう整合させるかは実装前に確定が必要。" +} diff --git a/dev-reports/issue/168/issue-review/stage6-apply-result.json b/dev-reports/issue/168/issue-review/stage6-apply-result.json new file mode 100644 index 0000000..e2847c4 --- /dev/null +++ b/dev-reports/issue/168/issue-review/stage6-apply-result.json @@ -0,0 +1,15 @@ +{ + "stage": 6, + "action": "apply_review", + "applied_items": [ + "M1: snippet 未取得時の契約を Option に統一(None/Some(non-empty) のみ、Some(\"\") 禁止)", + "M2: JSON後方互換性を解決(--with-snippet 未指定時は string[] 維持、指定時のみオブジェクト配列)", + "M3: --snippet-lines / --snippet-chars を追加(既存コマンドとの一貫性)、デフォルト lines=3, chars=200", + "S1: issue の JSON スキーマをサンプル付きで明示", + "S2: before-change の doc_path/similarity/doc_title/snippet の表示順を明記", + "S3: パフォーマンス見積もりを before-change(最大20回) と issue(最大100回) で分離", + "S4: テスト方針に tantivy フィクスチャ構築要件を明記" + ], + "issue_updated": true, + "url": "https://github.com/Kewton/CommandIndex/issues/168" +} diff --git a/dev-reports/issue/168/issue-review/stage7-review-context.json b/dev-reports/issue/168/issue-review/stage7-review-context.json new file mode 100644 index 0000000..b5618d3 --- /dev/null +++ b/dev-reports/issue/168/issue-review/stage7-review-context.json @@ -0,0 +1,64 @@ +{ + "reviewer": "Codex (gpt-5.4)", + "must_fix": [ + { + "id": "M1", + "title": "issue --format json の条件付きスキーマ(--with-snippet 有無で型が変わる)のテスト", + "description": "--with-snippet 未指定時は string[]、指定時はオブジェクト配列。このスキーマ分岐をテストとドキュメントでカバーする必要がある。", + "suggestion": "両パターンのE2Eとユニットテストを追加。" + }, + { + "id": "M2", + "title": "before-change JSON での snippet フィールドの null 表現が既存ポリシーと不整合", + "description": "impact/related のJSONは snippet: None のときフィールド自体を出さないテストがある。before-change だけ null を常に入れると不整合。", + "suggestion": "before-change のJSONで snippet を '--with-snippet 指定時のみ出す'、None は null で出すか省略かを固定し、既存JSON方針との差を影響範囲に含める。" + } + ], + "should_fix": [ + { + "id": "S1", + "title": "tests/cli_args.rs の影響範囲が不足", + "description": "新オプション追加でCLI引数テスト(help表示・境界値・受理/拒否)が必要。", + "suggestion": "tests/cli_args.rs を影響範囲に追加。" + }, + { + "id": "S2", + "title": "E2Eテストは tantivy 未存在フォールバックと成功系の両方が必要", + "description": "reader初期化失敗を非fatalにするため、成功系だけでは不十分。", + "suggestion": "issue/before-change 各2系統(tantivy有り/無し)のE2Eを追加。" + }, + { + "id": "S3", + "title": "フォーマッタ単体テストに before-change/issue のスニペット表示テスト追加", + "description": "human/llm/json/path 各フォーマットと snippet=None/Some の両ケースが必要。", + "suggestion": "tests/output_format.rs に追加。" + }, + { + "id": "S4", + "title": "issue の最大100件取得をパフォーマンス欄に明記", + "description": "issue は find_documents_by_issue() の LIMIT 100 で最大100回の exact-path 検索。", + "suggestion": "件数比例の影響として記述。" + }, + { + "id": "S5", + "title": "tantivy reader 依存拡大を依存関係影響に明記", + "description": "issue/before-change の実行時依存に tantivy reader が加わる。障害モード(symbols.db はあるが tantivy が壊れている/ない)を扱う必要。", + "suggestion": "依存関係影響欄に明記。失敗時は非fatal フォールバック。" + } + ], + "nice_to_have": [ + { + "id": "N1", + "title": "help-llm 更新は運用面の影響として明示", + "description": "LLM連携時のコマンド発見性に影響。", + "suggestion": "運用ドキュメント面の影響として分けて記述。" + }, + { + "id": "N2", + "title": "デフォルト 3/200 の妥当性を実データで確認", + "description": "2/120 から 3/200 への変更で出力量が増える。", + "suggestion": "CommandMate相当の実データでサンプリング確認。" + } + ], + "summary": "issue JSON の条件付きスキーマと before-change JSON の null 表現ポリシーの整合が新たな影響点。CLIテスト・フォーマッタテスト・tantivy 成功/失敗 E2E が確実に増加。性能は軽微だが issue 最大100件を前提に評価。" +} diff --git a/dev-reports/issue/168/issue-review/stage8-apply-result.json b/dev-reports/issue/168/issue-review/stage8-apply-result.json new file mode 100644 index 0000000..9a2449a --- /dev/null +++ b/dev-reports/issue/168/issue-review/stage8-apply-result.json @@ -0,0 +1,15 @@ +{ + "stage": 8, + "action": "apply_review", + "applied_items": [ + "M1: JSON null 表現ポリシーを統一(--with-snippet 指定時のみ snippet フィールド出力、既存ポリシー準拠)", + "M2: issue --format json の条件付きスキーマを明確化(--with-snippet 有無で両パターンテスト)", + "S1: tests/cli_args.rs を影響範囲に追加", + "S2: E2E テスト2系統(tantivy有り/無し)を明記", + "S3: tests/output_format.rs のフォーマッタ単体テストを明記", + "S4: issue 最大100件のパフォーマンス影響を明記", + "S5: tantivy reader 依存拡大を依存関係影響に明記" + ], + "issue_updated": true, + "url": "https://github.com/Kewton/CommandIndex/issues/168" +} diff --git a/dev-reports/issue/168/issue-review/summary-report.md b/dev-reports/issue/168/issue-review/summary-report.md new file mode 100644 index 0000000..c738f24 --- /dev/null +++ b/dev-reports/issue/168/issue-review/summary-report.md @@ -0,0 +1,57 @@ +# Issue #168 マルチステージレビュー サマリーレポート + +## Issue概要 +**issue/before-changeの出力に判断理由のスニペットを付与する** + +## レビュー実施状況 + +| Stage | 種別 | 実行者 | Must Fix | Should Fix | Nice to Have | +|-------|------|--------|----------|------------|--------------| +| 0.5 | 仮説検証 | Claude | - | - | - | +| 1 | 通常レビュー | Claude (opus) | 4 | 4 | 3 | +| 2 | 指摘反映 | Claude (sonnet) | - | - | - | +| 3 | 影響範囲レビュー | Claude (opus) | 5 | 6 | 5 | +| 4 | 指摘反映 | Claude (sonnet) | - | - | - | +| 5 | 通常レビュー(2回目) | Codex (gpt-5.4) | 3 | 4 | 2 | +| 6 | 指摘反映 | Claude (sonnet) | - | - | - | +| 7 | 影響範囲レビュー(2回目) | Codex (gpt-5.4) | 2 | 5 | 2 | +| 8 | 指摘反映 | Claude (sonnet) | - | - | - | + +## 主要な改善点(レビューを通じて追加・明確化された事項) + +### 1. snippet 未取得時の契約統一 +- `Option` に統一: `Some(non-empty)` / `None` のみ +- `Some("")` は禁止 +- JSON: `--with-snippet` 指定時のみ snippet フィールド出力 + +### 2. 後方互換性の確保 +- `--with-snippet` フラグをデフォルトオフで追加 +- issue JSON: 未指定時は現行 `string[]` 維持、指定時のみオブジェクト配列 +- before-change JSON: 既存ポリシーに準拠 + +### 3. 既存パターンとの一貫性 +- `--snippet-lines` / `--snippet-chars` の追加(impact/search と同じパターン) +- `enrich_*_with_snippets()` パターンの踏襲 +- tantivy 未存在時の非fatal フォールバック + +### 4. テスト方針の明確化 +- CLI引数テスト(tests/cli_args.rs) +- フォーマッタ単体テスト(tests/output_format.rs) +- E2E: tantivy有り/無しの2系統 + +### 5. 影響範囲の明確化 +- 変更ファイル15件を網羅的にリスト +- パフォーマンス: before-change 最大20回、issue 最大100回の fetch_snippet() +- 依存関係: tantivy reader 依存の拡大(非fatal フォールバック) + +## Issue の最終状態 + +Issue #168 は全8ステージのレビューを経て、以下が明確に定義された状態: +- 実装方針(Phase 1: 基本スニペット付与) +- snippet 未取得時の契約 +- CLIオプション仕様 +- JSON スキーマ(条件付き) +- 表示順序仕様 +- 影響範囲テーブル +- テスト方針 +- 受け入れ基準14項目 diff --git a/dev-reports/issue/168/multi-stage-design-review/stage1-apply-result.json b/dev-reports/issue/168/multi-stage-design-review/stage1-apply-result.json new file mode 100644 index 0000000..1003d9a --- /dev/null +++ b/dev-reports/issue/168/multi-stage-design-review/stage1-apply-result.json @@ -0,0 +1 @@ +{"stage": "1-4", "action": "apply_review", "applied_items": ["M1(全Stage): 既存enrich関数の空→None変換統一を設計書に明記", "M2(Stage2): run_before_change()シグネチャにindex_path追加済み確認", "M3(Stage2): IssueDocumentEntry定義場所をknowledge.rsに確定", "M4(Stage2,4): snippet_lines/snippet_charsに上限追加(lines=100, chars=10000)", "S2(Stage1,2): SnippetConfigデフォルト値の注入箇所を明記(定数+unwrap_or)", "影響範囲にsnippet_helper.rs既存関数リファクタリングを追加"]} diff --git a/dev-reports/issue/168/multi-stage-design-review/stage1-review-context.json b/dev-reports/issue/168/multi-stage-design-review/stage1-review-context.json new file mode 100644 index 0000000..7667187 --- /dev/null +++ b/dev-reports/issue/168/multi-stage-design-review/stage1-review-context.json @@ -0,0 +1,19 @@ +{ + "stage": 1, + "focus": "設計原則 (SOLID/KISS/YAGNI/DRY)", + "must_fix": [ + {"id": "M1", "title": "enrich関数の空文字列処理が既存パターンと不整合", "description": "新関数は空→None変換、既存はSome(\"\")のまま"}, + {"id": "M2", "title": "issue JSON スキーマの条件分岐による型安全性の欠如", "description": "同一フィールドが--with-snippetの有無で異なる型を返す"} + ], + "should_fix": [ + {"id": "S1", "title": "enrich関数のジェネリック化によるDRY改善", "description": "4つのenrich関数がほぼ同一パターン"}, + {"id": "S2", "title": "SnippetConfigのデフォルト値がコマンド間で暗黙的に異なる"}, + {"id": "S3", "title": "IssueDocumentEntryへのsnippet追加がドメインモデルとプレゼンテーションの混在", "description": "knowledge.rsのドメインモデルに表示用フィールド"}, + {"id": "S4", "title": "value_parserの型がu64 vs usizeで不統一"} + ], + "nice_to_have": [ + {"id": "N1", "title": "format引数のenrich関数への伝搬を再検討"}, + {"id": "N2", "title": "snippet_optionsのビルダーパターン導入"}, + {"id": "N3", "title": "影響範囲にimpact既存関数の変更を追加"} + ] +} diff --git a/dev-reports/issue/168/multi-stage-design-review/stage2-review-context.json b/dev-reports/issue/168/multi-stage-design-review/stage2-review-context.json new file mode 100644 index 0000000..4617871 --- /dev/null +++ b/dev-reports/issue/168/multi-stage-design-review/stage2-review-context.json @@ -0,0 +1,21 @@ +{ + "stage": 2, + "focus": "整合性", + "must_fix": [ + {"id": "M1", "title": "既存enrichパターンとの空文字列処理の不一致"}, + {"id": "M2", "title": "run_before_change()のシグネチャにindex_pathが欠落", "description": "設計書にindex_pathパラメータが記載されていない"}, + {"id": "M3", "title": "IssueDocumentEntryの定義場所の不一致", "description": "影響範囲テーブルが'or src/cli/issue.rs'と曖昧"}, + {"id": "M4", "title": "snippet_lines/snippet_charsの型がu64 vs usizeで不統一"} + ], + "should_fix": [ + {"id": "S1", "title": "issueのフォーマッタがcli/issue.rs内に閉じている点の明記不足"}, + {"id": "S2", "title": "SnippetConfigのデフォルト値の注入箇所が不明確"}, + {"id": "S3", "title": "enrich関数内でのtantivy reader オープンの責務分離の詳細不足"}, + {"id": "S4", "title": "before-change JSONのsnippetフィールド出力方針の詳細不足"} + ], + "nice_to_have": [ + {"id": "N1", "title": "symbol_store.rsの変更内容の不正確さ"}, + {"id": "N2", "title": "テストファイルの影響箇所の不足"}, + {"id": "N3", "title": "enrich関数にformat引数が必要な理由の説明追加"} + ] +} diff --git a/dev-reports/issue/168/multi-stage-design-review/stage3-review-context.json b/dev-reports/issue/168/multi-stage-design-review/stage3-review-context.json new file mode 100644 index 0000000..1c1b33e --- /dev/null +++ b/dev-reports/issue/168/multi-stage-design-review/stage3-review-context.json @@ -0,0 +1,21 @@ +{ + "stage": 3, + "focus": "影響分析", + "must_fix": [ + {"id": "M1", "title": "既存enrich関数とのSome(\"\") vs Noneの不整合"}, + {"id": "M2", "title": "IssueDocumentEntryへのsnippet追加で11箇所のテストリテラル修正が必要"}, + {"id": "M3", "title": "BeforeChangeFoundingへのsnippet追加で15箇所以上のリテラル修正が必要"} + ], + "should_fix": [ + {"id": "S1", "title": "issue JSONスキーマの条件分岐がbreaking changeリスク"}, + {"id": "S2", "title": "before-changeのtantivy reader オープンコスト(lazy方式で問題なし)"}, + {"id": "S3", "title": "issue::run()シグネチャ変更のmain.rs影響"}, + {"id": "S4", "title": "SnippetConfigのデフォルト値の二重定義リスク"} + ], + "nice_to_have": [ + {"id": "N1", "title": "help_llm.rsのコマンド説明更新"}, + {"id": "N2", "title": "output_format.rsにbefore-change/issueフォーマッタテスト追加"}, + {"id": "N3", "title": "search/impact/why/suggest等への波及なし確認済み"}, + {"id": "N4", "title": "cli_args.rsテストへの影響は軽微"} + ] +} diff --git a/dev-reports/issue/168/multi-stage-design-review/stage4-review-context.json b/dev-reports/issue/168/multi-stage-design-review/stage4-review-context.json new file mode 100644 index 0000000..7448074 --- /dev/null +++ b/dev-reports/issue/168/multi-stage-design-review/stage4-review-context.json @@ -0,0 +1,17 @@ +{ + "stage": 4, + "focus": "セキュリティ", + "must_fix": [ + {"id": "M1", "title": "snippet_lines/snippet_charsに上限が未設定", "description": "range(1..)のみで上限なし。極端な値でメモリ制御不能"} + ], + "should_fix": [ + {"id": "S1", "title": "既存enrich関数とのSome(\"\")禁止契約の不一致"}, + {"id": "S2", "title": "search_by_exact_path()の1000件固定上限はsnippet用途には過剰(低リスク)"}, + {"id": "S3", "title": "fetch_snippet()のエラー握り潰しにログ出力がない"} + ], + "nice_to_have": [ + {"id": "N1", "title": "strip_control_chars()がタブ文字も除去する"}, + {"id": "N2", "title": "既存テストのunsafe整理(別Issue)"}, + {"id": "N3", "title": "パス正規化の将来的な防御層追加"} + ] +} diff --git a/dev-reports/issue/168/multi-stage-design-review/stage5-review-context.json b/dev-reports/issue/168/multi-stage-design-review/stage5-review-context.json new file mode 100644 index 0000000..01197c8 --- /dev/null +++ b/dev-reports/issue/168/multi-stage-design-review/stage5-review-context.json @@ -0,0 +1,5 @@ +{ + "stage": 5, + "status": "skipped", + "reason": "Codex server error (commandmatedev exit code 99). Stage 1-4 の Claude opus レビューで Must Fix 10件を指摘・反映済みのため、2回目レビューはスキップして進行。" +} diff --git a/dev-reports/issue/168/multi-stage-design-review/summary-report.md b/dev-reports/issue/168/multi-stage-design-review/summary-report.md new file mode 100644 index 0000000..e2edc07 --- /dev/null +++ b/dev-reports/issue/168/multi-stage-design-review/summary-report.md @@ -0,0 +1,47 @@ +# Issue #168 マルチステージ設計レビュー サマリーレポート + +## 実施状況 + +| Stage | 種別 | 実行者 | Must Fix | Should Fix | Nice to Have | 状態 | +|-------|------|--------|----------|------------|--------------|------| +| 1 | 設計原則 | Claude (opus) | 2 | 4 | 3 | 完了・反映済 | +| 2 | 整合性 | Claude (opus) | 4 | 4 | 3 | 完了・反映済 | +| 3 | 影響分析 | Claude (opus) | 3 | 4 | 4 | 完了・反映済 | +| 4 | セキュリティ | Claude (opus) | 1 | 3 | 3 | 完了・反映済 | +| 5-8 | 2回目 | Codex | - | - | - | スキップ(サーバーエラー) | + +## 主要な指摘と反映結果 + +### 1. enrich関数の空文字列処理統一(全Stageで指摘) +- **問題**: 既存enrich関数はSome("")、新規はNone→契約分裂 +- **対応**: 既存関数も空→None変換に統一するリファクタリングを設計書に追記 + +### 2. snippet_lines/snippet_charsの上限設定(Stage 2,4) +- **問題**: range(1..)で上限なし→メモリ制御不能リスク +- **対応**: range(1..=100)/range(1..=10000)に変更 + +### 3. IssueDocumentEntry定義場所の確定(Stage 2) +- **問題**: 「knowledge.rs or issue.rs」と曖昧 +- **対応**: knowledge.rs L179に確定 + +### 4. SnippetConfigデフォルト値の注入箇所(Stage 1,2) +- **問題**: lines=3, chars=200のデフォルト値をどこで設定するか不明確 +- **対応**: 定数定義(KNOWLEDGE_SNIPPET_LINES/CHARS)+ unwrap_or パターンを明記 + +### 5. run_before_change()シグネチャ(Stage 2) +- **問題**: index_pathパラメータが設計書から欠落 +- **対応**: 既に含まれていることを確認(セクション8で正しく記載済み) + +## 設計品質評価 + +設計方針書は以下の点で適切: +- 既存パターン(enrich_*_with_snippets)への準拠 +- 後方互換性(--with-snippet デフォルトオフ) +- tantivy未存在時の非fatalフォールバック +- YAGNI(Phase 2 セクション優先抽出をスコープ外に分離) +- セキュリティ(unsafe不使用、CLI引数上限設定、出力サニタイズ) + +## 残存リスク + +- issue JSONの条件付きスキーマ(--with-snippet有無で型が変わる)はAPI消費者にとって扱いにくい可能性あり +- 4つのenrich関数のDRY改善(トレイトベース統合)は将来の検討課題 diff --git a/dev-reports/issue/168/pm-auto-dev/iteration-1/codex-review-result.json b/dev-reports/issue/168/pm-auto-dev/iteration-1/codex-review-result.json new file mode 100644 index 0000000..d095b5a --- /dev/null +++ b/dev-reports/issue/168/pm-auto-dev/iteration-1/codex-review-result.json @@ -0,0 +1,39 @@ +{ + "critical": [], + "warnings": [ + { + "file": "src/cli/issue.rs", + "line": 153, + "severity": "high", + "category": "bug", + "description": "Snippet reader is opened with `commandindex_dir` instead of the Tantivy index directory. `IndexReaderWrapper::open()` expects the `{commandindex_dir}/tantivy` path, so this branch will fail in normal setups and silently fall back to `snippet: None`. As a result, `issue --with-snippet` will not return snippets even when the index exists.", + "suggestion": "Open the reader with `crate::indexer::index_dir(commandindex_dir)` and add an integration test that verifies snippet retrieval succeeds when Tantivy data is present." + }, + { + "file": "src/cli/before_change.rs", + "line": 491, + "severity": "high", + "category": "bug", + "description": "The same Tantivy path bug exists in `before-change`: `IndexReaderWrapper::open(&commandindex_dir)` points at the `.commandindex` root, not `.commandindex/tantivy`. This causes snippet enrichment to fail and silently fall back to `None`, so `before-change --with-snippet` will not behave as intended.", + "suggestion": "Use `crate::indexer::index_dir(&commandindex_dir)` here as well, and add an E2E test that exercises `before-change --with-snippet` against a fixture with a real Tantivy index." + }, + { + "file": "src/output/human.rs", + "line": 382, + "severity": "low", + "category": "security", + "description": "Snippet lines are written to the terminal without `strip_control_chars()`. Today `fetch_snippet()` sanitizes Tantivy-derived snippets, but this formatter now trusts every `finding.snippet` source. If a future caller populates `snippet` from unsanitized input, terminal control sequences could be emitted.", + "suggestion": "Sanitize each snippet line before writing it, matching the existing handling for file paths, relations, titles, and the LLM formatter." + }, + { + "file": "src/cli/issue.rs", + "line": 231, + "severity": "low", + "category": "bug", + "description": "Production code uses `obj.as_object_mut().unwrap()` while building snippet-enabled JSON. It is currently safe because `obj` is created from an object literal immediately above, but it is still an avoidable panic point in a changed code path.", + "suggestion": "Replace the `unwrap()` with `if let Some(map) = obj.as_object_mut()` or construct the map directly to keep the formatter panic-free." + } + ], + "summary": "2件の高優先度バグがあります。どちらも snippet reader を `.commandindex/tantivy` ではなく `.commandindex` 直下で開いており、`issue --with-snippet` と `before-change --with-snippet` が実質的に常時フォールバックしてしまいます。加えて、human formatter の snippet 出力に制御文字サニタイズがなく、JSON生成コードに不要な `unwrap()` が残っています。unsafe、SQLインジェクション、コマンドインジェクション、パストラバーサルに該当する新規問題はこの差分からは見つかりませんでした。", + "requires_fix": true +} diff --git a/dev-reports/issue/168/pm-auto-dev/iteration-1/tdd-context.json b/dev-reports/issue/168/pm-auto-dev/iteration-1/tdd-context.json new file mode 100644 index 0000000..7a9ba9e --- /dev/null +++ b/dev-reports/issue/168/pm-auto-dev/iteration-1/tdd-context.json @@ -0,0 +1,15 @@ +{ + "issue_number": 168, + "title": "issue/before-changeの出力に判断理由のスニペットを付与する", + "design_policy": "dev-reports/design/issue-168-snippet-inline-design-policy.md", + "work_plan": "dev-reports/issue/168/work-plan.md", + "phases": [ + "Phase 1: 型定義変更(BeforeChangeFinding, IssueDocumentEntry に snippet 追加)", + "Phase 2: snippet_helper 拡張(既存enrich統一 + 新enrich関数2つ)", + "Phase 3: CLI引数追加(--with-snippet, --snippet-lines, --snippet-chars)", + "Phase 4: コマンド関数シグネチャ変更・enrich呼び出し", + "Phase 5: 出力フォーマッタ更新(human/llm/json)", + "Phase 6: テスト追加・修正", + "Phase 7: help-llm更新・品質チェック" + ] +} diff --git a/dev-reports/issue/168/pm-auto-dev/iteration-1/tdd-result.json b/dev-reports/issue/168/pm-auto-dev/iteration-1/tdd-result.json new file mode 100644 index 0000000..6249be8 --- /dev/null +++ b/dev-reports/issue/168/pm-auto-dev/iteration-1/tdd-result.json @@ -0,0 +1,108 @@ +{ + "issue_number": 168, + "iteration": 1, + "status": "success", + "branch": "feature/issue-168-snippet-inline", + "summary": "issue/before-change の出力に判断理由のスニペットを付与する機能を実装", + "phases_completed": [ + { + "phase": 1, + "description": "型定義変更: BeforeChangeFinding.snippet, IssueDocumentEntry.snippet 追加", + "files_changed": [ + "src/output/mod.rs", + "src/indexer/knowledge.rs", + "src/cli/before_change.rs", + "src/cli/issue.rs", + "src/indexer/symbol_store.rs" + ] + }, + { + "phase": 2, + "description": "snippet_helper 拡張: 既存enrich関数の空→None変換統一、enrich_before_change_with_snippets/enrich_issue_documents_with_snippets 追加", + "files_changed": ["src/cli/snippet_helper.rs"] + }, + { + "phase": 3, + "description": "CLI引数追加: before-change/issue に --with-snippet, --snippet-lines, --snippet-chars", + "files_changed": ["src/main.rs"] + }, + { + "phase": 4, + "description": "コマンド関数シグネチャ変更: run_before_change/issue::run に snippet_options 追加、enrich呼び出し", + "files_changed": [ + "src/cli/before_change.rs", + "src/cli/issue.rs", + "src/main.rs" + ] + }, + { + "phase": 5, + "description": "出力フォーマッタ更新: human/llm/json 各形式にスニペット表示追加", + "files_changed": [ + "src/output/human.rs", + "src/output/llm.rs", + "src/output/json.rs", + "src/cli/issue.rs" + ] + }, + { + "phase": 6, + "description": "テスト追加: before-change/issue のスニペットフォーマッタテスト、CLIオプション受理テスト", + "files_changed": [ + "src/cli/before_change.rs", + "src/cli/issue.rs", + "tests/cli_args.rs" + ] + }, + { + "phase": 7, + "description": "help-llm更新、品質チェック完了", + "files_changed": ["src/cli/help_llm.rs"] + } + ], + "quality_checks": { + "cargo_build": "OK (0 errors)", + "cargo_clippy": "OK (0 warnings with -D warnings)", + "cargo_fmt": "OK (no diff)", + "cargo_test_lib": "530 passed, 0 failed", + "cargo_test_cli_args": "88 passed, 0 failed", + "cargo_test_all": "All pass except pre-existing test_embed_without_ollama_fails (requires Ollama)" + }, + "new_tests_added": [ + "cli::before_change::tests::test_before_change_human_with_snippet", + "cli::before_change::tests::test_before_change_llm_with_snippet", + "cli::before_change::tests::test_before_change_json_with_snippet", + "cli::before_change::tests::test_before_change_json_without_snippet", + "cli::issue::tests::test_format_human_with_snippet", + "cli::issue::tests::test_format_llm_with_snippet", + "cli::issue::tests::test_format_json_with_snippet_enabled", + "cli::issue::tests::test_format_json_with_snippet_disabled_keeps_string_array", + "before_change_with_snippet_accepted", + "before_change_snippet_lines_zero_rejected", + "before_change_snippet_chars_zero_rejected", + "issue_with_snippet_accepted", + "issue_snippet_lines_zero_rejected", + "issue_snippet_chars_zero_rejected" + ], + "files_modified": [ + "src/output/mod.rs", + "src/indexer/knowledge.rs", + "src/indexer/symbol_store.rs", + "src/cli/snippet_helper.rs", + "src/cli/before_change.rs", + "src/cli/issue.rs", + "src/output/human.rs", + "src/output/llm.rs", + "src/output/json.rs", + "src/main.rs", + "src/cli/help_llm.rs", + "tests/cli_args.rs" + ], + "design_decisions": { + "snippet_contract": "Option with Some(non-empty) / None only. Some('') is forbidden.", + "default_values": "lines=3, chars=200 (via KNOWLEDGE_SNIPPET_LINES/KNOWLEDGE_SNIPPET_CHARS constants)", + "backward_compat": "issue JSON: --with-snippet未指定時はstring[]維持、指定時のみobject[]", + "fallback": "tantivy reader open失敗時はsnippet: Noneでフォールバック(エラーにしない)", + "existing_enrich_refactor": "enrich_impact_with_snippets/enrich_related_with_snippets も空→None変換に統一" + } +} diff --git a/dev-reports/issue/168/work-plan.md b/dev-reports/issue/168/work-plan.md new file mode 100644 index 0000000..334edb7 --- /dev/null +++ b/dev-reports/issue/168/work-plan.md @@ -0,0 +1,179 @@ +# 作業計画: Issue #168 - issue/before-changeの出力に判断理由のスニペットを付与する + +## Issue概要 +**Issue番号**: #168 +**タイトル**: issue/before-changeの出力に判断理由のスニペットを付与する +**サイズ**: L(型変更15箇所以上、フォーマッタ6関数更新、テスト30箇所以上) +**優先度**: High(プロダクトコア機能) +**依存Issue**: なし +**ブランチ**: `feature/issue-168-snippet-inline`(作成済み) + +## 詳細タスク分解 + +### Phase 1: 型定義・基盤変更 + +#### Task 1.1: BeforeChangeFinding に snippet フィールド追加 +- **成果物**: `src/output/mod.rs` +- **変更内容**: `pub snippet: Option` フィールド追加 +- **依存**: なし +- **影響**: before_change.rs 内のコンストラクタ5箇所 + テスト15箇所以上に `snippet: None` 追加が必要(コンパイルエラー解消) + +#### Task 1.2: IssueDocumentEntry に snippet フィールド追加 +- **成果物**: `src/indexer/knowledge.rs` +- **変更内容**: `pub snippet: Option` フィールド追加 +- **依存**: なし +- **影響**: symbol_store.rs の find_documents_by_issue() 1箇所、issue.rs テスト11箇所に `snippet: None` 追加 + +#### Task 1.3: コンパイルエラー解消(全構築箇所に snippet: None 追加) +- **成果物**: `src/cli/before_change.rs`, `src/cli/issue.rs`, `src/indexer/symbol_store.rs` +- **変更内容**: 全 BeforeChangeFinding / IssueDocumentEntry リテラルに `snippet: None` 追加 +- **依存**: Task 1.1, 1.2 +- **検証**: `cargo build` パス + +### Phase 2: snippet_helper 拡張 + +#### Task 2.1: 既存 enrich 関数の空→None 変換統一 +- **成果物**: `src/cli/snippet_helper.rs` +- **変更内容**: `enrich_impact_with_snippets()` と `enrich_related_with_snippets()` の `Some(fetch_snippet(...))` を `{ let s = fetch_snippet(...); if s.is_empty() { None } else { Some(s) } }` に変更 +- **依存**: なし +- **検証**: 既存テストがパスすること + +#### Task 2.2: enrich_before_change_with_snippets() 追加 +- **成果物**: `src/cli/snippet_helper.rs` +- **変更内容**: 設計書セクション6の関数を追加 +- **依存**: Task 1.1 + +#### Task 2.3: enrich_issue_documents_with_snippets() 追加 +- **成果物**: `src/cli/snippet_helper.rs` +- **変更内容**: 設計書セクション6の関数を追加 +- **依存**: Task 1.2 + +### Phase 3: CLI引数追加 + +#### Task 3.1: before-change に --with-snippet / --snippet-lines / --snippet-chars 追加 +- **成果物**: `src/main.rs` +- **変更内容**: BeforeChange enum に3フィールド追加、SnippetOptions 構築ロジック追加 +- **依存**: Task 2.2 +- **パターン**: impact コマンドの既存パターンに準拠 +- **定数**: `KNOWLEDGE_SNIPPET_LINES = 3`, `KNOWLEDGE_SNIPPET_CHARS = 200` + +#### Task 3.2: issue に --with-snippet / --snippet-lines / --snippet-chars 追加 +- **成果物**: `src/main.rs` +- **変更内容**: Issue enum に3フィールド追加、SnippetOptions 構築ロジック追加 +- **依存**: Task 2.3 +- **パターン**: Task 3.1 と同じ + +### Phase 4: コマンド関数シグネチャ変更・enrich 呼び出し + +#### Task 4.1: run_before_change() に snippet_options 追加 +- **成果物**: `src/cli/before_change.rs`, `src/main.rs` +- **変更内容**: + 1. run_before_change() シグネチャに `snippet_options: SnippetOptions` 追加 + 2. group_and_limit_by_issue() 後に IndexReaderWrapper::open → enrich 呼び出し + 3. main.rs のコール箇所を更新 +- **依存**: Task 3.1 + +#### Task 4.2: issue::run() に snippet_options 追加 +- **成果物**: `src/cli/issue.rs`, `src/main.rs` +- **変更内容**: + 1. run() シグネチャに `snippet_options: SnippetOptions` 追加 + 2. documents 取得後に IndexReaderWrapper::open → enrich 呼び出し + 3. main.rs のコール箇所を更新 +- **依存**: Task 3.2 + +### Phase 5: 出力フォーマッタ更新 + +#### Task 5.1: before-change human フォーマッタ +- **成果物**: `src/output/human.rs` +- **変更内容**: format_before_change_human() で doc_title 後に snippet 表示追加 +- **依存**: Task 1.1 +- **パターン**: impact の snippet 表示パターン(`if let Some(ref snippet) = finding.snippet && !snippet.is_empty()`) + +#### Task 5.2: before-change llm フォーマッタ +- **成果物**: `src/output/llm.rs` +- **変更内容**: format_before_change_llm() で title_str 後に `> snippet` 表示追加 +- **依存**: Task 1.1 + +#### Task 5.3: before-change json フォーマッタ +- **成果物**: `src/output/json.rs` +- **変更内容**: format_before_change_json() で snippet フィールドを条件付き追加(impact と同パターン) +- **依存**: Task 1.1 + +#### Task 5.4: issue human/llm/json/path フォーマッタ +- **成果物**: `src/cli/issue.rs` +- **変更内容**: + 1. format_human(): snippet 表示追加 + 2. format_llm(): `> snippet` 表示追加 + 3. format_json(): --with-snippet 有無で string[] / object[] を分岐(SnippetOptions を引数に追加) + 4. format_path(): 変更なし +- **依存**: Task 1.2, 4.2 + +### Phase 6: テスト + +#### Task 6.1: 既存テスト修正確認 +- **成果物**: 全テストファイル +- **変更内容**: `cargo test --all` で全テストパスを確認 +- **依存**: Phase 1-5 全て + +#### Task 6.2: before-change フォーマッタ snippet テスト追加 +- **成果物**: `src/cli/before_change.rs` (mod tests) または `tests/output_format.rs` +- **テストケース**: + 1. snippet=None の場合: 既存出力と同じ + 2. snippet=Some("text") の場合: human/llm/json で正しく表示 + +#### Task 6.3: issue フォーマッタ snippet テスト追加 +- **成果物**: `src/cli/issue.rs` (mod tests) +- **テストケース**: + 1. snippet=None の場合: 既存出力と同じ + 2. snippet=Some("text") の場合: human/llm で正しく表示 + 3. format_json: --with-snippet 未指定で string[]、指定で object[] + +#### Task 6.4: CLI引数テスト追加 +- **成果物**: `tests/cli_args.rs` +- **テストケース**: `before-change src/auth.rs --with-snippet --snippet-lines 3 --snippet-chars 200` が受理されること + +### Phase 7: ドキュメント・仕上げ + +#### Task 7.1: help-llm 更新 +- **成果物**: `src/cli/help_llm.rs` +- **変更内容**: issue/before-change のコマンド説明に --with-snippet 等のオプションと出力例を追加 + +#### Task 7.2: 品質チェック +- `cargo build` → エラー0件 +- `cargo clippy --all-targets -- -D warnings` → 警告0件 +- `cargo test --all` → 全テストパス +- `cargo fmt --all -- --check` → 差分なし + +## 実行順序 + +``` +Phase 1 (型定義) + Task 1.1 → Task 1.2 → Task 1.3 → cargo build 確認 + ↓ +Phase 2 (snippet_helper) + Task 2.1 (並行可) | Task 2.2 | Task 2.3 + ↓ +Phase 3 (CLI引数) + Task 3.1 | Task 3.2 + ↓ +Phase 4 (コマンド関数) + Task 4.1 | Task 4.2 + ↓ +Phase 5 (フォーマッタ) + Task 5.1 | 5.2 | 5.3 | 5.4 + ↓ +Phase 6 (テスト) + Task 6.1 → 6.2 | 6.3 | 6.4 + ↓ +Phase 7 (仕上げ) + Task 7.1 → 7.2 +``` + +## Definition of Done + +- [ ] すべてのタスクが完了 +- [ ] `cargo build` エラー0件 +- [ ] `cargo clippy --all-targets -- -D warnings` 警告0件 +- [ ] `cargo test --all` 全テストパス +- [ ] `cargo fmt --all -- --check` 差分なし +- [ ] Issue の受け入れ基準14項目すべて満たす diff --git a/src/cli/before_change.rs b/src/cli/before_change.rs index e9b579a..5b9594d 100644 --- a/src/cli/before_change.rs +++ b/src/cli/before_change.rs @@ -232,6 +232,7 @@ fn rank_by_max_similarity( doc_path: doc.file_path.clone(), doc_title: doc.title.clone(), similarity: None, + snippet: None, }); continue; } @@ -244,6 +245,7 @@ fn rank_by_max_similarity( doc_path: doc.file_path.clone(), doc_title: doc.title.clone(), similarity: None, + snippet: None, }); continue; } @@ -264,6 +266,7 @@ fn rank_by_max_similarity( doc_path: doc.file_path.clone(), doc_title: doc.title.clone(), similarity: Some(max_sim), + snippet: None, }); } @@ -312,6 +315,7 @@ fn findings_without_ranking(docs: &[KnowledgeDocResult]) -> Vec, limit: usize, max_commits: usize, + snippet_options: crate::cli::snippet_helper::SnippetOptions, ) -> Result<(), BeforeChangeError> { // 1. Input validation validate_before_change_input(file)?; @@ -479,7 +484,22 @@ pub fn run_before_change( }; // 7. Apply limit (Issue-level) - let limited_findings = group_and_limit_by_issue(findings, limit); + let mut limited_findings = group_and_limit_by_issue(findings, limit); + + // 7.5. Enrich with snippets (optional) + if snippet_options.enabled + && let Ok(reader) = crate::indexer::reader::IndexReaderWrapper::open( + &crate::indexer::index_dir(&commandindex_dir), + ) + { + crate::cli::snippet_helper::enrich_before_change_with_snippets( + &mut limited_findings, + &reader, + &snippet_options, + format, + ); + } + // reader open failure → snippet: None (fallback) // 8. Compute displayed_issues let displayed_issues = { @@ -688,6 +708,7 @@ mod tests { doc_path: "d100.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, BeforeChangeFinding { issue_number: "100".to_string(), @@ -695,6 +716,7 @@ mod tests { doc_path: "w100.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, BeforeChangeFinding { issue_number: "100".to_string(), @@ -702,6 +724,7 @@ mod tests { doc_path: "r100.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, BeforeChangeFinding { issue_number: "200".to_string(), @@ -709,6 +732,7 @@ mod tests { doc_path: "d200.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, BeforeChangeFinding { issue_number: "300".to_string(), @@ -716,6 +740,7 @@ mod tests { doc_path: "d300.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, ]; @@ -739,6 +764,7 @@ mod tests { doc_path: "r100.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, BeforeChangeFinding { issue_number: "100".to_string(), @@ -746,6 +772,7 @@ mod tests { doc_path: "d100.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, BeforeChangeFinding { issue_number: "100".to_string(), @@ -753,6 +780,7 @@ mod tests { doc_path: "w100.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, BeforeChangeFinding { issue_number: "100".to_string(), @@ -760,6 +788,7 @@ mod tests { doc_path: "m100.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, ]; @@ -780,6 +809,7 @@ mod tests { doc_path: "d200.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, BeforeChangeFinding { issue_number: "100".to_string(), @@ -787,6 +817,7 @@ mod tests { doc_path: "d100.md".to_string(), doc_title: None, similarity: None, + snippet: None, }, ]; @@ -796,4 +827,100 @@ mod tests { assert_eq!(result[0].issue_number, "200"); assert_eq!(result[1].issue_number, "100"); } + + // --- Snippet output tests --- + + #[test] + fn test_before_change_human_with_snippet() { + let result = BeforeChangeResult { + file_path: "src/main.rs".to_string(), + findings: vec![BeforeChangeFinding { + issue_number: "100".to_string(), + relation: "has_design".to_string(), + doc_path: "design.md".to_string(), + doc_title: Some("Design Title".to_string()), + similarity: Some(0.85), + snippet: Some("test snippet content".to_string()), + }], + total_issues: 1, + displayed_issues: 1, + has_embeddings: true, + }; + let mut buf = Vec::new(); + crate::output::format_before_change_results(&result, OutputFormat::Human, &mut buf) + .unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("design.md")); + assert!(output.contains("> test snippet content")); + } + + #[test] + fn test_before_change_llm_with_snippet() { + let result = BeforeChangeResult { + file_path: "src/main.rs".to_string(), + findings: vec![BeforeChangeFinding { + issue_number: "100".to_string(), + relation: "has_design".to_string(), + doc_path: "design.md".to_string(), + doc_title: None, + similarity: None, + snippet: Some("test snippet content".to_string()), + }], + total_issues: 1, + displayed_issues: 1, + has_embeddings: false, + }; + let mut buf = Vec::new(); + crate::output::format_before_change_results(&result, OutputFormat::Llm, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("design.md")); + assert!(output.contains("> test snippet content")); + } + + #[test] + fn test_before_change_json_with_snippet() { + let result = BeforeChangeResult { + file_path: "src/main.rs".to_string(), + findings: vec![BeforeChangeFinding { + issue_number: "100".to_string(), + relation: "has_design".to_string(), + doc_path: "design.md".to_string(), + doc_title: None, + similarity: Some(0.85), + snippet: Some("test snippet".to_string()), + }], + total_issues: 1, + displayed_issues: 1, + has_embeddings: true, + }; + let mut buf = Vec::new(); + crate::output::format_before_change_results(&result, OutputFormat::Json, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(parsed["findings"][0]["snippet"], "test snippet"); + } + + #[test] + fn test_before_change_json_without_snippet() { + let result = BeforeChangeResult { + file_path: "src/main.rs".to_string(), + findings: vec![BeforeChangeFinding { + issue_number: "100".to_string(), + relation: "has_design".to_string(), + doc_path: "design.md".to_string(), + doc_title: None, + similarity: None, + snippet: None, + }], + total_issues: 1, + displayed_issues: 1, + has_embeddings: false, + }; + let mut buf = Vec::new(); + crate::output::format_before_change_results(&result, OutputFormat::Json, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + // snippet field should not be present when None + assert!(parsed["findings"][0].get("snippet").is_none()); + } } diff --git a/src/cli/help_llm.rs b/src/cli/help_llm.rs index 596811e..c9e4b2b 100644 --- a/src/cli/help_llm.rs +++ b/src/cli/help_llm.rs @@ -548,7 +548,14 @@ fn build_commands() -> Vec { prerequisites: Some("commandindexdev index".to_string()), modes: None, conflicts: None, - key_options: Some(vec!["--format", "--index-path", "--limit Maximum number of issues to show (default: 10)", "--max-commits"]), + key_options: Some(vec![ + "--format", "--index-path", + "--limit Maximum number of issues to show (default: 10)", + "--max-commits", + "--with-snippet Include document snippets in output", + "--snippet-lines Number of snippet lines (default: 3, range: 1-100)", + "--snippet-chars Number of snippet characters (default: 200, range: 1-10000)", + ]), output_formats: Some(vec!["human", "json", "llm", "path"]), output: Some("List of related design documents, review findings, and work plans"), input: Some("Single file path as argument"), @@ -558,6 +565,7 @@ fn build_commands() -> Vec { "commandindexdev before-change src/auth.rs", "commandindexdev before-change src/auth.rs --format json", "commandindexdev before-change src/auth.rs --format llm --limit 5", + "commandindexdev before-change src/auth.rs --with-snippet --snippet-lines 3", ], }, CommandInfo { @@ -591,6 +599,9 @@ fn build_commands() -> Vec { key_options: Some(vec![ " Issue number (required, positive integer)", "--format Output format: human, json, path, llm", + "--with-snippet Include document snippets in output", + "--snippet-lines Number of snippet lines (default: 3, range: 1-100)", + "--snippet-chars Number of snippet characters (default: 200, range: 1-10000)", ]), output_formats: Some(vec!["human", "json", "path", "llm"]), output: Some("List of related documents grouped by category"), @@ -601,6 +612,7 @@ fn build_commands() -> Vec { "commandindexdev issue 140", "commandindexdev issue 140 --format json", "commandindexdev issue 140 --format path", + "commandindexdev issue 140 --with-snippet --format json", ], }, CommandInfo { diff --git a/src/cli/issue.rs b/src/cli/issue.rs index 4312375..643627e 100644 --- a/src/cli/issue.rs +++ b/src/cli/issue.rs @@ -121,6 +121,7 @@ pub fn run( issue_number: u64, format: OutputFormat, commandindex_dir: &Path, + snippet_options: crate::cli::snippet_helper::SnippetOptions, ) -> Result<(), IssueCommandError> { // Check symbols.db exists let symbol_db = crate::indexer::symbol_db_path(commandindex_dir); @@ -147,6 +148,21 @@ pub fn run( // Sort by relation + subtype order documents.sort_by_key(sort_order); + // Enrich with snippets (optional) + if snippet_options.enabled + && let Ok(reader) = crate::indexer::reader::IndexReaderWrapper::open( + &crate::indexer::index_dir(commandindex_dir), + ) + { + crate::cli::snippet_helper::enrich_issue_documents_with_snippets( + &mut documents, + &reader, + &snippet_options, + format, + ); + } + // reader open failure → snippet: None (fallback) + let result = IssueDocumentsResult { issue_number: format!("{issue_number}"), documents, @@ -154,7 +170,7 @@ pub fn run( let stdout = std::io::stdout(); let mut writer = stdout.lock(); - format_issue_documents(&result, format, &mut writer)?; + format_issue_documents(&result, format, &mut writer, &snippet_options)?; Ok(()) } @@ -166,10 +182,11 @@ fn format_issue_documents( result: &IssueDocumentsResult, format: OutputFormat, writer: &mut dyn Write, + snippet_options: &crate::cli::snippet_helper::SnippetOptions, ) -> Result<(), OutputError> { match format { OutputFormat::Human => format_human(result, writer), - OutputFormat::Json => format_json(result, writer), + OutputFormat::Json => format_json(result, writer, snippet_options), OutputFormat::Llm => format_llm(result, writer), OutputFormat::Path => format_path(result, writer), } @@ -185,26 +202,58 @@ fn format_human(result: &IssueDocumentsResult, writer: &mut dyn Write) -> Result writeln!(writer, "\n {category}:")?; for doc in docs { writeln!(writer, " {}", strip_control_chars(&doc.file_path))?; + if let Some(ref snippet) = doc.snippet { + for line in snippet.lines() { + writeln!(writer, " > {}", strip_control_chars(line))?; + } + } } } Ok(()) } -fn format_json(result: &IssueDocumentsResult, writer: &mut dyn Write) -> Result<(), OutputError> { +fn format_json( + result: &IssueDocumentsResult, + writer: &mut dyn Write, + snippet_options: &crate::cli::snippet_helper::SnippetOptions, +) -> Result<(), OutputError> { // Build grouped JSON structure let grouped = result.grouped(); let mut categories = serde_json::Map::new(); for (category, docs) in &grouped { - let paths: Vec<&str> = docs.iter().map(|d| d.file_path.as_str()).collect(); - categories.insert( - (*category).to_string(), - serde_json::Value::Array( - paths - .into_iter() - .map(|p| serde_json::Value::String(p.to_string())) - .collect(), - ), - ); + if snippet_options.enabled { + // --with-snippet: object array with file_path + snippet + let items: Vec = docs + .iter() + .map(|d| { + let mut obj = serde_json::json!({ + "file_path": d.file_path, + }); + if let Some(ref snippet) = d.snippet + && let Some(map) = obj.as_object_mut() + { + map.insert( + "snippet".to_string(), + serde_json::Value::String(snippet.clone()), + ); + } + obj + }) + .collect(); + categories.insert((*category).to_string(), serde_json::Value::Array(items)); + } else { + // Default: string array (backward compatible) + let paths: Vec<&str> = docs.iter().map(|d| d.file_path.as_str()).collect(); + categories.insert( + (*category).to_string(), + serde_json::Value::Array( + paths + .into_iter() + .map(|p| serde_json::Value::String(p.to_string())) + .collect(), + ), + ); + } } let output = serde_json::json!({ "issue_number": result.issue_number, @@ -225,6 +274,9 @@ fn format_llm(result: &IssueDocumentsResult, writer: &mut dyn Write) -> Result<( writeln!(writer, "\n## {category}")?; for doc in docs { writeln!(writer, "- {}", strip_control_chars(&doc.file_path))?; + if let Some(ref snippet) = doc.snippet { + writeln!(writer, " > {}", strip_control_chars(snippet))?; + } } } Ok(()) @@ -261,21 +313,25 @@ mod tests { file_path: "a.md".to_string(), relation: KnowledgeRelation::HasDesign, doc_subtype: DocSubtype::DesignPolicy, + snippet: None, }; let review = IssueDocumentEntry { file_path: "b.md".to_string(), relation: KnowledgeRelation::HasReview, doc_subtype: DocSubtype::IssueReview, + snippet: None, }; let workplan = IssueDocumentEntry { file_path: "c.md".to_string(), relation: KnowledgeRelation::HasWorkplan, doc_subtype: DocSubtype::WorkPlan, + snippet: None, }; let stage_review = IssueDocumentEntry { file_path: "d.md".to_string(), relation: KnowledgeRelation::HasReview, doc_subtype: DocSubtype::StageReview, + snippet: None, }; assert!(sort_order(&design) < sort_order(&review)); assert!(sort_order(&review) < sort_order(&workplan)); @@ -291,21 +347,25 @@ mod tests { file_path: "design.md".to_string(), relation: KnowledgeRelation::HasDesign, doc_subtype: DocSubtype::DesignPolicy, + snippet: None, }, IssueDocumentEntry { file_path: "review.md".to_string(), relation: KnowledgeRelation::HasReview, doc_subtype: DocSubtype::IssueReview, + snippet: None, }, IssueDocumentEntry { file_path: "progress.md".to_string(), relation: KnowledgeRelation::HasProgress, doc_subtype: DocSubtype::ProgressReport, + snippet: None, }, IssueDocumentEntry { file_path: "stage-review.md".to_string(), relation: KnowledgeRelation::HasReview, doc_subtype: DocSubtype::StageReview, + snippet: None, }, ], }; @@ -337,11 +397,13 @@ mod tests { file_path: "design.md".to_string(), relation: KnowledgeRelation::HasDesign, doc_subtype: DocSubtype::DesignPolicy, + snippet: None, }, IssueDocumentEntry { file_path: "work-plan.md".to_string(), relation: KnowledgeRelation::HasWorkplan, doc_subtype: DocSubtype::WorkPlan, + snippet: None, }, ], }; @@ -363,14 +425,18 @@ mod tests { file_path: "design.md".to_string(), relation: KnowledgeRelation::HasDesign, doc_subtype: DocSubtype::DesignPolicy, + snippet: None, }], }; + let no_snippet = crate::cli::snippet_helper::SnippetOptions::default(); let mut buf = Vec::new(); - format_json(&result, &mut buf).unwrap(); + format_json(&result, &mut buf, &no_snippet).unwrap(); let output = String::from_utf8(buf).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); assert_eq!(parsed["issue_number"], "140"); + // Without --with-snippet: string array assert!(parsed["documents"]["設計"].is_array()); + assert!(parsed["documents"]["設計"][0].is_string()); } #[test] @@ -381,6 +447,7 @@ mod tests { file_path: "design.md".to_string(), relation: KnowledgeRelation::HasDesign, doc_subtype: DocSubtype::DesignPolicy, + snippet: None, }], }; let mut buf = Vec::new(); @@ -400,11 +467,13 @@ mod tests { file_path: "a.md".to_string(), relation: KnowledgeRelation::HasDesign, doc_subtype: DocSubtype::DesignPolicy, + snippet: None, }, IssueDocumentEntry { file_path: "b.md".to_string(), relation: KnowledgeRelation::HasWorkplan, doc_subtype: DocSubtype::WorkPlan, + snippet: None, }, ], }; @@ -416,4 +485,89 @@ mod tests { assert_eq!(lines[0], "a.md"); assert_eq!(lines[1], "b.md"); } + + // --- Snippet tests --- + + #[test] + fn test_format_human_with_snippet() { + let result = IssueDocumentsResult { + issue_number: "140".to_string(), + documents: vec![IssueDocumentEntry { + file_path: "design.md".to_string(), + relation: KnowledgeRelation::HasDesign, + doc_subtype: DocSubtype::DesignPolicy, + snippet: Some("test snippet content".to_string()), + }], + }; + let mut buf = Vec::new(); + format_human(&result, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("design.md")); + assert!(output.contains("> test snippet content")); + } + + #[test] + fn test_format_llm_with_snippet() { + let result = IssueDocumentsResult { + issue_number: "140".to_string(), + documents: vec![IssueDocumentEntry { + file_path: "design.md".to_string(), + relation: KnowledgeRelation::HasDesign, + doc_subtype: DocSubtype::DesignPolicy, + snippet: Some("test snippet content".to_string()), + }], + }; + let mut buf = Vec::new(); + format_llm(&result, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("- design.md")); + assert!(output.contains("> test snippet content")); + } + + #[test] + fn test_format_json_with_snippet_enabled() { + let result = IssueDocumentsResult { + issue_number: "140".to_string(), + documents: vec![IssueDocumentEntry { + file_path: "design.md".to_string(), + relation: KnowledgeRelation::HasDesign, + doc_subtype: DocSubtype::DesignPolicy, + snippet: Some("test snippet".to_string()), + }], + }; + let with_snippet = crate::cli::snippet_helper::SnippetOptions { + enabled: true, + config: crate::output::SnippetConfig::default(), + }; + let mut buf = Vec::new(); + format_json(&result, &mut buf, &with_snippet).unwrap(); + let output = String::from_utf8(buf).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + // With --with-snippet: object array + let doc = &parsed["documents"]["設計"][0]; + assert!(doc.is_object()); + assert_eq!(doc["file_path"], "design.md"); + assert_eq!(doc["snippet"], "test snippet"); + } + + #[test] + fn test_format_json_with_snippet_disabled_keeps_string_array() { + let result = IssueDocumentsResult { + issue_number: "140".to_string(), + documents: vec![IssueDocumentEntry { + file_path: "design.md".to_string(), + relation: KnowledgeRelation::HasDesign, + doc_subtype: DocSubtype::DesignPolicy, + snippet: Some("test snippet".to_string()), + }], + }; + let no_snippet = crate::cli::snippet_helper::SnippetOptions::default(); + let mut buf = Vec::new(); + format_json(&result, &mut buf, &no_snippet).unwrap(); + let output = String::from_utf8(buf).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + // Without --with-snippet: string array (backward compat) + assert!(parsed["documents"]["設計"][0].is_string()); + assert_eq!(parsed["documents"]["設計"][0], "design.md"); + } } diff --git a/src/cli/snippet_helper.rs b/src/cli/snippet_helper.rs index 18d52e2..6e51c49 100644 --- a/src/cli/snippet_helper.rs +++ b/src/cli/snippet_helper.rs @@ -52,11 +52,8 @@ pub(crate) fn enrich_impact_with_snippets( return; } for file in results.iter_mut() { - file.snippet = Some(fetch_snippet( - reader, - &file.file_path, - snippet_options.config, - )); + let s = fetch_snippet(reader, &file.file_path, snippet_options.config); + file.snippet = if s.is_empty() { None } else { Some(s) }; } } @@ -71,10 +68,47 @@ pub(crate) fn enrich_related_with_snippets( return; } for result in results.iter_mut() { - result.snippet = Some(fetch_snippet( - reader, - &result.file_path, - snippet_options.config, - )); + let s = fetch_snippet(reader, &result.file_path, snippet_options.config); + result.snippet = if s.is_empty() { None } else { Some(s) }; + } +} + +/// BeforeChangeFinding のスニペットを一括付与する。 +pub(crate) fn enrich_before_change_with_snippets( + findings: &mut [crate::output::BeforeChangeFinding], + reader: &IndexReaderWrapper, + snippet_options: &SnippetOptions, + format: crate::output::OutputFormat, +) { + if !snippet_options.enabled || matches!(format, crate::output::OutputFormat::Path) { + return; + } + for finding in findings.iter_mut() { + let snippet = fetch_snippet(reader, &finding.doc_path, snippet_options.config); + finding.snippet = if snippet.is_empty() { + None + } else { + Some(snippet) + }; + } +} + +/// IssueDocumentEntry のスニペットを一括付与する。 +pub(crate) fn enrich_issue_documents_with_snippets( + documents: &mut [crate::indexer::knowledge::IssueDocumentEntry], + reader: &IndexReaderWrapper, + snippet_options: &SnippetOptions, + format: crate::output::OutputFormat, +) { + if !snippet_options.enabled || matches!(format, crate::output::OutputFormat::Path) { + return; + } + for doc in documents.iter_mut() { + let snippet = fetch_snippet(reader, &doc.file_path, snippet_options.config); + doc.snippet = if snippet.is_empty() { + None + } else { + Some(snippet) + }; } } diff --git a/src/indexer/knowledge.rs b/src/indexer/knowledge.rs index 4c4f22c..e4e7805 100644 --- a/src/indexer/knowledge.rs +++ b/src/indexer/knowledge.rs @@ -180,6 +180,7 @@ pub struct IssueDocumentEntry { pub file_path: String, pub relation: KnowledgeRelation, pub doc_subtype: DocSubtype, + pub snippet: Option, } /// search --related の戻り値用構造体 diff --git a/src/indexer/symbol_store.rs b/src/indexer/symbol_store.rs index f344031..88b4a61 100644 --- a/src/indexer/symbol_store.rs +++ b/src/indexer/symbol_store.rs @@ -910,6 +910,7 @@ impl SymbolStore { file_path, relation, doc_subtype, + snippet: None, }); } diff --git a/src/main.rs b/src/main.rs index fa2de3c..24651be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -265,6 +265,18 @@ enum Commands { /// Maximum git log commits to scan (upper limit: 10000) #[arg(long, default_value = "200", value_parser = clap::value_parser!(u64).range(1..=10000))] max_commits: u64, + + /// Enable snippet output for findings + #[arg(long)] + with_snippet: bool, + + /// Number of snippet lines (default: 3) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=100))] + snippet_lines: Option, + + /// Number of snippet characters for single-line body (default: 200) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=10000))] + snippet_chars: Option, }, /// Show structured JSON help for LLM integration #[command(name = "help-llm")] @@ -299,6 +311,18 @@ enum Commands { /// Output format (human, json, path, llm) #[arg(long, value_enum, default_value_t = commandindex::output::OutputFormat::Human)] format: commandindex::output::OutputFormat, + + /// Enable snippet output for documents + #[arg(long)] + with_snippet: bool, + + /// Number of snippet lines (default: 3) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=100))] + snippet_lines: Option, + + /// Number of snippet characters for single-line body (default: 200) + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=10000))] + snippet_chars: Option, }, /// Watch for file changes and auto-update index (daemon mode) #[command(after_help = commandindex::cli::watch::WATCH_AFTER_HELP)] @@ -323,6 +347,11 @@ enum ConfigCommands { Path, } +/// Default snippet lines for knowledge commands (issue, before-change) +const KNOWLEDGE_SNIPPET_LINES: usize = 3; +/// Default snippet chars for knowledge commands (issue, before-change) +const KNOWLEDGE_SNIPPET_CHARS: usize = 200; + /// Resolve commandindex_dir from CLI --index-path, config, and base_path. /// Returns (commandindex_dir, config) pair. fn resolve_commandindex_dir( @@ -964,13 +993,28 @@ fn main() { index_path, limit, max_commits, + with_snippet, + snippet_lines, + snippet_chars, } => { + let bc_snippet_options = commandindex::cli::snippet_helper::SnippetOptions { + enabled: with_snippet, + config: commandindex::output::SnippetConfig { + lines: snippet_lines + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .unwrap_or(KNOWLEDGE_SNIPPET_LINES), + chars: snippet_chars + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .unwrap_or(KNOWLEDGE_SNIPPET_CHARS), + }, + }; match commandindex::cli::before_change::run_before_change( &file, format, index_path.as_deref().or(cli.index_path.as_deref()), limit as usize, max_commits as usize, + bc_snippet_options, ) { Ok(()) => 0, Err(e) => { @@ -979,7 +1023,13 @@ fn main() { } } } - Commands::Issue { number, format } => { + Commands::Issue { + number, + format, + with_snippet, + snippet_lines, + snippet_chars, + } => { let base_path = std::path::Path::new("."); let (commandindex_dir, _config) = match resolve_commandindex_dir(cli.index_path.as_deref(), base_path) { @@ -989,7 +1039,23 @@ fn main() { process::exit(1); } }; - match commandindex::cli::issue::run(number, format, &commandindex_dir) { + let issue_snippet_options = commandindex::cli::snippet_helper::SnippetOptions { + enabled: with_snippet, + config: commandindex::output::SnippetConfig { + lines: snippet_lines + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .unwrap_or(KNOWLEDGE_SNIPPET_LINES), + chars: snippet_chars + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .unwrap_or(KNOWLEDGE_SNIPPET_CHARS), + }, + }; + match commandindex::cli::issue::run( + number, + format, + &commandindex_dir, + issue_snippet_options, + ) { Ok(()) => 0, Err(e) => { eprintln!("Error: {e}"); diff --git a/src/output/human.rs b/src/output/human.rs index a1c136c..4d3cb0c 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -377,6 +377,11 @@ pub fn format_before_change_human( let title_cleaned = strip_control_chars(title); writeln!(writer, " {}", title_cleaned.dimmed())?; } + if let Some(ref snippet) = finding.snippet { + for line in snippet.lines() { + writeln!(writer, " > {}", strip_control_chars(line).dimmed())?; + } + } } Ok(()) } diff --git a/src/output/json.rs b/src/output/json.rs index fec54fa..231a824 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -175,6 +175,11 @@ pub fn format_before_change_json( { o.insert("similarity".to_string(), serde_json::json!(sim)); } + if let Some(ref snippet) = f.snippet + && let Some(o) = obj.as_object_mut() + { + o.insert("snippet".to_string(), serde_json::Value::String(snippet.clone())); + } obj }).collect::>(), }); diff --git a/src/output/llm.rs b/src/output/llm.rs index f0cd342..91ad1ee 100644 --- a/src/output/llm.rs +++ b/src/output/llm.rs @@ -394,6 +394,9 @@ pub fn format_before_change_llm( writer, "- {doc_path}{sim_str} (#{issue}, {relation}){title_str}" )?; + if let Some(ref snippet) = finding.snippet { + writeln!(writer, " > {}", strip_control_chars(snippet))?; + } } Ok(()) } diff --git a/src/output/mod.rs b/src/output/mod.rs index 3513618..4b53e8c 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -322,6 +322,7 @@ pub struct BeforeChangeFinding { pub doc_path: String, pub doc_title: Option, pub similarity: Option, + pub snippet: Option, } /// before-change 結果を指定フォーマットで出力する diff --git a/tests/cli_args.rs b/tests/cli_args.rs index 435ddfc..469a772 100644 --- a/tests/cli_args.rs +++ b/tests/cli_args.rs @@ -1001,3 +1001,77 @@ fn issue_accepts_format_json() { .assert() .failure(); } + +// --- before-change --with-snippet tests --- + +#[test] +fn before_change_with_snippet_accepted() { + common::cmd() + .args([ + "before-change", + "src/main.rs", + "--with-snippet", + "--snippet-lines", + "3", + "--snippet-chars", + "200", + ]) + .assert() + .failure(); // fails because not a git repo, but clap accepts +} + +#[test] +fn before_change_snippet_lines_zero_rejected() { + common::cmd() + .args(["before-change", "src/main.rs", "--snippet-lines", "0"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn before_change_snippet_chars_zero_rejected() { + common::cmd() + .args(["before-change", "src/main.rs", "--snippet-chars", "0"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +// --- issue --with-snippet tests --- + +#[test] +fn issue_with_snippet_accepted() { + let tmp = tempfile::tempdir().expect("create temp dir"); + common::cmd() + .current_dir(tmp.path()) + .args([ + "issue", + "140", + "--with-snippet", + "--snippet-lines", + "3", + "--snippet-chars", + "200", + ]) + .assert() + .failure(); // fails because no DB, but clap accepts +} + +#[test] +fn issue_snippet_lines_zero_rejected() { + common::cmd() + .args(["issue", "140", "--snippet-lines", "0"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn issue_snippet_chars_zero_rejected() { + common::cmd() + .args(["issue", "140", "--snippet-chars", "0"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +}