@nexrall/code-core 1.3.1 → 1.4.1
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 +414 -13
- package/dist/agent/testIntegrity.d.ts +62 -0
- package/dist/agent/testIntegrity.d.ts.map +1 -0
- package/dist/agent/testIntegrity.js +249 -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 +352 -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,26 @@ 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 testIntegrity_1 = require("../agent/testIntegrity");
|
|
48
|
+
const crossFile_1 = require("../agent/crossFile");
|
|
46
49
|
const client_1 = require("../api/client");
|
|
47
50
|
const symbols_1 = require("./symbols");
|
|
51
|
+
const tsLangService_1 = require("./tsLangService");
|
|
48
52
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
49
53
|
const DEFAULT_TIMEOUT_MS = 60000;
|
|
50
54
|
const MAX_FETCH_BYTES = 200 * 1024; // 200 KB
|
|
51
55
|
const MAX_READ_BYTES = 500 * 1024; // 500 KB
|
|
52
56
|
const MAX_OUTPUT_CHARS = 100000; // bash / grep output cap (~100 KB)
|
|
57
|
+
// GAP B — output spillover. The inline bash result keeps only HEAD+TAIL (~100 KB),
|
|
58
|
+
// which loses the MIDDLE of a large log — often exactly where a stack trace's root
|
|
59
|
+
// cause or a failing assertion lives. To make the full log recoverable WITHOUT
|
|
60
|
+
// bloating the model context, we stream the complete stdout+stderr to a temp file
|
|
61
|
+
// (bounded by MAX_SPILL_BYTES) and tell the model it can `read_file` that path with
|
|
62
|
+
// offset/limit to inspect any section. This is the key long-debug capability: the
|
|
63
|
+
// agent decides which slice of a 5 MB log it needs, instead of us guessing.
|
|
64
|
+
const MAX_SPILL_BYTES = 20 * 1024 * 1024; // 20 MB cap on a single spill file
|
|
65
|
+
const SPILL_DIR = path.join(os.tmpdir(), 'nexrall-code', 'bash-output');
|
|
53
66
|
// ─── Blocked commands (safety) ────────────────────────────────────────────────
|
|
54
67
|
// Regexes are tested against the LOWERCASED command so case variants and minor
|
|
55
68
|
// spacing differences (double spaces, no-space pipes) are all caught.
|
|
@@ -204,6 +217,59 @@ function isBinaryFile(filePath) {
|
|
|
204
217
|
return false;
|
|
205
218
|
}
|
|
206
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Read a bounded line window [offset, offset+limit) from a file too large to load
|
|
222
|
+
* whole, by streaming it in chunks and keeping only the requested lines. Memory is
|
|
223
|
+
* bounded by (limit lines actually kept) + one chunk, never the whole file — so a
|
|
224
|
+
* 20MB bash-output spill file can be inspected section-by-section. (GAP B.)
|
|
225
|
+
*/
|
|
226
|
+
async function readLargeFileWindow(resolved, offset, limit, kb) {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const endLine = offset + limit; // exclusive, 0-based
|
|
229
|
+
const kept = [];
|
|
230
|
+
let lineNo = 0; // 0-based index of the NEXT line to be completed
|
|
231
|
+
let carry = ''; // partial line spanning chunk boundaries
|
|
232
|
+
let stopped = false;
|
|
233
|
+
const stream = fs.createReadStream(resolved, { encoding: 'utf-8', highWaterMark: 256 * 1024 });
|
|
234
|
+
const finish = () => {
|
|
235
|
+
if (stopped)
|
|
236
|
+
return;
|
|
237
|
+
stopped = true;
|
|
238
|
+
stream.destroy();
|
|
239
|
+
const first = offset + 1;
|
|
240
|
+
const last = offset + kept.length;
|
|
241
|
+
const numbered = kept.map((l, i) => `${String(offset + i + 1).padStart(4, ' ')}\t${l}`).join('\n');
|
|
242
|
+
const note = kept.length < limit
|
|
243
|
+
? ` (reached end of file at line ${last})`
|
|
244
|
+
: ` (more lines follow — increase offset to continue)`;
|
|
245
|
+
resolve({ output: `[File: ${resolved} — ${kb} KB, showing lines ${first}-${last}${note}]\n${numbered}` });
|
|
246
|
+
};
|
|
247
|
+
stream.on('data', (chunk) => {
|
|
248
|
+
const text = carry + (typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
|
|
249
|
+
const lines = text.split('\n');
|
|
250
|
+
carry = lines.pop() ?? ''; // last element is an incomplete line (or '')
|
|
251
|
+
for (const line of lines) {
|
|
252
|
+
if (lineNo >= offset && lineNo < endLine)
|
|
253
|
+
kept.push(line);
|
|
254
|
+
lineNo++;
|
|
255
|
+
if (lineNo >= endLine) {
|
|
256
|
+
finish();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
stream.on('end', () => {
|
|
262
|
+
// Flush the final carry (file not ending in newline) if it's in range.
|
|
263
|
+
if (!stopped && carry !== '' && lineNo >= offset && lineNo < endLine)
|
|
264
|
+
kept.push(carry);
|
|
265
|
+
finish();
|
|
266
|
+
});
|
|
267
|
+
stream.on('error', (err) => { if (!stopped) {
|
|
268
|
+
stopped = true;
|
|
269
|
+
resolve({ error: err.message });
|
|
270
|
+
} });
|
|
271
|
+
});
|
|
272
|
+
}
|
|
207
273
|
async function readFile(input, workDir) {
|
|
208
274
|
const filePath = typeof input.path === 'string' ? input.path : '';
|
|
209
275
|
const offset = typeof input.offset === 'number' ? Math.max(0, Math.floor(input.offset)) : 0;
|
|
@@ -213,17 +279,22 @@ async function readFile(input, workDir) {
|
|
|
213
279
|
try {
|
|
214
280
|
const resolved = resolvePath(filePath, workDir);
|
|
215
281
|
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
282
|
// Reject binary files early — reading them as UTF-8 produces garbage.
|
|
223
283
|
if (isBinaryFile(resolved)) {
|
|
224
284
|
const ext = path.extname(resolved).toLowerCase();
|
|
225
285
|
return { error: `Cannot read binary file: ${resolved} (${ext || 'no extension'}). Use a text-based tool or convert it first.` };
|
|
226
286
|
}
|
|
287
|
+
// Large-file path: when the file exceeds the in-memory cap, a full readFileSync
|
|
288
|
+
// would OOM — but we must still be able to inspect SECTIONS (this is what makes a
|
|
289
|
+
// 20MB bash-output spill file usable, GAP B). If offset+limit are given we stream
|
|
290
|
+
// line-by-line and materialise only the requested window; otherwise we require it.
|
|
291
|
+
if (stat.size > MAX_READ_BYTES) {
|
|
292
|
+
const kb = (stat.size / 1024).toFixed(0);
|
|
293
|
+
if (limit <= 0) {
|
|
294
|
+
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.` };
|
|
295
|
+
}
|
|
296
|
+
return await readLargeFileWindow(resolved, offset, limit, kb);
|
|
297
|
+
}
|
|
227
298
|
const content = fs.readFileSync(resolved, 'utf-8');
|
|
228
299
|
const allLines = content.split('\n');
|
|
229
300
|
const totalLines = allLines.length;
|
|
@@ -260,14 +331,32 @@ async function writeFile(input, workDir) {
|
|
|
260
331
|
if (!isNew && content === '') {
|
|
261
332
|
return { error: `Refusing to overwrite ${resolved} with empty content. Provide the full file content or use edit_file for targeted changes.` };
|
|
262
333
|
}
|
|
334
|
+
// Edit-completeness guard: refuse writes that elide code with a placeholder
|
|
335
|
+
// ("// ... rest of the code unchanged") — a silent, irreversible data loss —
|
|
336
|
+
// or that still contain unresolved merge-conflict markers. Opt-out with
|
|
337
|
+
// NEXRALL_ALLOW_ELIDED_WRITE=1 for the rare intentional case.
|
|
338
|
+
if (process.env.NEXRALL_ALLOW_ELIDED_WRITE !== '1') {
|
|
339
|
+
const problem = (0, editCompleteness_1.checkEditCompleteness)(content, !isNew);
|
|
340
|
+
if (problem) {
|
|
341
|
+
if (problem.kind === 'elision-placeholder') {
|
|
342
|
+
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.` };
|
|
343
|
+
}
|
|
344
|
+
return { error: `Refusing to write ${resolved}: ${problem.reason}.\nOffending line: ${problem.sample}\nResolve the conflict (remove <<<<<<< / ======= / >>>>>>> markers and keep the intended code) before writing.` };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
263
347
|
// Preserve file permissions (mode bits) when overwriting an existing file.
|
|
264
348
|
// writeFileSync creates new files with umask-default mode, stripping +x etc.
|
|
265
349
|
let existingMode;
|
|
350
|
+
let priorContent = '';
|
|
266
351
|
if (!isNew) {
|
|
267
352
|
try {
|
|
268
353
|
existingMode = fs.statSync(resolved).mode;
|
|
269
354
|
}
|
|
270
355
|
catch { /* ignore */ }
|
|
356
|
+
try {
|
|
357
|
+
priorContent = fs.readFileSync(resolved, 'utf-8');
|
|
358
|
+
}
|
|
359
|
+
catch { /* ignore */ }
|
|
271
360
|
}
|
|
272
361
|
fs.writeFileSync(resolved, content, 'utf-8');
|
|
273
362
|
if (existingMode !== undefined) {
|
|
@@ -281,7 +370,17 @@ async function writeFile(input, workDir) {
|
|
|
281
370
|
if (isNew) {
|
|
282
371
|
return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)` };
|
|
283
372
|
}
|
|
284
|
-
|
|
373
|
+
const xfile = crossFileBreakageWarning(resolved, normalizeLF(priorContent), normalizeLF(content), workDir ?? process.cwd());
|
|
374
|
+
// Reward-hacking guard: a write_file that OVERWRITES an existing test file can
|
|
375
|
+
// silently drop assertions / test cases. The loop layer can't see the prior
|
|
376
|
+
// content — but we can (we just read it). Run the full old→new analysis and
|
|
377
|
+
// embed an invisible marker that ledgerRecord parses, so this path gets the
|
|
378
|
+
// same ledger entry + pre-finish nudge as an edit_file weakening would.
|
|
379
|
+
let tiMarker = '';
|
|
380
|
+
const ti = (0, testIntegrity_1.analyzeTestEdit)(resolved, normalizeLF(priorContent), normalizeLF(content));
|
|
381
|
+
if (ti.suspicious)
|
|
382
|
+
tiMarker = (0, testIntegrity_1.encodeTestIntegrityMarker)(ti.findings);
|
|
383
|
+
return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)${xfile}${tiMarker}` };
|
|
285
384
|
}
|
|
286
385
|
catch (err) {
|
|
287
386
|
return { error: err.message };
|
|
@@ -340,11 +439,44 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
|
|
|
340
439
|
const shell = {
|
|
341
440
|
id, command: displayCommand, child, output: '', readCursor: 0, truncated: false,
|
|
342
441
|
status: 'running', exitCode: null,
|
|
442
|
+
spillPath: null, spillFd: null, spillBytes: 0, spillFull: false,
|
|
343
443
|
};
|
|
344
444
|
// Rolling buffer: keep the LAST MAX_OUTPUT_CHARS instead of the first, so a
|
|
345
445
|
// long-lived watcher/dev-server's recent output (the useful part) is never
|
|
346
|
-
// permanently lost behind an early cap.
|
|
446
|
+
// permanently lost behind an early cap. In parallel, once output exceeds the
|
|
447
|
+
// inline cap we mirror the FULL stream to a bounded spill file (GAP B) so the
|
|
448
|
+
// discarded earlier output is still recoverable via read_file.
|
|
347
449
|
const append = (chunk) => {
|
|
450
|
+
if (shell.spillPath || shell.output.length + chunk.length > MAX_OUTPUT_CHARS) {
|
|
451
|
+
if (!shell.spillPath && !shell.spillFull) {
|
|
452
|
+
try {
|
|
453
|
+
fs.mkdirSync(SPILL_DIR, { recursive: true });
|
|
454
|
+
shell.spillPath = path.join(SPILL_DIR, `${id}-${Date.now()}.log`);
|
|
455
|
+
shell.spillFd = fs.openSync(shell.spillPath, 'w');
|
|
456
|
+
fs.writeSync(shell.spillFd, shell.output); // backfill what we already have
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
shell.spillPath = null;
|
|
460
|
+
shell.spillFd = null;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (shell.spillFd !== null && !shell.spillFull) {
|
|
464
|
+
const buf = Buffer.from(chunk, 'utf-8');
|
|
465
|
+
const room = MAX_SPILL_BYTES - shell.spillBytes;
|
|
466
|
+
if (room <= 0) {
|
|
467
|
+
shell.spillFull = true;
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
try {
|
|
471
|
+
fs.writeSync(shell.spillFd, room >= buf.length ? buf : buf.subarray(0, room));
|
|
472
|
+
shell.spillBytes += Math.min(room, buf.length);
|
|
473
|
+
if (buf.length > room)
|
|
474
|
+
shell.spillFull = true;
|
|
475
|
+
}
|
|
476
|
+
catch { /* degrade to inline-only */ }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
348
480
|
shell.output += chunk;
|
|
349
481
|
if (shell.output.length > MAX_OUTPUT_CHARS) {
|
|
350
482
|
// Drop from the front but never behind the read cursor's logical position;
|
|
@@ -360,6 +492,13 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
|
|
|
360
492
|
child.on('close', (code) => {
|
|
361
493
|
shell.status = shell.status === 'killed' ? 'killed' : 'exited';
|
|
362
494
|
shell.exitCode = code;
|
|
495
|
+
if (shell.spillFd !== null) {
|
|
496
|
+
try {
|
|
497
|
+
fs.closeSync(shell.spillFd);
|
|
498
|
+
}
|
|
499
|
+
catch { /* ignore */ }
|
|
500
|
+
shell.spillFd = null;
|
|
501
|
+
}
|
|
363
502
|
// Evict oldest exited shell once map grows past 50 to prevent session-long leak
|
|
364
503
|
if (_bgShells.size > 50) {
|
|
365
504
|
for (const [k, v] of _bgShells) {
|
|
@@ -387,7 +526,9 @@ async function bashOutput(input) {
|
|
|
387
526
|
const statusLine = shell.status === 'running'
|
|
388
527
|
? '[still running]'
|
|
389
528
|
: `[${shell.status}${shell.exitCode != null ? `, exit ${shell.exitCode}` : ''}]`;
|
|
390
|
-
const trunc = shell.truncated
|
|
529
|
+
const trunc = shell.truncated
|
|
530
|
+
? `\n[output truncated at 100KB${shell.spillPath ? `; full log at ${shell.spillPath} — read_file with offset/limit` : ''}]`
|
|
531
|
+
: '';
|
|
391
532
|
return { output: `Shell ${id} ${statusLine}\n${fresh || '(no new output)'}${trunc}` };
|
|
392
533
|
}
|
|
393
534
|
async function killShell(input) {
|
|
@@ -496,8 +637,66 @@ async function bash(input, abortSignal, sandbox, workDir) {
|
|
|
496
637
|
let totalLen = 0;
|
|
497
638
|
let truncated = false;
|
|
498
639
|
let settled = false;
|
|
640
|
+
// GAP B — full-output spill. We lazily open a temp file the FIRST time output
|
|
641
|
+
// exceeds the inline budget and stream everything to it (bounded), so the
|
|
642
|
+
// complete log survives even though the inline result is HEAD+TAIL only. The
|
|
643
|
+
// model is told the path and can read_file it with offset/limit.
|
|
644
|
+
let spillPath = null;
|
|
645
|
+
let spillFd = null;
|
|
646
|
+
let spillBytes = 0;
|
|
647
|
+
let spillFull = false;
|
|
648
|
+
const openSpill = () => {
|
|
649
|
+
if (spillPath || spillFull)
|
|
650
|
+
return;
|
|
651
|
+
try {
|
|
652
|
+
fs.mkdirSync(SPILL_DIR, { recursive: true });
|
|
653
|
+
spillPath = path.join(SPILL_DIR, `bash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.log`);
|
|
654
|
+
spillFd = fs.openSync(spillPath, 'w');
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
spillPath = null;
|
|
658
|
+
spillFd = null;
|
|
659
|
+
} // spill is best-effort; never break the command
|
|
660
|
+
};
|
|
661
|
+
const writeSpill = (chunk) => {
|
|
662
|
+
if (spillFull || spillFd === null)
|
|
663
|
+
return;
|
|
664
|
+
const buf = Buffer.from(chunk, 'utf-8');
|
|
665
|
+
const room = MAX_SPILL_BYTES - spillBytes;
|
|
666
|
+
if (room <= 0) {
|
|
667
|
+
spillFull = true;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
fs.writeSync(spillFd, room >= buf.length ? buf : buf.subarray(0, room));
|
|
672
|
+
spillBytes += Math.min(room, buf.length);
|
|
673
|
+
if (buf.length > room)
|
|
674
|
+
spillFull = true;
|
|
675
|
+
}
|
|
676
|
+
catch { /* disk full / closed — degrade to inline-only */ }
|
|
677
|
+
};
|
|
678
|
+
const closeSpill = () => {
|
|
679
|
+
if (spillFd !== null) {
|
|
680
|
+
try {
|
|
681
|
+
fs.closeSync(spillFd);
|
|
682
|
+
}
|
|
683
|
+
catch { /* ignore */ }
|
|
684
|
+
spillFd = null;
|
|
685
|
+
}
|
|
686
|
+
};
|
|
499
687
|
const appendOutput = (chunk) => {
|
|
500
688
|
totalLen += chunk.length;
|
|
689
|
+
// Mirror the full stream to the spill file once we know output is large.
|
|
690
|
+
if (spillPath || totalLen > MAX_OUTPUT_CHARS) {
|
|
691
|
+
if (!spillPath) {
|
|
692
|
+
openSpill();
|
|
693
|
+
if (spillPath) {
|
|
694
|
+
writeSpill(head);
|
|
695
|
+
writeSpill(tail);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
writeSpill(chunk);
|
|
699
|
+
}
|
|
501
700
|
if (head.length < HEAD_CHARS) {
|
|
502
701
|
const room = HEAD_CHARS - head.length;
|
|
503
702
|
head += chunk.slice(0, room);
|
|
@@ -513,10 +712,14 @@ async function bash(input, abortSignal, sandbox, workDir) {
|
|
|
513
712
|
}
|
|
514
713
|
};
|
|
515
714
|
const collect = () => {
|
|
715
|
+
closeSpill();
|
|
516
716
|
if (!truncated)
|
|
517
717
|
return head + tail;
|
|
518
718
|
const omitted = totalLen - head.length - tail.length;
|
|
519
|
-
|
|
719
|
+
const spillNote = spillPath
|
|
720
|
+
? ` 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.`
|
|
721
|
+
: '';
|
|
722
|
+
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
723
|
};
|
|
521
724
|
const done = (code, signal, timedOut = false) => {
|
|
522
725
|
if (settled)
|
|
@@ -579,6 +782,90 @@ function ripgrepAvailable() {
|
|
|
579
782
|
}
|
|
580
783
|
return _rgAvailable;
|
|
581
784
|
}
|
|
785
|
+
// ── Cross-file breakage warning ──────────────────────────────────────────────
|
|
786
|
+
// After an edit removes/renames an exported symbol, scan the rest of the repo for
|
|
787
|
+
// surviving references. Returns a short warning string (or '' when clean). Best-
|
|
788
|
+
// effort, time-boxed, and never throws — a scan failure must not fail the edit.
|
|
789
|
+
function crossFileBreakageWarning(editedAbsPath, oldContent, newContent, workDir) {
|
|
790
|
+
if (process.env.NEXRALL_CROSSFILE_CHECK === '0')
|
|
791
|
+
return '';
|
|
792
|
+
let removed;
|
|
793
|
+
try {
|
|
794
|
+
removed = (0, crossFile_1.findRemovedExports)(editedAbsPath, oldContent, newContent)
|
|
795
|
+
.filter((s) => (0, crossFile_1.isCheckableSymbol)(s.name));
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
return '';
|
|
799
|
+
}
|
|
800
|
+
if (!removed.length)
|
|
801
|
+
return '';
|
|
802
|
+
const editedReal = (() => { try {
|
|
803
|
+
return fs.realpathSync(editedAbsPath);
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
return editedAbsPath;
|
|
807
|
+
} })();
|
|
808
|
+
const hits = [];
|
|
809
|
+
const MAX_SYMBOLS = 8;
|
|
810
|
+
for (const sym of removed.slice(0, MAX_SYMBOLS)) {
|
|
811
|
+
const refs = scanReferences(sym.name, workDir, editedReal);
|
|
812
|
+
if (refs.length) {
|
|
813
|
+
const shown = refs.slice(0, 3).map((r) => ` ${r}`).join('\n');
|
|
814
|
+
const more = refs.length > 3 ? `\n … and ${refs.length - 3} more` : '';
|
|
815
|
+
hits.push(` • ${sym.kind} "${sym.name}" (removed/renamed) still referenced in:\n${shown}${more}`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (!hits.length)
|
|
819
|
+
return '';
|
|
820
|
+
return (`\n\n⚠ CROSS-FILE BREAKAGE: you removed/renamed exported symbol(s) still used elsewhere.\n` +
|
|
821
|
+
hits.join('\n') +
|
|
822
|
+
`\nUpdate these call-sites (or restore the symbol), then run a build/typecheck to confirm nothing is broken.`);
|
|
823
|
+
}
|
|
824
|
+
// Find files (other than the edited one) that reference `name` as a whole word.
|
|
825
|
+
// Uses ripgrep when available (fast, .gitignore-aware), else a bounded grep -r.
|
|
826
|
+
function scanReferences(name, workDir, excludeRealPath) {
|
|
827
|
+
const pattern = `\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`;
|
|
828
|
+
const files = new Set();
|
|
829
|
+
try {
|
|
830
|
+
if (ripgrepAvailable()) {
|
|
831
|
+
const r = (0, child_process_1.spawnSync)('rg', ['-l', '--no-messages', '-e', pattern, '.'], {
|
|
832
|
+
cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
|
|
833
|
+
});
|
|
834
|
+
if (r.stdout)
|
|
835
|
+
for (const line of r.stdout.split('\n')) {
|
|
836
|
+
if (line.trim())
|
|
837
|
+
files.add(line.trim());
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
const r = (0, child_process_1.spawnSync)('grep', ['-rlI', '--exclude-dir=node_modules', '--exclude-dir=.git',
|
|
842
|
+
'--exclude-dir=dist', '--exclude-dir=.next', '-E', pattern, '.'], {
|
|
843
|
+
cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
|
|
844
|
+
});
|
|
845
|
+
if (r.stdout)
|
|
846
|
+
for (const line of r.stdout.split('\n')) {
|
|
847
|
+
if (line.trim())
|
|
848
|
+
files.add(line.trim());
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return [];
|
|
854
|
+
}
|
|
855
|
+
const rel = [];
|
|
856
|
+
for (const f of files) {
|
|
857
|
+
const abs = path.isAbsolute(f) ? f : path.join(workDir, f);
|
|
858
|
+
let real = abs;
|
|
859
|
+
try {
|
|
860
|
+
real = fs.realpathSync(abs);
|
|
861
|
+
}
|
|
862
|
+
catch { /* keep abs */ }
|
|
863
|
+
if (real === excludeRealPath)
|
|
864
|
+
continue; // the file we just edited
|
|
865
|
+
rel.push(path.relative(workDir, abs) || f);
|
|
866
|
+
}
|
|
867
|
+
return rel;
|
|
868
|
+
}
|
|
582
869
|
async function searchFiles(input, workDir) {
|
|
583
870
|
const pattern = typeof input.pattern === 'string' ? input.pattern : '';
|
|
584
871
|
const searchPath = typeof input.path === 'string' ? input.path : '.';
|
|
@@ -896,7 +1183,8 @@ async function editFile(input, workDir) {
|
|
|
896
1183
|
const diff = buildDiff(filePath, oldNorm, newNorm, origNorm);
|
|
897
1184
|
const linesBefore = origNorm.split('\n').length;
|
|
898
1185
|
const linesAfter = updated.split('\n').length;
|
|
899
|
-
|
|
1186
|
+
const xfile = crossFileBreakageWarning(resolved, origNorm, normalizeLF(updated), workDir ?? process.cwd());
|
|
1187
|
+
return { output: `Edited ${resolved} (${linesBefore} → ${linesAfter} lines)\n\n${diff}${xfile}` };
|
|
900
1188
|
}
|
|
901
1189
|
catch (err) {
|
|
902
1190
|
return { error: err.message };
|
|
@@ -1145,7 +1433,11 @@ async function fetchUrl(input, _workDir, _redirectCount = 0) {
|
|
|
1145
1433
|
if (isHtml)
|
|
1146
1434
|
body = stripHtml(body);
|
|
1147
1435
|
const truncNote = truncated ? `\n\n[Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : '';
|
|
1148
|
-
|
|
1436
|
+
// Prompt-injection mitigation: fetched web content is fully attacker-controlled
|
|
1437
|
+
// (anyone can put "ignore previous instructions..." on a page). Wrap it in an
|
|
1438
|
+
// explicit untrusted-data delimiter so the model treats it as DATA to read, not
|
|
1439
|
+
// as instructions to follow — defense-in-depth alongside the system-prompt rule.
|
|
1440
|
+
resolve({ output: `HTTP ${status}\n\n<untrusted_web_content url="${url}">\n${body}${truncNote}\n</untrusted_web_content>` });
|
|
1149
1441
|
});
|
|
1150
1442
|
res.on('error', (err) => resolve({ error: err.message }));
|
|
1151
1443
|
});
|
|
@@ -1202,6 +1494,7 @@ async function multiEdit(input, workDir) {
|
|
|
1202
1494
|
// Work in LF-normalised space so CRLF files match LF old_strings
|
|
1203
1495
|
const wasCRLF = rawFile.includes('\r\n');
|
|
1204
1496
|
let content = normalizeLF(rawFile);
|
|
1497
|
+
const originalNorm = content; // captured before edits mutate `content`
|
|
1205
1498
|
const diffs = [];
|
|
1206
1499
|
for (let i = 0; i < edits.length; i++) {
|
|
1207
1500
|
const edit = edits[i];
|
|
@@ -1228,9 +1521,10 @@ async function multiEdit(input, workDir) {
|
|
|
1228
1521
|
// preserving the original mode bits (+x on scripts etc.).
|
|
1229
1522
|
const finalContent = wasCRLF ? content.replace(/\n/g, '\r\n') : content;
|
|
1230
1523
|
atomicWritePreservingMode(resolved, finalContent, pre.mode);
|
|
1524
|
+
const xfile = crossFileBreakageWarning(resolved, originalNorm, content, workDir ?? process.cwd());
|
|
1231
1525
|
return {
|
|
1232
1526
|
output: `Applied ${edits.length} edit(s) to ${resolved}:\n` +
|
|
1233
|
-
diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n'),
|
|
1527
|
+
diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n') + xfile,
|
|
1234
1528
|
};
|
|
1235
1529
|
}
|
|
1236
1530
|
catch (err) {
|
|
@@ -1670,7 +1964,46 @@ async function memoryRead(_input) {
|
|
|
1670
1964
|
return { error: err.message };
|
|
1671
1965
|
}
|
|
1672
1966
|
}
|
|
1673
|
-
|
|
1967
|
+
function semanticPreamble(input, workDir) {
|
|
1968
|
+
const p = typeof input.path === 'string' ? input.path : '';
|
|
1969
|
+
const line = typeof input.line === 'number' ? Math.floor(input.line) : 0;
|
|
1970
|
+
const character = typeof input.character === 'number' ? Math.floor(input.character) : 0;
|
|
1971
|
+
if (!p)
|
|
1972
|
+
return { ok: false, err: { error: 'Missing required parameter: path' } };
|
|
1973
|
+
if (line < 1 || character < 1)
|
|
1974
|
+
return { ok: false, err: { error: 'line and character are 1-based and required' } };
|
|
1975
|
+
let resolved;
|
|
1976
|
+
try {
|
|
1977
|
+
resolved = resolvePath(p, workDir);
|
|
1978
|
+
}
|
|
1979
|
+
catch (e) {
|
|
1980
|
+
return { ok: false, err: { error: e.message } };
|
|
1981
|
+
}
|
|
1982
|
+
if (!(0, tsLangService_1.isTsLike)(resolved)) {
|
|
1983
|
+
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).` } };
|
|
1984
|
+
}
|
|
1985
|
+
return { ok: true, args: { workDir: workDir ?? process.cwd(), filePath: resolved, line, character } };
|
|
1986
|
+
}
|
|
1987
|
+
const NO_TS_SERVICE = 'No TypeScript language service available (install `typescript` in the project). Use get_workspace_symbols / get_symbols instead.';
|
|
1988
|
+
async function goToDefinition(input, workDir) {
|
|
1989
|
+
const pre = semanticPreamble(input, workDir);
|
|
1990
|
+
if (!pre.ok)
|
|
1991
|
+
return pre.err;
|
|
1992
|
+
return (0, tsLangService_1.tsGoToDefinition)(pre.args) ?? { error: NO_TS_SERVICE };
|
|
1993
|
+
}
|
|
1994
|
+
async function findReferences(input, workDir) {
|
|
1995
|
+
const pre = semanticPreamble(input, workDir);
|
|
1996
|
+
if (!pre.ok)
|
|
1997
|
+
return pre.err;
|
|
1998
|
+
const includeDeclaration = input.include_declaration !== false;
|
|
1999
|
+
return (0, tsLangService_1.tsFindReferences)({ ...pre.args, includeDeclaration }) ?? { error: NO_TS_SERVICE };
|
|
2000
|
+
}
|
|
2001
|
+
async function getHover(input, workDir) {
|
|
2002
|
+
const pre = semanticPreamble(input, workDir);
|
|
2003
|
+
if (!pre.ok)
|
|
2004
|
+
return pre.err;
|
|
2005
|
+
return (0, tsLangService_1.tsGetHover)(pre.args) ?? { error: NO_TS_SERVICE };
|
|
2006
|
+
}
|
|
1674
2007
|
const TOOL_MAP = {
|
|
1675
2008
|
read_file: readFile,
|
|
1676
2009
|
write_file: writeFile,
|
|
@@ -1700,6 +2033,10 @@ const TOOL_MAP = {
|
|
|
1700
2033
|
// so this fallback only serves the CLI / headless environments.
|
|
1701
2034
|
get_symbols: symbols_1.getSymbols,
|
|
1702
2035
|
get_workspace_symbols: symbols_1.getWorkspaceSymbols,
|
|
2036
|
+
// Real semantic navigation (TS/JS) for the CLI — see GAP A above.
|
|
2037
|
+
go_to_definition: goToDefinition,
|
|
2038
|
+
find_references: findReferences,
|
|
2039
|
+
get_hover: getHover,
|
|
1703
2040
|
};
|
|
1704
2041
|
// Tools that accept a workDir for correct relative-path resolution.
|
|
1705
2042
|
// TOOL_MAP stores the base signatures; executeTool injects workDir at call time.
|
|
@@ -1709,6 +2046,7 @@ const WORKDIR_TOOLS = new Set([
|
|
|
1709
2046
|
'delete_file', 'notebook_read', 'notebook_edit',
|
|
1710
2047
|
'generate_image', 'stock_photo',
|
|
1711
2048
|
'get_symbols', 'get_workspace_symbols',
|
|
2049
|
+
'go_to_definition', 'find_references', 'get_hover',
|
|
1712
2050
|
]);
|
|
1713
2051
|
async function executeTool(name, input, abortSignal, sandbox, workDir, agentScope) {
|
|
1714
2052
|
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"}
|