diff --git a/docs/knowledge/investigations.md b/docs/knowledge/investigations.md index fcfd1e2d..1bb801aa 100644 --- a/docs/knowledge/investigations.md +++ b/docs/knowledge/investigations.md @@ -67,6 +67,26 @@ PR #72 で `--fallback-env` を導入し、PR #74 で stale trailer retry と bo ## Resolved Investigations +### PR #76 deletion-only AI Ratio が 0% になる + +- 対象 PR: `#76` +- 対象 commit: `0bc0e77 fix(dashboard): remove preview badge` +- 観測結果: Prompt selection と file attribution は正しく、`packages/dashboard/src/pages/index.astro` は `by_ai: true` でした。一方、commit は `Preview` badge の 1 行削除のみだったため `attribution.lines` は `{ "ai_added": 0, "total_added": 0, "deleted": 1 }` になり、PR Report では `AI Ratio: 0%` と表示されました。 +- 原因: `calcAiRatio()` には `totalAddedLines === 0` のとき file-level ratio に fallback する unit test がありました。しかし `buildEntry()` の `resolveMethod()` が先に `method: "none"` を選び、`calcAiRatio()` を呼ぶ前に `ai_ratio: 0` を固定していました。helper 単体の test は存在していたが、実際の entry assembly path を固定していなかったことが漏れの本質です。 +- 修正: `lineCounts` があっても `totalAddedLines === 0` の場合は、eligible file が残っていれば `method: "file"` に fallback します。削除のみ、rename のみ、mode change のように added-line denominator がない commit でも、AI が触った file evidence は AI Ratio に反映します。すべての file が generated artifact / `.agentnoteignore` で AI Ratio 対象外なら、従来通り `method: "none"` です。 +- Regression coverage: `packages/cli/src/core/entry.test.ts` で、`buildEntry()` が `0 added / N deleted / AI file` を `method: "file"` として保存することを確認します。同じ test file で、eligible file が 0 件の場合だけ `method: "none"` に落ちることも確認します。これは `calcAiRatio()` helper だけでなく、git note に保存される final entry contract を固定するための regression です。 + +#### Test strengthening policy from PR #76 + +今回の漏れは「低レベル helper の test はあるが、上位 caller がその helper を迂回する」形でした。今後 attribution / prompt selection / report rendering を触るときは、以下を regression 方針として扱います。 + +- Helper test だけで完了扱いにしません。`calcAiRatio()`、prompt scoring、fallback predicate のような helper を変更した場合は、少なくとも 1 つ上の public contract (`buildEntry()`、`recordCommitEntry()`、`renderMarkdown()`、CLI output) でも同じ境界を固定します。 +- Ratio / attribution の test matrix には、`added > 0` だけでなく `added = 0, deleted > 0`、`added = 0, deleted = 0`、AI file あり、AI file なし、eligible file なし、generated / `.agentnoteignore` 除外を含めます。特に denominator が 0 になる path は、`0%` と `—` と file-level fallback のどれが正しいかを明示します。 +- PR Report / Dashboard / CLI 表示は、stored note の `method` と `lines` の組み合わせを fixture で確認します。`method: "line"` だけでなく、`method: "file"` + `lines` あり、`method: "file"` + `lines` なし、`method: "none"` を分けて見る必要があります。 +- Generated bundle を持つ package の shared core を触った場合は、source test と bundle sync を同じ PR で確認します。`packages/pr-report` は `packages/cli/src/core/entry.ts` を bundle に含むため、CLI 側の attribution bug は Action output にも影響します。 +- Regression test 名には、実ユーザーの症状を入れます。例: `falls back to method=file when line counts have no added lines` のように、内部条件だけでなく「何が守られるか」が分かる名前にします。 +- 「100 ケース」などの数を増やすだけでは十分ではありません。今回のような漏れは scenario count ではなく axis の欠落で起きるため、matrix の各 axis が user-visible contract に接続しているかを review checklist に含めます。 + ### CLI dist tracking policy - 決定: `packages/pr-report/dist/index.js` は tracked artifact として維持します。`action.yml` が GitHub Action runtime でこの bundle を直接実行するため、repository に存在しないと published Action が動きません。 diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index cf010f41..1bd851b3 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -2447,10 +2447,11 @@ function calcAiRatio(files, lineCounts) { if (eligible.total === 0) return 0; return Math.round(eligible.ai / eligible.total * PERCENT_DENOMINATOR); } -function resolveMethod(lineCounts) { +function resolveMethod(files, lineCounts) { if (!lineCounts) return "file"; - if (lineCounts.totalAddedLines === 0) return "none"; - return "line"; + if (lineCounts.totalAddedLines > 0) return "line"; + const eligible = countAiRatioEligibleFiles(files); + return eligible.total > 0 ? "file" : "none"; } function buildEntry(opts) { const generatedFiles = new Set(opts.generatedFiles ?? []); @@ -2461,7 +2462,7 @@ function buildEntry(opts) { ...generatedFiles.has(path) ? { generated: true } : {}, ...aiRatioExcludedFiles.has(path) ? { ai_ratio_excluded: true } : {} })); - const method = resolveMethod(opts.lineCounts); + const method = resolveMethod(files, opts.lineCounts); const aiRatio = method === "none" ? 0 : calcAiRatio(files, opts.lineCounts); const attribution = { ai_ratio: aiRatio, method }; if (opts.lineCounts) { diff --git a/packages/cli/src/core/entry.test.ts b/packages/cli/src/core/entry.test.ts index cd99614c..3ce0e1a0 100644 --- a/packages/cli/src/core/entry.test.ts +++ b/packages/cli/src/core/entry.test.ts @@ -517,7 +517,7 @@ describe("buildEntry", () => { assert.equal(entry.attribution.lines, undefined); }); - it("sets method=none and ai_ratio=0 when totalAddedLines is 0", () => { + it("falls back to method=file when line counts have no added lines", () => { const lineCounts: LineCounts = { aiAddedLines: 0, totalAddedLines: 0, deletedLines: 5 }; const entry = buildEntry({ sessionId, @@ -526,6 +526,25 @@ describe("buildEntry", () => { aiFiles: ["a.ts"], lineCounts, }); + assert.equal(entry.attribution.method, "file"); + assert.equal(entry.attribution.ai_ratio, 100); + assert.deepEqual(entry.attribution.lines, { + ai_added: 0, + total_added: 0, + deleted: 5, + }); + }); + + it("sets method=none when no added-line or file-level denominator exists", () => { + const lineCounts: LineCounts = { aiAddedLines: 0, totalAddedLines: 0, deletedLines: 5 }; + const entry = buildEntry({ + sessionId, + interactions: [], + commitFiles: ["packages/cli/dist/cli.js"], + aiFiles: ["packages/cli/dist/cli.js"], + aiRatioExcludedFiles: ["packages/cli/dist/cli.js"], + lineCounts, + }); assert.equal(entry.attribution.method, "none"); assert.equal(entry.attribution.ai_ratio, 0); }); diff --git a/packages/cli/src/core/entry.ts b/packages/cli/src/core/entry.ts index 141ccb2c..a2816370 100644 --- a/packages/cli/src/core/entry.ts +++ b/packages/cli/src/core/entry.ts @@ -536,11 +536,15 @@ export function calcAiRatio(files: FileEntry[], lineCounts?: LineCounts): number return Math.round((eligible.ai / eligible.total) * PERCENT_DENOMINATOR); } -/** Determine attribution method from available data. */ -function resolveMethod(lineCounts?: LineCounts): "line" | "file" | "none" { +/** Determine the strongest safe attribution method for the current commit evidence. */ +function resolveMethod(files: FileEntry[], lineCounts?: LineCounts): "line" | "file" | "none" { if (!lineCounts) return "file"; - if (lineCounts.totalAddedLines === 0) return "none"; - return "line"; + if (lineCounts.totalAddedLines > 0) return "line"; + + // A deletion-only or rename-only commit has no added-line denominator. + // Keep AI involvement visible by falling back to file-level attribution. + const eligible = countAiRatioEligibleFiles(files); + return eligible.total > 0 ? "file" : "none"; } /** Build the final git-note entry from collected session, attribution, and prompt data. */ @@ -566,7 +570,7 @@ export function buildEntry(opts: { ...(aiRatioExcludedFiles.has(path) ? { ai_ratio_excluded: true } : {}), })); - const method = resolveMethod(opts.lineCounts); + const method = resolveMethod(files, opts.lineCounts); const aiRatio = method === "none" ? 0 : calcAiRatio(files, opts.lineCounts); const attribution: Attribution = { ai_ratio: aiRatio, method }; diff --git a/packages/dashboard/src/pages/index.astro b/packages/dashboard/src/pages/index.astro index bf7466ff..cb2b016b 100644 --- a/packages/dashboard/src/pages/index.astro +++ b/packages/dashboard/src/pages/index.astro @@ -1599,7 +1599,6 @@ const dashboardIndex = buildDashboardIndex();