mixdog 0.9.150 → 0.9.151

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.
Files changed (53) hide show
  1. package/README.md +28 -11
  2. package/package.json +1 -1
  3. package/src/headless-exec.mjs +16 -3
  4. package/src/headless-exec.test.mjs +44 -1
  5. package/src/rules/shared/30-exploration.md +2 -1
  6. package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
  7. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
  8. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
  9. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -20
  11. package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
  12. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -16
  17. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +28 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
  19. package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
  20. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
  21. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +33 -0
  22. package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
  23. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
  24. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +26 -6
  25. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +7 -66
  26. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +11 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +25 -1
  28. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
  31. package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
  32. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
  36. package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
  38. package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
  39. package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
  40. package/src/runtime/memory/index.mjs +2 -4
  41. package/src/runtime/memory/lib/pg/process.mjs +29 -2
  42. package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
  43. package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
  44. package/src/runtime/memory/lib/query-handlers.mjs +7 -6
  45. package/src/runtime/memory/lib/recall-format.mjs +13 -31
  46. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  47. package/src/runtime/shared/pristine-execution.mjs +5 -2
  48. package/src/session-runtime/lifecycle-api.mjs +1 -0
  49. package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
  50. package/src/standalone/channel-worker.mjs +31 -1
  51. package/src/standalone/memory-runtime-proxy.mjs +5 -2
  52. package/src/standalone/session-client.mjs +23 -3
  53. package/src/tui/session/turn.mjs +12 -0
@@ -69,6 +69,10 @@ import { runGrepPathFanout } from './lib/grep-path-fanout.mjs';
69
69
  import { runGrepPatternFanout } from './lib/grep-pattern-fanout.mjs';
70
70
  import { runGrepChunkMerge } from './lib/grep-chunk-merge.mjs';
71
71
  import { runGrepFixedStringFallback } from './lib/grep-fixed-fallback.mjs';
72
+ import {
73
+ MAX_RESCUE_BYTES as GREP_RESCUE_MAX_BYTES,
74
+ runGrepSingleFileRescue,
75
+ } from './lib/grep-single-file-rescue.mjs';
72
76
 
73
77
  // A single glob string may pack multiple filters
74
78
  // separated by whitespace or commas, e.g. "*.ts,*.tsx" or "*.ts *.tsx". Split
@@ -95,6 +99,15 @@ function _grepDefaultHeadLimit() {
95
99
  return parsed > 0 ? parsed : 250;
96
100
  }
97
101
 
102
+ // One explicit FILE is scanned directly by the native server — there is no tree
103
+ // to walk — so a long deadline on a file scope can only ever be spent QUEUED
104
+ // behind another request's walk. A scope rooted in a virtual filesystem
105
+ // (/proc, /sys) is exactly that walk, and it starved the searches behind it:
106
+ // a six-line /proc/self/status failed after 17.7s while a plain read of the
107
+ // same file answered in 7ms. Bound the file case to a fraction of the turn and
108
+ // let the JS rescue below answer it instead of spending the turn waiting.
109
+ const SINGLE_FILE_SEARCH_DEADLINE_MS = 2_500;
110
+
98
111
  function _grepContextCharBudget(options = {}) {
99
112
  const explicit = Number(options?._grepContextCharBudget);
100
113
  if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
@@ -542,7 +555,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
542
555
  fileType,
543
556
  onlyMatching: false,
544
557
  });
545
- const probeOut = await runRg(probeArgs, { cwd: rgSpawnCwd, signal: sharedSignal });
558
+ const probeOut = await runRg(probeArgs, searchExecOptions);
546
559
  if (String(probeOut).split('\n').some(Boolean)) {
547
560
  return ' (case-insensitive would match — try -i)';
548
561
  }
@@ -560,6 +573,18 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
560
573
  : null;
561
574
 
562
575
  const GREP_CONTENT_HARD_CAP = 300;
