@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.
@@ -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, scopeIncludes, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
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: review.local_scope_start || DEFAULT_SCOPE.start,
82
- scopeEnd: review.local_scope_end || DEFAULT_SCOPE.end,
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?.local_scope_start || DEFAULT_SCOPE.start;
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
- const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
585
- const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
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.local_scope_start || DEFAULT_SCOPE.start;
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.local_scope_start || DEFAULT_SCOPE.start;
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.local_scope_start || DEFAULT_SCOPE.start;
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
- title: null,
1047
- description: null,
1048
- cwd: localPath,
1049
- model,
1050
- baseSha: null,
1051
- headSha: r.local_head_sha || null,
1052
- baseBranch: r.local_base_branch || null,
1053
- headBranch: r.local_head_branch || null,
1054
- scopeStart: r.local_scope_start || DEFAULT_SCOPE.start,
1055
- scopeEnd: r.local_scope_end || DEFAULT_SCOPE.end,
1056
- customInstructions: customInstructions || null
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.local_scope_start || DEFAULT_SCOPE.start;
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
- // When branch is in scope, pass null so analyzer falls through to getChangedFilesList
1286
- // which correctly uses git diff base_sha...head_sha --name-only
1287
- const hasStaged = scopeIncludes(scopeStart, scopeEnd, 'staged');
1288
- const changedFiles = hasBranch
1289
- ? null
1290
- : await analyzer.getLocalChangedFiles(localPath, { includeStaged: hasStaged });
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 ? changedFiles.length : '(branch mode)'}`, 'cyan');
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.local_scope_start || DEFAULT_SCOPE.start;
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.local_scope_start || DEFAULT_SCOPE.start;
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.local_scope_start || DEFAULT_SCOPE.start;
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.local_scope_start || DEFAULT_SCOPE.start;
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: review.name || (councilHasBranch ? `Branch changes: ${review.local_base_branch}..HEAD` : 'Local changes'),
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
- const councilHasStaged = scopeIncludes(councilScopeStart, councilScopeEnd, 'staged');
2053
- const changedFiles = councilHasBranch
2054
- ? null
2055
- : await analyzer.getLocalChangedFiles(localPath, { includeStaged: councilHasStaged });
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 {
@@ -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: targetPath } = req.body;
146
+ const { path: rawPath } = req.body;
147
147
 
148
- if (!targetPath) {
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
@@ -1,10 +1,10 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
- const { findGitRoot, getHeadSha, getCurrentBranch, getRepositoryName, generateLocalDiff, generateLocalReviewId, computeLocalDiffDigest, findMainGitRoot } = require('../local-review');
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 generateLocalDiff(repoPath);
110
+ const diffResult = await generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
108
111
  diff = diffResult.diff;
109
112
  stats = diffResult.stats;
110
113
 
111
- digest = await computeLocalDiffDigest(repoPath);
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?.local_scope_start || DEFAULT_SCOPE.start;
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);