hotfix(ci): keep ai review feedback fast and deterministic

This commit is contained in:
Tam Nhu Tran
2026-04-01 22:20:55 -04:00
parent bbf86155bc
commit 4e05488c19
7 changed files with 192 additions and 6 deletions
+1
View File
@@ -20,6 +20,7 @@ Use only the review contract in this file plus the checked-out PR diff and nearb
- Do not pad the review with praise or generic best-practice advice. - Do not pad the review with praise or generic best-practice advice.
- Read `.ccs-ai-review-scope.md` first when it is present. It defines the bounded review scope for this run. - Read `.ccs-ai-review-scope.md` first when it is present. It defines the bounded review scope for this run.
- If the mode is `triage`, be explicit in the summary that the review was hotspot-based rather than exhaustive. - If the mode is `triage`, be explicit in the summary that the review was hotspot-based rather than exhaustive.
- Do not spend turns narrating your process. Read the bounded inputs, confirm issues, and emit the schema.
## Core Questions ## Core Questions
+5 -1
View File
@@ -402,6 +402,7 @@ jobs:
AI_REVIEW_PR_NUMBER: ${{ needs.prepare.outputs.pr_number }} AI_REVIEW_PR_NUMBER: ${{ needs.prepare.outputs.pr_number }}
AI_REVIEW_BASE_REF: ${{ needs.prepare.outputs.base_ref }} AI_REVIEW_BASE_REF: ${{ needs.prepare.outputs.base_ref }}
AI_REVIEW_MODE: ${{ needs.prepare.outputs.review_mode }} AI_REVIEW_MODE: ${{ needs.prepare.outputs.review_mode }}
AI_REVIEW_PR_SIZE_CLASS: ${{ needs.prepare.outputs.pr_size_class }}
AI_REVIEW_MAX_TURNS: ${{ needs.prepare.outputs.max_turns }} AI_REVIEW_MAX_TURNS: ${{ needs.prepare.outputs.max_turns }}
AI_REVIEW_TIMEOUT_MINUTES: ${{ needs.prepare.outputs.claude_timeout_minutes }} AI_REVIEW_TIMEOUT_MINUTES: ${{ needs.prepare.outputs.claude_timeout_minutes }}
AI_REVIEW_SCOPE_FILE: ${{ env.REVIEW_SCOPE_FILE }} AI_REVIEW_SCOPE_FILE: ${{ env.REVIEW_SCOPE_FILE }}
@@ -518,6 +519,7 @@ jobs:
- Respect the selected review mode budget and scope. Do not try to exhaustively map the repository on auto runs. - Respect the selected review mode budget and scope. Do not try to exhaustively map the repository on auto runs.
- If the mode is `triage`, prioritize hotspots and be explicit that the review was bounded rather than exhaustive. - If the mode is `triage`, prioritize hotspots and be explicit that the review was bounded rather than exhaustive.
- Do not reconstruct the full PR diff for `fast` or `triage` reviews, and do not look for omitted files outside the bounded workspace. - Do not reconstruct the full PR diff for `fast` or `triage` reviews, and do not look for omitted files outside the bounded workspace.
- Do not narrate your process or summarize interim reads. Spend turns on bounded file reads and the final JSON output only.
- Return only structured output that matches the provided JSON schema. - Return only structured output that matches the provided JSON schema.
- Do NOT write files. - Do NOT write files.
- Do NOT post GitHub comments yourself. - Do NOT post GitHub comments yourself.
@@ -528,7 +530,8 @@ jobs:
--model ${{ env.REVIEW_MODEL }} --model ${{ env.REVIEW_MODEL }}
--permission-mode bypassPermissions --permission-mode bypassPermissions
--max-turns ${{ needs.prepare.outputs.max_turns }} --max-turns ${{ needs.prepare.outputs.max_turns }}
--allowedTools "Read" --tools "Read"
--disallowedTools "Bash,Edit"
--json-schema '${{ env.REVIEW_OUTPUT_SCHEMA }}' --json-schema '${{ env.REVIEW_OUTPUT_SCHEMA }}'
- name: Render review comment - name: Render review comment
@@ -554,6 +557,7 @@ jobs:
AI_REVIEW_TIMEOUT_MINUTES: ${{ needs.prepare.outputs.claude_timeout_minutes }} AI_REVIEW_TIMEOUT_MINUTES: ${{ needs.prepare.outputs.claude_timeout_minutes }}
AI_REVIEW_STATUS: ${{ steps.claude-review.outcome }} AI_REVIEW_STATUS: ${{ steps.claude-review.outcome }}
AI_REVIEW_STRUCTURED_OUTPUT: ${{ steps.claude-review.outputs.structured_output }} AI_REVIEW_STRUCTURED_OUTPUT: ${{ steps.claude-review.outputs.structured_output }}
AI_REVIEW_SCOPE_MANIFEST_FILE: ${{ env.REVIEW_SCOPE_MANIFEST_FILE }}
- name: Publish review comment - name: Publish review comment
if: always() && steps.app-token.outcome == 'success' if: always() && steps.app-token.outcome == 'success'
+73 -1
View File
@@ -290,6 +290,66 @@ function readExecutionMetadata(executionFile) {
} }
} }
function readSelectedFiles(manifestFile) {
if (!manifestFile || !fs.existsSync(manifestFile)) {
return [];
}
try {
return fs
.readFileSync(manifestFile, 'utf8')
.split('\n')
.map((line) => cleanText(line))
.filter(Boolean);
} catch {
return [];
}
}
function formatHotspotFiles(files) {
if (!files.length) {
return null;
}
const visible = files.slice(0, 4).map(renderCode).join(', ');
return files.length > 4 ? `${visible}, and ${files.length - 4} more` : visible;
}
function formatRemainingCoverage(rendering) {
if (
typeof rendering.selectedFiles !== 'number' ||
typeof rendering.reviewableFiles !== 'number'
) {
return null;
}
const remainingFiles = Math.max(rendering.reviewableFiles - rendering.selectedFiles, 0);
const hasChangeCounts =
typeof rendering.selectedChanges === 'number' &&
typeof rendering.reviewableChanges === 'number';
const remainingChanges = hasChangeCounts
? Math.max(rendering.reviewableChanges - rendering.selectedChanges, 0)
: null;
if (remainingFiles === 0 && (!hasChangeCounts || remainingChanges === 0)) {
return null;
}
if (typeof remainingChanges === 'number') {
return `${remainingFiles} file${remainingFiles === 1 ? '' : 's'}; ${remainingChanges} changed lines`;
}
return `${remainingFiles} file${remainingFiles === 1 ? '' : 's'}`;
}
function formatFallbackFollowUp(rendering) {
if (rendering.mode === 'triage') {
return 'Focus manual review on the hotspot files above, and use `/review` for a deeper pass when release, auth, config, or workflow paths changed.';
}
return 'Use `/review` when you need a deeper maintainer rerun with more surrounding context.';
}
export function normalizeStructuredOutput(raw) { export function normalizeStructuredOutput(raw) {
if (!raw) { if (!raw) {
return { ok: false, reason: 'missing structured output' }; return { ok: false, reason: 'missing structured output' };
@@ -461,6 +521,7 @@ export function renderIncompleteReview({
runUrl, runUrl,
runtimeTools, runtimeTools,
turnsUsed, turnsUsed,
selectedFiles,
rendering: renderOptions, rendering: renderOptions,
status, status,
}) { }) {
@@ -468,7 +529,7 @@ export function renderIncompleteReview({
const lines = [ const lines = [
'### ⚠️ AI Review Incomplete', '### ⚠️ AI Review Incomplete',
'', '',
'Claude did not return validated structured review output, so this workflow did not publish raw scratch text.', 'Claude did not return validated structured review output, so this workflow published deterministic hotspot context instead of raw scratch text.',
'', '',
`- Outcome: ${describeIncompleteOutcome({ reason, rendering, turnsUsed, status })}`, `- Outcome: ${describeIncompleteOutcome({ reason, rendering, turnsUsed, status })}`,
]; ];
@@ -484,6 +545,15 @@ export function renderIncompleteReview({
if (runtimeBudget) { if (runtimeBudget) {
lines.push(`- Runtime budget: ${escapeMarkdownText(runtimeBudget)}`); lines.push(`- Runtime budget: ${escapeMarkdownText(runtimeBudget)}`);
} }
const hotspotFiles = formatHotspotFiles(selectedFiles || []);
if (hotspotFiles) {
lines.push(`- Hotspot files in this pass: ${hotspotFiles}`);
}
const remainingCoverage = formatRemainingCoverage(rendering);
if (remainingCoverage) {
lines.push(`- Remaining reviewable scope not fully covered: ${escapeMarkdownText(remainingCoverage)}`);
}
lines.push(`- Manual follow-up: ${escapeMarkdownText(formatFallbackFollowUp(rendering))}`);
if (runtimeTools?.length) { if (runtimeTools?.length) {
lines.push(`- Runtime tools: ${runtimeTools.map(renderCode).join(', ')}`); lines.push(`- Runtime tools: ${runtimeTools.map(renderCode).join(', ')}`);
} }
@@ -501,6 +571,7 @@ export function writeReviewFromEnv(env = process.env) {
const runUrl = env.AI_REVIEW_RUN_URL || '#'; const runUrl = env.AI_REVIEW_RUN_URL || '#';
const validation = normalizeStructuredOutput(env.AI_REVIEW_STRUCTURED_OUTPUT); const validation = normalizeStructuredOutput(env.AI_REVIEW_STRUCTURED_OUTPUT);
const metadata = readExecutionMetadata(env.AI_REVIEW_EXECUTION_FILE); const metadata = readExecutionMetadata(env.AI_REVIEW_EXECUTION_FILE);
const selectedFiles = readSelectedFiles(env.AI_REVIEW_SCOPE_MANIFEST_FILE);
const status = cleanText(env.AI_REVIEW_STATUS).toLowerCase() || null; const status = cleanText(env.AI_REVIEW_STATUS).toLowerCase() || null;
const rendering = normalizeRenderingMetadata({ const rendering = normalizeRenderingMetadata({
mode: env.AI_REVIEW_MODE, mode: env.AI_REVIEW_MODE,
@@ -521,6 +592,7 @@ export function writeReviewFromEnv(env = process.env) {
runUrl, runUrl,
runtimeTools: metadata.runtimeTools, runtimeTools: metadata.runtimeTools,
turnsUsed: metadata.turnsUsed, turnsUsed: metadata.turnsUsed,
selectedFiles,
rendering, rendering,
status, status,
}); });
+25 -4
View File
@@ -4,10 +4,14 @@ import { fileURLToPath } from 'node:url';
const MODE_LIMITS = { const MODE_LIMITS = {
fast: { maxFiles: 16, maxChangedLines: 900, maxPatchLines: 90, maxPatchChars: 7000 }, fast: { maxFiles: 16, maxChangedLines: 900, maxPatchLines: 90, maxPatchChars: 7000 },
triage: { maxFiles: 10, maxChangedLines: 700, maxPatchLines: 80, maxPatchChars: 6000 }, triage: { maxFiles: 6, maxChangedLines: 520, maxPatchLines: 60, maxPatchChars: 4500 },
deep: { maxFiles: 20, maxChangedLines: 1600, maxPatchLines: 120, maxPatchChars: 9000 }, deep: { maxFiles: 20, maxChangedLines: 1600, maxPatchLines: 120, maxPatchChars: 9000 },
}; };
const TRIAGE_SIZE_CLASS_LIMITS = {
xlarge: { maxFiles: 4, maxChangedLines: 360, maxPatchLines: 45, maxPatchChars: 3200 },
};
const MODE_LABELS = { const MODE_LABELS = {
fast: 'diff-focused bounded review', fast: 'diff-focused bounded review',
triage: 'hotspot-based bounded review (non-exhaustive)', triage: 'hotspot-based bounded review (non-exhaustive)',
@@ -35,6 +39,13 @@ function cleanText(value) {
return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : ''; return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : '';
} }
function normalizeSizeClass(value) {
const sizeClass = cleanText(value).toLowerCase();
return sizeClass === 'small' || sizeClass === 'medium' || sizeClass === 'large' || sizeClass === 'xlarge'
? sizeClass
: null;
}
function escapeMarkdown(value) { function escapeMarkdown(value) {
return cleanText(value).replace(/\\/g, '\\\\').replace(/([`*_{}[\]<>|])/g, '\\$1'); return cleanText(value).replace(/\\/g, '\\\\').replace(/([`*_{}[\]<>|])/g, '\\$1');
} }
@@ -127,8 +138,17 @@ export function normalizePullFiles(files) {
}).map((file) => ({ ...file, score: scoreFile(file) })); }).map((file) => ({ ...file, score: scoreFile(file) }));
} }
export function buildReviewScope(files, mode) { function resolveModeLimits(mode, sizeClass) {
const limits = MODE_LIMITS[mode] || MODE_LIMITS.fast; if (mode !== 'triage') {
return MODE_LIMITS[mode] || MODE_LIMITS.fast;
}
return TRIAGE_SIZE_CLASS_LIMITS[sizeClass] || MODE_LIMITS.triage;
}
export function buildReviewScope(files, mode, options = {}) {
const sizeClass = normalizeSizeClass(options.sizeClass);
const limits = resolveModeLimits(mode, sizeClass);
const reviewable = files.filter((file) => file.reviewable); const reviewable = files.filter((file) => file.reviewable);
const lowSignal = files.filter((file) => !file.reviewable); const lowSignal = files.filter((file) => !file.reviewable);
const usingChangedFallback = reviewable.length === 0; const usingChangedFallback = reviewable.length === 0;
@@ -260,6 +280,7 @@ export async function writeScopeFromEnv(env = process.env, request) {
const prNumber = Number.parseInt(cleanText(env.AI_REVIEW_PR_NUMBER), 10); const prNumber = Number.parseInt(cleanText(env.AI_REVIEW_PR_NUMBER), 10);
const baseRef = cleanText(env.AI_REVIEW_BASE_REF || 'dev'); const baseRef = cleanText(env.AI_REVIEW_BASE_REF || 'dev');
const mode = cleanText(env.AI_REVIEW_MODE || 'fast').toLowerCase(); const mode = cleanText(env.AI_REVIEW_MODE || 'fast').toLowerCase();
const sizeClass = normalizeSizeClass(env.AI_REVIEW_PR_SIZE_CLASS);
const turnBudget = Number.parseInt(cleanText(env.AI_REVIEW_MAX_TURNS || '0'), 10) || 0; const turnBudget = Number.parseInt(cleanText(env.AI_REVIEW_MAX_TURNS || '0'), 10) || 0;
const timeoutMinutes = Number.parseInt(cleanText(env.AI_REVIEW_TIMEOUT_MINUTES || '0'), 10) || 0; const timeoutMinutes = Number.parseInt(cleanText(env.AI_REVIEW_TIMEOUT_MINUTES || '0'), 10) || 0;
const outputFile = env.AI_REVIEW_SCOPE_FILE || '.ccs-ai-review-scope.md'; const outputFile = env.AI_REVIEW_SCOPE_FILE || '.ccs-ai-review-scope.md';
@@ -287,7 +308,7 @@ export async function writeScopeFromEnv(env = process.env, request) {
const files = normalizePullFiles( const files = normalizePullFiles(
await collectPullRequestFiles(`${apiUrl}/repos/${repository}/pulls/${prNumber}/files?per_page=100`, fetchPage) await collectPullRequestFiles(`${apiUrl}/repos/${repository}/pulls/${prNumber}/files?per_page=100`, fetchPage)
); );
const scope = buildReviewScope(files, mode); const scope = buildReviewScope(files, mode, { sizeClass });
const markdown = renderReviewScope({ prNumber, baseRef, turnBudget, timeoutMinutes, scope }); const markdown = renderReviewScope({ prNumber, baseRef, turnBudget, timeoutMinutes, scope });
fs.mkdirSync(path.dirname(outputFile), { recursive: true }); fs.mkdirSync(path.dirname(outputFile), { recursive: true });
@@ -35,10 +35,16 @@ describe('ai-review workflow', () => {
expect(claudeReviewStep?.with?.path_to_claude_code_executable).toBe( expect(claudeReviewStep?.with?.path_to_claude_code_executable).toBe(
'${{ steps.toolchain.outputs.claude_path }}' '${{ steps.toolchain.outputs.claude_path }}'
); );
expect(claudeReviewStep?.with?.claude_args).toContain('--tools "Read"');
expect(claudeReviewStep?.with?.claude_args).toContain('--disallowedTools "Bash,Edit"');
const promptStep = steps.find((step) => step.id === 'review-prompt'); const promptStep = steps.find((step) => step.id === 'review-prompt');
expect(promptStep).toBeDefined(); expect(promptStep).toBeDefined();
expect(promptStep?.run).toContain("printf '%s\\n' \\"); expect(promptStep?.run).toContain("printf '%s\\n' \\");
expect(promptStep?.run).not.toContain("| sed 's/^ //'"); expect(promptStep?.run).not.toContain("| sed 's/^ //'");
const reviewScopeStep = steps.find((step) => step.id === 'review-scope');
expect(reviewScopeStep).toBeDefined();
expect(reviewScopeStep?.env?.AI_REVIEW_PR_SIZE_CLASS).toBe('${{ needs.prepare.outputs.pr_size_class }}');
}); });
}); });
@@ -146,6 +146,7 @@ describe('normalize-ai-review-output', () => {
test('writes a safe incomplete comment with mode and runtime context instead of leaking raw assistant text', () => { test('writes a safe incomplete comment with mode and runtime context instead of leaking raw assistant text', () => {
withTempDir('ai-review-', (tempDir) => { withTempDir('ai-review-', (tempDir) => {
const executionFile = path.join(tempDir, 'claude-execution-output.json'); const executionFile = path.join(tempDir, 'claude-execution-output.json');
const manifestFile = path.join(tempDir, 'selected-files.txt');
const outputFile = path.join(tempDir, 'pr_review.md'); const outputFile = path.join(tempDir, 'pr_review.md');
fs.writeFileSync( fs.writeFileSync(
@@ -160,6 +161,10 @@ describe('normalize-ai-review-output', () => {
}, },
]) ])
); );
fs.writeFileSync(
manifestFile,
['.github/workflows/ai-review.yml', 'scripts/github/prepare-ai-review-scope.mjs', 'src/ccs.ts'].join('\n')
);
const result = reviewOutput.writeReviewFromEnv({ const result = reviewOutput.writeReviewFromEnv({
AI_REVIEW_EXECUTION_FILE: executionFile, AI_REVIEW_EXECUTION_FILE: executionFile,
@@ -173,6 +178,7 @@ describe('normalize-ai-review-output', () => {
AI_REVIEW_TIMEOUT_MINUTES: '5', AI_REVIEW_TIMEOUT_MINUTES: '5',
AI_REVIEW_OUTPUT_FILE: outputFile, AI_REVIEW_OUTPUT_FILE: outputFile,
AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592', AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592',
AI_REVIEW_SCOPE_MANIFEST_FILE: manifestFile,
AI_REVIEW_STRUCTURED_OUTPUT: '', AI_REVIEW_STRUCTURED_OUTPUT: '',
}); });
@@ -186,6 +192,11 @@ describe('normalize-ai-review-output', () => {
expect(markdown).toContain('- Review mode: `triage` (hotspot-based bounded review (non-exhaustive))'); expect(markdown).toContain('- Review mode: `triage` (hotspot-based bounded review (non-exhaustive))');
expect(markdown).toContain('- Review scope: 10/46 reviewable files; 700/2310 reviewable changed lines'); expect(markdown).toContain('- Review scope: 10/46 reviewable files; 700/2310 reviewable changed lines');
expect(markdown).toContain('- Runtime budget: 25 turns / 5 minutes'); expect(markdown).toContain('- Runtime budget: 25 turns / 5 minutes');
expect(markdown).toContain(
'- Hotspot files in this pass: `.github/workflows/ai-review.yml`, `scripts/github/prepare-ai-review-scope.mjs`, `src/ccs.ts`'
);
expect(markdown).toContain('- Remaining reviewable scope not fully covered: 36 files; 1610 changed lines');
expect(markdown).toContain('- Manual follow-up: Focus manual review on the hotspot files above');
expect(markdown).toContain('Runtime tools: `Bash`, `Edit`, `Read`'); expect(markdown).toContain('Runtime tools: `Bash`, `Edit`, `Read`');
expect(markdown).toContain('Turns used: 25'); expect(markdown).toContain('Turns used: 25');
expect(markdown).not.toContain('Now let me verify the findings'); expect(markdown).not.toContain('Now let me verify the findings');
@@ -195,6 +206,7 @@ describe('normalize-ai-review-output', () => {
test('uses a timeout-safe fallback message when the bounded review hits the workflow cap', () => { test('uses a timeout-safe fallback message when the bounded review hits the workflow cap', () => {
withTempDir('ai-review-', (tempDir) => { withTempDir('ai-review-', (tempDir) => {
const executionFile = path.join(tempDir, 'claude-execution-output.json'); const executionFile = path.join(tempDir, 'claude-execution-output.json');
const manifestFile = path.join(tempDir, 'selected-files.txt');
const outputFile = path.join(tempDir, 'pr_review.md'); const outputFile = path.join(tempDir, 'pr_review.md');
fs.writeFileSync( fs.writeFileSync(
@@ -209,6 +221,7 @@ describe('normalize-ai-review-output', () => {
}, },
]) ])
); );
fs.writeFileSync(manifestFile, ['src/commands/help-command.ts', 'src/ccs.ts'].join('\n'));
const result = reviewOutput.writeReviewFromEnv({ const result = reviewOutput.writeReviewFromEnv({
AI_REVIEW_EXECUTION_FILE: executionFile, AI_REVIEW_EXECUTION_FILE: executionFile,
@@ -223,6 +236,7 @@ describe('normalize-ai-review-output', () => {
AI_REVIEW_STATUS: 'cancelled', AI_REVIEW_STATUS: 'cancelled',
AI_REVIEW_OUTPUT_FILE: outputFile, AI_REVIEW_OUTPUT_FILE: outputFile,
AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592', AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592',
AI_REVIEW_SCOPE_MANIFEST_FILE: manifestFile,
AI_REVIEW_STRUCTURED_OUTPUT: '', AI_REVIEW_STRUCTURED_OUTPUT: '',
}); });
@@ -235,6 +249,10 @@ describe('normalize-ai-review-output', () => {
expect(markdown).toContain('- Review mode: `fast` (diff-focused bounded review)'); expect(markdown).toContain('- Review mode: `fast` (diff-focused bounded review)');
expect(markdown).toContain('- Review scope: 6/52 reviewable files; 640/2480 reviewable changed lines'); expect(markdown).toContain('- Review scope: 6/52 reviewable files; 640/2480 reviewable changed lines');
expect(markdown).toContain('- Runtime budget: 5 turns / 5 minutes'); expect(markdown).toContain('- Runtime budget: 5 turns / 5 minutes');
expect(markdown).toContain(
'- Hotspot files in this pass: `src/commands/help-command.ts`, `src/ccs.ts`'
);
expect(markdown).toContain('- Remaining reviewable scope not fully covered: 46 files; 1840 changed lines');
expect(markdown).not.toContain('Partial draft that should never reach the published markdown.'); expect(markdown).not.toContain('Partial draft that should never reach the published markdown.');
}); });
}); });
@@ -113,6 +113,70 @@ describe('prepare-ai-review-scope', () => {
expect(scope.scopeLabel).toBe('changed files'); expect(scope.scopeLabel).toBe('changed files');
}); });
test('shrinks triage scope for xlarge PRs to keep hotspot reviews fast', () => {
const scope = reviewScope.buildReviewScope(
reviewScope.normalizePullFiles([
{
filename: '.github/workflows/ai-review.yml',
status: 'modified',
additions: 130,
deletions: 30,
changes: 160,
patch: '@@ -1 +1 @@\n-old\n+new',
},
{
filename: 'scripts/github/prepare-ai-review-scope.mjs',
status: 'modified',
additions: 120,
deletions: 20,
changes: 140,
patch: '@@ -1 +1 @@\n-old\n+new',
},
{
filename: 'src/commands/help-command.ts',
status: 'modified',
additions: 70,
deletions: 10,
changes: 80,
patch: '@@ -1 +1 @@\n-old\n+new',
},
{
filename: 'src/ccs.ts',
status: 'modified',
additions: 50,
deletions: 15,
changes: 65,
patch: '@@ -1 +1 @@\n-old\n+new',
},
{
filename: 'tests/unit/commands/help-command.test.ts',
status: 'modified',
additions: 40,
deletions: 5,
changes: 45,
patch: '@@ -1 +1 @@\n-old\n+new',
},
]),
'triage',
{ sizeClass: 'xlarge' }
);
expect(scope.selected.length).toBeLessThanOrEqual(4);
expect(scope.selected.map((file: { filename: string }) => file.filename)).toEqual(
expect.arrayContaining([
'.github/workflows/ai-review.yml',
'scripts/github/prepare-ai-review-scope.mjs',
])
);
expect(scope.selectedChanges).toBeLessThanOrEqual(360);
expect(scope.limits).toEqual({
maxFiles: 4,
maxChangedLines: 360,
maxPatchLines: 45,
maxPatchChars: 3200,
});
});
test('renders deterministic scope metadata and fences patch content safely', () => { test('renders deterministic scope metadata and fences patch content safely', () => {
const oversizedPatch = ['+line 1', '```', ...Array.from({ length: 118 }, (_, index) => `+line ${index + 2}`)].join('\n'); const oversizedPatch = ['+line 1', '```', ...Array.from({ length: 118 }, (_, index) => `+line ${index + 2}`)].join('\n');
const scope = reviewScope.buildReviewScope( const scope = reviewScope.buildReviewScope(