@levnikolaevich/hex-line-mcp 1.3.3 → 1.3.4

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/lib/revisions.mjs DELETED
@@ -1,238 +0,0 @@
1
- /**
2
- * Revision journal for hex-line-mcp.
3
- *
4
- * Keeps short-lived file snapshots in memory so read/edit/verify can reuse
5
- * line hashes, file checksums, and changed ranges without re-reading the file
6
- * on every same-file operation.
7
- */
8
-
9
- import { statSync } from "node:fs";
10
- import { diffLines } from "diff";
11
- import { fnv1a, lineTag, rangeChecksum } from "@levnikolaevich/hex-common/text-protocol/hash";
12
- import { readText } from "./format.mjs";
13
-
14
- const MAX_FILES = 200;
15
- const MAX_REVISIONS_PER_FILE = 5;
16
- const TTL_MS = 5 * 60 * 1000;
17
-
18
- const latestByFile = new Map();
19
- const revisionsById = new Map();
20
- const fileRevisionIds = new Map();
21
- let revisionSeq = 0;
22
-
23
- function touchFile(filePath) {
24
- const latest = latestByFile.get(filePath);
25
- if (!latest) return;
26
- latestByFile.delete(filePath);
27
- latestByFile.set(filePath, latest);
28
- }
29
-
30
- function pruneExpired(now = Date.now()) {
31
- for (const [revision, snapshot] of revisionsById) {
32
- if (now - snapshot.createdAt <= TTL_MS) continue;
33
- revisionsById.delete(revision);
34
- const ids = fileRevisionIds.get(snapshot.path);
35
- if (!ids) continue;
36
- const next = ids.filter(id => id !== revision);
37
- if (next.length > 0) fileRevisionIds.set(snapshot.path, next);
38
- else fileRevisionIds.delete(snapshot.path);
39
- const latest = latestByFile.get(snapshot.path);
40
- if (latest?.revision === revision) latestByFile.delete(snapshot.path);
41
- }
42
- while (latestByFile.size > MAX_FILES) {
43
- const oldestPath = latestByFile.keys().next().value;
44
- const ids = fileRevisionIds.get(oldestPath) || [];
45
- for (const id of ids) revisionsById.delete(id);
46
- fileRevisionIds.delete(oldestPath);
47
- latestByFile.delete(oldestPath);
48
- }
49
- }
50
-
51
- function rememberRevisionId(filePath, revision) {
52
- const ids = fileRevisionIds.get(filePath) || [];
53
- ids.push(revision);
54
- while (ids.length > MAX_REVISIONS_PER_FILE) {
55
- const removed = ids.shift();
56
- revisionsById.delete(removed);
57
- }
58
- fileRevisionIds.set(filePath, ids);
59
- }
60
-
61
- function buildUniqueTagIndex(lineHashes) {
62
- const index = new Map();
63
- const duplicates = new Set();
64
- for (let i = 0; i < lineHashes.length; i++) {
65
- const tag = lineTag(lineHashes[i]);
66
- if (duplicates.has(tag)) continue;
67
- if (index.has(tag)) {
68
- index.delete(tag);
69
- duplicates.add(tag);
70
- continue;
71
- }
72
- index.set(tag, i);
73
- }
74
- return index;
75
- }
76
-
77
- function computeFileChecksum(lineHashes) {
78
- return rangeChecksum(lineHashes, 1, lineHashes.length);
79
- }
80
-
81
- function diffLineCount(value) {
82
- if (!value) return 0;
83
- return value.split("\n").length - 1;
84
- }
85
-
86
- function coalesceRanges(ranges) {
87
- if (!ranges.length) return [];
88
- const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end);
89
- const merged = [sorted[0]];
90
- for (let i = 1; i < sorted.length; i++) {
91
- const prev = merged[merged.length - 1];
92
- const curr = sorted[i];
93
- if (curr.start <= prev.end + 1) {
94
- prev.end = Math.max(prev.end, curr.end);
95
- prev.kind = prev.kind === curr.kind ? prev.kind : "mixed";
96
- continue;
97
- }
98
- merged.push({ ...curr });
99
- }
100
- return merged;
101
- }
102
-
103
- export function computeChangedRanges(oldLines, newLines) {
104
- const oldText = oldLines.join("\n") + "\n";
105
- const newText = newLines.join("\n") + "\n";
106
- const parts = diffLines(oldText, newText);
107
- const ranges = [];
108
- let oldNum = 1;
109
- let newNum = 1;
110
-
111
- for (let i = 0; i < parts.length; i++) {
112
- const part = parts[i];
113
- const count = diffLineCount(part.value);
114
-
115
- if (part.removed) {
116
- const next = parts[i + 1];
117
- if (next?.added) {
118
- const addedCount = diffLineCount(next.value);
119
- ranges.push({
120
- start: newNum,
121
- end: addedCount > 0 ? newNum + addedCount - 1 : newNum,
122
- kind: "replace",
123
- });
124
- oldNum += count;
125
- newNum += addedCount;
126
- i++;
127
- continue;
128
- }
129
- ranges.push({ start: newNum, end: newNum, kind: "delete" });
130
- oldNum += count;
131
- continue;
132
- }
133
-
134
- if (part.added) {
135
- ranges.push({
136
- start: newNum,
137
- end: count > 0 ? newNum + count - 1 : newNum,
138
- kind: "insert",
139
- });
140
- newNum += count;
141
- continue;
142
- }
143
-
144
- oldNum += count;
145
- newNum += count;
146
- }
147
-
148
- return coalesceRanges(ranges);
149
- }
150
-
151
- export function describeChangedRanges(ranges) {
152
- if (!ranges?.length) return "none";
153
- return ranges.map(r => `${r.start}-${r.end}${r.kind ? `(${r.kind})` : ""}`).join(", ");
154
- }
155
-
156
- function createSnapshot(filePath, content, mtimeMs, size, prevSnapshot = null) {
157
- const lines = content.split("\n");
158
- const lineHashes = lines.map(line => fnv1a(line));
159
- const fileChecksum = computeFileChecksum(lineHashes);
160
- const revision = `rev-${++revisionSeq}-${fileChecksum.split(":")[1]}`;
161
- return {
162
- revision,
163
- path: filePath,
164
- content,
165
- lines,
166
- lineHashes,
167
- fileChecksum,
168
- uniqueTagIndex: buildUniqueTagIndex(lineHashes),
169
- changedRangesFromPrev: prevSnapshot ? computeChangedRanges(prevSnapshot.lines, lines) : [],
170
- prevRevision: prevSnapshot?.revision || null,
171
- mtimeMs,
172
- size,
173
- createdAt: Date.now(),
174
- };
175
- }
176
-
177
- export function rememberSnapshot(filePath, content, meta = {}) {
178
- pruneExpired();
179
- const latest = latestByFile.get(filePath);
180
- const mtimeMs = meta.mtimeMs ?? latest?.mtimeMs ?? Date.now();
181
- const size = meta.size ?? Buffer.byteLength(content, "utf8");
182
-
183
- if (latest && latest.content === content && latest.mtimeMs === mtimeMs && latest.size === size) {
184
- touchFile(filePath);
185
- return latest;
186
- }
187
-
188
- const snapshot = createSnapshot(filePath, content, mtimeMs, size, latest || null);
189
- latestByFile.set(filePath, snapshot);
190
- revisionsById.set(snapshot.revision, snapshot);
191
- rememberRevisionId(filePath, snapshot.revision);
192
- touchFile(filePath);
193
- pruneExpired();
194
- return snapshot;
195
- }
196
-
197
- export function readSnapshot(filePath) {
198
- pruneExpired();
199
- const stat = statSync(filePath);
200
- const latest = latestByFile.get(filePath);
201
- if (latest && latest.mtimeMs === stat.mtimeMs && latest.size === stat.size) {
202
- touchFile(filePath);
203
- return latest;
204
- }
205
- const content = readText(filePath);
206
- return rememberSnapshot(filePath, content, { mtimeMs: stat.mtimeMs, size: stat.size });
207
- }
208
-
209
- export function getLatestSnapshot(filePath) {
210
- pruneExpired();
211
- const latest = latestByFile.get(filePath);
212
- if (!latest) return null;
213
- touchFile(filePath);
214
- return latest;
215
- }
216
-
217
- export function getSnapshotByRevision(revision) {
218
- pruneExpired();
219
- return revisionsById.get(revision) || null;
220
- }
221
-
222
- export function overlapsChangedRanges(ranges, startLine, endLine) {
223
- return (ranges || []).some((range) => range.start <= endLine && startLine <= range.end);
224
- }
225
-
226
- export function buildRangeChecksum(snapshot, startLine, endLine) {
227
- const startIdx = startLine - 1;
228
- const endIdx = endLine - 1;
229
- if (startIdx < 0 || endIdx >= snapshot.lineHashes.length || startIdx > endIdx) return null;
230
- return rangeChecksum(snapshot.lineHashes.slice(startIdx, endIdx + 1), startLine, endLine);
231
- }
232
-
233
- export function _resetRevisionCache() {
234
- latestByFile.clear();
235
- revisionsById.clear();
236
- fileRevisionIds.clear();
237
- revisionSeq = 0;
238
- }
package/lib/search.mjs DELETED
@@ -1,268 +0,0 @@
1
- /**
2
- * File search via ripgrep with hash-annotated results.
3
- * Uses spawn with arg arrays (no shell string interpolation).
4
- *
5
- * Output modes:
6
- * content (default) — hash-annotated lines with per-group checksums (uses rg --json)
7
- * files — file paths only (rg -l)
8
- * count — match counts per file (rg -c)
9
- */
10
-
11
- import { spawn } from "node:child_process";
12
- import { resolve } from "node:path";
13
- import { fnv1a, lineTag, rangeChecksum } from "@levnikolaevich/hex-common/text-protocol/hash";
14
- import { getGraphDB, matchAnnotation, getRelativePath } from "./graph-enrich.mjs";
15
- import { normalizePath } from "./security.mjs";
16
-
17
- let rgBin = "rg";
18
- try { rgBin = (await import("@vscode/ripgrep")).rgPath; } catch { /* system rg */ }
19
-
20
- const DEFAULT_LIMIT = 100;
21
- const MAX_OUTPUT = 10 * 1024 * 1024; // 10 MB
22
- const TIMEOUT = 30000; // 30s
23
-
24
-
25
-
26
- /**
27
- * Spawn ripgrep and collect stdout.
28
- * Returns { stdout, code, stderr, killed }.
29
- */
30
- function spawnRg(args) {
31
- return new Promise((resolve_, reject) => {
32
- let stdout = "";
33
- let totalBytes = 0;
34
- let killed = false;
35
- let stderrBuf = "";
36
-
37
- const child = spawn(rgBin, args, { timeout: TIMEOUT });
38
-
39
- child.stdout.on("data", (chunk) => {
40
- totalBytes += chunk.length;
41
- if (totalBytes > MAX_OUTPUT) {
42
- killed = true;
43
- child.kill();
44
- return;
45
- }
46
- stdout += chunk.toString("utf-8");
47
- });
48
-
49
- child.stderr.on("data", (chunk) => { stderrBuf += chunk.toString("utf-8"); });
50
-
51
- child.on("error", (err) => {
52
- reject(new Error(`rg spawn error: ${err.message}`));
53
- });
54
-
55
- child.on("close", (code) => {
56
- resolve_({ stdout, code, stderr: stderrBuf, killed });
57
- });
58
- });
59
- }
60
-
61
- /**
62
- * Search files using ripgrep.
63
- *
64
- * @param {string} pattern - regex or literal pattern
65
- * @param {object} opts
66
- * @returns {Promise<string>} formatted results
67
- */
68
- export function grepSearch(pattern, opts = {}) {
69
- const normPath = normalizePath(opts.path || "");
70
- const target = normPath ? resolve(normPath) : process.cwd();
71
- const output = opts.output || "content";
72
- const plain = !!opts.plain;
73
- const totalLimit = (opts.totalLimit && opts.totalLimit > 0) ? opts.totalLimit : 0;
74
-
75
- // Branch by output mode
76
- if (output === "files") return filesMode(pattern, target, opts);
77
- if (output === "count") return countMode(pattern, target, opts);
78
- return contentMode(pattern, target, opts, plain, totalLimit);
79
- }
80
-
81
- /**
82
- * files mode: rg -l — just file paths.
83
- */
84
- async function filesMode(pattern, target, opts) {
85
- // -l + shared flags (without -n/heading/-m since -l ignores them)
86
- const realArgs = ["-l"];
87
- if (opts.caseInsensitive) realArgs.push("-i");
88
- else if (opts.smartCase) realArgs.push("-S");
89
- if (opts.literal) realArgs.push("-F");
90
- if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
91
- if (opts.glob) realArgs.push("--glob", opts.glob);
92
- if (opts.type) realArgs.push("--type", opts.type);
93
- realArgs.push("--", pattern, target);
94
-
95
- const { stdout, code, stderr, killed } = await spawnRg(realArgs);
96
- if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
97
- if (code === 1) return "No matches found.";
98
- if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} — ${stderr.trim() || "unknown error"}`);
99
-
100
- const lines = stdout.trimEnd().split("\n").filter(Boolean);
101
- const normalized = lines.map(l => l.replace(/\\/g, "/"));
102
- return `\`\`\`\n${normalized.join("\n")}\n\`\`\``;
103
- }
104
-
105
- /**
106
- * count mode: rg -c — match counts per file.
107
- */
108
- async function countMode(pattern, target, opts) {
109
- const realArgs = ["-c"];
110
- if (opts.caseInsensitive) realArgs.push("-i");
111
- else if (opts.smartCase) realArgs.push("-S");
112
- if (opts.literal) realArgs.push("-F");
113
- if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
114
- if (opts.glob) realArgs.push("--glob", opts.glob);
115
- if (opts.type) realArgs.push("--type", opts.type);
116
- realArgs.push("--", pattern, target);
117
-
118
- const { stdout, code, stderr, killed } = await spawnRg(realArgs);
119
- if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
120
- if (code === 1) return "No matches found.";
121
- if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} — ${stderr.trim() || "unknown error"}`);
122
-
123
- const lines = stdout.trimEnd().split("\n").filter(Boolean);
124
- const normalized = lines.map(l => l.replace(/\\/g, "/"));
125
- return `\`\`\`\n${normalized.join("\n")}\n\`\`\``;
126
- }
127
-
128
- /**
129
- * content mode: rg --json — hash-annotated lines with per-group checksums.
130
- */
131
- async function contentMode(pattern, target, opts, plain, totalLimit) {
132
- const realArgs = ["--json"];
133
- if (opts.caseInsensitive) realArgs.push("-i");
134
- else if (opts.smartCase) realArgs.push("-S");
135
- if (opts.literal) realArgs.push("-F");
136
- if (opts.multiline) realArgs.push("-U", "--multiline-dotall");
137
- if (opts.glob) realArgs.push("--glob", opts.glob);
138
- if (opts.type) realArgs.push("--type", opts.type);
139
- if (opts.context && opts.context > 0) realArgs.push("-C", String(opts.context));
140
- if (opts.contextBefore && opts.contextBefore > 0) realArgs.push("-B", String(opts.contextBefore));
141
- if (opts.contextAfter && opts.contextAfter > 0) realArgs.push("-A", String(opts.contextAfter));
142
-
143
- const limit = (opts.limit && opts.limit > 0) ? opts.limit : DEFAULT_LIMIT;
144
- realArgs.push("-m", String(limit));
145
- realArgs.push("--", pattern, target);
146
-
147
- const { stdout, code, stderr, killed } = await spawnRg(realArgs);
148
- if (killed) return "GREP_OUTPUT_TRUNCATED: exceeded 10MB. Use specific glob/path.";
149
- if (code === 1) return "No matches found.";
150
- if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} — ${stderr.trim() || "unknown error"}`);
151
-
152
- // Parse NDJSON output
153
- const jsonLines = stdout.trimEnd().split("\n").filter(Boolean);
154
- const formatted = [];
155
- const db = getGraphDB(target);
156
- const relCache = new Map();
157
-
158
- // Track current group for checksums
159
- let groupFile = null;
160
- let groupLines = []; // { lineNum, hash32 }
161
- let matchCount = 0;
162
-
163
- function flushGroup() {
164
- if (groupLines.length === 0) return;
165
- const sorted = [...groupLines].sort((a, b) => a.lineNum - b.lineNum);
166
- const start = sorted[0].lineNum;
167
- const end = sorted[sorted.length - 1].lineNum;
168
- const hashes = sorted.map(l => l.hash32);
169
- const cs = rangeChecksum(hashes, start, end);
170
- formatted.push(`checksum: ${cs}`);
171
- groupLines = [];
172
- }
173
-
174
- for (const jl of jsonLines) {
175
- let msg;
176
- try { msg = JSON.parse(jl); } catch { continue; }
177
-
178
- if (msg.type === "begin" || msg.type === "end" || msg.type === "summary") {
179
- if (msg.type === "end") {
180
- flushGroup();
181
- groupFile = null;
182
- }
183
- if (msg.type === "begin") {
184
- // Separator between file groups
185
- if (formatted.length > 0 && formatted[formatted.length - 1] !== "") {
186
- formatted.push("");
187
- }
188
- }
189
- continue;
190
- }
191
-
192
- if (msg.type !== "match" && msg.type !== "context") continue;
193
-
194
- const data = msg.data;
195
- const filePath = (data.path?.text || "").replace(/\\/g, "/");
196
- const lineNum = data.line_number;
197
- if (!lineNum) continue;
198
-
199
- // Get line content (handle text vs bytes)
200
- let content = data.lines?.text;
201
- if (content === undefined && data.lines?.bytes) {
202
- content = Buffer.from(data.lines.bytes, "base64").toString("utf-8");
203
- }
204
- if (content === undefined) continue;
205
-
206
- // Trim trailing newline from rg JSON output
207
- content = content.replace(/\n$/, "");
208
-
209
- // Handle multiline: split into individual lines
210
- const subLines = content.split("\n");
211
-
212
- // Track group boundaries
213
- if (filePath !== groupFile) {
214
- flushGroup();
215
- groupFile = filePath;
216
- }
217
-
218
- for (let i = 0; i < subLines.length; i++) {
219
- const ln = lineNum + i;
220
- const lineContent = subLines[i];
221
- const hash32 = fnv1a(lineContent);
222
- const tag = lineTag(hash32);
223
-
224
- // Flush on line gap (disjoint match clusters get separate checksums)
225
- if (groupLines.length > 0) {
226
- const lastLn = groupLines[groupLines.length - 1].lineNum;
227
- if (ln > lastLn + 1) flushGroup();
228
- }
229
- groupLines.push({ lineNum: ln, hash32 });
230
-
231
- const isMatch = msg.type === "match";
232
- if (plain) {
233
- formatted.push(`${filePath}:${ln}:${lineContent}`);
234
- } else {
235
- let anno = "";
236
- if (db && isMatch) {
237
- let rel = relCache.get(filePath);
238
- if (rel === undefined) {
239
- rel = getRelativePath(resolve(filePath)) || "";
240
- relCache.set(filePath, rel);
241
- }
242
- if (rel) {
243
- const a = matchAnnotation(db, rel, ln);
244
- if (a) anno = ` ${a}`;
245
- }
246
- }
247
- const prefix = isMatch ? ">>" : " ";
248
- formatted.push(`${filePath}:${prefix}${tag}.${ln}\t${lineContent}${anno}`);
249
- }
250
-
251
- }
252
-
253
- // Count matches per rg event, not per subLine
254
- if (msg.type === "match") {
255
- matchCount++;
256
- if (totalLimit > 0 && matchCount >= totalLimit) {
257
- flushGroup();
258
- formatted.push(`--- total_limit reached (${totalLimit}) ---`);
259
- return `\`\`\`\n${formatted.join("\n")}\n\`\`\``;
260
- }
261
- }
262
- }
263
-
264
- // Flush last group
265
- flushGroup();
266
-
267
- return `\`\`\`\n${formatted.join("\n")}\n\`\`\``;
268
- }
package/lib/security.mjs DELETED
@@ -1,112 +0,0 @@
1
- /**
2
- * Security boundaries for file operations.
3
- *
4
- * Claude Code provides its own sandbox (permissions, project scope).
5
- * This module handles: path canonicalization, symlink resolution,
6
- * binary file detection, and size limits.
7
- */
8
-
9
- import { realpathSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
10
- import { resolve, isAbsolute, dirname } from "node:path";
11
- import { listDirectory } from "./format.mjs";
12
-
13
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
14
-
15
- /**
16
- * Convert Git Bash /c/Users/... → c:/Users/... on Windows.
17
- * Node.js resolve() treats /c/ as absolute from current drive root, producing D:\c\Users.
18
- */
19
- export function normalizePath(p) {
20
- if (process.platform === "win32" && /^\/[a-zA-Z]\//.test(p)) {
21
- p = p[1] + ":" + p.slice(2);
22
- }
23
- return p.replace(/\\/g, "/");
24
- }
25
-
26
- /**
27
- * Validate a file path against security boundaries.
28
- * Returns the canonicalized absolute path.
29
- * Throws on violation.
30
- */
31
- export function validatePath(filePath) {
32
- if (!filePath) throw new Error("Empty file path");
33
-
34
- const normalized = normalizePath(filePath);
35
- const abs = isAbsolute(normalized) ? normalized : resolve(process.cwd(), normalized);
36
-
37
- // Check existence — show parent directory contents as fallback
38
- if (!existsSync(abs)) {
39
- let hint = "";
40
- try {
41
- const parent = dirname(abs);
42
- if (existsSync(parent)) {
43
- const { text, total } = listDirectory(parent, { limit: 20, metadata: true });
44
- hint = `\n\nParent directory ${parent} contains:\n${text}`;
45
- if (total > 20) hint += `\n ... (${total - 20} more)`;
46
- }
47
- } catch {}
48
- throw new Error(`FILE_NOT_FOUND: ${abs}${hint}`);
49
- }
50
-
51
- // Canonicalize (resolves symlinks)
52
- let real;
53
- try {
54
- real = realpathSync(abs);
55
- } catch (e) {
56
- throw new Error(`Cannot resolve path: ${abs} (${e.message})`);
57
- }
58
-
59
- // Check file type
60
- const stat = statSync(real);
61
- if (stat.isDirectory()) return real; // directories allowed for listing
62
- if (!stat.isFile()) {
63
- const type = stat.isSymbolicLink() ? "symlink" : "special";
64
- throw new Error(`NOT_REGULAR_FILE: ${real} (${type}). Cannot read special files.`);
65
- }
66
-
67
- // Size check
68
- if (stat.size > MAX_FILE_SIZE) {
69
- throw new Error(`FILE_TOO_LARGE: ${real} (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_FILE_SIZE / 1024 / 1024}MB). Use offset/limit to read a range.`);
70
- }
71
-
72
- // Binary detection (check first 8KB for null bytes — only read 8KB, not whole file)
73
- const bfd = openSync(real, "r");
74
- const probe = Buffer.alloc(8192);
75
- const bytesRead = readSync(bfd, probe, 0, 8192, 0);
76
- closeSync(bfd);
77
- for (let i = 0; i < bytesRead; i++) {
78
- if (probe[i] === 0) {
79
- throw new Error(`BINARY_FILE: ${real}. Use built-in Read tool (supports images, PDFs, notebooks).`);
80
- }
81
- }
82
-
83
- return real.replace(/\\/g, "/");
84
- }
85
-
86
- /**
87
- * Validate path for write (does NOT require file to exist).
88
- * Resolves to absolute path, validates parent exists or can be created.
89
- */
90
- export function validateWritePath(filePath) {
91
- if (!filePath) throw new Error("Empty file path");
92
-
93
- const normalized = normalizePath(filePath);
94
- const abs = isAbsolute(normalized) ? normalized : resolve(process.cwd(), normalized);
95
-
96
- // For write, the file might not exist yet — validate the parent directory
97
- if (!existsSync(abs)) {
98
- const parent = resolve(abs, "..");
99
- if (!existsSync(parent)) {
100
- // Walk up to find an existing ancestor (parent dirs will be created by write_file)
101
- let ancestor = resolve(parent, "..");
102
- while (!existsSync(ancestor) && ancestor !== resolve(ancestor, "..")) {
103
- ancestor = resolve(ancestor, "..");
104
- }
105
- if (!existsSync(ancestor)) {
106
- throw new Error(`No existing ancestor directory for: ${abs}`);
107
- }
108
- }
109
- }
110
-
111
- return abs.replace(/\\/g, "/");
112
- }