生成HLSLが束縛を自ら述べることを文書化する #498
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PRレビュー | |
| # フォークからのPRでも書き込み権限のあるトークンで動かすため pull_request_target を使う。 | |
| # このワークフローはPR側のコードを一切チェックアウトせず、APIから得た情報だけを読む。 | |
| # checkout を足すとフォークの任意コードが書き込み権限付きで走るため、絶対に足さないこと。 | |
| on: | |
| pull_request_target: | |
| types: [opened, edited, synchronize, reopened, ready_for_review] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| concurrency: | |
| group: pr-review-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| review: | |
| name: 変更範囲の判定と確認 | |
| runs-on: ubuntu-latest | |
| if: github.event.pull_request.draft == false | |
| steps: | |
| - name: PRを検査して所見を投稿 | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const pr = context.payload.pull_request; | |
| const { owner, repo } = context.repo; | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner, repo, pull_number: pr.number, per_page: 100, | |
| }); | |
| const paths = files.map(f => f.filename); | |
| // 変更範囲の判定。どのテストスイートを走らせる必要があるかを決める。 | |
| const touched = re => paths.some(p => re.test(p)); | |
| const isDoc = p => /\.md$/i.test(p) || p.startsWith('.github/LICENSE/'); | |
| const docsOnly = paths.length > 0 && paths.every(isDoc); | |
| const runtime = touched(/^src\/ComputeWeave\/(?!.*\.md$)/); | |
| const coreLib = touched(/^src\/ComputeWeave\.Core\//); | |
| const generators = touched(/^src\/ComputeWeave\.SourceGenerators\//); | |
| const dxc = touched(/^src\/ComputeWeave\.Dxc\//); | |
| const allocator = touched(/^src\/ComputeWeave\.D3D12MemoryAllocator\//); | |
| const testsOnly = touched(/^tests\//); | |
| const buildFiles = touched(/^(build\/|global\.json|nuget\.config|.*\.sln$|Directory\.Build)/); | |
| const workflows = touched(/^\.github\/workflows\//); | |
| const anySource = runtime || coreLib || generators || dxc || allocator; | |
| // CONTRIBUTING は src/ComputeWeave の変更を4スイート全部で確認するよう求める。 | |
| const suites = new Set(); | |
| if (runtime || coreLib || dxc || allocator || buildFiles) { | |
| ['SourceGenerators', 'Internals', 'Tests', 'DeviceLost'].forEach(s => suites.add(s)); | |
| } | |
| if (generators) { | |
| ['SourceGenerators', 'Internals', 'Tests'].forEach(s => suites.add(s)); | |
| } | |
| if (testsOnly && !anySource) suites.add('変更した該当スイート'); | |
| // 慎重を要する箇所。CONTRIBUTING が名指ししている領域を経路と名前から推定する。 | |
| const guarded = []; | |
| const guardRules = [ | |
| ['interop', /Interop|Shared|External|D3D11|Dxgi/i, '相互運用 / interoperation'], | |
| ['sync', /Fence|Sync|Barrier|Hazard|Queue|Schedul/i, '同期と危険追跡 / synchronization and hazard tracking'], | |
| ['lifetime', /Lifetime|Lease|Dispose|Disposal|Reclaim|Generation/i, '寿命と破棄 / lifetime and disposal'], | |
| ['contract', /Descriptor|Generator|Analyzer|Diagnostic/i, '記述子と診断 / descriptors and diagnostics'], | |
| ['failure', /DeviceLost|Removed|Recover/i, '失敗処理 / failure handling'], | |
| ['memory', /Memory|Allocat|Budget|Trim/i, 'メモリ管理 / memory management'], | |
| ]; | |
| const srcPaths = paths.filter(p => p.startsWith('src/')); | |
| for (const [id, re, label] of guardRules) { | |
| const hit = srcPaths.filter(p => re.test(p)); | |
| if (hit.length) guarded.push({ id, label, hit }); | |
| } | |
| const guardIds = new Set(guarded.map(g => g.id)); | |
| // コミットの作法。実装と検証テストは別コミットにする決まりがある。 | |
| const commits = await github.paginate(github.rest.pulls.listCommits, { | |
| owner, repo, pull_number: pr.number, per_page: 100, | |
| }); | |
| const mixed = []; | |
| if (commits.length <= 30) { | |
| for (const c of commits) { | |
| const detail = await github.rest.repos.getCommit({ owner, repo, ref: c.sha }); | |
| const f = (detail.data.files || []).map(x => x.filename); | |
| const hasSrc = f.some(p => /^src\//.test(p)); | |
| const hasTest = f.some(p => /^tests\//.test(p)); | |
| if (hasSrc && hasTest) { | |
| mixed.push(`${c.sha.slice(0, 8)} ${c.commit.message.split('\n')[0]}`); | |
| } | |
| } | |
| } | |
| // 本文の記入状況。見出しの直後に本文があるかどうかだけを見る。 | |
| const body = pr.body || ''; | |
| const stripped = body.replace(/<!--[\s\S]*?-->/g, ''); | |
| const sectionFilled = (heading) => { | |
| const m = stripped.match(new RegExp(`^##\\s*${heading}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s|\\Z)`, 'm')); | |
| if (!m) return null; | |
| return m[1].replace(/```[\s\S]*?```/g, '').replace(/^[-*]\s.*$/gm, '') | |
| .replace(/Closes #\s*$/m, '').trim().length > 0; | |
| }; | |
| const findings = []; | |
| const confirmed = []; | |
| if (!stripped.trim()) { | |
| findings.push('本文が空です。テンプレートが消されています。 / The body is empty; the template was removed.'); | |
| } else { | |
| const summary = sectionFilled('Summary'); | |
| if (summary === false) findings.push('概要が空です。 / The summary section is empty.'); | |
| else if (summary) confirmed.push('概要が書かれています。 / A summary is present.'); | |
| const verify = sectionFilled('Verification'); | |
| if (verify === false && !docsOnly) { | |
| findings.push('検証の節が空です。走らせた結果を貼ってください。 / The verification section is empty; paste the results you ran.'); | |
| } else if (verify) confirmed.push('検証の結果が書かれています。 / Verification results are present.'); | |
| const behavior = sectionFilled('Behavior change'); | |
| if (behavior === false && anySource) { | |
| findings.push('挙動の変更の節が空です。無ければ「なし」と書いてください。 / The behavior-change section is empty; write "none" if nothing changes.'); | |
| } | |
| } | |
| const linked = /\b(closes|fixes|resolves)\s+#\d+/i.test(stripped) || /#\d+/.test(stripped); | |
| if (!linked && anySource && paths.length > 3) { | |
| findings.push('関連する課題が見当たりません。大きめの変更は先に課題で範囲を合意する決まりです。 / No linked issue found; larger changes need an issue agreed first.'); | |
| } else if (linked) { | |
| confirmed.push('関連する課題が示されています。 / A linked issue is referenced.'); | |
| } | |
| if (mixed.length) { | |
| findings.push(`実装と検証テストが同じコミットに入っています。分けてください。 / Implementation and tests share a commit; split them.\n${mixed.map(m => ` - \`${m}\``).join('\n')}`); | |
| } else if (commits.length <= 30 && anySource && testsOnly) { | |
| confirmed.push('実装と検証テストが別のコミットに分かれています。 / Implementation and tests are in separate commits.'); | |
| } | |
| if (anySource && !testsOnly) { | |
| findings.push('ソースだけが変わり、テストが増えていません。守るべき挙動を動かす試験が要るか検討してください。 / Source changed without any test change; consider whether the guarded behavior needs one.'); | |
| } | |
| const uncheckedGuard = guarded.length > 0 | |
| && /- \[ \].*(?:Lifetime tracking and hazard|寿命の追跡と危険の追跡)/.test(body); | |
| if (guarded.length && uncheckedGuard) { | |
| findings.push('慎重を要する箇所に触れていますが、その節の確認が未記入です。 / A guarded area is touched but its checklist is unticked.'); | |
| } | |
| // 所見の組み立て。 | |
| const suiteList = [...suites]; | |
| const lines = []; | |
| lines.push('<!-- computeweave-pr-review -->'); | |
| lines.push('## PRレビュー / PR review'); | |
| lines.push(''); | |
| lines.push(`変更 ${paths.length} ファイル、コミット ${commits.length} 件。 / ${paths.length} files, ${commits.length} commits.`); | |
| lines.push(''); | |
| lines.push('### 検証の要否 / Verification required'); | |
| lines.push(''); | |
| if (docsOnly) { | |
| lines.push('文書だけの変更です。**テストの実行は不要**です。該当箇所の確認で足ります。'); | |
| lines.push('Documentation-only change. **Tests are not required**; verify the affected area instead.'); | |
| } else if (suiteList.length === 0) { | |
| lines.push('ソースに触れていません。**このPRの内容に応じた確認**で足ります。'); | |
| lines.push('No source touched. Verify whatever this change actually affects.'); | |
| } else { | |
| lines.push('以下を走らせ、失敗した試験の**名前と失敗の様子**を変更前と突き合わせてください。総数だけで判断しないでください。'); | |
| lines.push('Run the following and compare the **names and failure modes** against the same suites before your change. Do not judge by totals alone.'); | |
| lines.push(''); | |
| lines.push('```console'); | |
| lines.push('dotnet build ComputeWeave.sln -c Release -p:Platform=x64'); | |
| for (const s of ['SourceGenerators', 'Internals', 'Tests', 'DeviceLost']) { | |
| if (!suites.has(s)) continue; | |
| const proj = s === 'Tests' ? 'ComputeWeave.Tests' : `ComputeWeave.Tests.${s}`; | |
| lines.push(`dotnet test tests/${proj}/${proj}.csproj -c Release -p:Platform=x64`); | |
| } | |
| lines.push('```'); | |
| if (generators) { | |
| lines.push(''); | |
| lines.push('ビルドエラーを出す診断を変えた場合は、アナライザーの試験に加えて解全体の検証が要ります。'); | |
| lines.push('If a diagnostic that produces build errors changed, verify the complete solution as well.'); | |
| } | |
| } | |
| lines.push(''); | |
| if (guarded.length) { | |
| lines.push('### 慎重を要する箇所 / Guarded areas touched'); | |
| lines.push(''); | |
| for (const g of guarded) { | |
| lines.push(`- **${g.label}**`); | |
| for (const p of g.hit.slice(0, 5)) lines.push(` - \`${p}\``); | |
| if (g.hit.length > 5) lines.push(` - ほか ${g.hit.length - 5} 件 / ${g.hit.length - 5} more`); | |
| } | |
| lines.push(''); | |
| if (guardIds.has('lifetime') || guardIds.has('sync')) { | |
| lines.push('寿命の追跡と危険の追跡は別々の保証です。一方を保っても他方が保たれるとは限りません。'); | |
| lines.push('Lifetime tracking and hazard tracking are separate guarantees; preserving one does not preserve the other.'); | |
| lines.push(''); | |
| } | |
| if (guardIds.has('interop') || guardIds.has('sync')) { | |
| lines.push('コマンドの順序、資源の状態、バリア、キューの同期、相互運用を変えた場合は Direct3D デバッグレイヤーと GPU 検証を使ってください。'); | |
| lines.push('If command ordering, resource states, barriers, queue synchronization or interoperation changed, use the Direct3D debug layer and GPU validation.'); | |
| lines.push(''); | |
| } | |
| if (guardIds.has('contract')) { | |
| lines.push('公開APIまたは生成される記述子を変えた場合は、該当部分の互換性、生成の決定性、基準データの検査を走らせてください。'); | |
| lines.push('For public API or generated descriptor changes, run the compatibility, deterministic-generation or golden-data checks of the affected subsystem.'); | |
| lines.push(''); | |
| } | |
| if (guardIds.has('memory')) { | |
| lines.push('確保の契約が定まっている経路は、それを保つか、変える根拠を示してください。性能に効く構造体は `Unsafe.SizeOf<T>()` で測ってください。'); | |
| lines.push('Preserve an established allocation contract or justify changing it. Measure performance-sensitive structures with `Unsafe.SizeOf<T>()`.'); | |
| lines.push(''); | |
| } | |
| } | |
| if (findings.length) { | |
| lines.push('### 指摘 / Findings'); | |
| lines.push(''); | |
| for (const f of findings) lines.push(`- [ ] ${f}`); | |
| lines.push(''); | |
| } | |
| if (confirmed.length) { | |
| lines.push('### 確認できた点 / Confirmed'); | |
| lines.push(''); | |
| for (const c of confirmed) lines.push(`- ${c}`); | |
| lines.push(''); | |
| } | |
| lines.push('---'); | |
| lines.push(''); | |
| lines.push('これは自動の確認で、査読の代わりにはなりません。指摘は判断の材料であって、従う義務はありません。'); | |
| lines.push('This is an automated check, not a substitute for review. Findings are input to a decision, not obligations.'); | |
| const bodyText = lines.join('\n'); | |
| // 同じ内容の投稿を増やさず、既存の所見を書き換える。 | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, repo, issue_number: pr.number, per_page: 100, | |
| }); | |
| const existing = comments.find(c => c.body && c.body.includes('<!-- computeweave-pr-review -->')); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: bodyText }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: bodyText }); | |
| } | |
| core.summary.addRaw(bodyText).write(); |