@probelabs/probe 0.6.0-rc291 → 0.6.0-rc293

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.
@@ -12,9 +12,22 @@
12
12
  */
13
13
 
14
14
  import { createHash } from 'crypto';
15
- import { resolve, isAbsolute } from 'path';
15
+ import { resolve, isAbsolute, normalize } from 'path';
16
16
  import { findSymbol } from './symbolEdit.js';
17
17
 
18
+ /**
19
+ * Normalize a file path for consistent storage and lookup.
20
+ * Resolves '.', '..', double slashes, and ensures absolute paths are canonical.
21
+ * Does NOT resolve symlinks (that would be expensive and might fail for non-existent files).
22
+ * @param {string} filePath - Path to normalize
23
+ * @returns {string} Normalized path
24
+ */
25
+ function normalizePath(filePath) {
26
+ if (!filePath) return filePath;
27
+ // resolve() handles '.', '..', double slashes, and makes the path absolute
28
+ return resolve(filePath);
29
+ }
30
+
18
31
  /**
19
32
  * Compute a SHA-256 content hash for a code block.
20
33
  * Normalizes trailing whitespace per line for robustness against editor formatting.
@@ -106,10 +119,11 @@ export class FileTracker {
106
119
  * @param {string} resolvedPath - Absolute path to the file
107
120
  */
108
121
  markFileSeen(resolvedPath) {
109
- this._seenFiles.add(resolvedPath);
110
- this._textEditCounts.set(resolvedPath, 0);
122
+ const normalized = normalizePath(resolvedPath);
123
+ this._seenFiles.add(normalized);
124
+ this._textEditCounts.set(normalized, 0);
111
125
  if (this.debug) {
112
- console.error(`[FileTracker] Marked as seen: ${resolvedPath}`);
126
+ console.error(`[FileTracker] Marked as seen: ${normalized}`);
113
127
  }
114
128
  }
115
129
 
