@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.
@@ -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
- return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)` };
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 ? '\n[output truncated at 100KB]' : '';
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
- return `${head}\n\n[… ${omitted} chars omitted (kept first ${HEAD_CHARS / 1024}KB + last ${Math.round(TAIL_CHARS / 1024)}KB) …]\n\n${tail}`;
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
- return { output: `Edited ${resolved} (${linesBefore} ${linesAfter} lines)\n\n${diff}` };
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
- resolve({ output: `HTTP ${status}\n\n${body}${truncNote}` });
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
- // ─── Tool Dispatch ────────────────────────────────────────────────────────────
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"}