576
+ const singleFileScope = grepStat.isFile();
577
+ // Bound ONLY the file sizes the JS rescue below can actually answer. A
578
+ // bigger file keeps the full budget, so the short deadline can never cost
579
+ // a caller an answer it would otherwise have received. (A procfs file
580
+ // reports size 0 and is therefore always bounded — exactly the case that
581
+ // motivated the bound.)
582
+ const boundSingleFile = singleFileScope && Number(grepStat.size) <= GREP_RESCUE_MAX_BYTES;
583
+ const searchExecOptions = {
584
+ cwd: rgSpawnCwd,
585
+ signal: sharedSignal,
586
+ ...(boundSingleFile ? { timeout: SINGLE_FILE_SEARCH_DEADLINE_MS } : {}),
587
+ };
563
588
  try {
564
589
  const callerExplicitUnlimited = headLimitCoerced === 0;
565
590
  const effectiveHeadLimit = headLimit === Infinity
@@ -620,7 +645,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
620
645
  let ctxPartialSuffix = '';
621
646
  const streamed = await runRgWindowedLines(
622
647
  anchorArgs,
623
- { cwd: rgSpawnCwd, signal: sharedSignal },
648
+ searchExecOptions,
624
649
  { offset: 0, limit: anchorCap, summaryLimit: 0 },
625
650
  );
626
651
  let ctxTotalKnown = streamed.complete;
@@ -689,7 +714,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
689
714
  ? GREP_CONTEXT_LINE_HARD_CAP
690
715
  : Math.min(GREP_CONTEXT_LINE_HARD_CAP, Math.max(200, blockBudget * Math.max(1, perBlock) + 8));
691
716
  let ctxPartialSuffix = '';
692
- const streamed = await runRgWindowedLines(rgArgs, { cwd: rgSpawnCwd, signal: sharedSignal }, { offset: 0, limit: lineCap, summaryLimit: 0 });
717
+ const streamed = await runRgWindowedLines(rgArgs, searchExecOptions, { offset: 0, limit: lineCap, summaryLimit: 0 });
693
718
  const allLines = streamed.lines;
694
719
  let ctxTotalKnown = streamed.complete;
695
720
  if (streamed.partial) {
@@ -739,7 +764,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
739
764
  let rgPartialSuffix = '';
740
765
  if (effectiveHeadLimit !== Infinity) {
741
766
  const summaryLimit = outputMode === 'content' ? 120 : 0;
742
- const streamed = await runRgWindowedLines(rgArgs, { cwd: rgSpawnCwd, signal: sharedSignal }, {
767
+ const streamed = await runRgWindowedLines(rgArgs, searchExecOptions, {
743
768
  offset,
744
769
  limit: effectiveHeadLimit,
745
770
  summaryLimit,
@@ -757,7 +782,7 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
757
782
  : '\n[warning] rg exit 2 (partial results)';
758
783
  }
759
784
  } else {
760
- const stdout = await runRg(rgArgs, { cwd: rgSpawnCwd, signal: sharedSignal });
785
+ const stdout = await runRg(rgArgs, searchExecOptions);
761
786
  const allLines = String(stdout).split('\n').filter(Boolean);
762
787
  windowed = offset > 0 ? allLines.slice(offset) : allLines;
763
788
  totalWindowed = windowed.length;
@@ -857,6 +882,36 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
857
882
  }
858
883
  catch (err) {
859
884
  if (sharedSignal?.aborted) throw err;
885
+ // A file scope the native server could not serve is answered by reading
886
+ // the file; only a scanner that cannot reproduce the request declines.
887
+ const nativeMissed = err?.code === 'NATIVE_SEARCH_TIMEOUT'
888
+ || err?.code === 'NATIVE_SEARCH_UNAVAILABLE'
889
+ || err?.code === 'NATIVE_SEARCH_UNSUPPORTED';
890
+ if (nativeMissed && singleFileScope) {
891
+ const rescued = await runGrepSingleFileRescue({
892
+ filePath: grepResolvedPath,
893
+ searchPath,
894
+ patterns,
895
+ caseInsensitive,
896
+ multilineMode,
897
+ onlyMatching: args['-o'] === true,
898
+ fileType,
899
+ outputMode,
900
+ showLineNumbers,
901
+ withFilename: forceGrepFilename,
902
+ filenameOmitted,
903
+ beforeN,
904
+ afterN,
905
+ contextN,
906
+ headLimit,
907
+ offset,
908
+ workDir,
909
+ grepResolvedPath,
910
+ patternCapNote,
911
+ globPatterns: normalizedGlobPatterns,
912
+ });
913
+ if (rescued !== null) return rescued;
914
+ }
860
915
  if (isRgRegexParseError(err) && !multilineMode) {
861
916
  // Fixed-string rescue lives in lib/grep-fixed-fallback.mjs;
862
917
  // null falls through to the original rg error below.
@@ -4,7 +4,7 @@
4
4
  * thin executor; search-tool.mjs re-exports these for unchanged importers.
5
5
  */
6
6
  import { statSync } from 'fs';
7
- import { basename, isAbsolute, join, resolve } from 'path';
7
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
8
8
  import { findBySuffixStrip, findDirectoryByBasename, findFileByBasename, listSiblings } from './path-diagnostics.mjs';
9
9
  import { normalizeOutputPath, resolveAgainstCwd } from './path-utils.mjs';
10
10
 
@@ -18,6 +18,9 @@ import { normalizeOutputPath, resolveAgainstCwd } from './path-utils.mjs';
18
18
  // indexed file exists or the glob child is unavailable.
19
19
  export async function _suggestIndexedPaths(missingPath, executeChildBuiltinTool, workDir) {
20
20
  if (typeof executeChildBuiltinTool !== 'function') return '';
21
+ // An outside path has no indexed twin worth proposing; the project-wide
22
+ // glob would only spend the turn.
23
+ if (missingPathIsOutsideProject(workDir, missingPath)) return '';
21
24
  const base = String(missingPath).replace(/\\/g, '/').split('/').pop();
22
25
  // Skip when there is no usable basename or it carries glob magic (the
23
26
  // pattern, not a literal filename, would not map to a real file).
@@ -90,6 +93,50 @@ function cachedFileByBasename(workDir, missingPath, cache) {
90
93
  return cache.fileHits;
91
94
  }
92
95
 
96
+ // A missing ABSOLUTE path that resolves outside the project cannot be
97
+ // corrected by anything inside it: `/sys/block/sd*/size` is not a misplaced
98
+ // project file, and scanning the tree for a file named `size` only spends the
99
+ // turn (measured: three such reads burned 17.5s each and left the shared
100
+ // search server saturated for the calls behind them). The honest recovery for
101
+ // an outside path is its own filesystem: name the nearest directory that does
102
+ // exist and what it holds.
103
+ function missingPathIsOutsideProject(workDir, missingPath) {
104
+ const raw = String(missingPath || '');
105
+ if (!raw || !isAbsolute(raw)) return false;
106
+ if (typeof workDir !== 'string' || !workDir) return false;
107
+ const rel = relative(resolve(workDir), resolve(raw));
108
+ return !rel || rel === '..' || rel.startsWith(`..${sep}`) || rel.startsWith('../') || isAbsolute(rel);
109
+ }
110
+
111
+ function nearestExistingRealParent(missingPath) {
112
+ try {
113
+ let dir = dirname(resolve(String(missingPath)));
114
+ for (let depth = 0; depth < 24; depth += 1) {
115
+ try {
116
+ if (statSync(dir).isDirectory()) return dir;
117
+ } catch { /* keep climbing */ }
118
+ const parent = dirname(dir);
119
+ if (!parent || parent === dir) break;
120
+ dir = parent;
121
+ }
122
+ } catch { /* fall through */ }
123
+ return null;
124
+ }
125
+
126
+ function outsideProjectNotFoundHint(missingPath, actionVerb) {
127
+ const parent = nearestExistingRealParent(missingPath);
128
+ if (!parent) return ' Not found, and no parent directory of it exists either.';
129
+ const entries = listSiblings(parent, 12).slice(0, 5);
130
+ const where = `"${normalizeOutputPath(parent)}"`;
131
+ if (entries.length === 0) {
132
+ return ` Not found; ${where} exists but is empty — nothing under it to ${actionVerb.toLowerCase()}.`;
133
+ }
134
+ const magic = /[*?[\]{}]/.test(String(missingPath))
135
+ ? ' The path carries a glob pattern; name one of those entries directly.'
136
+ : '';
137
+ return ` Not found; the nearest existing directory ${where} holds: ${entries.map((n) => `"${n}"`).join(', ')}.${magic}`;
138
+ }
139
+
93
140
  function isDirectoryPathGuess(missingPath) {
94
141
  const base = basename(String(missingPath || '').replace(/\\/g, '/'));
95
142
  if (!base || /[*?[\]{}]/.test(base)) return false;
@@ -137,6 +184,9 @@ export function finalizeReadFamilyEnoentTail(hint, requestedPath, errCode = 'ENO
137
184
 
138
185
  function resolveUniqueEnoentRedirect(workDir, missingPath, errCode = 'ENOENT', cache = null) {
139
186
  if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return null;
187
+ // Redirecting an outside path INTO the project would answer a different
188
+ // question than the one asked.
189
+ if (missingPathIsOutsideProject(workDir, missingPath)) return null;
140
190
  const suffixHit = cachedSuffixStrip(workDir, missingPath, cache);
141
191
  if (suffixHit) return suffixHit;
142
192
  const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
@@ -183,6 +233,9 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
183
233
  if (/^[A-Za-z]:(?![\\/])[^\\/]/.test(rawMissing.replace(/\\/g, '\\'))) {
184
234
  return ` The path has a drive letter but no separator after it — the backslashes were probably lost in escaping. Re-issue with forward slashes (e.g. "C:/tmp/smp").`;
185
235
  }
236
+ if (missingPathIsOutsideProject(workDir, missingPath)) {
237
+ return outsideProjectNotFoundHint(missingPath, actionVerb);
238
+ }
186
239
  if (resolveUniqueEnoentRedirect(workDir, missingPath, errCode, cache)) return '';
187
240
  const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
188
241
  if (elsewhere.length) {
@@ -0,0 +1,153 @@
1
+ // Writing through a symlink must land on the file the link points at and leave
2
+ // the link in place. The atomic rename would otherwise replace the link with a
3
+ // regular file and silently detach layouts like nginx's sites-enabled/default
4
+ // from sites-available/default.
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import {
8
+ lstatSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ rmSync,
13
+ statSync,
14
+ symlinkSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ import { atomicWrite, symlinkWriteTarget } from './atomic-write.mjs';
21
+ import { tryExecuteExternalToolAdapter } from './external-tool-adapters.mjs';
22
+
23
+ function makeDir() {
24
+ return mkdtempSync(join(tmpdir(), 'mixdog-write-symlink-'));
25
+ }
26
+
27
+ // Windows without Developer Mode / admin rights refuses symlink creation
28
+ // (EPERM), so the host capability decides whether these cases can run.
29
+ function symlinkSupported(dir) {
30
+ try {
31
+ writeFileSync(join(dir, 'probe.txt'), 'x');
32
+ symlinkSync(join(dir, 'probe.txt'), join(dir, 'probe.link'));
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ test('symlinkWriteTarget resolves a live link and nothing else', (t) => {
40
+ const dir = makeDir();
41
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
42
+ if (!symlinkSupported(dir)) {
43
+ t.skip('symlink creation is not permitted on this host');
44
+ return;
45
+ }
46
+ const real = join(dir, 'real.txt');
47
+ const link = join(dir, 'link.txt');
48
+ writeFileSync(real, 'a\n');
49
+ symlinkSync(real, link);
50
+
51
+ assert.equal(symlinkWriteTarget(link), realpathSync(real));
52
+ assert.equal(symlinkWriteTarget(real), null);
53
+ assert.equal(symlinkWriteTarget(join(dir, 'missing.txt')), null);
54
+ // A dangling link has no target to resolve, so the caller keeps its path.
55
+ const dangling = join(dir, 'dangling.link');
56
+ symlinkSync(join(dir, 'gone.txt'), dangling);
57
+ assert.equal(symlinkWriteTarget(dangling), null);
58
+ });
59
+
60
+ test('atomicWrite writes through a symlink and keeps the link', async (t) => {
61
+ const dir = makeDir();
62
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
63
+ if (!symlinkSupported(dir)) {
64
+ t.skip('symlink creation is not permitted on this host');
65
+ return;
66
+ }
67
+ const real = join(dir, 'real.conf');
68
+ const link = join(dir, 'link.conf');
69
+ writeFileSync(real, 'old\n');
70
+ symlinkSync(real, link);
71
+
72
+ await atomicWrite(link, 'new\n');
73
+
74
+ assert.equal(readFileSync(real, 'utf8'), 'new\n');
75
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
76
+ });
77
+
78
+ test('the expected-target guard still fires when the real file moved', async (t) => {
79
+ const dir = makeDir();
80
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
81
+ if (!symlinkSupported(dir)) {
82
+ t.skip('symlink creation is not permitted on this host');
83
+ return;
84
+ }
85
+ const real = join(dir, 'guard.conf');
86
+ const link = join(dir, 'guard.link');
87
+ writeFileSync(real, 'old\n');
88
+ symlinkSync(real, link);
89
+ const before = statSync(link);
90
+
91
+ // Somebody else rewrites the real file after the snapshot was taken.
92
+ writeFileSync(real, 'rewritten by another writer\n');
93
+
94
+ await assert.rejects(
95
+ () => atomicWrite(link, 'mine\n', {
96
+ expectedTargetSnapshot: {
97
+ exists: true,
98
+ size: before.size,
99
+ mtimeMs: before.mtimeMs,
100
+ ctimeMs: before.ctimeMs,
101
+ ino: before.ino,
102
+ },
103
+ }),
104
+ (err) => err?.code === 'ESTALE_TARGET',
105
+ );
106
+ assert.equal(readFileSync(real, 'utf8'), 'rewritten by another writer\n');
107
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
108
+ });
109
+
110
+ test('edit updates through a symlink and keeps the link', async (t) => {
111
+ const dir = makeDir();
112
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
113
+ if (!symlinkSupported(dir)) {
114
+ t.skip('symlink creation is not permitted on this host');
115
+ return;
116
+ }
117
+ const real = join(dir, 'sites-available.conf');
118
+ const link = join(dir, 'sites-enabled.conf');
119
+ writeFileSync(real, 'root /var/www/html;\nindex index.html;\n');
120
+ symlinkSync(real, link);
121
+
122
+ const result = String(await tryExecuteExternalToolAdapter('edit', {
123
+ file_path: link,
124
+ old_string: 'root /var/www/html;',
125
+ new_string: 'root /usr/share/novnc;',
126
+ }, dir, {}));
127
+
128
+ assert.match(result, /^Updated /);
129
+ assert.equal(readFileSync(real, 'utf8'), 'root /usr/share/novnc;\nindex index.html;\n');
130
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
131
+ });
132
+
133
+ test('write replaces content through a symlink and keeps the link', async (t) => {
134
+ const dir = makeDir();
135
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
136
+ if (!symlinkSupported(dir)) {
137
+ t.skip('symlink creation is not permitted on this host');
138
+ return;
139
+ }
140
+ const real = join(dir, 'payload.txt');
141
+ const link = join(dir, 'payload.link');
142
+ writeFileSync(real, 'old\n');
143
+ symlinkSync(real, link);
144
+
145
+ const result = String(await tryExecuteExternalToolAdapter('write', {
146
+ file_path: link,
147
+ contents: 'fresh\n',
148
+ }, dir, {}));
149
+
150
+ assert.match(result, /^Updated /);
151
+ assert.equal(readFileSync(real, 'utf8'), 'fresh\n');
152
+ assert.equal(lstatSync(link).isSymbolicLink(), true);
153
+ });
@@ -0,0 +1,55 @@
1
+ // A sentinel-free cwd that HOLDS one project answers an anchored call by
2
+ // adopting that project; a genuinely ambiguous tree still refuses, but names
3
+ // where to anchor instead.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+
10
+ import { executeCodeGraphTool } from './dispatch.mjs';
11
+
12
+ const PY = 'class Mean:\n def compute(self):\n return 42\n';
13
+
14
+ function makeRepo(root, name) {
15
+ const repo = join(root, name);
16
+ mkdirSync(join(repo, '.git'), { recursive: true });
17
+ mkdirSync(join(repo, 'tools'), { recursive: true });
18
+ writeFileSync(join(repo, 'tools', 'compute_mean.py'), PY);
19
+ return repo;
20
+ }
21
+
22
+ test('a relative anchor held by exactly one child project adopts that project', async (t) => {
23
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-anchor-one-'));
24
+ t.after(() => rmSync(root, { recursive: true, force: true }));
25
+ makeRepo(root, 'caffe');
26
+
27
+ const out = String(await executeCodeGraphTool(
28
+ 'code_graph',
29
+ { mode: 'symbols', files: 'tools/compute_mean.py' },
30
+ root,
31
+ ));
32
+ assert.doesNotMatch(out, /Refusing to index an arbitrary tree/);
33
+ assert.match(out, /compute/);
34
+ });
35
+
36
+ test('two child projects holding the same anchor stay refused, with the roots named', async (t) => {
37
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-anchor-many-'));
38
+ t.after(() => rmSync(root, { recursive: true, force: true }));
39
+ makeRepo(root, 'caffe');
40
+ makeRepo(root, 'caffe-fork');
41
+
42
+ let message = '';
43
+ try {
44
+ await executeCodeGraphTool(
45
+ 'code_graph',
46
+ { mode: 'symbols', files: 'tools/compute_mean.py' },
47
+ root,
48
+ );
49
+ } catch (error) {
50
+ message = String(error?.message || error);
51
+ }
52
+ assert.match(message, /Refusing to index an arbitrary tree/);
53
+ assert.match(message, /Project roots under this cwd/);
54
+ assert.match(message, /"caffe"/);
55
+ });
@@ -365,6 +365,47 @@ function _boundedExactFileAggregateRoot(args, baseCwd) {
365
365
  return roots.size === 1 ? [...roots][0] : null;
366
366
  }
367
367
 
368
+ // A sentinel-free working directory that HOLDS a project (a container's /app
369
+ // with one cloned repo under it) answered every anchored call with a refusal:
370
+ // the caller wrote the anchor the way the repo's own docs do
371
+ // (`tools/compute_image_mean.cpp`), it does not exist under /app, and no root
372
+ // could be derived from a path that resolves nowhere. The relative anchor is
373
+ // still unambiguous whenever exactly ONE child project holds it — that project
374
+ // is the answer, and adopting it is the same recovery read and grep already
375
+ // perform for a misplaced path. Ambiguity (several holders, none, an absolute
376
+ // anchor, a wildcard) keeps refusing.
377
+ function _relocateAggregateAnchorsUnderChildProject(args, baseCwd) {
378
+ if (!_hasAggregateFileArgs(args)) return null;
379
+ const files = _collectGraphFileList(args);
380
+ if (files.length === 0) return null;
381
+ for (const file of files) {
382
+ if (_AGGREGATE_FILE_WILDCARD_RE.test(file) || isAbsolute(file)) return null;
383
+ if (existsSync(pathResolve(baseCwd, file))) return null;
384
+ }
385
+ const holders = _childProjectRoots(baseCwd, { cap: 8 })
386
+ .filter((root) => pathResolve(root) !== pathResolve(baseCwd))
387
+ .filter((root) => files.every((file) => {
388
+ try {
389
+ return statSync(pathResolve(root, file)).isFile();
390
+ } catch {
391
+ return false;
392
+ }
393
+ }));
394
+ if (holders.length !== 1) return null;
395
+ const root = pathResolve(holders[0]);
396
+ const remap = (file) => pathResolve(root, String(file || '').trim());
397
+ return {
398
+ root,
399
+ args: {
400
+ ...args,
401
+ file: typeof args?.file === 'string' && args.file.trim() ? remap(args.file) : args?.file,
402
+ files: Array.isArray(args?.files)
403
+ ? args.files.map(remap)
404
+ : (typeof args?.files === 'string' && args.files.trim() ? remap(args.files) : args?.files),
405
+ },
406
+ };
407
+ }
408
+
368
409
  export function _resolveBoundedSentinelFreeAggregateRootForTest(args, baseCwd) {
369
410
  if (!_hasAggregateFileArgs(args)) return null;
370
411
  const base = pathResolve(baseCwd);
@@ -1045,13 +1086,23 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
1045
1086
  stopAtUserBoundary: !explicitCwdArg,
1046
1087
  }) || _resolveBoundedSentinelFreeAggregateRootForTest(args, baseCwd)
1047
1088
  || _boundedExactFileAggregateRoot(args, baseCwd);
1048
- if (!aggregateRoot) {
1089
+ const relocated = aggregateRoot ? null : _relocateAggregateAnchorsUnderChildProject(args, baseCwd);
1090
+ if (!aggregateRoot && !relocated) {
1091
+ // Name the projects that DO sit under this cwd: the refusal is only
1092
+ // actionable if the caller learns where to anchor instead.
1093
+ const candidates = _childProjectRoots(baseCwd, { cap: 5 })
1094
+ .filter((root) => pathResolve(root) !== pathResolve(baseCwd))
1095
+ .map((root) => `"${pathBasename(root)}"`);
1049
1096
  throw new Error(
1050
1097
  `${name}: cwd '${baseCwd}' is not inside a project and aggregate file anchors do not all `
1051
- + `exist under exactly one detectable project root. Refusing to index an arbitrary tree.`,
1098
+ + `exist under exactly one detectable project root. Refusing to index an arbitrary tree.`
1099
+ + (candidates.length
1100
+ ? ` Project roots under this cwd: ${candidates.join(', ')} — anchor the call inside one of them.`
1101
+ : ''),
1052
1102
  );
1053
1103
  }
1054
- effectiveCwd = aggregateRoot;
1104
+ effectiveCwd = relocated ? relocated.root : aggregateRoot;
1105
+ if (relocated) args = relocated.args;
1055
1106
  args = _absolutizeAggregateFileArgs(args, baseCwd);
1056
1107
  }
1057
1108
  if (fileArg && !hasAggregateFileArgs) {
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.1.19",
2
+ "version": "0.1.20",
3
3
  "_comment": "Synced from immutable graph-v release assets.",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-darwin-arm64",
7
- "sha256": "7829ad6e428f090843907fd76c2e7b45b9c2314f706776614004fe6715a09313"
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-darwin-arm64",
7
+ "sha256": "d059f2ab33cb7f97b7c96b4e21e3d16880cc44baf1400ce81374a1031a11ddd9"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-darwin-x64",
11
- "sha256": "84d43ad67fda7399d633d93e51438a01ed817137153e9c6ecb8f911e255b235e"
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-darwin-x64",
11
+ "sha256": "c91873322ce6390a7103daa4b872e31de1abbe7e922967727f41b5b7b2f21373"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-linux-arm64",
15
- "sha256": "9a0590415b40e90723653205f9476210d031216edff6225b1391a7f350900a12"
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-linux-arm64",
15
+ "sha256": "e8183e1a36aae1887245bd55b94fae6d3fa874bb350559de95ea6ca65a14c6a0"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-linux-x64",
19
- "sha256": "5bd08eb666c9ad82b0b152ee6207b6c315182b861c60baf58f76d87025dcc2bc"
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-linux-x64",
19
+ "sha256": "40142ad17359cc2eb490f639de7c358c4449ca36dc7bbb966bff9a21f81df40f"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.19/mixdog-graph-win32-x64.exe",
23
- "sha256": "f1a0ed0058797655ca231078da0ceacd0d35a71289a8089c7ba637fcd8634a03"
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.20/mixdog-graph-win32-x64.exe",
23
+ "sha256": "a727ca6ea385d715232ce246c2519037ccd74b509e57d97ad7c1b4a61c26aade"
24
24
  }
25
25
  }
26
26
  }
@@ -2,7 +2,7 @@
2
2
  // verbatim from patch.mjs; native protocol, cache/snapshot side effects, and
3
3
  // output formatting are unchanged.
4
4
 
5
- import { readFileSync, lstatSync, mkdirSync } from 'node:fs';
5
+ import { readFileSync, lstatSync, statSync, realpathSync, mkdirSync } from 'node:fs';
6
6
  import { unlink } from 'node:fs/promises';
7
7
  import { dirname as pathDirname } from 'node:path';
8
8
  import { performance } from 'node:perf_hooks';
@@ -393,6 +393,38 @@ function lstatRegularPatchFile(fullPath, displayPath) {
393
393
  return st;
394
394
  }
395
395
 
396
+ // A symlinked target is patched THROUGH the link. atomicWrite compares the
397
+ // live target with statSync, which FOLLOWS a leaf symlink, so the expected
398
+ // snapshot must be taken the same way: an lstat describes the LINK inode
399
+ // (size = target path length, its own mtime/ino) and can never equal the
400
+ // followed stat, which failed every patch aimed at a symlinked file with
401
+ // "changed on disk during the patch". Writing to the resolved real path also
402
+ // keeps the link intact — the atomic rename would otherwise replace the
403
+ // symlink itself with a regular file.
404
+ function resolvePatchWriteTarget(fullPath, displayPath, lst) {
405
+ if (!lst?.isSymbolicLink?.()) return { writePath: fullPath, snapshotStat: lst };
406
+ let writePath;
407
+ let snapshotStat;
408
+ try {
409
+ writePath = realpathSync(fullPath);
410
+ snapshotStat = statSync(fullPath);
411
+ } catch (err) {
412
+ throw new Error(
413
+ `apply_patch: symlink target missing or unreadable: ${normalizeOutputPath(displayPath)}`
414
+ + ` (${err?.code || err?.message || String(err)})`,
415
+ );
416
+ }
417
+ if (isSpecialFileStat(snapshotStat)) {
418
+ throw new Error(
419
+ `apply_patch: cannot patch special file (FIFO / character / block device / socket): ${normalizeOutputPath(displayPath)}`,
420
+ );
421
+ }
422
+ if (!snapshotStat.isFile()) {
423
+ throw new Error(`apply_patch: symlink target is not a regular file: ${normalizeOutputPath(displayPath)}`);
424
+ }
425
+ return { writePath, snapshotStat };
426
+ }
427
+
396
428
  function assertAddTargetAbsent(fullPath, displayPath) {
397
429
  try {
398
430
  lstatSync(fullPath);
@@ -466,6 +498,7 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
466
498
  }
467
499
 
468
500
  const preMutationStat = lstatRegularPatchFile(fullPath, displayPath);
501
+ const { writePath, snapshotStat } = resolvePatchWriteTarget(fullPath, displayPath, preMutationStat);
469
502
  // Rewrite path: decode by BOM, re-encode into the same codec, and keep every
470
503
  // untouched line's own terminator (spliceTextLinesForPatch).
471
504
  // `toString('utf8')` here transcoded non-UTF-8 files and mangled UTF-16.
@@ -482,14 +515,14 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
482
515
  // rewrote this file after we read it, the write fails instead of silently
483
516
  // clobbering it (and instead of inheriting a stale "body delivered" claim).
484
517
  try {
485
- await atomicWrite(fullPath, content, {
518
+ await atomicWrite(writePath, content, {
486
519
  sessionId: readStateScope,
487
- expectedTargetSnapshot: preMutationStat ? {
520
+ expectedTargetSnapshot: snapshotStat ? {
488
521
  exists: true,
489
- size: preMutationStat.size,
490
- mtimeMs: preMutationStat.mtimeMs,
491
- ctimeMs: preMutationStat.ctimeMs,
492
- ino: preMutationStat.ino,
522
+ size: snapshotStat.size,
523
+ mtimeMs: snapshotStat.mtimeMs,
524
+ ctimeMs: snapshotStat.ctimeMs,
525
+ ino: snapshotStat.ino,
493
526
  } : undefined,
494
527
  });
495
528
  } catch (err) {
@@ -498,12 +531,13 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
498
531
  }
499
532
  throw err;
500
533
  }
501
- invalidateBuiltinResultCache([fullPath]);
502
- markCodeGraphDirtyPaths([fullPath]);
534
+ const touchedPaths = writePath === fullPath ? [fullPath] : [fullPath, writePath];
535
+ invalidateBuiltinResultCache(touchedPaths);
536
+ markCodeGraphDirtyPaths(touchedPaths);
503
537
  recordReadSnapshotForPath(fullPath, readStateScope, {
504
538
  source: 'apply_patch_js',
505
539
  isPartialView: false,
506
- preMutationStat,
540
+ preMutationStat: snapshotStat,
507
541
  });
508
542
  }
509
543
  const { added, removed } = countHunkChanges(entry.hunks);