@@ -119,7 +133,7 @@ export class FileTracker {
119
133
  * @returns {boolean}
120
134
  */
121
135
  isFileSeen(resolvedPath) {
122
- return this._seenFiles.has(resolvedPath);
136
+ return this._seenFiles.has(normalizePath(resolvedPath));
123
137
  }
124
138
 
125
139
  /**
@@ -132,7 +146,7 @@ export class FileTracker {
132
146
  * @param {string} [source='extract'] - How the content was obtained
133
147
  */
134
148
  trackSymbolContent(resolvedPath, symbolName, code, startLine, endLine, source = 'extract') {
135
- const key = `${resolvedPath}#${symbolName}`;
149
+ const key = `${normalizePath(resolvedPath)}#${symbolName}`;
136
150
  const contentHash = computeContentHash(code);
137
151
  this._contentRecords.set(key, {
138
152
  contentHash,
@@ -154,7 +168,7 @@ export class FileTracker {
154
168
  * @returns {Object|null} The stored record or null
155
169
  */
156
170
  getSymbolRecord(resolvedPath, symbolName) {
157
- return this._contentRecords.get(`${resolvedPath}#${symbolName}`) || null;
171
+ return this._contentRecords.get(`${normalizePath(resolvedPath)}#${symbolName}`) || null;
158
172
  }
159
173
 
160
174
  /**
@@ -165,7 +179,7 @@ export class FileTracker {
165
179
  * @returns {{ok: boolean, reason?: string, message?: string}}
166
180
  */
167
181
  checkSymbolContent(resolvedPath, symbolName, currentCode) {
168
- const key = `${resolvedPath}#${symbolName}`;
182
+ const key = `${normalizePath(resolvedPath)}#${symbolName}`;
169
183
  const record = this._contentRecords.get(key);
170
184
 
171
185
  if (!record) {
@@ -253,7 +267,7 @@ export class FileTracker {
253
267
  * @returns {{ok: boolean, reason?: string, message?: string}}
254
268
  */
255
269
  checkBeforeEdit(resolvedPath) {
256
- if (!this._seenFiles.has(resolvedPath)) {
270
+ if (!this._seenFiles.has(normalizePath(resolvedPath))) {
257
271
  return {
258
272
  ok: false,
259
273
  reason: 'untracked',
@@ -269,8 +283,9 @@ export class FileTracker {
269
283
  * @param {string} resolvedPath - Absolute path to the file
270
284
  */
271
285
  async trackFileAfterWrite(resolvedPath) {
272
- this._seenFiles.add(resolvedPath);
273
- this.invalidateFileRecords(resolvedPath);
286
+ const normalized = normalizePath(resolvedPath);
287
+ this._seenFiles.add(normalized);
288
+ this.invalidateFileRecords(normalized);
274
289
  }
275
290
 
276
291
  /**
@@ -279,10 +294,11 @@ export class FileTracker {
279
294
  * @param {string} resolvedPath - Absolute path to the file
280
295
  */
281
296
  recordTextEdit(resolvedPath) {
282
- const count = (this._textEditCounts.get(resolvedPath) || 0) + 1;
283
- this._textEditCounts.set(resolvedPath, count);
297
+ const normalized = normalizePath(resolvedPath);
298
+ const count = (this._textEditCounts.get(normalized) || 0) + 1;
299
+ this._textEditCounts.set(normalized, count);
284
300
  if (this.debug) {
285
- console.error(`[FileTracker] Text edit #${count} for ${resolvedPath}`);
301
+ console.error(`[FileTracker] Text edit #${count} for ${normalized}`);
286
302
  }
287
303
  }
288
304
 
@@ -292,7 +308,7 @@ export class FileTracker {
292
308
  * @returns {{ok: boolean, editCount?: number, message?: string}}
293
309
  */
294
310
  checkTextEditStaleness(resolvedPath) {
295
- const count = this._textEditCounts.get(resolvedPath) || 0;
311
+ const count = this._textEditCounts.get(normalizePath(resolvedPath)) || 0;
296
312
  if (count >= this.maxConsecutiveTextEdits) {
297
313
  return {
298
314
  ok: false,
@@ -323,7 +339,7 @@ export class FileTracker {
323
339
  * @param {string} resolvedPath - Absolute path to the file
324
340
  */
325
341
  invalidateFileRecords(resolvedPath) {
326
- const prefix = resolvedPath + '#';
342
+ const prefix = normalizePath(resolvedPath) + '#';
327
343
  for (const key of this._contentRecords.keys()) {
328
344
  if (key.startsWith(prefix)) {
329
345
  this._contentRecords.delete(key);
@@ -340,7 +356,7 @@ export class FileTracker {
340
356
  * @returns {boolean}
341
357
  */
342
358
  isTracked(resolvedPath) {
343
- return this.isFileSeen(resolvedPath);
359
+ return this.isFileSeen(normalizePath(resolvedPath));
344
360
  }
345
361
 
346
362
  /**
@@ -73,7 +73,17 @@ export function lineTrimmedMatch(contentLines, searchLines) {
73
73
  }
74
74
  }
75
75
  if (allMatch) {
76
- const matchedText = contentLines.slice(i, i + windowSize).join('\n');
76
+ // Limit indent tolerance: even though trimmed content matches, reject when
77
+ // the indentation level difference is too large — it likely means the match
78
+ // is in a completely different scope (issue #507).
79
+ const windowLines = contentLines.slice(i, i + windowSize);
80
+ const windowMinIndent = getMinIndent(windowLines);
81
+ const searchMinIndent = getMinIndent(searchLines);
82
+ const indentDiff = Math.abs(windowMinIndent - searchMinIndent);
83
+ if (isIndentDiffTooLarge(windowLines, searchLines, indentDiff)) {
84
+ continue; // Skip — too far off in nesting
85
+ }
86
+ const matchedText = windowLines.join('\n');
77
87
  matches.push(matchedText);
78
88
  }
79
89
  }
@@ -134,6 +144,19 @@ export function whitespaceNormalizedMatch(content, search) {
134
144
  }
135
145
 
136
146
  const matchedText = content.substring(originalStart, actualEnd);
147
+
148
+ // Limit indent tolerance: reject matches where the indentation level
149
+ // difference is too large — likely a wrong-scope match (issue #507).
150
+ const matchedLines = matchedText.split('\n');
151
+ const searchLines = search.split('\n');
152
+ const matchMinIndent = getMinIndent(matchedLines);
153
+ const searchMinIndent = getMinIndent(searchLines);
154
+ const indentDiff = Math.abs(matchMinIndent - searchMinIndent);
155
+ if (isIndentDiffTooLarge(matchedLines, searchLines, indentDiff)) {
156
+ searchStart = idx + 1;
157
+ continue; // Skip — too far off in nesting
158
+ }
159
+
137
160
  matches.push(matchedText);
138
161
 
139
162
  searchStart = idx + 1;
@@ -219,6 +242,15 @@ export function indentFlexibleMatch(contentLines, searchLines) {
219
242
  }
220
243
 
221
244
  if (allMatch) {
245
+ // Limit indent tolerance: reject matches where indentation differs by more than
246
+ // 1 level. Larger differences likely mean the match is in a completely different
247
+ // scope/nesting level — silent file corruption risk (issue #507).
248
+ // For tabs: 1 tab = 1 level, so max diff = 1.
249
+ // For spaces: detect indent unit (2 or 4), allow 1 unit of difference.
250
+ const indentDiff = Math.abs(windowMinIndent - searchMinIndent);
251
+ if (isIndentDiffTooLarge(windowLines, searchLines, indentDiff)) {
252
+ continue; // Skip — too far off in nesting
253
+ }
222
254
  const matchedText = windowLines.join('\n');
223
255
  matches.push(matchedText);
224
256
  }
@@ -232,6 +264,25 @@ export function indentFlexibleMatch(contentLines, searchLines) {
232
264
  };
233
265
  }
234
266
 
267
+ /**
268
+ * Check if an indentation difference exceeds the allowed limit.
269
+ * Uses tab-aware threshold: 1 for tabs, 4 for spaces.
270
+ * Checks BOTH sides for tab usage to avoid asymmetric detection.
271
+ *
272
+ * @param {string[]} linesA - First set of lines
273
+ * @param {string[]} linesB - Second set of lines
274
+ * @param {number} indentDiff - Absolute difference in min indent
275
+ * @returns {boolean} true if the diff exceeds the limit
276
+ */
277
+ function isIndentDiffTooLarge(linesA, linesB, indentDiff) {
278
+ if (indentDiff <= 0) return false;
279
+ const sampleA = linesA.find(l => l.trim().length > 0) || '';
280
+ const sampleB = linesB.find(l => l.trim().length > 0) || '';
281
+ const useTabs = sampleA.startsWith('\t') || sampleB.startsWith('\t');
282
+ const maxAllowedDiff = useTabs ? 1 : 4;
283
+ return indentDiff > maxAllowedDiff;
284
+ }
285
+
235
286
  /**
236
287
  * Get the minimum indentation level (number of leading whitespace characters)
237
288
  * across all non-empty lines.
@@ -92,6 +92,17 @@ export function restoreIndentation(newStr, originalLines) {
92
92
  const newIndent = detectBaseIndent(newStr);
93
93
 
94
94
  if (targetIndent !== newIndent) {
95
+ // Limit auto-reindent tolerance: reject when indentation differs by more than
96
+ // 1 level. Larger differences likely mean the match landed in a completely
97
+ // different scope — allowing it risks silent file corruption (issue #507).
98
+ // For tabs: 1 tab = 1 level, so max diff = 1 char.
99
+ // For spaces: 1 level = up to 4 spaces, so max diff = 4 chars.
100
+ const indentDiff = Math.abs(targetIndent.length - newIndent.length);
101
+ const useTabs = targetIndent.includes('\t') || newIndent.includes('\t');
102
+ const maxAllowedDiff = useTabs ? 1 : 4;
103
+ if (indentDiff > maxAllowedDiff) {
104
+ return { result: newStr, modifications };
105
+ }
95
106
  const reindented = reindent(newStr, targetIndent);
96
107
  if (reindented !== newStr) {
97
108
  modifications.push(`reindented from "${newIndent}" to "${targetIndent}"`);
@@ -385,7 +385,7 @@ export const searchTool = (options = {}) => {
385
385
  ? searchDelegateDescription
386
386
  : searchDescription,
387
387
  inputSchema: searchSchema,
388
- execute: async ({ query: searchQuery, path, allow_tests, exact, maxTokens: paramMaxTokens, language, session, nextPage }) => {
388
+ execute: async ({ query: searchQuery, path, allow_tests, exact, maxTokens: paramMaxTokens, language, session, nextPage, workingDirectory }) => {
389
389
  // Auto-quote mixed-case and underscore terms to prevent unwanted stemming/splitting
390
390
  // Skip when exact=true since that already preserves the literal string
391
391
  if (!exact && searchQuery) {
@@ -399,15 +399,18 @@ export const searchTool = (options = {}) => {
399
399
  // Use parameter maxTokens if provided, otherwise use the default
400
400
  const effectiveMaxTokens = paramMaxTokens || maxTokens;
401
401
 
402
+ // Use workingDirectory (injected by _buildNativeTools at runtime) > cwd from config > fallback
403
+ const effectiveSearchCwd = workingDirectory || options.cwd || '.';
404
+
402
405
  // Parse and resolve paths (supports comma-separated and relative paths)
403
406
  let searchPaths;
404
407
  if (path) {
405
- searchPaths = parseAndResolvePaths(path, options.cwd);
408
+ searchPaths = parseAndResolvePaths(path, effectiveSearchCwd);
406
409
  }
407
410
 
408
411
  // Default to cwd or '.' if no paths provided
409
412
  if (!searchPaths || searchPaths.length === 0) {
410
- searchPaths = [options.cwd || '.'];
413
+ searchPaths = [effectiveSearchCwd];
411
414
  }
412
415
 
413
416
  // Join paths with space for CLI (probe search supports multiple paths)
@@ -416,7 +419,7 @@ export const searchTool = (options = {}) => {
416
419
  const searchOptions = {
417
420
  query: searchQuery,
418
421
  path: searchPath,
419
- cwd: options.cwd, // Working directory for resolving relative paths
422
+ cwd: effectiveSearchCwd, // Working directory for resolving relative paths
420
423
  allowTests: allow_tests ?? true,
421
424
  exact,
422
425
  json: false,
@@ -473,7 +476,7 @@ export const searchTool = (options = {}) => {
473
476
  const result = maybeAnnotate(await runRawSearch());
474
477
  // Track files found in search results for staleness detection
475
478
  if (options.fileTracker && typeof result === 'string') {
476
- options.fileTracker.trackFilesFromOutput(result, options.cwd || '.').catch(() => {});
479
+ options.fileTracker.trackFilesFromOutput(result, effectiveSearchCwd).catch(() => {});
477
480
  }
478
481
  return result;
479
482
  } catch (error) {
@@ -532,7 +535,7 @@ export const searchTool = (options = {}) => {
532
535
  }
533
536
  const fallbackResult = maybeAnnotate(await runRawSearch());
534
537
  if (options.fileTracker && typeof fallbackResult === 'string') {
535
- options.fileTracker.trackFilesFromOutput(fallbackResult, options.cwd || '.').catch(() => {});
538
+ options.fileTracker.trackFilesFromOutput(fallbackResult, effectiveSearchCwd).catch(() => {});
536
539
  }
537
540
  return fallbackResult;
538
541
  }
@@ -614,7 +617,7 @@ export const searchTool = (options = {}) => {
614
617
  try {
615
618
  const fallbackResult2 = maybeAnnotate(await runRawSearch());
616
619
  if (options.fileTracker && typeof fallbackResult2 === 'string') {
617
- options.fileTracker.trackFilesFromOutput(fallbackResult2, options.cwd || '.').catch(() => {});
620
+ options.fileTracker.trackFilesFromOutput(fallbackResult2, effectiveSearchCwd).catch(() => {});
618
621
  }
619
622
  return fallbackResult2;
620
623
  } catch (fallbackError) {
@@ -693,10 +696,10 @@ export const extractTool = (options = {}) => {
693
696
  name: 'extract',
694
697
  description: extractDescription,
695
698
  inputSchema: extractSchema,
696
- execute: async ({ targets, input_content, line, end_line, allow_tests, context_lines, format }) => {
699
+ execute: async ({ targets, input_content, line, end_line, allow_tests, context_lines, format, workingDirectory }) => {
697
700
  try {
698
- // Use the cwd from config for working directory
699
- const effectiveCwd = options.cwd || '.';
701
+ // Use workingDirectory (injected by _buildNativeTools at runtime) > cwd from config > fallback
702
+ const effectiveCwd = workingDirectory || options.cwd || '.';
700
703
 
701
704
  if (debug) {
702
705
  if (targets) {