@in-the-loop-labs/pair-review 3.1.2 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/components/DiffOptionsDropdown.js +93 -19
- package/src/ai/analyzer.js +21 -3
- package/src/chat/prompt-builder.js +2 -3
- package/src/config.js +1 -0
- package/src/database.js +31 -1
- package/src/local-review.js +21 -30
- package/src/local-scope.js +31 -23
- package/src/routes/executable-analysis.js +11 -9
- package/src/routes/local.js +52 -50
- package/src/routes/setup.js +4 -3
- package/src/setup/local-setup.js +8 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
4
4
|
"description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-critic",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
4
4
|
"description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -57,11 +57,29 @@ class DiffOptionsDropdown {
|
|
|
57
57
|
this._outsideClickHandler = null;
|
|
58
58
|
this._escapeHandler = null;
|
|
59
59
|
|
|
60
|
-
// Scope state
|
|
61
|
-
|
|
60
|
+
// Scope state — resolve LocalScope with inline fallback so the scope selector
|
|
61
|
+
// renders even if window.LocalScope failed to load (race condition guard).
|
|
62
|
+
const FALLBACK_STOPS = ['branch', 'staged', 'unstaged', 'untracked']; // Keep in sync with local-scope.js:STOPS
|
|
63
|
+
const FALLBACK_DEFAULT = { start: 'unstaged', end: 'untracked' };
|
|
64
|
+
this._localScope = window.LocalScope || {
|
|
65
|
+
STOPS: FALLBACK_STOPS,
|
|
66
|
+
DEFAULT_SCOPE: FALLBACK_DEFAULT,
|
|
67
|
+
isValidScope: (s, e) => {
|
|
68
|
+
const si = FALLBACK_STOPS.indexOf(s);
|
|
69
|
+
const ei = FALLBACK_STOPS.indexOf(e);
|
|
70
|
+
return si !== -1 && ei !== -1 && si <= ei;
|
|
71
|
+
},
|
|
72
|
+
scopeIncludes: (s, e, stop) => {
|
|
73
|
+
const si = FALLBACK_STOPS.indexOf(s);
|
|
74
|
+
const ei = FALLBACK_STOPS.indexOf(e);
|
|
75
|
+
const ti = FALLBACK_STOPS.indexOf(stop);
|
|
76
|
+
return ti !== -1 && ti >= si && ti <= ei;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const LS = this._localScope;
|
|
62
80
|
this._branchAvailable = Boolean(branchAvailable);
|
|
63
|
-
this._scopeStart = (initialScope && initialScope.start) ||
|
|
64
|
-
this._scopeEnd = (initialScope && initialScope.end) ||
|
|
81
|
+
this._scopeStart = (initialScope && initialScope.start) || LS.DEFAULT_SCOPE.start;
|
|
82
|
+
this._scopeEnd = (initialScope && initialScope.end) || LS.DEFAULT_SCOPE.end;
|
|
65
83
|
this._scopeStops = [];
|
|
66
84
|
this._scopeTrackEl = null;
|
|
67
85
|
this._scopeDebounceTimer = null;
|
|
@@ -144,7 +162,7 @@ class DiffOptionsDropdown {
|
|
|
144
162
|
/** Programmatically set scope. */
|
|
145
163
|
set scope(val) {
|
|
146
164
|
if (!val) return;
|
|
147
|
-
const LS =
|
|
165
|
+
const LS = this._localScope;
|
|
148
166
|
if (LS && !LS.isValidScope(val.start, val.end)) return;
|
|
149
167
|
this._scopeStart = val.start;
|
|
150
168
|
this._scopeEnd = val.end;
|
|
@@ -188,8 +206,11 @@ class DiffOptionsDropdown {
|
|
|
188
206
|
popover.style.zIndex = '1100';
|
|
189
207
|
popover.style.transition = 'opacity 0.15s ease, transform 0.15s ease';
|
|
190
208
|
|
|
191
|
-
// Scope selector first — only in local mode
|
|
192
|
-
|
|
209
|
+
// Scope selector first — only in local mode.
|
|
210
|
+
// Belt-and-suspenders: also render when scope callbacks were explicitly provided,
|
|
211
|
+
// in case a race condition prevents the globals from being set in time.
|
|
212
|
+
const hasLocalScope = (window.PAIR_REVIEW_LOCAL_MODE && window.LocalScope) || this._onScopeChange;
|
|
213
|
+
if (hasLocalScope) {
|
|
193
214
|
this._renderScopeSelector(popover);
|
|
194
215
|
|
|
195
216
|
// Divider between scope selector and whitespace checkbox
|
|
@@ -261,7 +282,7 @@ class DiffOptionsDropdown {
|
|
|
261
282
|
}
|
|
262
283
|
|
|
263
284
|
_renderScopeSelector(popover) {
|
|
264
|
-
const LS =
|
|
285
|
+
const LS = this._localScope;
|
|
265
286
|
|
|
266
287
|
// Section container — generous horizontal padding so dots/labels breathe
|
|
267
288
|
const section = document.createElement('div');
|
|
@@ -362,13 +383,43 @@ class DiffOptionsDropdown {
|
|
|
362
383
|
stopEl.appendChild(dot);
|
|
363
384
|
stopEl.appendChild(labelEl);
|
|
364
385
|
|
|
386
|
+
// Custom tooltip element (positioned above the dot, hidden by default)
|
|
387
|
+
const tooltipEl = document.createElement('div');
|
|
388
|
+
tooltipEl.style.position = 'absolute';
|
|
389
|
+
tooltipEl.style.bottom = '100%';
|
|
390
|
+
tooltipEl.style.left = '50%';
|
|
391
|
+
tooltipEl.style.transform = 'translateX(-50%)';
|
|
392
|
+
tooltipEl.style.marginBottom = '6px';
|
|
393
|
+
tooltipEl.style.padding = '4px 8px';
|
|
394
|
+
tooltipEl.style.fontSize = '11px';
|
|
395
|
+
tooltipEl.style.lineHeight = '1.3';
|
|
396
|
+
tooltipEl.style.color = 'var(--color-text-on-emphasis, #ffffff)';
|
|
397
|
+
tooltipEl.style.background = 'var(--color-neutral-emphasis, #24292f)';
|
|
398
|
+
tooltipEl.style.borderRadius = '4px';
|
|
399
|
+
tooltipEl.style.whiteSpace = 'nowrap';
|
|
400
|
+
tooltipEl.style.pointerEvents = 'none';
|
|
401
|
+
tooltipEl.style.opacity = '0';
|
|
402
|
+
tooltipEl.style.transition = 'opacity 0.12s ease';
|
|
403
|
+
tooltipEl.style.zIndex = '2';
|
|
404
|
+
stopEl.style.position = 'relative';
|
|
405
|
+
stopEl.appendChild(tooltipEl);
|
|
406
|
+
|
|
407
|
+
stopEl.addEventListener('mouseenter', () => {
|
|
408
|
+
if (tooltipEl.textContent) {
|
|
409
|
+
tooltipEl.style.opacity = '1';
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
stopEl.addEventListener('mouseleave', () => {
|
|
413
|
+
tooltipEl.style.opacity = '0';
|
|
414
|
+
});
|
|
415
|
+
|
|
365
416
|
stopEl.addEventListener('click', (e) => {
|
|
366
417
|
e.stopPropagation();
|
|
367
418
|
this._handleStopClick(stop, e);
|
|
368
419
|
});
|
|
369
420
|
|
|
370
421
|
stopsRow.appendChild(stopEl);
|
|
371
|
-
this._scopeStops.push({ stop, dotEl: dot, labelEl, containerEl: stopEl });
|
|
422
|
+
this._scopeStops.push({ stop, dotEl: dot, labelEl, containerEl: stopEl, tooltipEl });
|
|
372
423
|
});
|
|
373
424
|
|
|
374
425
|
trackContainer.appendChild(stopsRow);
|
|
@@ -382,7 +433,7 @@ class DiffOptionsDropdown {
|
|
|
382
433
|
}
|
|
383
434
|
|
|
384
435
|
_handleStopClick(clickedStop, event) {
|
|
385
|
-
const LS =
|
|
436
|
+
const LS = this._localScope;
|
|
386
437
|
if (!LS) return;
|
|
387
438
|
|
|
388
439
|
// Branch disabled? Ignore.
|
|
@@ -396,10 +447,14 @@ class DiffOptionsDropdown {
|
|
|
396
447
|
let newStart = this._scopeStart;
|
|
397
448
|
let newEnd = this._scopeEnd;
|
|
398
449
|
|
|
399
|
-
//
|
|
450
|
+
// 'unstaged' is always included — the AI reads files from the working
|
|
451
|
+
// tree, so the diff must always cover at least the unstaged state.
|
|
452
|
+
const ui = stops.indexOf('unstaged');
|
|
453
|
+
|
|
454
|
+
// Alt/Option-click: select this stop with minimum scope including unstaged
|
|
400
455
|
if (event && event.altKey) {
|
|
401
|
-
newStart =
|
|
402
|
-
newEnd =
|
|
456
|
+
newStart = stops[Math.min(ci, ui)];
|
|
457
|
+
newEnd = stops[Math.max(ci, ui)];
|
|
403
458
|
} else {
|
|
404
459
|
// Checkbox-like toggle with contiguity constraint
|
|
405
460
|
const included = ci >= si && ci <= ei;
|
|
@@ -407,6 +462,7 @@ class DiffOptionsDropdown {
|
|
|
407
462
|
if (included) {
|
|
408
463
|
// Toggling OFF — only allowed at boundaries, and range must have >1 stop
|
|
409
464
|
if (si === ei) return;
|
|
465
|
+
if (clickedStop === 'unstaged') return; // unstaged is mandatory
|
|
410
466
|
if (ci === si) {
|
|
411
467
|
newStart = stops[si + 1];
|
|
412
468
|
} else if (ci === ei) {
|
|
@@ -457,30 +513,48 @@ class DiffOptionsDropdown {
|
|
|
457
513
|
}
|
|
458
514
|
|
|
459
515
|
_updateScopeUI() {
|
|
460
|
-
const LS =
|
|
516
|
+
const LS = this._localScope;
|
|
461
517
|
if (!LS || !this._scopeStops.length) return;
|
|
462
518
|
|
|
463
519
|
const stops = LS.STOPS;
|
|
464
520
|
const si = stops.indexOf(this._scopeStart);
|
|
465
521
|
const ei = stops.indexOf(this._scopeEnd);
|
|
466
522
|
|
|
467
|
-
this._scopeStops.forEach(({ stop, dotEl, labelEl, containerEl }, i) => {
|
|
523
|
+
this._scopeStops.forEach(({ stop, dotEl, labelEl, containerEl, tooltipEl }, i) => {
|
|
468
524
|
const included = LS.scopeIncludes(this._scopeStart, this._scopeEnd, stop);
|
|
469
525
|
const isBranch = stop === 'branch';
|
|
470
526
|
const disabled = isBranch && !this._branchAvailable;
|
|
471
527
|
|
|
472
|
-
// Determine if clicking this stop would do anything (for cursor hint)
|
|
473
|
-
|
|
528
|
+
// Determine if clicking this stop would do anything (for cursor hint).
|
|
529
|
+
// 'unstaged' is mandatory and cannot be toggled off, so it is never a
|
|
530
|
+
// clickable boundary even when it sits at a range edge.
|
|
531
|
+
const isMandatory = stop === 'unstaged';
|
|
532
|
+
const atRangeEdge = included && (i === si || i === ei) && si !== ei;
|
|
533
|
+
const isBoundary = atRangeEdge && !isMandatory;
|
|
474
534
|
const isAdjacent = !included && (i === si - 1 || i === ei + 1);
|
|
475
535
|
const clickable = !disabled && (isBoundary || isAdjacent);
|
|
476
536
|
|
|
537
|
+
// Tooltip for disabled branch stop
|
|
538
|
+
containerEl.title = disabled ? 'No feature branch detected' : '';
|
|
539
|
+
|
|
540
|
+
// Mandatory stop sitting at a range edge — user might expect to toggle
|
|
541
|
+
// it off but can't. Show not-allowed cursor and explanatory tooltip.
|
|
542
|
+
const mandatoryEdge = isMandatory && atRangeEdge;
|
|
543
|
+
|
|
544
|
+
// Update tooltip text (empty string hides the tooltip on hover)
|
|
545
|
+
if (tooltipEl) {
|
|
546
|
+
tooltipEl.textContent = mandatoryEdge
|
|
547
|
+
? 'Unstaged changes are always included \u2014 the agent reads from your working tree'
|
|
548
|
+
: '';
|
|
549
|
+
}
|
|
550
|
+
|
|
477
551
|
if (disabled) {
|
|
478
552
|
// Disabled state
|
|
479
553
|
dotEl.style.background = 'var(--color-bg-tertiary, #f6f8fa)';
|
|
480
554
|
dotEl.style.borderColor = 'var(--color-border-secondary, #e1e4e8)';
|
|
481
555
|
dotEl.style.boxShadow = 'none';
|
|
482
556
|
labelEl.style.color = 'var(--color-text-tertiary, #8b949e)';
|
|
483
|
-
containerEl.style.cursor = '
|
|
557
|
+
containerEl.style.cursor = 'default';
|
|
484
558
|
containerEl.style.opacity = '0.5';
|
|
485
559
|
} else if (included) {
|
|
486
560
|
// Included (filled) state
|
|
@@ -489,7 +563,7 @@ class DiffOptionsDropdown {
|
|
|
489
563
|
dotEl.style.boxShadow = '0 0 0 2px rgba(139, 92, 246, 0.2)';
|
|
490
564
|
labelEl.style.color = 'var(--color-text-primary, #24292f)';
|
|
491
565
|
labelEl.style.fontWeight = '600';
|
|
492
|
-
containerEl.style.cursor = clickable ? 'pointer' : 'default';
|
|
566
|
+
containerEl.style.cursor = clickable ? 'pointer' : (mandatoryEdge ? 'not-allowed' : 'default');
|
|
493
567
|
containerEl.style.opacity = '1';
|
|
494
568
|
} else {
|
|
495
569
|
// Excluded (empty) state
|
package/src/ai/analyzer.js
CHANGED
|
@@ -126,8 +126,8 @@ async function runExecutableVoice(voiceProvider, reviewId, worktreePath, prMetad
|
|
|
126
126
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pair-review-exec-'));
|
|
127
127
|
try {
|
|
128
128
|
const executableContext = {
|
|
129
|
-
title: prMetadata.title ||
|
|
130
|
-
description: prMetadata.description ||
|
|
129
|
+
title: prMetadata.title || null,
|
|
130
|
+
description: prMetadata.description || null,
|
|
131
131
|
cwd: worktreePath,
|
|
132
132
|
outputDir: tmpDir,
|
|
133
133
|
model: voiceProvider.resolvedModel !== undefined ? voiceProvider.resolvedModel : (voiceProvider.model || null),
|
|
@@ -176,11 +176,16 @@ async function runExecutableVoice(voiceProvider, reviewId, worktreePath, prMetad
|
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
if (!result?.success || !result?.data) {
|
|
179
|
+
if (progressCallback) {
|
|
180
|
+
progressCallback({ level: 'exec', status: 'failed', progress: 'External tool returned no data' });
|
|
181
|
+
}
|
|
179
182
|
throw new Error(`${logPrefix || ''}Executable provider returned no data`);
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
const suggestions = result.data.suggestions || [];
|
|
186
|
+
|
|
182
187
|
return {
|
|
183
|
-
suggestions
|
|
188
|
+
suggestions,
|
|
184
189
|
summary: result.data.summary || ''
|
|
185
190
|
};
|
|
186
191
|
} finally {
|
|
@@ -2948,6 +2953,10 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
2948
2953
|
const finalSuggestions = this.validateAndFinalizeSuggestions(result.suggestions, fileLineCountMap, validFiles);
|
|
2949
2954
|
await this.storeSuggestions(reviewId, parentRunId, finalSuggestions, null, validFiles);
|
|
2950
2955
|
|
|
2956
|
+
if (progressCallback) {
|
|
2957
|
+
progressCallback({ level: 'exec', status: 'completed', progress: `External tool complete: ${finalSuggestions.length} suggestions` });
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2951
2960
|
try {
|
|
2952
2961
|
await analysisRunRepo.update(parentRunId, {
|
|
2953
2962
|
status: 'completed',
|
|
@@ -3074,6 +3083,10 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3074
3083
|
// Store validated voice suggestions
|
|
3075
3084
|
await commentRepo.bulkInsertAISuggestions(reviewId, childRunId, validatedSuggestions, null);
|
|
3076
3085
|
|
|
3086
|
+
if (voiceProgressCallback) {
|
|
3087
|
+
voiceProgressCallback({ level: 'exec', status: 'completed', progress: `External tool complete: ${validatedSuggestions.length} suggestions` });
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3077
3090
|
const validatedResult = { ...result, suggestions: validatedSuggestions };
|
|
3078
3091
|
return { voiceKey, reviewerLabel, childRunId, result: validatedResult, provider: voice.provider, model: voice.model, isExecutable: true, customInstructions: voice.customInstructions || null };
|
|
3079
3092
|
}
|
|
@@ -3121,6 +3134,11 @@ File-level suggestions should NOT have a line number. They apply to the entire f
|
|
|
3121
3134
|
} catch (err) {
|
|
3122
3135
|
logger.warn(`[ReviewerCouncil] Failed to update child run ${childRunId}: ${err.message}`);
|
|
3123
3136
|
}
|
|
3137
|
+
// Notify the progress dialog so the voice row stops spinning
|
|
3138
|
+
if (isExecutable && voiceProgressCallback) {
|
|
3139
|
+
const terminalStatus = error.isCancellation ? 'cancelled' : 'failed';
|
|
3140
|
+
voiceProgressCallback({ level: 'exec', status: terminalStatus, progress: error.message || 'Voice failed' });
|
|
3141
|
+
}
|
|
3124
3142
|
throw error;
|
|
3125
3143
|
}
|
|
3126
3144
|
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const logger = require('../utils/logger');
|
|
11
|
-
const { scopeGitHints,
|
|
11
|
+
const { scopeGitHints, reviewScope } = require('../local-scope');
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Build a lean system prompt for chat sessions.
|
|
@@ -142,8 +142,7 @@ function buildReviewContext(review, prData) {
|
|
|
142
142
|
lines.push('');
|
|
143
143
|
lines.push('## Viewing Code Changes');
|
|
144
144
|
|
|
145
|
-
const scopeStart = review
|
|
146
|
-
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
145
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
147
146
|
const baseBranch = review.local_base_branch || null;
|
|
148
147
|
const hints = scopeGitHints(scopeStart, scopeEnd, baseBranch);
|
|
149
148
|
|
package/src/config.js
CHANGED
package/src/database.js
CHANGED
|
@@ -20,7 +20,7 @@ function getDbPath() {
|
|
|
20
20
|
/**
|
|
21
21
|
* Current schema version - increment this when adding new migrations
|
|
22
22
|
*/
|
|
23
|
-
const CURRENT_SCHEMA_VERSION =
|
|
23
|
+
const CURRENT_SCHEMA_VERSION = 36;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Database schema SQL statements
|
|
@@ -1605,6 +1605,36 @@ const MIGRATIONS = {
|
|
|
1605
1605
|
addColumnIfNotExists('comments', 'severity', 'TEXT');
|
|
1606
1606
|
|
|
1607
1607
|
console.log('Migration to schema version 35 complete');
|
|
1608
|
+
},
|
|
1609
|
+
|
|
1610
|
+
// Migration to version 36: Normalize diff scopes to always include 'unstaged'.
|
|
1611
|
+
// AI models read files from the working tree, so the diff scope must always
|
|
1612
|
+
// cover at least the unstaged state for review context to be coherent.
|
|
1613
|
+
36: (db) => {
|
|
1614
|
+
console.log('Running migration to schema version 36...');
|
|
1615
|
+
|
|
1616
|
+
// Expand scopes where end < unstaged (branch-only, branch-staged, staged-only)
|
|
1617
|
+
const expandEnd = db.prepare(
|
|
1618
|
+
`UPDATE reviews SET local_scope_end = 'unstaged'
|
|
1619
|
+
WHERE local_scope_end IN ('branch', 'staged') AND review_type = 'local'`
|
|
1620
|
+
);
|
|
1621
|
+
const expandResult = expandEnd.run();
|
|
1622
|
+
if (expandResult.changes > 0) {
|
|
1623
|
+
console.log(` Expanded scope end to 'unstaged' for ${expandResult.changes} review(s)`);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Fix untracked-only → unstaged..untracked
|
|
1627
|
+
const fixUntracked = db.prepare(
|
|
1628
|
+
`UPDATE reviews SET local_scope_start = 'unstaged'
|
|
1629
|
+
WHERE local_scope_start = 'untracked' AND local_scope_end = 'untracked'
|
|
1630
|
+
AND review_type = 'local'`
|
|
1631
|
+
);
|
|
1632
|
+
const fixResult = fixUntracked.run();
|
|
1633
|
+
if (fixResult.changes > 0) {
|
|
1634
|
+
console.log(` Normalized untracked-only scope for ${fixResult.changes} review(s)`);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
console.log('Migration to schema version 36 complete');
|
|
1608
1638
|
}
|
|
1609
1639
|
};
|
|
1610
1640
|
|
package/src/local-review.js
CHANGED
|
@@ -10,7 +10,7 @@ const { fireHooks, hasHooks } = require('./hooks/hook-runner');
|
|
|
10
10
|
const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
|
|
11
11
|
|
|
12
12
|
const execAsync = promisify(exec);
|
|
13
|
-
const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel } = require('./local-scope');
|
|
13
|
+
const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope } = require('./local-scope');
|
|
14
14
|
const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
|
|
15
15
|
const { startServer } = require('./server');
|
|
16
16
|
const { localReviewDiffs } = require('./routes/shared');
|
|
@@ -472,6 +472,13 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
|
|
|
472
472
|
const hasUnstaged = scopeIncludes(scopeStart, scopeEnd, 'unstaged');
|
|
473
473
|
const hasUntracked = scopeIncludes(scopeStart, scopeEnd, 'untracked');
|
|
474
474
|
|
|
475
|
+
// Fail fast if the scope is invalid. scopeIncludes returns false for all
|
|
476
|
+
// stops when the scope is invalid, so all four flags would be false and the
|
|
477
|
+
// branching logic below would silently produce a wrong diff.
|
|
478
|
+
if (!hasUnstaged) {
|
|
479
|
+
throw new Error(`Invalid scope ${scopeStart}..${scopeEnd}: scope must include 'unstaged'`);
|
|
480
|
+
}
|
|
481
|
+
|
|
475
482
|
let mergeBaseSha = null;
|
|
476
483
|
let diff = '';
|
|
477
484
|
|
|
@@ -483,46 +490,30 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
|
|
|
483
490
|
mergeBaseSha = await findMergeBase(repoPath, baseBranch);
|
|
484
491
|
}
|
|
485
492
|
|
|
486
|
-
// Build the git diff command based on scope range
|
|
493
|
+
// Build the git diff command based on scope range.
|
|
494
|
+
// hasUnstaged is always true by invariant — isValidScope requires the scope
|
|
495
|
+
// to include 'unstaged', since AI models read files from the working tree
|
|
496
|
+
// and the diff must cover at least the unstaged state.
|
|
487
497
|
try {
|
|
488
|
-
if (hasBranch
|
|
489
|
-
// Branch
|
|
490
|
-
diff = execSync(`git diff ${mergeBaseSha}..HEAD ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
|
|
491
|
-
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
492
|
-
maxBuffer: 50 * 1024 * 1024
|
|
493
|
-
});
|
|
494
|
-
} else if (hasBranch && hasStaged && !hasUnstaged) {
|
|
495
|
-
// Branch–Staged → staged changes relative to merge-base
|
|
496
|
-
diff = execSync(`git diff --cached ${mergeBaseSha} ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
|
|
497
|
-
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
498
|
-
maxBuffer: 50 * 1024 * 1024
|
|
499
|
-
});
|
|
500
|
-
} else if (hasBranch && hasUnstaged) {
|
|
501
|
-
// Branch–Unstaged (or Branch–Untracked) → working tree vs merge-base
|
|
498
|
+
if (hasBranch) {
|
|
499
|
+
// Branch scope → working tree vs merge-base (includes committed + staged + unstaged)
|
|
502
500
|
diff = execSync(`git diff ${mergeBaseSha} ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
|
|
503
501
|
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
504
502
|
maxBuffer: 50 * 1024 * 1024
|
|
505
503
|
});
|
|
506
|
-
} else if (hasStaged
|
|
507
|
-
// Staged
|
|
508
|
-
diff = execSync(`git diff --cached ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
|
|
509
|
-
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
510
|
-
maxBuffer: 50 * 1024 * 1024
|
|
511
|
-
});
|
|
512
|
-
} else if (hasStaged && hasUnstaged) {
|
|
513
|
-
// Staged–Unstaged (or Staged–Untracked) → all changes vs HEAD
|
|
504
|
+
} else if (hasStaged) {
|
|
505
|
+
// Staged + Unstaged scope → all changes vs HEAD
|
|
514
506
|
diff = execSync(`git diff HEAD ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
|
|
515
507
|
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
516
508
|
maxBuffer: 50 * 1024 * 1024
|
|
517
509
|
});
|
|
518
|
-
} else
|
|
519
|
-
// Unstaged only or Unstaged–Untracked → working tree changes
|
|
510
|
+
} else {
|
|
511
|
+
// Unstaged only (or Unstaged–Untracked) → working tree changes
|
|
520
512
|
diff = execSync(`git diff ${GIT_DIFF_FLAGS}${contextFlag}${extraArgsStr}${wFlag}`, {
|
|
521
513
|
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
522
514
|
maxBuffer: 50 * 1024 * 1024
|
|
523
515
|
});
|
|
524
516
|
}
|
|
525
|
-
// hasUntracked-only: no git diff needed, just untracked files below
|
|
526
517
|
} catch (error) {
|
|
527
518
|
if (error.message && error.message.includes('maxBuffer')) {
|
|
528
519
|
throw new Error('Diff output exceeded maximum buffer size (50MB).');
|
|
@@ -798,8 +789,8 @@ async function handleLocalReview(targetPath, flags = {}) {
|
|
|
798
789
|
}
|
|
799
790
|
|
|
800
791
|
// Read scope from session (or use defaults for new sessions)
|
|
801
|
-
|
|
802
|
-
const scopeEnd = existingReview
|
|
792
|
+
// Use reviewScope() to normalize legacy scopes that may not include 'unstaged'
|
|
793
|
+
const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
|
|
803
794
|
|
|
804
795
|
// Fire review hook (non-blocking)
|
|
805
796
|
const hookEvent = existingReview ? 'review.loaded' : 'review.started';
|
|
@@ -937,7 +928,7 @@ async function computeLocalDiffDigest(localPath) {
|
|
|
937
928
|
* @returns {Promise<{diff: string, stats: Object, mergeBaseSha: string}>}
|
|
938
929
|
*/
|
|
939
930
|
async function generateBranchDiff(repoPath, baseBranch, options = {}) {
|
|
940
|
-
return generateScopedDiff(repoPath, 'branch', '
|
|
931
|
+
return generateScopedDiff(repoPath, 'branch', 'unstaged', baseBranch, options);
|
|
941
932
|
}
|
|
942
933
|
|
|
943
934
|
/**
|
package/src/local-scope.js
CHANGED
|
@@ -4,10 +4,29 @@ const STOPS = ['branch', 'staged', 'unstaged', 'untracked'];
|
|
|
4
4
|
|
|
5
5
|
const DEFAULT_SCOPE = { start: 'unstaged', end: 'untracked' };
|
|
6
6
|
|
|
7
|
+
const UNSTAGED_INDEX = STOPS.indexOf('unstaged');
|
|
8
|
+
|
|
7
9
|
function isValidScope(start, end) {
|
|
8
10
|
const si = STOPS.indexOf(start);
|
|
9
11
|
const ei = STOPS.indexOf(end);
|
|
10
|
-
|
|
12
|
+
// Scope must be contiguous AND must include the 'unstaged' stop.
|
|
13
|
+
// This ensures the diff always covers the working tree state that AI models
|
|
14
|
+
// see when reading files, since we cannot modify local git state.
|
|
15
|
+
return si !== -1 && ei !== -1 && si <= ei && si <= UNSTAGED_INDEX && ei >= UNSTAGED_INDEX;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeScope(start, end) {
|
|
19
|
+
if (isValidScope(start, end)) return { start, end };
|
|
20
|
+
const si = STOPS.indexOf(start);
|
|
21
|
+
const ei = STOPS.indexOf(end);
|
|
22
|
+
if (si === -1 || ei === -1) return { start: DEFAULT_SCOPE.start, end: DEFAULT_SCOPE.end };
|
|
23
|
+
const newEi = Math.max(ei, UNSTAGED_INDEX);
|
|
24
|
+
const newSi = Math.min(si, UNSTAGED_INDEX);
|
|
25
|
+
const finalSi = Math.min(newSi, newEi);
|
|
26
|
+
const newStart = STOPS[finalSi];
|
|
27
|
+
const newEnd = STOPS[newEi];
|
|
28
|
+
if (isValidScope(newStart, newEnd)) return { start: newStart, end: newEnd };
|
|
29
|
+
return { start: DEFAULT_SCOPE.start, end: DEFAULT_SCOPE.end };
|
|
11
30
|
}
|
|
12
31
|
|
|
13
32
|
function scopeIncludes(start, end, stop) {
|
|
@@ -27,11 +46,18 @@ function fromLegacyMode(localMode) {
|
|
|
27
46
|
return { start: 'unstaged', end: 'untracked' };
|
|
28
47
|
}
|
|
29
48
|
if (localMode === 'branch') {
|
|
30
|
-
return { start: 'branch', end: '
|
|
49
|
+
return { start: 'branch', end: 'unstaged' };
|
|
31
50
|
}
|
|
32
51
|
return { start: DEFAULT_SCOPE.start, end: DEFAULT_SCOPE.end };
|
|
33
52
|
}
|
|
34
53
|
|
|
54
|
+
function reviewScope(review) {
|
|
55
|
+
return normalizeScope(
|
|
56
|
+
review.local_scope_start || DEFAULT_SCOPE.start,
|
|
57
|
+
review.local_scope_end || DEFAULT_SCOPE.end
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
function scopeLabel(start, end) {
|
|
36
62
|
if (!isValidScope(start, end)) return '';
|
|
37
63
|
const label = s => s.charAt(0).toUpperCase() + s.slice(1);
|
|
@@ -57,16 +83,6 @@ function scopeGitHints(start, end, baseBranch) {
|
|
|
57
83
|
|
|
58
84
|
const key = start + '-' + end;
|
|
59
85
|
const hints = {
|
|
60
|
-
'branch-branch': {
|
|
61
|
-
description: 'Committed changes on this branch since the merge-base.',
|
|
62
|
-
diffCommand: 'git diff --no-ext-diff ' + mb + '..HEAD',
|
|
63
|
-
excludes: 'Staged, unstaged, and untracked changes are NOT included in the review.'
|
|
64
|
-
},
|
|
65
|
-
'branch-staged': {
|
|
66
|
-
description: 'Committed changes plus staged changes, relative to the merge-base.',
|
|
67
|
-
diffCommand: 'git diff --no-ext-diff --cached ' + mb,
|
|
68
|
-
excludes: 'Unstaged and untracked changes are NOT included in the review.'
|
|
69
|
-
},
|
|
70
86
|
'branch-unstaged': {
|
|
71
87
|
description: 'All tracked changes (committed, staged, and unstaged) relative to the merge-base.',
|
|
72
88
|
diffCommand: 'git diff --no-ext-diff ' + mb,
|
|
@@ -77,11 +93,6 @@ function scopeGitHints(start, end, baseBranch) {
|
|
|
77
93
|
diffCommand: 'git diff --no-ext-diff ' + mb,
|
|
78
94
|
excludes: ''
|
|
79
95
|
},
|
|
80
|
-
'staged-staged': {
|
|
81
|
-
description: 'Only staged changes (added to the index but not yet committed).',
|
|
82
|
-
diffCommand: 'git diff --no-ext-diff --cached',
|
|
83
|
-
excludes: 'Unstaged, untracked, and committed branch changes are NOT included in the review.'
|
|
84
|
-
},
|
|
85
96
|
'staged-unstaged': {
|
|
86
97
|
description: 'Staged and unstaged changes relative to HEAD.',
|
|
87
98
|
diffCommand: 'git diff --no-ext-diff HEAD',
|
|
@@ -101,11 +112,6 @@ function scopeGitHints(start, end, baseBranch) {
|
|
|
101
112
|
description: 'Unstaged and untracked local changes.',
|
|
102
113
|
diffCommand: 'git diff --no-ext-diff',
|
|
103
114
|
excludes: 'Staged changes (`git diff --no-ext-diff --cached`) are treated as already reviewed.'
|
|
104
|
-
},
|
|
105
|
-
'untracked-untracked': {
|
|
106
|
-
description: 'Only untracked files (new files not yet added to git).',
|
|
107
|
-
diffCommand: 'git ls-files --others --exclude-standard',
|
|
108
|
-
excludes: 'Tracked file changes (staged, unstaged, committed) are NOT included in the review.'
|
|
109
115
|
}
|
|
110
116
|
};
|
|
111
117
|
|
|
@@ -117,7 +123,7 @@ function scopeGitHints(start, end, baseBranch) {
|
|
|
117
123
|
description: entry.description,
|
|
118
124
|
diffCommand: entry.diffCommand,
|
|
119
125
|
excludes: entry.excludes,
|
|
120
|
-
includesUntracked: incUntracked
|
|
126
|
+
includesUntracked: incUntracked
|
|
121
127
|
};
|
|
122
128
|
}
|
|
123
129
|
|
|
@@ -125,6 +131,8 @@ const LocalScope = {
|
|
|
125
131
|
STOPS,
|
|
126
132
|
DEFAULT_SCOPE,
|
|
127
133
|
isValidScope,
|
|
134
|
+
normalizeScope,
|
|
135
|
+
reviewScope,
|
|
128
136
|
scopeIncludes,
|
|
129
137
|
includesBranch,
|
|
130
138
|
fromLegacyMode,
|
|
@@ -55,15 +55,10 @@ async function generateDiffForExecutable(cwd, context, diffArgs, outputPath) {
|
|
|
55
55
|
let diff;
|
|
56
56
|
const extraFlags = diffArgs.length > 0 ? ' ' + diffArgs.join(' ') : '';
|
|
57
57
|
|
|
58
|
-
if (context.
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{ cwd, maxBuffer: 50 * 1024 * 1024 }
|
|
63
|
-
);
|
|
64
|
-
diff = stdout;
|
|
65
|
-
} else if (context.scopeStart && context.scopeEnd) {
|
|
66
|
-
// Local mode: scope-aware diff generation
|
|
58
|
+
if (context.scopeStart && context.scopeEnd) {
|
|
59
|
+
// Local mode: scope-aware diff generation (checked first because local reviews
|
|
60
|
+
// may also carry baseSha/headSha pointing at the same commit, which would
|
|
61
|
+
// produce an empty diff if the SHA path ran instead).
|
|
67
62
|
// Note: diffArgs are passed as extraArgs to generateScopedDiff, which handles
|
|
68
63
|
// appending them to the git diff command internally (extraFlags is not used here).
|
|
69
64
|
const result = await generateScopedDiff(
|
|
@@ -74,6 +69,13 @@ async function generateDiffForExecutable(cwd, context, diffArgs, outputPath) {
|
|
|
74
69
|
{ contextLines: 3, extraArgs: diffArgs }
|
|
75
70
|
);
|
|
76
71
|
diff = result.diff;
|
|
72
|
+
} else if (context.baseSha && context.headSha) {
|
|
73
|
+
// PR mode: straightforward base...head diff
|
|
74
|
+
const { stdout } = await execPromise(
|
|
75
|
+
`git diff ${GIT_DIFF_FLAGS}${extraFlags} ${context.baseSha}...${context.headSha}`,
|
|
76
|
+
{ cwd, maxBuffer: 50 * 1024 * 1024 }
|
|
77
|
+
);
|
|
78
|
+
diff = stdout;
|
|
77
79
|
} else {
|
|
78
80
|
// Fallback: simple working-tree diff
|
|
79
81
|
const { stdout } = await execPromise(
|
package/src/routes/local.js
CHANGED
|
@@ -26,7 +26,7 @@ const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStarte
|
|
|
26
26
|
const { mergeInstructions } = require('../utils/instructions');
|
|
27
27
|
const { getGitHubToken } = require('../config');
|
|
28
28
|
const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = require('../local-review');
|
|
29
|
-
const { STOPS, isValidScope,
|
|
29
|
+
const { STOPS, isValidScope, normalizeScope, reviewScope, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
|
|
30
30
|
const { getGeneratedFilePatterns } = require('../git/gitattributes');
|
|
31
31
|
const { getShaAbbrevLength } = require('../git/sha-abbrev');
|
|
32
32
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
@@ -77,9 +77,10 @@ function deleteLocalReviewDiff(reviewId) {
|
|
|
77
77
|
* Returns true if the guard fired (response already sent), false otherwise.
|
|
78
78
|
*/
|
|
79
79
|
async function rejectIfEmptyScope(res, review, localPath) {
|
|
80
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
80
81
|
const scopeContext = {
|
|
81
|
-
scopeStart
|
|
82
|
-
scopeEnd
|
|
82
|
+
scopeStart,
|
|
83
|
+
scopeEnd,
|
|
83
84
|
baseBranch: review.local_base_branch || null,
|
|
84
85
|
};
|
|
85
86
|
const changedFiles = await getChangedFiles(localPath, scopeContext);
|
|
@@ -461,8 +462,7 @@ router.post('/api/local/start', async (req, res) => {
|
|
|
461
462
|
const config = req.app.get('config') || {};
|
|
462
463
|
// Generate diff using default scope
|
|
463
464
|
logger.log('API', `Starting local review for ${repoPath}`, 'cyan');
|
|
464
|
-
const scopeStart = existing
|
|
465
|
-
const scopeEnd = existing?.local_scope_end || DEFAULT_SCOPE.end;
|
|
465
|
+
const { start: scopeStart, end: scopeEnd } = existing ? reviewScope(existing) : DEFAULT_SCOPE;
|
|
466
466
|
|
|
467
467
|
// Fire review hook (non-blocking, after scope is resolved)
|
|
468
468
|
const hookEvent = existing ? 'review.loaded' : 'review.started';
|
|
@@ -580,9 +580,11 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
580
580
|
}
|
|
581
581
|
}
|
|
582
582
|
|
|
583
|
-
// Build scope info for the response
|
|
584
|
-
|
|
585
|
-
|
|
583
|
+
// Build scope info for the response.
|
|
584
|
+
// normalizeScope clamps any legacy invalid scopes (e.g. branch-only,
|
|
585
|
+
// staged-only) to always include 'unstaged', since AI models read files
|
|
586
|
+
// from the working tree and the diff must match what they see.
|
|
587
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
586
588
|
const baseBranch = review.local_base_branch || null;
|
|
587
589
|
|
|
588
590
|
// When scope does NOT include branch, check for branch detection info
|
|
@@ -693,8 +695,7 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
693
695
|
const hookConfig = req.app.get('config') || {};
|
|
694
696
|
if (hasHooks('review.loaded', hookConfig)) {
|
|
695
697
|
getCachedUser(hookConfig).then(user => {
|
|
696
|
-
const hookScopeStart = review
|
|
697
|
-
const hookScopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
698
|
+
const { start: hookScopeStart, end: hookScopeEnd } = reviewScope(review);
|
|
698
699
|
const si = STOPS.indexOf(hookScopeStart);
|
|
699
700
|
const ei = STOPS.indexOf(hookScopeEnd);
|
|
700
701
|
const scope = STOPS.slice(si, ei + 1);
|
|
@@ -787,8 +788,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
787
788
|
// When ?w=1 or ?base=<branch>, regenerate the diff (transient view, not cached)
|
|
788
789
|
const hideWhitespace = req.query.w === '1';
|
|
789
790
|
const baseBranchOverride = req.query.base;
|
|
790
|
-
const scopeStart = review
|
|
791
|
-
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
791
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
792
792
|
const baseBranch = baseBranchOverride || review.local_base_branch;
|
|
793
793
|
let diffData;
|
|
794
794
|
|
|
@@ -900,8 +900,7 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
|
900
900
|
});
|
|
901
901
|
}
|
|
902
902
|
|
|
903
|
-
const scopeStart = review
|
|
904
|
-
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
903
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
905
904
|
|
|
906
905
|
// Always check HEAD SHA for supplementary fields
|
|
907
906
|
let headShaChanged = false;
|
|
@@ -1042,19 +1041,22 @@ async function handleExecutableAnalysis(req, res, {
|
|
|
1042
1041
|
registerProcessForCancellation
|
|
1043
1042
|
}, {
|
|
1044
1043
|
logLabel: `Review #${reviewId}`,
|
|
1045
|
-
buildContext: (r, { selectedModel: model, requestInstructions: customInstructions }) =>
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1044
|
+
buildContext: (r, { selectedModel: model, requestInstructions: customInstructions }) => {
|
|
1045
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(r);
|
|
1046
|
+
return {
|
|
1047
|
+
title: null,
|
|
1048
|
+
description: null,
|
|
1049
|
+
cwd: localPath,
|
|
1050
|
+
model,
|
|
1051
|
+
baseSha: null,
|
|
1052
|
+
headSha: r.local_head_sha || null,
|
|
1053
|
+
baseBranch: r.local_base_branch || null,
|
|
1054
|
+
headBranch: r.local_head_branch || null,
|
|
1055
|
+
scopeStart,
|
|
1056
|
+
scopeEnd,
|
|
1057
|
+
customInstructions: customInstructions || null
|
|
1058
|
+
};
|
|
1059
|
+
},
|
|
1058
1060
|
buildHookPayload: () => ({
|
|
1059
1061
|
mode: review.review_type || 'local',
|
|
1060
1062
|
localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha }
|
|
@@ -1185,8 +1187,7 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
1185
1187
|
}
|
|
1186
1188
|
|
|
1187
1189
|
// Extract scope early — needed for both analysis run creation and diff generation
|
|
1188
|
-
const scopeStart = review
|
|
1189
|
-
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
1190
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
1190
1191
|
|
|
1191
1192
|
// Create DB analysis_runs record immediately so it's queryable for polling
|
|
1192
1193
|
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
@@ -1281,13 +1282,14 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
1281
1282
|
reviewType: 'local'
|
|
1282
1283
|
};
|
|
1283
1284
|
|
|
1284
|
-
// Get changed files for local mode path validation
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1287
|
-
const
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
:
|
|
1285
|
+
// Get changed files for local mode path validation.
|
|
1286
|
+
// Use the scope-aware helper so the file list matches the generated diff
|
|
1287
|
+
// (covers branch, staged, unstaged, and untracked stops as appropriate).
|
|
1288
|
+
const changedFiles = await getChangedFiles(localPath, {
|
|
1289
|
+
scopeStart,
|
|
1290
|
+
scopeEnd,
|
|
1291
|
+
baseBranch: review.local_base_branch || null,
|
|
1292
|
+
});
|
|
1291
1293
|
|
|
1292
1294
|
// Log analysis start
|
|
1293
1295
|
logger.section(`Local AI Analysis Request - Review #${reviewId}`);
|
|
@@ -1297,7 +1299,7 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
|
|
|
1297
1299
|
logger.log('API', `Provider: ${selectedProvider}`, 'cyan');
|
|
1298
1300
|
logger.log('API', `Model: ${selectedModel}`, 'cyan');
|
|
1299
1301
|
logger.log('API', `Tier: ${tier}`, 'cyan');
|
|
1300
|
-
logger.log('API', `Changed files: ${changedFiles
|
|
1302
|
+
logger.log('API', `Changed files: ${changedFiles.length}`, 'cyan');
|
|
1301
1303
|
if (combinedInstructions) {
|
|
1302
1304
|
logger.log('API', `Custom instructions: ${combinedInstructions.length} chars`, 'cyan');
|
|
1303
1305
|
}
|
|
@@ -1508,8 +1510,7 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
|
1508
1510
|
|
|
1509
1511
|
// Check if HEAD has changed
|
|
1510
1512
|
const { getHeadSha } = require('../local-review');
|
|
1511
|
-
const scopeStart = review
|
|
1512
|
-
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
1513
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
1513
1514
|
const hasBranch = includesBranch(scopeStart);
|
|
1514
1515
|
let currentHeadSha;
|
|
1515
1516
|
let headShaChanged = false;
|
|
@@ -1627,8 +1628,7 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
|
|
|
1627
1628
|
return res.status(400).json({ error: 'Local review is missing path information' });
|
|
1628
1629
|
}
|
|
1629
1630
|
|
|
1630
|
-
const scopeStart = review
|
|
1631
|
-
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
1631
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(review);
|
|
1632
1632
|
|
|
1633
1633
|
if (action === 'update') {
|
|
1634
1634
|
// Read live branch — may differ from stored value after a checkout.
|
|
@@ -1788,7 +1788,7 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
|
|
|
1788
1788
|
await reviewRepo.updateLocalHeadSha(reviewId, headSha);
|
|
1789
1789
|
|
|
1790
1790
|
// Auto-name review from first commit subject when branch is newly in scope
|
|
1791
|
-
const oldScopeStart = review
|
|
1791
|
+
const { start: oldScopeStart } = reviewScope(review);
|
|
1792
1792
|
if (!review.name && includesBranch(scopeStart) && !includesBranch(oldScopeStart) && baseBranch) {
|
|
1793
1793
|
const firstSubject = await getFirstCommitSubject(localPath, baseBranch);
|
|
1794
1794
|
if (firstSubject) {
|
|
@@ -2021,8 +2021,7 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
2021
2021
|
// Guard: reject if scope resolves to zero changed files
|
|
2022
2022
|
if (await rejectIfEmptyScope(res, review, localPath)) return;
|
|
2023
2023
|
|
|
2024
|
-
const councilScopeStart = review
|
|
2025
|
-
const councilScopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
2024
|
+
const { start: councilScopeStart, end: councilScopeEnd } = reviewScope(review);
|
|
2026
2025
|
const councilHasBranch = includesBranch(councilScopeStart);
|
|
2027
2026
|
|
|
2028
2027
|
// Compute merge-base when branch is in scope
|
|
@@ -2038,8 +2037,8 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
2038
2037
|
const prMetadata = {
|
|
2039
2038
|
reviewType: 'local',
|
|
2040
2039
|
repository: review.repository,
|
|
2041
|
-
title:
|
|
2042
|
-
description:
|
|
2040
|
+
title: null,
|
|
2041
|
+
description: null,
|
|
2043
2042
|
base_sha: analysisBaseSha,
|
|
2044
2043
|
head_sha: review.local_head_sha,
|
|
2045
2044
|
base_branch: review.local_base_branch || null,
|
|
@@ -2049,10 +2048,13 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
|
|
|
2049
2048
|
};
|
|
2050
2049
|
|
|
2051
2050
|
const analyzer = new Analyzer(db, 'council', 'council');
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
:
|
|
2051
|
+
// Use the scope-aware helper so the file list matches the generated diff
|
|
2052
|
+
// (covers branch, staged, unstaged, and untracked stops as appropriate).
|
|
2053
|
+
const changedFiles = await getChangedFiles(localPath, {
|
|
2054
|
+
scopeStart: councilScopeStart,
|
|
2055
|
+
scopeEnd: councilScopeEnd,
|
|
2056
|
+
baseBranch: review.local_base_branch || null,
|
|
2057
|
+
});
|
|
2056
2058
|
|
|
2057
2059
|
// Generate and cache diff
|
|
2058
2060
|
try {
|
package/src/routes/setup.js
CHANGED
|
@@ -15,7 +15,7 @@ const crypto = require('crypto');
|
|
|
15
15
|
const { activeSetups, broadcastSetupProgress } = require('./shared');
|
|
16
16
|
const { setupPRReview } = require('../setup/pr-setup');
|
|
17
17
|
const { setupLocalReview } = require('../setup/local-setup');
|
|
18
|
-
const { getGitHubToken } = require('../config');
|
|
18
|
+
const { getGitHubToken, expandPath } = require('../config');
|
|
19
19
|
const { queryOne } = require('../database');
|
|
20
20
|
const { normalizeRepository } = require('../utils/paths');
|
|
21
21
|
const logger = require('../utils/logger');
|
|
@@ -143,12 +143,13 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
143
143
|
*/
|
|
144
144
|
router.post('/api/setup/local', async (req, res) => {
|
|
145
145
|
try {
|
|
146
|
-
const { path:
|
|
146
|
+
const { path: rawPath } = req.body;
|
|
147
147
|
|
|
148
|
-
if (!
|
|
148
|
+
if (!rawPath) {
|
|
149
149
|
return res.status(400).json({ error: 'Missing required field: path' });
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
const targetPath = expandPath(rawPath);
|
|
152
153
|
const db = req.app.get('db');
|
|
153
154
|
|
|
154
155
|
// Concurrency guard
|
package/src/setup/local-setup.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
const { findGitRoot, getHeadSha, getCurrentBranch, getRepositoryName,
|
|
2
|
+
const { findGitRoot, getHeadSha, getCurrentBranch, getRepositoryName, generateScopedDiff, generateLocalReviewId, computeScopedDigest, findMainGitRoot } = require('../local-review');
|
|
3
3
|
const { ReviewRepository, RepoSettingsRepository } = require('../database');
|
|
4
4
|
const { localReviewDiffs } = require('../routes/shared');
|
|
5
5
|
const { fireHooks, hasHooks } = require('../hooks/hook-runner');
|
|
6
6
|
const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('../hooks/payloads');
|
|
7
|
-
const { STOPS, DEFAULT_SCOPE } = require('../local-scope');
|
|
7
|
+
const { STOPS, DEFAULT_SCOPE, reviewScope } = require('../local-scope');
|
|
8
8
|
const logger = require('../utils/logger');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const fs = require('fs').promises;
|
|
@@ -102,13 +102,16 @@ async function setupLocalReview({ db, targetPath, onProgress, config }) {
|
|
|
102
102
|
// ── Step: diff ──────────────────────────────────────────────────────
|
|
103
103
|
let diff, stats, digest;
|
|
104
104
|
try {
|
|
105
|
+
const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
|
|
106
|
+
const baseBranch = existingReview?.local_base_branch || null;
|
|
107
|
+
|
|
105
108
|
progress({ step: 'diff', status: 'running', message: 'Generating diff for local changes...' });
|
|
106
109
|
|
|
107
|
-
const diffResult = await
|
|
110
|
+
const diffResult = await generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
|
|
108
111
|
diff = diffResult.diff;
|
|
109
112
|
stats = diffResult.stats;
|
|
110
113
|
|
|
111
|
-
digest = await
|
|
114
|
+
digest = await computeScopedDigest(repoPath, scopeStart, scopeEnd);
|
|
112
115
|
|
|
113
116
|
progress({ step: 'diff', status: 'completed', message: `Diff ready: ${stats.unstagedChanges} unstaged, ${stats.untrackedFiles} untracked` });
|
|
114
117
|
} catch (err) {
|
|
@@ -152,8 +155,7 @@ async function setupLocalReview({ db, targetPath, onProgress, config }) {
|
|
|
152
155
|
if (config && hasHooks(hookEvent, config)) {
|
|
153
156
|
getCachedUser(config).then(user => {
|
|
154
157
|
const builder = existingReview ? buildReviewLoadedPayload : buildReviewStartedPayload;
|
|
155
|
-
const scopeStart = existingReview
|
|
156
|
-
const scopeEnd = existingReview?.local_scope_end || DEFAULT_SCOPE.end;
|
|
158
|
+
const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
|
|
157
159
|
const si = STOPS.indexOf(scopeStart);
|
|
158
160
|
const ei = STOPS.indexOf(scopeEnd);
|
|
159
161
|
const scope = STOPS.slice(si, ei + 1);
|