@in-the-loop-labs/pair-review 3.5.2 → 3.7.0
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/README.md +4 -0
- 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/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
|
@@ -21,6 +21,7 @@ const logger = require('../utils/logger');
|
|
|
21
21
|
const { extractJSON } = require('../utils/json-extractor');
|
|
22
22
|
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
23
23
|
const { StreamParser, parseCursorAgentLine } = require('./stream-parser');
|
|
24
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
24
25
|
|
|
25
26
|
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
26
27
|
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
@@ -262,7 +263,7 @@ class CursorAgentProvider extends AIProvider {
|
|
|
262
263
|
*/
|
|
263
264
|
async execute(prompt, options = {}) {
|
|
264
265
|
return new Promise((resolve, reject) => {
|
|
265
|
-
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
|
|
266
|
+
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
|
|
266
267
|
|
|
267
268
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
268
269
|
logger.info(`${levelPrefix} Executing Cursor Agent CLI...`);
|
|
@@ -275,7 +276,8 @@ class CursorAgentProvider extends AIProvider {
|
|
|
275
276
|
...this.extraEnv,
|
|
276
277
|
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
277
278
|
},
|
|
278
|
-
shell: this.useShell
|
|
279
|
+
shell: this.useShell,
|
|
280
|
+
detached: this.useShell
|
|
279
281
|
});
|
|
280
282
|
|
|
281
283
|
const pid = agent.pid;
|
|
@@ -287,6 +289,9 @@ class CursorAgentProvider extends AIProvider {
|
|
|
287
289
|
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
288
290
|
}
|
|
289
291
|
|
|
292
|
+
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
293
|
+
const abortWiring = wireAbortToChild(agent, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
294
|
+
|
|
290
295
|
let stdout = '';
|
|
291
296
|
let stderr = '';
|
|
292
297
|
let timeoutId = null;
|
|
@@ -298,6 +303,7 @@ class CursorAgentProvider extends AIProvider {
|
|
|
298
303
|
if (settled) return;
|
|
299
304
|
settled = true;
|
|
300
305
|
if (timeoutId) clearTimeout(timeoutId);
|
|
306
|
+
abortWiring.detach();
|
|
301
307
|
fn(value);
|
|
302
308
|
};
|
|
303
309
|
|
|
@@ -348,11 +354,20 @@ class CursorAgentProvider extends AIProvider {
|
|
|
348
354
|
agent.on('close', (code) => {
|
|
349
355
|
if (settled) return; // Already settled by timeout or error
|
|
350
356
|
|
|
357
|
+
// Detach is centralized in `settle`.
|
|
358
|
+
|
|
351
359
|
// Flush any remaining stream parser buffer
|
|
352
360
|
if (streamParser) {
|
|
353
361
|
streamParser.flush();
|
|
354
362
|
}
|
|
355
363
|
|
|
364
|
+
// BackgroundQueue-driven cancellation — mirror of claude-provider.
|
|
365
|
+
if (abortWiring.cancelled()) {
|
|
366
|
+
logger.info(`${levelPrefix} Cursor Agent CLI terminated by user cancel (exit code ${code})`);
|
|
367
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
356
371
|
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
357
372
|
const isCancellationCode = code === 143 || code === 137;
|
|
358
373
|
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
@@ -431,6 +446,7 @@ class CursorAgentProvider extends AIProvider {
|
|
|
431
446
|
|
|
432
447
|
// Handle errors
|
|
433
448
|
agent.on('error', (error) => {
|
|
449
|
+
// Detach happens inside `settle`.
|
|
434
450
|
if (error.code === 'ENOENT') {
|
|
435
451
|
logger.error(`${levelPrefix} Cursor Agent CLI not found. Please ensure Cursor Agent CLI is installed.`);
|
|
436
452
|
settle(reject, new Error(`${levelPrefix} Cursor Agent CLI not found. ${CursorAgentProvider.getInstallInstructions()}`));
|
|
@@ -529,7 +545,7 @@ class CursorAgentProvider extends AIProvider {
|
|
|
529
545
|
// Primary: try to extract JSON from accumulated assistant text
|
|
530
546
|
if (assistantText) {
|
|
531
547
|
logger.debug(`${levelPrefix} Extracted ${assistantText.length} chars of assistant text from JSONL`);
|
|
532
|
-
const extracted = extractJSON(assistantText, level);
|
|
548
|
+
const extracted = extractJSON(assistantText, level, levelPrefix);
|
|
533
549
|
if (extracted.success) {
|
|
534
550
|
return extracted;
|
|
535
551
|
}
|
|
@@ -540,7 +556,7 @@ class CursorAgentProvider extends AIProvider {
|
|
|
540
556
|
// Fallback: try extracting JSON from the result event's text
|
|
541
557
|
if (resultText) {
|
|
542
558
|
logger.debug(`${levelPrefix} Trying result text: ${resultText.length} chars`);
|
|
543
|
-
const extracted = extractJSON(resultText, level);
|
|
559
|
+
const extracted = extractJSON(resultText, level, levelPrefix);
|
|
544
560
|
if (extracted.success) {
|
|
545
561
|
return extracted;
|
|
546
562
|
}
|
|
@@ -550,7 +566,7 @@ class CursorAgentProvider extends AIProvider {
|
|
|
550
566
|
|
|
551
567
|
// Last resort: try extracting JSON directly from raw stdout
|
|
552
568
|
if (!assistantText && !resultText) {
|
|
553
|
-
const extracted = extractJSON(stdout, level);
|
|
569
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
554
570
|
return extracted;
|
|
555
571
|
}
|
|
556
572
|
|
|
@@ -560,7 +576,7 @@ class CursorAgentProvider extends AIProvider {
|
|
|
560
576
|
|
|
561
577
|
} catch (parseError) {
|
|
562
578
|
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
563
|
-
const extracted = extractJSON(stdout, level);
|
|
579
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
564
580
|
if (extracted.success) {
|
|
565
581
|
return extracted;
|
|
566
582
|
}
|
|
@@ -396,30 +396,15 @@ function createExecutableProviderClass(id, config) {
|
|
|
396
396
|
|
|
397
397
|
// Find a mapping provider: prefer the user's configured default, fall back to
|
|
398
398
|
// any registered non-executable provider. Never hardcode a specific provider.
|
|
399
|
-
let
|
|
400
|
-
|
|
401
|
-
// Try the user's configured default provider first
|
|
399
|
+
let preferredId = null;
|
|
402
400
|
try {
|
|
403
401
|
const config = await configModule.loadConfig();
|
|
404
|
-
|
|
405
|
-
const defaultClass = providerModule.getProviderClass(defaultId);
|
|
406
|
-
if (defaultClass && !defaultClass.isExecutable) {
|
|
407
|
-
mappingProviderId = defaultId;
|
|
408
|
-
}
|
|
402
|
+
preferredId = configModule.getDefaultProvider(config);
|
|
409
403
|
} catch {
|
|
410
|
-
// Config
|
|
404
|
+
// Config not available — fall through to fallback
|
|
411
405
|
}
|
|
412
406
|
|
|
413
|
-
|
|
414
|
-
// Fall back to any registered non-executable provider
|
|
415
|
-
for (const pid of providerModule.getRegisteredProviderIds()) {
|
|
416
|
-
const pClass = providerModule.getProviderClass(pid);
|
|
417
|
-
if (pClass && !pClass.isExecutable) {
|
|
418
|
-
mappingProviderId = pid;
|
|
419
|
-
break;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
407
|
+
const mappingProviderId = providerModule.resolveNonExecutableProviderId(preferredId);
|
|
423
408
|
|
|
424
409
|
if (!mappingProviderId) {
|
|
425
410
|
throw new Error(`[${id}] No mapping provider available. Need at least one non-executable provider (e.g., claude) registered.`);
|
|
@@ -12,6 +12,7 @@ const logger = require('../utils/logger');
|
|
|
12
12
|
const { extractJSON } = require('../utils/json-extractor');
|
|
13
13
|
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
14
14
|
const { StreamParser, parseGeminiLine } = require('./stream-parser');
|
|
15
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
15
16
|
|
|
16
17
|
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
17
18
|
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
@@ -190,7 +191,7 @@ class GeminiProvider extends AIProvider {
|
|
|
190
191
|
*/
|
|
191
192
|
async execute(prompt, options = {}) {
|
|
192
193
|
return new Promise((resolve, reject) => {
|
|
193
|
-
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
|
|
194
|
+
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
|
|
194
195
|
|
|
195
196
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
196
197
|
logger.info(`${levelPrefix} Executing Gemini CLI...`);
|
|
@@ -203,7 +204,8 @@ class GeminiProvider extends AIProvider {
|
|
|
203
204
|
...this.extraEnv,
|
|
204
205
|
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
205
206
|
},
|
|
206
|
-
shell: this.useShell
|
|
207
|
+
shell: this.useShell,
|
|
208
|
+
detached: this.useShell
|
|
207
209
|
});
|
|
208
210
|
|
|
209
211
|
const pid = gemini.pid;
|
|
@@ -215,6 +217,9 @@ class GeminiProvider extends AIProvider {
|
|
|
215
217
|
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
221
|
+
const abortWiring = wireAbortToChild(gemini, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
222
|
+
|
|
218
223
|
let stdout = '';
|
|
219
224
|
let stderr = '';
|
|
220
225
|
let timeoutId = null;
|
|
@@ -226,6 +231,7 @@ class GeminiProvider extends AIProvider {
|
|
|
226
231
|
if (settled) return;
|
|
227
232
|
settled = true;
|
|
228
233
|
if (timeoutId) clearTimeout(timeoutId);
|
|
234
|
+
abortWiring.detach();
|
|
229
235
|
fn(value);
|
|
230
236
|
};
|
|
231
237
|
|
|
@@ -276,11 +282,21 @@ class GeminiProvider extends AIProvider {
|
|
|
276
282
|
gemini.on('close', (code) => {
|
|
277
283
|
if (settled) return; // Already settled by timeout or error
|
|
278
284
|
|
|
285
|
+
// Detach is centralized in `settle`.
|
|
286
|
+
|
|
279
287
|
// Flush any remaining stream parser buffer
|
|
280
288
|
if (streamParser) {
|
|
281
289
|
streamParser.flush();
|
|
282
290
|
}
|
|
283
291
|
|
|
292
|
+
// BackgroundQueue-driven cancellation — see claude-provider for
|
|
293
|
+
// the rationale; pattern is mirrored here.
|
|
294
|
+
if (abortWiring.cancelled()) {
|
|
295
|
+
logger.info(`${levelPrefix} Gemini CLI terminated by user cancel (exit code ${code})`);
|
|
296
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
284
300
|
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
285
301
|
const isCancellationCode = code === 143 || code === 137;
|
|
286
302
|
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
@@ -355,6 +371,7 @@ class GeminiProvider extends AIProvider {
|
|
|
355
371
|
|
|
356
372
|
// Handle errors
|
|
357
373
|
gemini.on('error', (error) => {
|
|
374
|
+
// Detach happens inside `settle`.
|
|
358
375
|
if (error.code === 'ENOENT') {
|
|
359
376
|
logger.error(`${levelPrefix} Gemini CLI not found. Please ensure Gemini CLI is installed.`);
|
|
360
377
|
settle(reject, new Error(`${levelPrefix} Gemini CLI not found. ${GeminiProvider.getInstallInstructions()}`));
|
|
@@ -429,7 +446,7 @@ class GeminiProvider extends AIProvider {
|
|
|
429
446
|
// The accumulated assistant text contains the AI's response
|
|
430
447
|
// Try to extract JSON from it (the AI was asked to output JSON)
|
|
431
448
|
logger.debug(`${levelPrefix} Extracted ${assistantText.length} chars of assistant message text from JSONL`);
|
|
432
|
-
const extracted = extractJSON(assistantText, level);
|
|
449
|
+
const extracted = extractJSON(assistantText, level, levelPrefix);
|
|
433
450
|
if (extracted.success) {
|
|
434
451
|
return extracted;
|
|
435
452
|
}
|
|
@@ -441,12 +458,12 @@ class GeminiProvider extends AIProvider {
|
|
|
441
458
|
}
|
|
442
459
|
|
|
443
460
|
// No assistant message found, try extracting JSON directly from stdout
|
|
444
|
-
const extracted = extractJSON(stdout, level);
|
|
461
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
445
462
|
return extracted;
|
|
446
463
|
|
|
447
464
|
} catch (parseError) {
|
|
448
465
|
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
449
|
-
const extracted = extractJSON(stdout, level);
|
|
466
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
450
467
|
if (extracted.success) {
|
|
451
468
|
return extracted;
|
|
452
469
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} Hunk
|
|
7
|
+
* @property {string} header - Hunk header line, e.g. "@@ -10,5 +10,7 @@".
|
|
8
|
+
* @property {string[]} lines - Diff lines including their leading marker
|
|
9
|
+
* ('+', '-', ' ', or the literal '\' marker).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const LOCKFILE_BASENAMES = new Set([
|
|
13
|
+
'package-lock.json',
|
|
14
|
+
'pnpm-lock.yaml',
|
|
15
|
+
'yarn.lock',
|
|
16
|
+
'Cargo.lock',
|
|
17
|
+
'Pipfile.lock',
|
|
18
|
+
'poetry.lock',
|
|
19
|
+
'composer.lock',
|
|
20
|
+
'go.sum'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const JS_TS_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
|
24
|
+
const PYTHON_EXTENSIONS = new Set(['.py']);
|
|
25
|
+
|
|
26
|
+
const JS_IMPORT_PATTERN = /^(?:import\b|from\s+\S+\s+import\b|(?:const|let|var)\s+\w+\s*=\s*require\()/;
|
|
27
|
+
const PY_IMPORT_PATTERN = /^(?:import\b|from\s+\S+\s+import\b)/;
|
|
28
|
+
const PACKAGE_JSON_VERSION_PATTERN = /^"([^"]+)"\s*:\s*"[~^>=<]*\d[\w.\-+*]*"\,?\s*$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* SHA-256 hex of `${filePath}\n${hunkContent}`.
|
|
32
|
+
* @param {string} filePath
|
|
33
|
+
* @param {string} hunkContent
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function hashHunk(filePath, hunkContent) {
|
|
37
|
+
return crypto.createHash('sha256').update(`${filePath}\n${hunkContent}`).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getExtension(filePath) {
|
|
41
|
+
const slash = filePath.lastIndexOf('/');
|
|
42
|
+
const base = slash === -1 ? filePath : filePath.slice(slash + 1);
|
|
43
|
+
const dot = base.lastIndexOf('.');
|
|
44
|
+
if (dot <= 0) return '';
|
|
45
|
+
return base.slice(dot).toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getBasename(filePath) {
|
|
49
|
+
const slash = filePath.lastIndexOf('/');
|
|
50
|
+
return slash === -1 ? filePath : filePath.slice(slash + 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function classifyLines(lines) {
|
|
54
|
+
const added = [];
|
|
55
|
+
const removed = [];
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
if (line.startsWith('\\')) continue;
|
|
58
|
+
if (line.startsWith('+')) added.push(line.slice(1));
|
|
59
|
+
else if (line.startsWith('-')) removed.push(line.slice(1));
|
|
60
|
+
}
|
|
61
|
+
return { added, removed };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isImportOnlyReorder(added, removed, ext) {
|
|
65
|
+
let pattern;
|
|
66
|
+
if (JS_TS_EXTENSIONS.has(ext)) pattern = JS_IMPORT_PATTERN;
|
|
67
|
+
else if (PYTHON_EXTENSIONS.has(ext)) pattern = PY_IMPORT_PATTERN;
|
|
68
|
+
else return false;
|
|
69
|
+
|
|
70
|
+
if (added.length === 0 && removed.length === 0) return false;
|
|
71
|
+
|
|
72
|
+
const addedTrimmed = added.map((l) => l.trim());
|
|
73
|
+
const removedTrimmed = removed.map((l) => l.trim());
|
|
74
|
+
|
|
75
|
+
for (const line of addedTrimmed) {
|
|
76
|
+
if (!pattern.test(line)) return false;
|
|
77
|
+
}
|
|
78
|
+
for (const line of removedTrimmed) {
|
|
79
|
+
if (!pattern.test(line)) return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (addedTrimmed.length !== removedTrimmed.length) return false;
|
|
83
|
+
const a = [...addedTrimmed].sort();
|
|
84
|
+
const r = [...removedTrimmed].sort();
|
|
85
|
+
for (let i = 0; i < a.length; i++) {
|
|
86
|
+
if (a[i] !== r[i]) return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractPackageJsonVersionKey(line) {
|
|
92
|
+
const match = PACKAGE_JSON_VERSION_PATTERN.exec(line);
|
|
93
|
+
return match ? match[1] : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isVersionBumpChange(added, removed, basename) {
|
|
97
|
+
if (basename !== 'package.json' && !LOCKFILE_BASENAMES.has(basename)) return false;
|
|
98
|
+
if (added.length === 0 && removed.length === 0) return false;
|
|
99
|
+
|
|
100
|
+
if (LOCKFILE_BASENAMES.has(basename)) return true;
|
|
101
|
+
|
|
102
|
+
const addedKeys = [];
|
|
103
|
+
for (const line of added) {
|
|
104
|
+
const key = extractPackageJsonVersionKey(line.trim());
|
|
105
|
+
if (key === null) return false;
|
|
106
|
+
addedKeys.push(key);
|
|
107
|
+
}
|
|
108
|
+
const removedKeys = [];
|
|
109
|
+
for (const line of removed) {
|
|
110
|
+
const key = extractPackageJsonVersionKey(line.trim());
|
|
111
|
+
if (key === null) return false;
|
|
112
|
+
removedKeys.push(key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (addedKeys.length !== removedKeys.length) return false;
|
|
116
|
+
const a = [...addedKeys].sort();
|
|
117
|
+
const r = [...removedKeys].sort();
|
|
118
|
+
for (let i = 0; i < a.length; i++) {
|
|
119
|
+
if (a[i] !== r[i]) return false;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify a hunk as trivial under one of several heuristics.
|
|
126
|
+
*
|
|
127
|
+
* Callers that need generated-file detection should pass
|
|
128
|
+
* isGeneratedFile: parser.isGenerated.bind(parser)
|
|
129
|
+
* where `parser` is the result of
|
|
130
|
+
* await getGeneratedFilePatterns(worktreePath)
|
|
131
|
+
* from src/git/gitattributes.js. When `isGeneratedFile` is omitted, the
|
|
132
|
+
* generated-file rule is skipped silently.
|
|
133
|
+
* @param {Hunk} hunk
|
|
134
|
+
* @param {string} filePath
|
|
135
|
+
* @param {{ isGeneratedFile?: (filePath: string) => boolean }} [options]
|
|
136
|
+
* @returns {{ trivial: boolean, reason?: 'imports'|'version_bump'|'generated' }}
|
|
137
|
+
*/
|
|
138
|
+
function isTrivialHunk(hunk, filePath, options) {
|
|
139
|
+
const opts = options || {};
|
|
140
|
+
|
|
141
|
+
if (typeof opts.isGeneratedFile === 'function' && opts.isGeneratedFile(filePath) === true) {
|
|
142
|
+
return { trivial: true, reason: 'generated' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lines = hunk && Array.isArray(hunk.lines) ? hunk.lines : [];
|
|
146
|
+
const { added, removed } = classifyLines(lines);
|
|
147
|
+
|
|
148
|
+
const ext = getExtension(filePath);
|
|
149
|
+
if (isImportOnlyReorder(added, removed, ext)) {
|
|
150
|
+
return { trivial: true, reason: 'imports' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const basename = getBasename(filePath);
|
|
154
|
+
if (isVersionBumpChange(added, removed, basename)) {
|
|
155
|
+
return { trivial: true, reason: 'version_bump' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { trivial: false };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { hashHunk, isTrivialHunk };
|
package/src/ai/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
registerProvider,
|
|
14
14
|
getProviderClass,
|
|
15
15
|
getRegisteredProviderIds,
|
|
16
|
+
resolveNonExecutableProviderId,
|
|
16
17
|
getAllProvidersInfo,
|
|
17
18
|
createProvider,
|
|
18
19
|
testProviderAvailability,
|
|
@@ -60,6 +61,7 @@ module.exports = {
|
|
|
60
61
|
registerProvider,
|
|
61
62
|
getProviderClass,
|
|
62
63
|
getRegisteredProviderIds,
|
|
64
|
+
resolveNonExecutableProviderId,
|
|
63
65
|
getAllProvidersInfo,
|
|
64
66
|
|
|
65
67
|
// Factory
|
|
@@ -18,6 +18,7 @@ const logger = require('../utils/logger');
|
|
|
18
18
|
const { extractJSON } = require('../utils/json-extractor');
|
|
19
19
|
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
20
20
|
const { StreamParser, parseOpenCodeLine } = require('./stream-parser');
|
|
21
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
21
22
|
|
|
22
23
|
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
23
24
|
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
@@ -102,7 +103,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
102
103
|
*/
|
|
103
104
|
async execute(prompt, options = {}) {
|
|
104
105
|
return new Promise((resolve, reject) => {
|
|
105
|
-
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
|
|
106
|
+
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
|
|
106
107
|
|
|
107
108
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
108
109
|
logger.info(`${levelPrefix} Executing OpenCode CLI...`);
|
|
@@ -128,7 +129,8 @@ class OpenCodeProvider extends AIProvider {
|
|
|
128
129
|
...this.extraEnv,
|
|
129
130
|
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
130
131
|
},
|
|
131
|
-
shell: this.useShell
|
|
132
|
+
shell: this.useShell,
|
|
133
|
+
detached: this.useShell
|
|
132
134
|
});
|
|
133
135
|
|
|
134
136
|
const pid = opencode.pid;
|
|
@@ -140,6 +142,9 @@ class OpenCodeProvider extends AIProvider {
|
|
|
140
142
|
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
146
|
+
const abortWiring = wireAbortToChild(opencode, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
147
|
+
|
|
143
148
|
let stdout = '';
|
|
144
149
|
let stderr = '';
|
|
145
150
|
let timeoutId = null;
|
|
@@ -151,6 +156,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
151
156
|
if (settled) return;
|
|
152
157
|
settled = true;
|
|
153
158
|
if (timeoutId) clearTimeout(timeoutId);
|
|
159
|
+
abortWiring.detach();
|
|
154
160
|
fn(value);
|
|
155
161
|
};
|
|
156
162
|
|
|
@@ -201,11 +207,20 @@ class OpenCodeProvider extends AIProvider {
|
|
|
201
207
|
opencode.on('close', (code) => {
|
|
202
208
|
if (settled) return; // Already settled by timeout or error
|
|
203
209
|
|
|
210
|
+
// Detach is centralized in `settle`.
|
|
211
|
+
|
|
204
212
|
// Flush any remaining stream parser buffer
|
|
205
213
|
if (streamParser) {
|
|
206
214
|
streamParser.flush();
|
|
207
215
|
}
|
|
208
216
|
|
|
217
|
+
// BackgroundQueue-driven cancellation — mirror of claude-provider.
|
|
218
|
+
if (abortWiring.cancelled()) {
|
|
219
|
+
logger.info(`${levelPrefix} OpenCode CLI terminated by user cancel (exit code ${code})`);
|
|
220
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
209
224
|
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
210
225
|
const isCancellationCode = code === 143 || code === 137;
|
|
211
226
|
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
@@ -281,6 +296,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
281
296
|
|
|
282
297
|
// Handle errors
|
|
283
298
|
opencode.on('error', (error) => {
|
|
299
|
+
// Detach happens inside `settle`.
|
|
284
300
|
if (error.code === 'ENOENT') {
|
|
285
301
|
logger.error(`${levelPrefix} OpenCode CLI not found. Please ensure OpenCode CLI is installed.`);
|
|
286
302
|
settle(reject, new Error(`${levelPrefix} OpenCode CLI not found. ${OpenCodeProvider.getInstallInstructions()}`));
|
|
@@ -491,7 +507,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
491
507
|
|
|
492
508
|
if (textContent) {
|
|
493
509
|
// Try to extract JSON from the accumulated text content
|
|
494
|
-
const extracted = extractJSON(textContent, level);
|
|
510
|
+
const extracted = extractJSON(textContent, level, levelPrefix);
|
|
495
511
|
if (extracted.success) {
|
|
496
512
|
return extracted;
|
|
497
513
|
}
|
|
@@ -503,12 +519,12 @@ class OpenCodeProvider extends AIProvider {
|
|
|
503
519
|
}
|
|
504
520
|
|
|
505
521
|
// No text content found, try extracting JSON directly from stdout
|
|
506
|
-
const extracted = extractJSON(stdout, level);
|
|
522
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
507
523
|
return extracted;
|
|
508
524
|
|
|
509
525
|
} catch (parseError) {
|
|
510
526
|
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
511
|
-
const extracted = extractJSON(stdout, level);
|
|
527
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
512
528
|
if (extracted.success) {
|
|
513
529
|
return extracted;
|
|
514
530
|
}
|
package/src/ai/pi-provider.js
CHANGED
|
@@ -26,6 +26,7 @@ const logger = require('../utils/logger');
|
|
|
26
26
|
const { extractJSON } = require('../utils/json-extractor');
|
|
27
27
|
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
28
28
|
const { createPiLineParser } = require('./stream-parser');
|
|
29
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
29
30
|
|
|
30
31
|
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
31
32
|
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
@@ -430,7 +431,7 @@ function appendPiChunkToLineBuffer(state, chunk, levelPrefix) {
|
|
|
430
431
|
*/
|
|
431
432
|
function finalizePiResponseParsing({ textContent, rawOutput, rawOutputTruncated = false }, level, levelPrefix) {
|
|
432
433
|
if (textContent) {
|
|
433
|
-
const extracted = extractJSON(textContent, level);
|
|
434
|
+
const extracted = extractJSON(textContent, level, levelPrefix);
|
|
434
435
|
if (extracted.success) {
|
|
435
436
|
return extracted;
|
|
436
437
|
}
|
|
@@ -447,7 +448,7 @@ function finalizePiResponseParsing({ textContent, rawOutput, rawOutputTruncated
|
|
|
447
448
|
};
|
|
448
449
|
}
|
|
449
450
|
|
|
450
|
-
return extractJSON(rawOutput, level);
|
|
451
|
+
return extractJSON(rawOutput, level, levelPrefix);
|
|
451
452
|
}
|
|
452
453
|
|
|
453
454
|
class PiProvider extends AIProvider {
|
|
@@ -579,7 +580,7 @@ class PiProvider extends AIProvider {
|
|
|
579
580
|
*/
|
|
580
581
|
async execute(prompt, options = {}) {
|
|
581
582
|
return new Promise((resolve, reject) => {
|
|
582
|
-
const { cwd = process.cwd(), timeout = 900000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
|
|
583
|
+
const { cwd = process.cwd(), timeout = 900000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
|
|
583
584
|
|
|
584
585
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
585
586
|
logger.info(`${levelPrefix} Executing Pi CLI...`);
|
|
@@ -609,7 +610,8 @@ class PiProvider extends AIProvider {
|
|
|
609
610
|
...this.extraEnv,
|
|
610
611
|
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
611
612
|
},
|
|
612
|
-
shell: this.useShell
|
|
613
|
+
shell: this.useShell,
|
|
614
|
+
detached: this.useShell
|
|
613
615
|
});
|
|
614
616
|
|
|
615
617
|
// Close stdin immediately — prompt is delivered via @file, but some
|
|
@@ -626,6 +628,9 @@ class PiProvider extends AIProvider {
|
|
|
626
628
|
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
627
629
|
}
|
|
628
630
|
|
|
631
|
+
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
632
|
+
const abortWiring = wireAbortToChild(pi, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
633
|
+
|
|
629
634
|
const stderrCapture = {
|
|
630
635
|
head: '',
|
|
631
636
|
tail: '',
|
|
@@ -652,6 +657,7 @@ class PiProvider extends AIProvider {
|
|
|
652
657
|
if (settled) return;
|
|
653
658
|
settled = true;
|
|
654
659
|
if (timeoutId) clearTimeout(timeoutId);
|
|
660
|
+
abortWiring.detach();
|
|
655
661
|
fn(value);
|
|
656
662
|
};
|
|
657
663
|
|
|
@@ -711,6 +717,15 @@ class PiProvider extends AIProvider {
|
|
|
711
717
|
cleanupTmpFile();
|
|
712
718
|
if (settled) return; // Already settled by timeout or error
|
|
713
719
|
|
|
720
|
+
// Detach is centralized in `settle`.
|
|
721
|
+
|
|
722
|
+
// BackgroundQueue-driven cancellation — mirror of claude-provider.
|
|
723
|
+
if (abortWiring.cancelled()) {
|
|
724
|
+
logger.info(`${levelPrefix} Pi CLI terminated by user cancel (exit code ${code})`);
|
|
725
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
714
729
|
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
715
730
|
const isCancellationCode = code === 143 || code === 137;
|
|
716
731
|
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
@@ -810,6 +825,7 @@ class PiProvider extends AIProvider {
|
|
|
810
825
|
// Handle errors
|
|
811
826
|
pi.on('error', (error) => {
|
|
812
827
|
cleanupTmpFile();
|
|
828
|
+
// Detach happens inside `settle`.
|
|
813
829
|
if (error.code === 'ENOENT') {
|
|
814
830
|
logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
|
|
815
831
|
settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
|
|
@@ -994,7 +1010,7 @@ class PiProvider extends AIProvider {
|
|
|
994
1010
|
|
|
995
1011
|
} catch (parseError) {
|
|
996
1012
|
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
997
|
-
const extracted = extractJSON(stdout, level);
|
|
1013
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
998
1014
|
if (extracted.success) {
|
|
999
1015
|
return extracted;
|
|
1000
1016
|
}
|