@nexrall/code-core 1.3.1 → 1.4.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/dist/agent/crossFile.d.ts +42 -0
- package/dist/agent/crossFile.d.ts.map +1 -0
- package/dist/agent/crossFile.js +119 -0
- package/dist/agent/editCompleteness.d.ts +47 -0
- package/dist/agent/editCompleteness.d.ts.map +1 -0
- package/dist/agent/editCompleteness.js +109 -0
- package/dist/agent/flaky.d.ts +47 -0
- package/dist/agent/flaky.d.ts.map +1 -0
- package/dist/agent/flaky.js +79 -0
- package/dist/agent/loop.d.ts +59 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +392 -12
- package/dist/agent/testIntegrity.d.ts +56 -0
- package/dist/agent/testIntegrity.d.ts.map +1 -0
- package/dist/agent/testIntegrity.js +199 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/permissions/destructive.d.ts +43 -0
- package/dist/permissions/destructive.d.ts.map +1 -0
- package/dist/permissions/destructive.js +244 -0
- package/dist/tools/executor.d.ts.map +1 -1
- package/dist/tools/executor.js +342 -14
- package/dist/tools/tsLangService.d.ts +21 -0
- package/dist/tools/tsLangService.d.ts.map +1 -0
- package/dist/tools/tsLangService.js +282 -0
- package/package.json +1 -1
package/dist/tools/executor.js
CHANGED
|
@@ -43,13 +43,25 @@ const dns = __importStar(require("dns"));
|
|
|
43
43
|
const child_process_1 = require("child_process");
|
|
44
44
|
const sandbox_1 = require("./sandbox");
|
|
45
45
|
const auth_1 = require("../auth");
|
|
46
|
+
const editCompleteness_1 = require("../agent/editCompleteness");
|
|
47
|
+
const crossFile_1 = require("../agent/crossFile");
|
|
46
48
|
const client_1 = require("../api/client");
|
|
47
49
|
const symbols_1 = require("./symbols");
|
|
50
|
+
const tsLangService_1 = require("./tsLangService");
|
|
48
51
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
49
52
|
const DEFAULT_TIMEOUT_MS = 60000;
|
|
50
53
|
const MAX_FETCH_BYTES = 200 * 1024; // 200 KB
|
|
51
54
|
const MAX_READ_BYTES = 500 * 1024; // 500 KB
|
|
52
55
|
const MAX_OUTPUT_CHARS = 100000; // bash / grep output cap (~100 KB)
|
|
56
|
+
// GAP B — output spillover. The inline bash result keeps only HEAD+TAIL (~100 KB),
|
|
57
|
+
// which loses the MIDDLE of a large log — often exactly where a stack trace's root
|
|
58
|
+
// cause or a failing assertion lives. To make the full log recoverable WITHOUT
|
|
59
|
+
// bloating the model context, we stream the complete stdout+stderr to a temp file
|
|
60
|
+
// (bounded by MAX_SPILL_BYTES) and tell the model it can `read_file` that path with
|
|
61
|
+
// offset/limit to inspect any section. This is the key long-debug capability: the
|
|
62
|
+
// agent decides which slice of a 5 MB log it needs, instead of us guessing.
|
|
63
|
+
const MAX_SPILL_BYTES = 20 * 1024 * 1024; // 20 MB cap on a single spill file
|
|
64
|
+
const SPILL_DIR = path.join(os.tmpdir(), 'nexrall-code', 'bash-output');
|
|
53
65
|
// ─── Blocked commands (safety) ────────────────────────────────────────────────
|
|
54
66
|
// Regexes are tested against the LOWERCASED command so case variants and minor
|
|
55
67
|
// spacing differences (double spaces, no-space pipes) are all caught.
|
|
@@ -204,6 +216,59 @@ function isBinaryFile(filePath) {
|
|
|
204
216
|
return false;
|
|
205
217
|
}
|
|
206
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Read a bounded line window [offset, offset+limit) from a file too large to load
|
|
221
|
+
* whole, by streaming it in chunks and keeping only the requested lines. Memory is
|
|
222
|
+
* bounded by (limit lines actually kept) + one chunk, never the whole file — so a
|
|
223
|
+
* 20MB bash-output spill file can be inspected section-by-section. (GAP B.)
|
|
224
|
+
*/
|
|
225
|
+
async function readLargeFileWindow(resolved, offset, limit, kb) {
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
const endLine = offset + limit; // exclusive, 0-based
|
|
228
|
+
const kept = [];
|
|
229
|
+
let lineNo = 0; // 0-based index of the NEXT line to be completed
|
|
230
|
+
let carry = ''; // partial line spanning chunk boundaries
|
|
231
|
+
let stopped = false;
|
|
232
|
+
const stream = fs.createReadStream(resolved, { encoding: 'utf-8', highWaterMark: 256 * 1024 });
|
|
233
|
+
const finish = () => {
|
|
234
|
+
if (stopped)
|
|
235
|
+
return;
|
|
236
|
+
stopped = true;
|
|
237
|
+
stream.destroy();
|
|
238
|
+
const first = offset + 1;
|
|
239
|
+
const last = offset + kept.length;
|
|
240
|
+
const numbered = kept.map((l, i) => `${String(offset + i + 1).padStart(4, ' ')}\t${l}`).join('\n');
|
|
241
|
+
const note = kept.length < limit
|
|
242
|
+
? ` (reached end of file at line ${last})`
|
|
243
|
+
: ` (more lines follow — increase offset to continue)`;
|
|
244
|
+
resolve({ output: `[File: ${resolved} — ${kb} KB, showing lines ${first}-${last}${note}]\n${numbered}` });
|
|
245
|
+
};
|
|
246
|
+
stream.on('data', (chunk) => {
|
|
247
|
+
const text = carry + (typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
|
|
248
|
+
const lines = text.split('\n');
|
|
249
|
+
carry = lines.pop() ?? ''; // last element is an incomplete line (or '')
|
|
250
|
+
for (const line of lines) {
|
|
251
|
+
if (lineNo >= offset && lineNo < endLine)
|
|
252
|
+
kept.push(line);
|
|
253
|
+
lineNo++;
|
|
254
|
+
if (lineNo >= endLine) {
|
|
255
|
+
finish();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
stream.on('end', () => {
|
|
261
|
+
// Flush the final carry (file not ending in newline) if it's in range.
|
|
262
|
+
if (!stopped && carry !== '' && lineNo >= offset && lineNo < endLine)
|
|
263
|
+
kept.push(carry);
|
|
264
|
+
finish();
|
|
265
|
+
});
|
|
266
|
+
stream.on('error', (err) => { if (!stopped) {
|
|
267
|
+
stopped = true;
|
|
268
|
+
resolve({ error: err.message });
|
|
269
|
+
} });
|
|
270
|
+
});
|
|
271
|
+
}
|
|
207
272
|
async function readFile(input, workDir) {
|
|
208
273
|
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
209
274
|
const offset = typeof input.offset === 'number' ? Math.max(0, Math.floor(input.offset)) : 0;
|
|
@@ -213,17 +278,22 @@ async function readFile(input, workDir) {
|
|
|
213
278
|
try {
|
|
214
279
|
const resolved = resolvePath(filePath, workDir);
|
|
215
280
|
const stat = fs.statSync(resolved);
|
|
216
|
-
// Always enforce size cap — even with offset/limit we still readFileSync the whole
|
|
217
|
-
// file before slicing, so a 500 MB file would OOM regardless of the slice range.
|
|
218
|
-
if (stat.size > MAX_READ_BYTES) {
|
|
219
|
-
const kb = (stat.size / 1024).toFixed(0);
|
|
220
|
-
return { error: `File too large (${kb} KB, max ${MAX_READ_BYTES / 1024} KB). Use offset+limit to read sections, or search_files to locate the relevant part first.` };
|
|
221
|
-
}
|
|
222
281
|
// Reject binary files early — reading them as UTF-8 produces garbage.
|
|
223
282
|
if (isBinaryFile(resolved)) {
|
|
224
283
|
const ext = path.extname(resolved).toLowerCase();
|
|
225
284
|
return { error: `Cannot read binary file: ${resolved} (${ext || 'no extension'}). Use a text-based tool or convert it first.` };
|
|
226
285
|
}
|
|
286
|
+
// Large-file path: when the file exceeds the in-memory cap, a full readFileSync
|
|
287
|
+
// would OOM — but we must still be able to inspect SECTIONS (this is what makes a
|
|
288
|
+
// 20MB bash-output spill file usable, GAP B). If offset+limit are given we stream
|
|
289
|
+
// line-by-line and materialise only the requested window; otherwise we require it.
|
|
290
|
+
if (stat.size > MAX_READ_BYTES) {
|
|
291
|
+
const kb = (stat.size / 1024).toFixed(0);
|
|
292
|
+
if (limit <= 0) {
|
|
293
|
+
return { error: `File too large (${kb} KB, max ${MAX_READ_BYTES / 1024} KB). Pass offset + limit to read a section (e.g. {offset:0, limit:500}), or search_files to locate the relevant part first.` };
|
|
294
|
+
}
|
|
295
|
+
return await readLargeFileWindow(resolved, offset, limit, kb);
|
|
296
|
+
}
|
|
227
297
|
const content = fs.readFileSync(resolved, 'utf-8');
|
|
228
298
|
const allLines = content.split('\n');
|
|
229
299
|
const totalLines = allLines.length;
|
|
@@ -260,14 +330,32 @@ async function writeFile(input, workDir) {
|
|
|
260
330
|
if (!isNew && content === '') {
|
|
261
331
|
return { error: `Refusing to overwrite ${resolved} with empty content. Provide the full file content or use edit_file for targeted changes.` };
|
|
262
332
|
}
|
|
333
|
+
// Edit-completeness guard: refuse writes that elide code with a placeholder
|
|
334
|
+
// ("// ... rest of the code unchanged") — a silent, irreversible data loss —
|
|
335
|
+
// or that still contain unresolved merge-conflict markers. Opt-out with
|
|
336
|
+
// NEXRALL_ALLOW_ELIDED_WRITE=1 for the rare intentional case.
|
|
337
|
+
if (process.env.NEXRALL_ALLOW_ELIDED_WRITE !== '1') {
|
|
338
|
+
const problem = (0, editCompleteness_1.checkEditCompleteness)(content, !isNew);
|
|
339
|
+
if (problem) {
|
|
340
|
+
if (problem.kind === 'elision-placeholder') {
|
|
341
|
+
return { error: `Refusing to write ${resolved}: ${problem.reason}.\nOffending line: ${problem.sample}\nwrite_file must contain the COMPLETE file — never a "rest unchanged" placeholder. Send the full content, or use edit_file/multi_edit for a targeted change so surrounding code is preserved.` };
|
|
342
|
+
}
|
|
343
|
+
return { error: `Refusing to write ${resolved}: ${problem.reason}.\nOffending line: ${problem.sample}\nResolve the conflict (remove <<<<<<< / ======= / >>>>>>> markers and keep the intended code) before writing.` };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
263
346
|
// Preserve file permissions (mode bits) when overwriting an existing file.
|
|
264
347
|
// writeFileSync creates new files with umask-default mode, stripping +x etc.
|
|
265
348
|
let existingMode;
|
|
349
|
+
let priorContent = '';
|
|
266
350
|
if (!isNew) {
|
|
267
351
|
try {
|
|
268
352
|
existingMode = fs.statSync(resolved).mode;
|
|
269
353
|
}
|
|
270
354
|
catch { /* ignore */ }
|
|
355
|
+
try {
|
|
356
|
+
priorContent = fs.readFileSync(resolved, 'utf-8');
|
|
357
|
+
}
|
|
358
|
+
catch { /* ignore */ }
|
|
271
359
|
}
|
|
272
360
|
fs.writeFileSync(resolved, content, 'utf-8');
|
|
273
361
|
if (existingMode !== undefined) {
|
|
@@ -281,7 +369,8 @@ async function writeFile(input, workDir) {
|
|
|
281
369
|
if (isNew) {
|
|
282
370
|
return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)` };
|
|
283
371
|
}
|
|
284
|
-
|
|
372
|
+
const xfile = crossFileBreakageWarning(resolved, normalizeLF(priorContent), normalizeLF(content), workDir ?? process.cwd());
|
|
373
|
+
return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)${xfile}` };
|
|
285
374
|
}
|
|
286
375
|
catch (err) {
|
|
287
376
|
return { error: err.message };
|
|
@@ -340,11 +429,44 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
|
|
|
340
429
|
const shell = {
|
|
341
430
|
id, command: displayCommand, child, output: '', readCursor: 0, truncated: false,
|
|
342
431
|
status: 'running', exitCode: null,
|
|
432
|
+
spillPath: null, spillFd: null, spillBytes: 0, spillFull: false,
|
|
343
433
|
};
|
|
344
434
|
// Rolling buffer: keep the LAST MAX_OUTPUT_CHARS instead of the first, so a
|
|
345
435
|
// long-lived watcher/dev-server's recent output (the useful part) is never
|
|
346
|
-
// permanently lost behind an early cap.
|
|
436
|
+
// permanently lost behind an early cap. In parallel, once output exceeds the
|
|
437
|
+
// inline cap we mirror the FULL stream to a bounded spill file (GAP B) so the
|
|
438
|
+
// discarded earlier output is still recoverable via read_file.
|
|
347
439
|
const append = (chunk) => {
|
|
440
|
+
if (shell.spillPath || shell.output.length + chunk.length > MAX_OUTPUT_CHARS) {
|
|
441
|
+
if (!shell.spillPath && !shell.spillFull) {
|
|
442
|
+
try {
|
|
443
|
+
fs.mkdirSync(SPILL_DIR, { recursive: true });
|
|
444
|
+
shell.spillPath = path.join(SPILL_DIR, `${id}-${Date.now()}.log`);
|
|
445
|
+
shell.spillFd = fs.openSync(shell.spillPath, 'w');
|
|
446
|
+
fs.writeSync(shell.spillFd, shell.output); // backfill what we already have
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
shell.spillPath = null;
|
|
450
|
+
shell.spillFd = null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (shell.spillFd !== null && !shell.spillFull) {
|
|
454
|
+
const buf = Buffer.from(chunk, 'utf-8');
|
|
455
|
+
const room = MAX_SPILL_BYTES - shell.spillBytes;
|
|
456
|
+
if (room <= 0) {
|
|
457
|
+
shell.spillFull = true;
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
try {
|
|
461
|
+
fs.writeSync(shell.spillFd, room >= buf.length ? buf : buf.subarray(0, room));
|
|
462
|
+
shell.spillBytes += Math.min(room, buf.length);
|
|
463
|
+
if (buf.length > room)
|
|
464
|
+
shell.spillFull = true;
|
|
465
|
+
}
|
|
466
|
+
catch { /* degrade to inline-only */ }
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
348
470
|
shell.output += chunk;
|
|
349
471
|
if (shell.output.length > MAX_OUTPUT_CHARS) {
|
|
350
472
|
// Drop from the front but never behind the read cursor's logical position;
|
|
@@ -360,6 +482,13 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
|
|
|
360
482
|
child.on('close', (code) => {
|
|
361
483
|
shell.status = shell.status === 'killed' ? 'killed' : 'exited';
|
|
362
484
|
shell.exitCode = code;
|
|
485
|
+
if (shell.spillFd !== null) {
|
|
486
|
+
try {
|
|
487
|
+
fs.closeSync(shell.spillFd);
|
|
488
|
+
}
|
|
489
|
+
catch { /* ignore */ }
|
|
490
|
+
shell.spillFd = null;
|
|
491
|
+
}
|
|
363
492
|
// Evict oldest exited shell once map grows past 50 to prevent session-long leak
|
|
364
493
|
if (_bgShells.size > 50) {
|
|
365
494
|
for (const [k, v] of _bgShells) {
|
|
@@ -387,7 +516,9 @@ async function bashOutput(input) {
|
|
|
387
516
|
const statusLine = shell.status === 'running'
|
|
388
517
|
? '[still running]'
|
|
389
518
|
: `[${shell.status}${shell.exitCode != null ? `, exit ${shell.exitCode}` : ''}]`;
|
|
390
|
-
const trunc = shell.truncated
|
|
519
|
+
const trunc = shell.truncated
|
|
520
|
+
? `\n[output truncated at 100KB${shell.spillPath ? `; full log at ${shell.spillPath} — read_file with offset/limit` : ''}]`
|
|
521
|
+
: '';
|
|
391
522
|
return { output: `Shell ${id} ${statusLine}\n${fresh || '(no new output)'}${trunc}` };
|
|
392
523
|
}
|
|
393
524
|
async function killShell(input) {
|
|
@@ -496,8 +627,66 @@ async function bash(input, abortSignal, sandbox, workDir) {
|
|
|
496
627
|
let totalLen = 0;
|
|
497
628
|
let truncated = false;
|
|
498
629
|
let settled = false;
|
|
630
|
+
// GAP B — full-output spill. We lazily open a temp file the FIRST time output
|
|
631
|
+
// exceeds the inline budget and stream everything to it (bounded), so the
|
|
632
|
+
// complete log survives even though the inline result is HEAD+TAIL only. The
|
|
633
|
+
// model is told the path and can read_file it with offset/limit.
|
|
634
|
+
let spillPath = null;
|
|
635
|
+
let spillFd = null;
|
|
636
|
+
let spillBytes = 0;
|
|
637
|
+
let spillFull = false;
|
|
638
|
+
const openSpill = () => {
|
|
639
|
+
if (spillPath || spillFull)
|
|
640
|
+
return;
|
|
641
|
+
try {
|
|
642
|
+
fs.mkdirSync(SPILL_DIR, { recursive: true });
|
|
643
|
+
spillPath = path.join(SPILL_DIR, `bash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.log`);
|
|
644
|
+
spillFd = fs.openSync(spillPath, 'w');
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
spillPath = null;
|
|
648
|
+
spillFd = null;
|
|
649
|
+
} // spill is best-effort; never break the command
|
|
650
|
+
};
|
|
651
|
+
const writeSpill = (chunk) => {
|
|
652
|
+
if (spillFull || spillFd === null)
|
|
653
|
+
return;
|
|
654
|
+
const buf = Buffer.from(chunk, 'utf-8');
|
|
655
|
+
const room = MAX_SPILL_BYTES - spillBytes;
|
|
656
|
+
if (room <= 0) {
|
|
657
|
+
spillFull = true;
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
fs.writeSync(spillFd, room >= buf.length ? buf : buf.subarray(0, room));
|
|
662
|
+
spillBytes += Math.min(room, buf.length);
|
|
663
|
+
if (buf.length > room)
|
|
664
|
+
spillFull = true;
|
|
665
|
+
}
|
|
666
|
+
catch { /* disk full / closed — degrade to inline-only */ }
|
|
667
|
+
};
|
|
668
|
+
const closeSpill = () => {
|
|
669
|
+
if (spillFd !== null) {
|
|
670
|
+
try {
|
|
671
|
+
fs.closeSync(spillFd);
|
|
672
|
+
}
|
|
673
|
+
catch { /* ignore */ }
|
|
674
|
+
spillFd = null;
|
|
675
|
+
}
|
|
676
|
+
};
|
|
499
677
|
const appendOutput = (chunk) => {
|
|
500
678
|
totalLen += chunk.length;
|
|
679
|
+
// Mirror the full stream to the spill file once we know output is large.
|
|
680
|
+
if (spillPath || totalLen > MAX_OUTPUT_CHARS) {
|
|
681
|
+
if (!spillPath) {
|
|
682
|
+
openSpill();
|
|
683
|
+
if (spillPath) {
|
|
684
|
+
writeSpill(head);
|
|
685
|
+
writeSpill(tail);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
writeSpill(chunk);
|
|
689
|
+
}
|
|
501
690
|
if (head.length < HEAD_CHARS) {
|
|
502
691
|
const room = HEAD_CHARS - head.length;
|
|
503
692
|
head += chunk.slice(0, room);
|
|
@@ -513,10 +702,14 @@ async function bash(input, abortSignal, sandbox, workDir) {
|
|
|
513
702
|
}
|
|
514
703
|
};
|
|
515
704
|
const collect = () => {
|
|
705
|
+
closeSpill();
|
|
516
706
|
if (!truncated)
|
|
517
707
|
return head + tail;
|
|
518
708
|
const omitted = totalLen - head.length - tail.length;
|
|
519
|
-
|
|
709
|
+
const spillNote = spillPath
|
|
710
|
+
? ` Full output (${(totalLen / 1024).toFixed(0)}KB${spillFull ? '+, truncated at 20MB' : ''}) saved to ${spillPath} — read_file that path with offset/limit to inspect the omitted middle.`
|
|
711
|
+
: '';
|
|
712
|
+
return `${head}\n\n[… ${omitted} chars omitted (kept first ${HEAD_CHARS / 1024}KB + last ${Math.round(TAIL_CHARS / 1024)}KB).${spillNote} …]\n\n${tail}`;
|
|
520
713
|
};
|
|
521
714
|
const done = (code, signal, timedOut = false) => {
|
|
522
715
|
if (settled)
|
|
@@ -579,6 +772,90 @@ function ripgrepAvailable() {
|
|
|
579
772
|
}
|
|
580
773
|
return _rgAvailable;
|
|
581
774
|
}
|
|
775
|
+
// ── Cross-file breakage warning ──────────────────────────────────────────────
|
|
776
|
+
// After an edit removes/renames an exported symbol, scan the rest of the repo for
|
|
777
|
+
// surviving references. Returns a short warning string (or '' when clean). Best-
|
|
778
|
+
// effort, time-boxed, and never throws — a scan failure must not fail the edit.
|
|
779
|
+
function crossFileBreakageWarning(editedAbsPath, oldContent, newContent, workDir) {
|
|
780
|
+
if (process.env.NEXRALL_CROSSFILE_CHECK === '0')
|
|
781
|
+
return '';
|
|
782
|
+
let removed;
|
|
783
|
+
try {
|
|
784
|
+
removed = (0, crossFile_1.findRemovedExports)(editedAbsPath, oldContent, newContent)
|
|
785
|
+
.filter((s) => (0, crossFile_1.isCheckableSymbol)(s.name));
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
return '';
|
|
789
|
+
}
|
|
790
|
+
if (!removed.length)
|
|
791
|
+
return '';
|
|
792
|
+
const editedReal = (() => { try {
|
|
793
|
+
return fs.realpathSync(editedAbsPath);
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
return editedAbsPath;
|
|
797
|
+
} })();
|
|
798
|
+
const hits = [];
|
|
799
|
+
const MAX_SYMBOLS = 8;
|
|
800
|
+
for (const sym of removed.slice(0, MAX_SYMBOLS)) {
|
|
801
|
+
const refs = scanReferences(sym.name, workDir, editedReal);
|
|
802
|
+
if (refs.length) {
|
|
803
|
+
const shown = refs.slice(0, 3).map((r) => ` ${r}`).join('\n');
|
|
804
|
+
const more = refs.length > 3 ? `\n … and ${refs.length - 3} more` : '';
|
|
805
|
+
hits.push(` • ${sym.kind} "${sym.name}" (removed/renamed) still referenced in:\n${shown}${more}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (!hits.length)
|
|
809
|
+
return '';
|
|
810
|
+
return (`\n\n⚠ CROSS-FILE BREAKAGE: you removed/renamed exported symbol(s) still used elsewhere.\n` +
|
|
811
|
+
hits.join('\n') +
|
|
812
|
+
`\nUpdate these call-sites (or restore the symbol), then run a build/typecheck to confirm nothing is broken.`);
|
|
813
|
+
}
|
|
814
|
+
// Find files (other than the edited one) that reference `name` as a whole word.
|
|
815
|
+
// Uses ripgrep when available (fast, .gitignore-aware), else a bounded grep -r.
|
|
816
|
+
function scanReferences(name, workDir, excludeRealPath) {
|
|
817
|
+
const pattern = `\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`;
|
|
818
|
+
const files = new Set();
|
|
819
|
+
try {
|
|
820
|
+
if (ripgrepAvailable()) {
|
|
821
|
+
const r = (0, child_process_1.spawnSync)('rg', ['-l', '--no-messages', '-e', pattern, '.'], {
|
|
822
|
+
cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
|
|
823
|
+
});
|
|
824
|
+
if (r.stdout)
|
|
825
|
+
for (const line of r.stdout.split('\n')) {
|
|
826
|
+
if (line.trim())
|
|
827
|
+
files.add(line.trim());
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
const r = (0, child_process_1.spawnSync)('grep', ['-rlI', '--exclude-dir=node_modules', '--exclude-dir=.git',
|
|
832
|
+
'--exclude-dir=dist', '--exclude-dir=.next', '-E', pattern, '.'], {
|
|
833
|
+
cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
|
|
834
|
+
});
|
|
835
|
+
if (r.stdout)
|
|
836
|
+
for (const line of r.stdout.split('\n')) {
|
|
837
|
+
if (line.trim())
|
|
838
|
+
files.add(line.trim());
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
return [];
|
|
844
|
+
}
|
|
845
|
+
const rel = [];
|
|
846
|
+
for (const f of files) {
|
|
847
|
+
const abs = path.isAbsolute(f) ? f : path.join(workDir, f);
|
|
848
|
+
let real = abs;
|
|
849
|
+
try {
|
|
850
|
+
real = fs.realpathSync(abs);
|
|
851
|
+
}
|
|
852
|
+
catch { /* keep abs */ }
|
|
853
|
+
if (real === excludeRealPath)
|
|
854
|
+
continue; // the file we just edited
|
|
855
|
+
rel.push(path.relative(workDir, abs) || f);
|
|
856
|
+
}
|
|
857
|
+
return rel;
|
|
858
|
+
}
|
|
582
859
|
async function searchFiles(input, workDir) {
|
|
583
860
|
const pattern = typeof input.pattern === 'string' ? input.pattern : '';
|
|
584
861
|
const searchPath = typeof input.path === 'string' ? input.path : '.';
|
|
@@ -896,7 +1173,8 @@ async function editFile(input, workDir) {
|
|
|
896
1173
|
const diff = buildDiff(filePath, oldNorm, newNorm, origNorm);
|
|
897
1174
|
const linesBefore = origNorm.split('\n').length;
|
|
898
1175
|
const linesAfter = updated.split('\n').length;
|
|
899
|
-
|
|
1176
|
+
const xfile = crossFileBreakageWarning(resolved, origNorm, normalizeLF(updated), workDir ?? process.cwd());
|
|
1177
|
+
return { output: `Edited ${resolved} (${linesBefore} → ${linesAfter} lines)\n\n${diff}${xfile}` };
|
|
900
1178
|
}
|
|
901
1179
|
catch (err) {
|
|
902
1180
|
return { error: err.message };
|
|
@@ -1145,7 +1423,11 @@ async function fetchUrl(input, _workDir, _redirectCount = 0) {
|
|
|
1145
1423
|
if (isHtml)
|
|
1146
1424
|
body = stripHtml(body);
|
|
1147
1425
|
const truncNote = truncated ? `\n\n[Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : '';
|
|
1148
|
-
|
|
1426
|
+
// Prompt-injection mitigation: fetched web content is fully attacker-controlled
|
|
1427
|
+
// (anyone can put "ignore previous instructions..." on a page). Wrap it in an
|
|
1428
|
+
// explicit untrusted-data delimiter so the model treats it as DATA to read, not
|
|
1429
|
+
// as instructions to follow — defense-in-depth alongside the system-prompt rule.
|
|
1430
|
+
resolve({ output: `HTTP ${status}\n\n<untrusted_web_content url="${url}">\n${body}${truncNote}\n</untrusted_web_content>` });
|
|
1149
1431
|
});
|
|
1150
1432
|
res.on('error', (err) => resolve({ error: err.message }));
|
|
1151
1433
|
});
|
|
@@ -1202,6 +1484,7 @@ async function multiEdit(input, workDir) {
|
|
|
1202
1484
|
// Work in LF-normalised space so CRLF files match LF old_strings
|
|
1203
1485
|
const wasCRLF = rawFile.includes('\r\n');
|
|
1204
1486
|
let content = normalizeLF(rawFile);
|
|
1487
|
+
const originalNorm = content; // captured before edits mutate `content`
|
|
1205
1488
|
const diffs = [];
|
|
1206
1489
|
for (let i = 0; i < edits.length; i++) {
|
|
1207
1490
|
const edit = edits[i];
|
|
@@ -1228,9 +1511,10 @@ async function multiEdit(input, workDir) {
|
|
|
1228
1511
|
// preserving the original mode bits (+x on scripts etc.).
|
|
1229
1512
|
const finalContent = wasCRLF ? content.replace(/\n/g, '\r\n') : content;
|
|
1230
1513
|
atomicWritePreservingMode(resolved, finalContent, pre.mode);
|
|
1514
|
+
const xfile = crossFileBreakageWarning(resolved, originalNorm, content, workDir ?? process.cwd());
|
|
1231
1515
|
return {
|
|
1232
1516
|
output: `Applied ${edits.length} edit(s) to ${resolved}:\n` +
|
|
1233
|
-
diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n'),
|
|
1517
|
+
diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n') + xfile,
|
|
1234
1518
|
};
|
|
1235
1519
|
}
|
|
1236
1520
|
catch (err) {
|
|
@@ -1670,7 +1954,46 @@ async function memoryRead(_input) {
|
|
|
1670
1954
|
return { error: err.message };
|
|
1671
1955
|
}
|
|
1672
1956
|
}
|
|
1673
|
-
|
|
1957
|
+
function semanticPreamble(input, workDir) {
|
|
1958
|
+
const p = typeof input.path === 'string' ? input.path : '';
|
|
1959
|
+
const line = typeof input.line === 'number' ? Math.floor(input.line) : 0;
|
|
1960
|
+
const character = typeof input.character === 'number' ? Math.floor(input.character) : 0;
|
|
1961
|
+
if (!p)
|
|
1962
|
+
return { ok: false, err: { error: 'Missing required parameter: path' } };
|
|
1963
|
+
if (line < 1 || character < 1)
|
|
1964
|
+
return { ok: false, err: { error: 'line and character are 1-based and required' } };
|
|
1965
|
+
let resolved;
|
|
1966
|
+
try {
|
|
1967
|
+
resolved = resolvePath(p, workDir);
|
|
1968
|
+
}
|
|
1969
|
+
catch (e) {
|
|
1970
|
+
return { ok: false, err: { error: e.message } };
|
|
1971
|
+
}
|
|
1972
|
+
if (!(0, tsLangService_1.isTsLike)(resolved)) {
|
|
1973
|
+
return { ok: false, err: { error: `Semantic navigation currently supports TypeScript/JavaScript only. For ${path.extname(resolved) || 'this file type'} use get_symbols / get_workspace_symbols (regex-based).` } };
|
|
1974
|
+
}
|
|
1975
|
+
return { ok: true, args: { workDir: workDir ?? process.cwd(), filePath: resolved, line, character } };
|
|
1976
|
+
}
|
|
1977
|
+
const NO_TS_SERVICE = 'No TypeScript language service available (install `typescript` in the project). Use get_workspace_symbols / get_symbols instead.';
|
|
1978
|
+
async function goToDefinition(input, workDir) {
|
|
1979
|
+
const pre = semanticPreamble(input, workDir);
|
|
1980
|
+
if (!pre.ok)
|
|
1981
|
+
return pre.err;
|
|
1982
|
+
return (0, tsLangService_1.tsGoToDefinition)(pre.args) ?? { error: NO_TS_SERVICE };
|
|
1983
|
+
}
|
|
1984
|
+
async function findReferences(input, workDir) {
|
|
1985
|
+
const pre = semanticPreamble(input, workDir);
|
|
1986
|
+
if (!pre.ok)
|
|
1987
|
+
return pre.err;
|
|
1988
|
+
const includeDeclaration = input.include_declaration !== false;
|
|
1989
|
+
return (0, tsLangService_1.tsFindReferences)({ ...pre.args, includeDeclaration }) ?? { error: NO_TS_SERVICE };
|
|
1990
|
+
}
|
|
1991
|
+
async function getHover(input, workDir) {
|
|
1992
|
+
const pre = semanticPreamble(input, workDir);
|
|
1993
|
+
if (!pre.ok)
|
|
1994
|
+
return pre.err;
|
|
1995
|
+
return (0, tsLangService_1.tsGetHover)(pre.args) ?? { error: NO_TS_SERVICE };
|
|
1996
|
+
}
|
|
1674
1997
|
const TOOL_MAP = {
|
|
1675
1998
|
read_file: readFile,
|
|
1676
1999
|
write_file: writeFile,
|
|
@@ -1700,6 +2023,10 @@ const TOOL_MAP = {
|
|
|
1700
2023
|
// so this fallback only serves the CLI / headless environments.
|
|
1701
2024
|
get_symbols: symbols_1.getSymbols,
|
|
1702
2025
|
get_workspace_symbols: symbols_1.getWorkspaceSymbols,
|
|
2026
|
+
// Real semantic navigation (TS/JS) for the CLI — see GAP A above.
|
|
2027
|
+
go_to_definition: goToDefinition,
|
|
2028
|
+
find_references: findReferences,
|
|
2029
|
+
get_hover: getHover,
|
|
1703
2030
|
};
|
|
1704
2031
|
// Tools that accept a workDir for correct relative-path resolution.
|
|
1705
2032
|
// TOOL_MAP stores the base signatures; executeTool injects workDir at call time.
|
|
@@ -1709,6 +2036,7 @@ const WORKDIR_TOOLS = new Set([
|
|
|
1709
2036
|
'delete_file', 'notebook_read', 'notebook_edit',
|
|
1710
2037
|
'generate_image', 'stock_photo',
|
|
1711
2038
|
'get_symbols', 'get_workspace_symbols',
|
|
2039
|
+
'go_to_definition', 'find_references', 'get_hover',
|
|
1712
2040
|
]);
|
|
1713
2041
|
async function executeTool(name, input, abortSignal, sandbox, workDir, agentScope) {
|
|
1714
2042
|
if (!TOOL_MAP[name])
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ToolResult } from '../types';
|
|
2
|
+
export declare function isTsLike(filePath: string): boolean;
|
|
3
|
+
export interface SemanticDeps {
|
|
4
|
+
workDir: string;
|
|
5
|
+
filePath: string;
|
|
6
|
+
line: number;
|
|
7
|
+
character: number;
|
|
8
|
+
}
|
|
9
|
+
/** go_to_definition — resolve the symbol at (line, character) to its declaration(s). */
|
|
10
|
+
export declare function tsGoToDefinition(deps: SemanticDeps): ToolResult | null;
|
|
11
|
+
/** find_references — every usage of the symbol at (line, character) across the project. */
|
|
12
|
+
export declare function tsFindReferences(deps: SemanticDeps & {
|
|
13
|
+
includeDeclaration?: boolean;
|
|
14
|
+
}): ToolResult | null;
|
|
15
|
+
/** get_hover — the inferred type + doc for the symbol at (line, character). */
|
|
16
|
+
export declare function tsGetHover(deps: SemanticDeps): ToolResult | null;
|
|
17
|
+
/** Test/diagnostic helper: is a real TS language service available for this workspace? */
|
|
18
|
+
export declare function tsServiceAvailable(workDir: string): boolean;
|
|
19
|
+
/** Reset cached services + module resolution (tests only). */
|
|
20
|
+
export declare function _resetTsServiceCache(): void;
|
|
21
|
+
//# sourceMappingURL=tsLangService.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tsLangService.d.ts","sourceRoot":"","sources":["../../src/tools/tsLangService.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAsB3C,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAElD;AAsID,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wFAAwF;AACxF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU,GAAG,IAAI,CAgBtE;AAED,2FAA2F;AAC3F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG;IAAE,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,UAAU,GAAG,IAAI,CAmBzG;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU,GAAG,IAAI,CAchE;AAED,0FAA0F;AAC1F,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED,8DAA8D;AAC9D,wBAAgB,oBAAoB,IAAI,IAAI,CAI3C"}
|