@in-the-loop-labs/pair-review 3.1.2 → 3.1.4
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/css/pr.css +86 -2
- package/public/js/components/AIPanel.js +7 -4
- package/public/js/components/ChatPanel.js +34 -4
- package/public/js/components/CouncilProgressModal.js +3 -0
- package/public/js/components/DiffOptionsDropdown.js +93 -19
- package/public/js/components/SuggestionNavigator.js +2 -0
- package/public/js/modules/comment-manager.js +7 -0
- package/public/js/modules/comment-minimizer.js +151 -4
- package/public/js/modules/file-comment-manager.js +66 -2
- package/public/js/modules/suggestion-manager.js +2 -1
- package/public/js/pr.js +13 -0
- package/src/ai/analyzer.js +21 -3
- package/src/ai/claude-provider.js +1 -11
- package/src/ai/codex-provider.js +18 -16
- package/src/ai/copilot-provider.js +21 -21
- package/src/ai/gemini-provider.js +10 -0
- package/src/ai/pi-provider.js +22 -25
- package/src/ai/provider.js +26 -3
- package/src/chat/pi-bridge.js +8 -0
- package/src/chat/prompt-builder.js +2 -3
- package/src/chat/session-manager.js +1 -0
- 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
|
@@ -20,6 +20,16 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
20
20
|
* Gemini model definitions with tier mappings
|
|
21
21
|
*/
|
|
22
22
|
const GEMINI_MODELS = [
|
|
23
|
+
{
|
|
24
|
+
id: 'gemini-3.1-flash-lite-preview',
|
|
25
|
+
aliases: ['gemini-3.1-flash-lite'],
|
|
26
|
+
name: '3.1 Flash Lite',
|
|
27
|
+
tier: 'fast',
|
|
28
|
+
tagline: 'Cheapest',
|
|
29
|
+
description: 'Ultra-efficient model for high-volume cost-conscious scans',
|
|
30
|
+
badge: 'Cheapest',
|
|
31
|
+
badgeClass: 'badge-speed'
|
|
32
|
+
},
|
|
23
33
|
{
|
|
24
34
|
id: 'gemini-3-flash-preview',
|
|
25
35
|
aliases: ['gemini-3-flash'],
|
package/src/ai/pi-provider.js
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
* for cross-provider switching, which translates to `--provider <provider> --model <model>`.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
const crypto = require('crypto');
|
|
19
20
|
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const fs = require('fs');
|
|
20
23
|
const { spawn } = require('child_process');
|
|
21
24
|
const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
|
|
22
25
|
const logger = require('../utils/logger');
|
|
@@ -244,19 +247,23 @@ class PiProvider extends AIProvider {
|
|
|
244
247
|
|
|
245
248
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
246
249
|
logger.info(`${levelPrefix} Executing Pi CLI...`);
|
|
247
|
-
logger.info(`${levelPrefix}
|
|
250
|
+
logger.info(`${levelPrefix} Prompt: ${prompt.length} bytes`);
|
|
251
|
+
|
|
252
|
+
// Write prompt to a temp file and use Pi's @file syntax as a positional arg.
|
|
253
|
+
// This bypasses devx stdin interference that breaks --mode json output.
|
|
254
|
+
const tmpFile = path.join(os.tmpdir(), `pair-review-prompt-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
|
|
255
|
+
fs.writeFileSync(tmpFile, prompt);
|
|
256
|
+
const cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
|
|
248
257
|
|
|
249
|
-
// Use stdin for prompt instead of CLI argument (avoids shell escaping issues)
|
|
250
|
-
// Pi reads from stdin when using -p with no positional message arguments
|
|
251
258
|
let fullCommand;
|
|
252
259
|
let fullArgs;
|
|
253
260
|
|
|
254
261
|
if (this.useShell) {
|
|
255
|
-
fullCommand = `${this.piCmd} ${quoteShellArgs(this.baseArgs).join(' ')}`;
|
|
262
|
+
fullCommand = `${this.piCmd} ${quoteShellArgs([...this.baseArgs, `@${tmpFile}`]).join(' ')}`;
|
|
256
263
|
fullArgs = [];
|
|
257
264
|
} else {
|
|
258
265
|
fullCommand = this.piCmd;
|
|
259
|
-
fullArgs = [...this.baseArgs];
|
|
266
|
+
fullArgs = [...this.baseArgs, `@${tmpFile}`];
|
|
260
267
|
}
|
|
261
268
|
|
|
262
269
|
const pi = spawn(fullCommand, fullArgs, {
|
|
@@ -269,6 +276,10 @@ class PiProvider extends AIProvider {
|
|
|
269
276
|
shell: this.useShell
|
|
270
277
|
});
|
|
271
278
|
|
|
279
|
+
// Close stdin immediately — prompt is delivered via @file, but some
|
|
280
|
+
// wrappers (e.g., devx) keep the process alive until stdin is closed.
|
|
281
|
+
pi.stdin.end();
|
|
282
|
+
|
|
272
283
|
const pid = pi.pid;
|
|
273
284
|
logger.debug(`${levelPrefix} Pi CLI command: ${fullCommand} ${fullArgs.join(' ')}`);
|
|
274
285
|
logger.info(`${levelPrefix} Spawned Pi CLI process: PID ${pid}`);
|
|
@@ -340,6 +351,7 @@ class PiProvider extends AIProvider {
|
|
|
340
351
|
|
|
341
352
|
// Handle completion
|
|
342
353
|
pi.on('close', (code) => {
|
|
354
|
+
cleanupTmpFile();
|
|
343
355
|
if (settled) return; // Already settled by timeout or error
|
|
344
356
|
|
|
345
357
|
// Flush any remaining stream parser buffer
|
|
@@ -413,7 +425,7 @@ class PiProvider extends AIProvider {
|
|
|
413
425
|
|
|
414
426
|
// Use async IIFE to handle the async LLM extraction
|
|
415
427
|
(async () => {
|
|
416
|
-
// Guard: if already settled (by timeout,
|
|
428
|
+
// Guard: if already settled (by timeout, process error, or cancellation),
|
|
417
429
|
// skip the LLM extraction entirely to avoid misleading log output
|
|
418
430
|
if (settled) return;
|
|
419
431
|
|
|
@@ -437,6 +449,7 @@ class PiProvider extends AIProvider {
|
|
|
437
449
|
|
|
438
450
|
// Handle errors
|
|
439
451
|
pi.on('error', (error) => {
|
|
452
|
+
cleanupTmpFile();
|
|
440
453
|
if (error.code === 'ENOENT') {
|
|
441
454
|
logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
|
|
442
455
|
settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
|
|
@@ -445,21 +458,6 @@ class PiProvider extends AIProvider {
|
|
|
445
458
|
settle(reject, error);
|
|
446
459
|
}
|
|
447
460
|
});
|
|
448
|
-
|
|
449
|
-
// Handle stdin errors (e.g., EPIPE if process exits before write completes)
|
|
450
|
-
pi.stdin.on('error', (err) => {
|
|
451
|
-
logger.error(`${levelPrefix} stdin error: ${err.message}`);
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
// Send the prompt to stdin (Pi reads from stdin when using -p with no args)
|
|
455
|
-
pi.stdin.write(prompt, (err) => {
|
|
456
|
-
if (err) {
|
|
457
|
-
logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
|
|
458
|
-
pi.kill('SIGTERM');
|
|
459
|
-
settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
|
|
460
|
-
}
|
|
461
|
-
});
|
|
462
|
-
pi.stdin.end();
|
|
463
461
|
});
|
|
464
462
|
}
|
|
465
463
|
|
|
@@ -740,14 +738,13 @@ class PiProvider extends AIProvider {
|
|
|
740
738
|
// Build args consistently using the shared method, applying provider and model extra_args
|
|
741
739
|
const args = this.buildArgsForModel(model);
|
|
742
740
|
|
|
743
|
-
//
|
|
744
|
-
// Pi reads from stdin when using -p with no positional message arguments
|
|
741
|
+
// Use @file syntax for prompt delivery (bypasses devx stdin interference)
|
|
745
742
|
if (useShell) {
|
|
746
743
|
return {
|
|
747
744
|
command: `${piCmd} ${quoteShellArgs(args).join(' ')}`,
|
|
748
745
|
args: [],
|
|
749
746
|
useShell: true,
|
|
750
|
-
|
|
747
|
+
promptViaFile: true,
|
|
751
748
|
env: this.extraEnv
|
|
752
749
|
};
|
|
753
750
|
}
|
|
@@ -755,7 +752,7 @@ class PiProvider extends AIProvider {
|
|
|
755
752
|
command: piCmd,
|
|
756
753
|
args,
|
|
757
754
|
useShell: false,
|
|
758
|
-
|
|
755
|
+
promptViaFile: true,
|
|
759
756
|
env: this.extraEnv
|
|
760
757
|
};
|
|
761
758
|
}
|
package/src/ai/provider.js
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* and provides a factory function to create provider instances.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
const crypto = require('crypto');
|
|
9
10
|
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const fs = require('fs');
|
|
10
13
|
const { spawn } = require('child_process');
|
|
11
14
|
const logger = require('../utils/logger');
|
|
12
15
|
const { extractJSON } = require('../utils/json-extractor');
|
|
@@ -181,6 +184,7 @@ class AIProvider {
|
|
|
181
184
|
* @property {string[]} args - Arguments (prompt will be appended if promptViaStdin is false)
|
|
182
185
|
* @property {boolean} useShell - Whether to use shell mode
|
|
183
186
|
* @property {boolean} promptViaStdin - If true, send prompt to stdin; if false, append to args
|
|
187
|
+
* @property {boolean} promptViaFile - If true, write prompt to a temp file and pass @filepath as a positional arg (Pi-specific @file syntax; currently only used by PiProvider)
|
|
184
188
|
*/
|
|
185
189
|
getExtractionConfig(model) {
|
|
186
190
|
// Default: extraction not supported
|
|
@@ -213,7 +217,7 @@ class AIProvider {
|
|
|
213
217
|
};
|
|
214
218
|
}
|
|
215
219
|
|
|
216
|
-
const { command, args, useShell, promptViaStdin, env: configEnv } = config;
|
|
220
|
+
const { command, args, useShell, promptViaStdin, promptViaFile, env: configEnv } = config;
|
|
217
221
|
const prompt = `Extract the JSON object from the following text. Return ONLY the valid JSON, nothing else. Do not include any explanation, markdown formatting, or code blocks - just the raw JSON.
|
|
218
222
|
|
|
219
223
|
=== BEGIN INPUT TEXT ===
|
|
@@ -222,7 +226,21 @@ ${rawResponse}
|
|
|
222
226
|
|
|
223
227
|
return new Promise((resolve) => {
|
|
224
228
|
// Build final command and args based on prompt delivery method
|
|
225
|
-
|
|
229
|
+
// promptViaFile: write to temp file, pass @filepath as positional arg (Pi @file syntax)
|
|
230
|
+
// promptViaStdin: write to process stdin after spawn
|
|
231
|
+
// default: pass prompt as positional CLI arg
|
|
232
|
+
let tmpFile = null;
|
|
233
|
+
let cleanupTmpFile = () => {};
|
|
234
|
+
let finalArgs;
|
|
235
|
+
|
|
236
|
+
if (promptViaFile) {
|
|
237
|
+
tmpFile = path.join(os.tmpdir(), `pair-review-extract-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
|
|
238
|
+
fs.writeFileSync(tmpFile, prompt);
|
|
239
|
+
cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
|
|
240
|
+
finalArgs = [...args, `@${tmpFile}`];
|
|
241
|
+
} else {
|
|
242
|
+
finalArgs = promptViaStdin ? args : [...args, prompt];
|
|
243
|
+
}
|
|
226
244
|
|
|
227
245
|
logger.info(`${levelPrefix} Attempting LLM-based JSON extraction with ${extractionModel}...`);
|
|
228
246
|
|
|
@@ -269,6 +287,7 @@ ${rawResponse}
|
|
|
269
287
|
});
|
|
270
288
|
|
|
271
289
|
proc.on('close', (code) => {
|
|
290
|
+
cleanupTmpFile();
|
|
272
291
|
if (settled) return;
|
|
273
292
|
|
|
274
293
|
if (code !== 0) {
|
|
@@ -295,11 +314,12 @@ ${rawResponse}
|
|
|
295
314
|
});
|
|
296
315
|
|
|
297
316
|
proc.on('error', (error) => {
|
|
317
|
+
cleanupTmpFile();
|
|
298
318
|
logger.warn(`${levelPrefix} LLM extraction process error: ${error.message}`);
|
|
299
319
|
settle({ success: false, error: error.message });
|
|
300
320
|
});
|
|
301
321
|
|
|
302
|
-
//
|
|
322
|
+
// Deliver prompt based on config method
|
|
303
323
|
if (promptViaStdin) {
|
|
304
324
|
// Handle stdin errors (e.g., EPIPE if process exits before write completes)
|
|
305
325
|
proc.stdin.on('error', (err) => {
|
|
@@ -314,6 +334,9 @@ ${rawResponse}
|
|
|
314
334
|
}
|
|
315
335
|
});
|
|
316
336
|
proc.stdin.end();
|
|
337
|
+
} else if (promptViaFile) {
|
|
338
|
+
// Prompt delivered via @file arg — close stdin so wrappers (e.g., devx) don't hang
|
|
339
|
+
proc.stdin.end();
|
|
317
340
|
}
|
|
318
341
|
});
|
|
319
342
|
}
|
package/src/chat/pi-bridge.js
CHANGED
|
@@ -35,6 +35,7 @@ class PiBridge extends EventEmitter {
|
|
|
35
35
|
* @param {boolean} [options.useShell] - Use shell mode for multi-word commands
|
|
36
36
|
* @param {string[]} [options.skills] - Array of skill file paths to load via --skill
|
|
37
37
|
* @param {string[]} [options.extensions] - Array of extension directory paths to load via -e
|
|
38
|
+
* @param {string[]} [options.extraArgs] - Extra CLI args to append (e.g., from config extra_args)
|
|
38
39
|
* @param {string} [options.sessionPath] - Path to a session file for resumption
|
|
39
40
|
*/
|
|
40
41
|
constructor(options = {}) {
|
|
@@ -49,6 +50,7 @@ class PiBridge extends EventEmitter {
|
|
|
49
50
|
this.useShell = options.useShell || false;
|
|
50
51
|
this.skills = options.skills || [];
|
|
51
52
|
this.extensions = options.extensions || [];
|
|
53
|
+
this.extraArgs = options.extraArgs || [];
|
|
52
54
|
this.sessionPath = options.sessionPath || null;
|
|
53
55
|
|
|
54
56
|
this._process = null;
|
|
@@ -288,6 +290,12 @@ class PiBridge extends EventEmitter {
|
|
|
288
290
|
args.push('-e', ext);
|
|
289
291
|
}
|
|
290
292
|
|
|
293
|
+
// Append extra args from provider config (e.g., extra_args in chat_providers).
|
|
294
|
+
// These go last so they can override earlier flags if needed.
|
|
295
|
+
if (this.extraArgs.length > 0) {
|
|
296
|
+
args.push(...this.extraArgs);
|
|
297
|
+
}
|
|
298
|
+
|
|
291
299
|
return args;
|
|
292
300
|
}
|
|
293
301
|
|
|
@@ -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(
|