@nexrall/code-core 1.3.0 → 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 +351 -18
- 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.
|
|
@@ -59,10 +71,15 @@ const MAX_OUTPUT_CHARS = 100000; // bash / grep output cap (~100 KB)
|
|
|
59
71
|
// hidden in a fake heredoc/quote (e.g. `echo "<<EOF"\nrm -rf /\nEOF`) is still
|
|
60
72
|
// caught.
|
|
61
73
|
const BLOCKED_REGEXES = [
|
|
62
|
-
// Filesystem destruction — match rm/chmod with any whitespace between flags
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
/rm\s+-rf\s
|
|
74
|
+
// Filesystem destruction — match rm/chmod with any whitespace between flags.
|
|
75
|
+
// Only root itself and BARE top-level system dirs are blocked; ordinary
|
|
76
|
+
// absolute subpaths (/tmp/x, /var/folders/x, /Users/me/proj/node_modules)
|
|
77
|
+
// must stay allowed — the old /rm\s+-rf\s+\// matched every absolute path.
|
|
78
|
+
// `-[a-z]*(?:rf|fr)[a-z]*` accepts -rf, -fr, -rfv, -vrf … (combined flag).
|
|
79
|
+
/rm\s+-[a-z]*(?:rf|fr)[a-z]*\s+\/(?:\*|\s|;|&|\||$)/, // rm -rf / · / * · root, bare
|
|
80
|
+
/rm\s+-[a-z]*(?:rf|fr)[a-z]*\s+\/(?:bin|sbin|etc|usr|var|lib|lib64|boot|sys|proc|dev|root|home|system|library|applications|opt|users)(?:\/)?(?:\*|\s|;|&|\||$)/, // rm -rf /etc … bare system dir
|
|
81
|
+
/rm\s+-[a-z]*(?:rf|fr)[a-z]*\s+~(?:\/)?(?:\*|\s|;|&|\||$)/, // rm -rf ~ · ~/ · ~/*
|
|
82
|
+
/rm\s+-[a-z]*(?:rf|fr)[a-z]*\s+\*/, // rm -rf *
|
|
66
83
|
/rm\s+--no-preserve-root/,
|
|
67
84
|
/chmod\s+-[rr]\s+777\s+\//,
|
|
68
85
|
/chown\s+-[rr]/,
|
|
@@ -199,6 +216,59 @@ function isBinaryFile(filePath) {
|
|
|
199
216
|
return false;
|
|
200
217
|
}
|
|
201
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
|
+
}
|
|
202
272
|
async function readFile(input, workDir) {
|
|
203
273
|
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
204
274
|
const offset = typeof input.offset === 'number' ? Math.max(0, Math.floor(input.offset)) : 0;
|
|
@@ -208,17 +278,22 @@ async function readFile(input, workDir) {
|
|
|
208
278
|
try {
|
|
209
279
|
const resolved = resolvePath(filePath, workDir);
|
|
210
280
|
const stat = fs.statSync(resolved);
|
|
211
|
-
// Always enforce size cap — even with offset/limit we still readFileSync the whole
|
|
212
|
-
// file before slicing, so a 500 MB file would OOM regardless of the slice range.
|
|
213
|
-
if (stat.size > MAX_READ_BYTES) {
|
|
214
|
-
const kb = (stat.size / 1024).toFixed(0);
|
|
215
|
-
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.` };
|
|
216
|
-
}
|
|
217
281
|
// Reject binary files early — reading them as UTF-8 produces garbage.
|
|
218
282
|
if (isBinaryFile(resolved)) {
|
|
219
283
|
const ext = path.extname(resolved).toLowerCase();
|
|
220
284
|
return { error: `Cannot read binary file: ${resolved} (${ext || 'no extension'}). Use a text-based tool or convert it first.` };
|
|
221
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
|
+
}
|
|
222
297
|
const content = fs.readFileSync(resolved, 'utf-8');
|
|
223
298
|
const allLines = content.split('\n');
|
|
224
299
|
const totalLines = allLines.length;
|
|
@@ -255,14 +330,32 @@ async function writeFile(input, workDir) {
|
|
|
255
330
|
if (!isNew && content === '') {
|
|
256
331
|
return { error: `Refusing to overwrite ${resolved} with empty content. Provide the full file content or use edit_file for targeted changes.` };
|
|
257
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
|
+
}
|
|
258
346
|
// Preserve file permissions (mode bits) when overwriting an existing file.
|
|
259
347
|
// writeFileSync creates new files with umask-default mode, stripping +x etc.
|
|
260
348
|
let existingMode;
|
|
349
|
+
let priorContent = '';
|
|
261
350
|
if (!isNew) {
|
|
262
351
|
try {
|
|
263
352
|
existingMode = fs.statSync(resolved).mode;
|
|
264
353
|
}
|
|
265
354
|
catch { /* ignore */ }
|
|
355
|
+
try {
|
|
356
|
+
priorContent = fs.readFileSync(resolved, 'utf-8');
|
|
357
|
+
}
|
|
358
|
+
catch { /* ignore */ }
|
|
266
359
|
}
|
|
267
360
|
fs.writeFileSync(resolved, content, 'utf-8');
|
|
268
361
|
if (existingMode !== undefined) {
|
|
@@ -276,7 +369,8 @@ async function writeFile(input, workDir) {
|
|
|
276
369
|
if (isNew) {
|
|
277
370
|
return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)` };
|
|
278
371
|
}
|
|
279
|
-
|
|
372
|
+
const xfile = crossFileBreakageWarning(resolved, normalizeLF(priorContent), normalizeLF(content), workDir ?? process.cwd());
|
|
373
|
+
return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)${xfile}` };
|
|
280
374
|
}
|
|
281
375
|
catch (err) {
|
|
282
376
|
return { error: err.message };
|
|
@@ -335,11 +429,44 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
|
|
|
335
429
|
const shell = {
|
|
336
430
|
id, command: displayCommand, child, output: '', readCursor: 0, truncated: false,
|
|
337
431
|
status: 'running', exitCode: null,
|
|
432
|
+
spillPath: null, spillFd: null, spillBytes: 0, spillFull: false,
|
|
338
433
|
};
|
|
339
434
|
// Rolling buffer: keep the LAST MAX_OUTPUT_CHARS instead of the first, so a
|
|
340
435
|
// long-lived watcher/dev-server's recent output (the useful part) is never
|
|
341
|
-
// 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.
|
|
342
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
|
+
}
|
|
343
470
|
shell.output += chunk;
|
|
344
471
|
if (shell.output.length > MAX_OUTPUT_CHARS) {
|
|
345
472
|
// Drop from the front but never behind the read cursor's logical position;
|
|
@@ -355,6 +482,13 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
|
|
|
355
482
|
child.on('close', (code) => {
|
|
356
483
|
shell.status = shell.status === 'killed' ? 'killed' : 'exited';
|
|
357
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
|
+
}
|
|
358
492
|
// Evict oldest exited shell once map grows past 50 to prevent session-long leak
|
|
359
493
|
if (_bgShells.size > 50) {
|
|
360
494
|
for (const [k, v] of _bgShells) {
|
|
@@ -382,7 +516,9 @@ async function bashOutput(input) {
|
|
|
382
516
|
const statusLine = shell.status === 'running'
|
|
383
517
|
? '[still running]'
|
|
384
518
|
: `[${shell.status}${shell.exitCode != null ? `, exit ${shell.exitCode}` : ''}]`;
|
|
385
|
-
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
|
+
: '';
|
|
386
522
|
return { output: `Shell ${id} ${statusLine}\n${fresh || '(no new output)'}${trunc}` };
|
|
387
523
|
}
|
|
388
524
|
async function killShell(input) {
|
|
@@ -491,8 +627,66 @@ async function bash(input, abortSignal, sandbox, workDir) {
|
|
|
491
627
|
let totalLen = 0;
|
|
492
628
|
let truncated = false;
|
|
493
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
|
+
};
|
|
494
677
|
const appendOutput = (chunk) => {
|
|
495
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
|
+
}
|
|
496
690
|
if (head.length < HEAD_CHARS) {
|
|
497
691
|
const room = HEAD_CHARS - head.length;
|
|
498
692
|
head += chunk.slice(0, room);
|
|
@@ -508,10 +702,14 @@ async function bash(input, abortSignal, sandbox, workDir) {
|
|
|
508
702
|
}
|
|
509
703
|
};
|
|
510
704
|
const collect = () => {
|
|
705
|
+
closeSpill();
|
|
511
706
|
if (!truncated)
|
|
512
707
|
return head + tail;
|
|
513
708
|
const omitted = totalLen - head.length - tail.length;
|
|
514
|
-
|
|
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}`;
|
|
515
713
|
};
|
|
516
714
|
const done = (code, signal, timedOut = false) => {
|
|
517
715
|
if (settled)
|
|
@@ -574,6 +772,90 @@ function ripgrepAvailable() {
|
|
|
574
772
|
}
|
|
575
773
|
return _rgAvailable;
|
|
576
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
|
+
}
|
|
577
859
|
async function searchFiles(input, workDir) {
|
|
578
860
|
const pattern = typeof input.pattern === 'string' ? input.pattern : '';
|
|
579
861
|
const searchPath = typeof input.path === 'string' ? input.path : '.';
|
|
@@ -891,7 +1173,8 @@ async function editFile(input, workDir) {
|
|
|
891
1173
|
const diff = buildDiff(filePath, oldNorm, newNorm, origNorm);
|
|
892
1174
|
const linesBefore = origNorm.split('\n').length;
|
|
893
1175
|
const linesAfter = updated.split('\n').length;
|
|
894
|
-
|
|
1176
|
+
const xfile = crossFileBreakageWarning(resolved, origNorm, normalizeLF(updated), workDir ?? process.cwd());
|
|
1177
|
+
return { output: `Edited ${resolved} (${linesBefore} → ${linesAfter} lines)\n\n${diff}${xfile}` };
|
|
895
1178
|
}
|
|
896
1179
|
catch (err) {
|
|
897
1180
|
return { error: err.message };
|
|
@@ -1140,7 +1423,11 @@ async function fetchUrl(input, _workDir, _redirectCount = 0) {
|
|
|
1140
1423
|
if (isHtml)
|
|
1141
1424
|
body = stripHtml(body);
|
|
1142
1425
|
const truncNote = truncated ? `\n\n[Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : '';
|
|
1143
|
-
|
|
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>` });
|
|
1144
1431
|
});
|
|
1145
1432
|
res.on('error', (err) => resolve({ error: err.message }));
|
|
1146
1433
|
});
|
|
@@ -1197,6 +1484,7 @@ async function multiEdit(input, workDir) {
|
|
|
1197
1484
|
// Work in LF-normalised space so CRLF files match LF old_strings
|
|
1198
1485
|
const wasCRLF = rawFile.includes('\r\n');
|
|
1199
1486
|
let content = normalizeLF(rawFile);
|
|
1487
|
+
const originalNorm = content; // captured before edits mutate `content`
|
|
1200
1488
|
const diffs = [];
|
|
1201
1489
|
for (let i = 0; i < edits.length; i++) {
|
|
1202
1490
|
const edit = edits[i];
|
|
@@ -1223,9 +1511,10 @@ async function multiEdit(input, workDir) {
|
|
|
1223
1511
|
// preserving the original mode bits (+x on scripts etc.).
|
|
1224
1512
|
const finalContent = wasCRLF ? content.replace(/\n/g, '\r\n') : content;
|
|
1225
1513
|
atomicWritePreservingMode(resolved, finalContent, pre.mode);
|
|
1514
|
+
const xfile = crossFileBreakageWarning(resolved, originalNorm, content, workDir ?? process.cwd());
|
|
1226
1515
|
return {
|
|
1227
1516
|
output: `Applied ${edits.length} edit(s) to ${resolved}:\n` +
|
|
1228
|
-
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,
|
|
1229
1518
|
};
|
|
1230
1519
|
}
|
|
1231
1520
|
catch (err) {
|
|
@@ -1665,7 +1954,46 @@ async function memoryRead(_input) {
|
|
|
1665
1954
|
return { error: err.message };
|
|
1666
1955
|
}
|
|
1667
1956
|
}
|
|
1668
|
-
|
|
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
|
+
}
|
|
1669
1997
|
const TOOL_MAP = {
|
|
1670
1998
|
read_file: readFile,
|
|
1671
1999
|
write_file: writeFile,
|
|
@@ -1695,6 +2023,10 @@ const TOOL_MAP = {
|
|
|
1695
2023
|
// so this fallback only serves the CLI / headless environments.
|
|
1696
2024
|
get_symbols: symbols_1.getSymbols,
|
|
1697
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,
|
|
1698
2030
|
};
|
|
1699
2031
|
// Tools that accept a workDir for correct relative-path resolution.
|
|
1700
2032
|
// TOOL_MAP stores the base signatures; executeTool injects workDir at call time.
|
|
@@ -1704,6 +2036,7 @@ const WORKDIR_TOOLS = new Set([
|
|
|
1704
2036
|
'delete_file', 'notebook_read', 'notebook_edit',
|
|
1705
2037
|
'generate_image', 'stock_photo',
|
|
1706
2038
|
'get_symbols', 'get_workspace_symbols',
|
|
2039
|
+
'go_to_definition', 'find_references', 'get_hover',
|
|
1707
2040
|
]);
|
|
1708
2041
|
async function executeTool(name, input, abortSignal, sandbox, workDir, agentScope) {
|
|
1709
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"}
|