@levnikolaevich/hex-line-mcp 1.3.3 → 1.3.5

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/package.json CHANGED
@@ -1,23 +1,22 @@
1
1
  {
2
2
  "name": "@levnikolaevich/hex-line-mcp",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "mcpName": "io.github.levnikolaevich/hex-line-mcp",
5
5
  "type": "module",
6
6
  "description": "Hash-verified file editing MCP + token efficiency hook for AI coding agents. 11 tools: read, edit, write, grep, outline, verify, directory_tree, file_info, setup_hooks, changes, bulk_replace.",
7
- "main": "server.mjs",
7
+ "main": "dist/server.mjs",
8
8
  "bin": {
9
- "hex-line-mcp": "server.mjs"
9
+ "hex-line-mcp": "dist/server.mjs"
10
10
  },
11
11
  "files": [
12
- "server.mjs",
13
- "hook.mjs",
14
- "benchmark/",
15
- "lib/",
12
+ "dist/",
16
13
  "README.md",
17
14
  "output-style.md"
18
15
  ],
19
16
  "scripts": {
20
17
  "start": "node server.mjs",
18
+ "build": "node build.mjs",
19
+ "prepublishOnly": "npm run build",
21
20
  "lint": "eslint .",
22
21
  "lint:fix": "eslint . --fix",
23
22
  "test": "node --test test/*.mjs",
@@ -32,8 +31,9 @@
32
31
  "better-sqlite3": "Optional. Used only by lib/graph-enrich.mjs for readonly access to hex-graph .codegraph/index.db. Graceful fallback if absent."
33
32
  },
34
33
  "dependencies": {
35
- "@levnikolaevich/hex-common": "file:../hex-common",
36
34
  "@modelcontextprotocol/sdk": "^1.27.0",
35
+ "tree-sitter-wasms": "^0.1.0",
36
+ "web-tree-sitter": "^0.25.0",
37
37
  "diff": "^8.0.3",
38
38
  "ignore": "^7.0.5",
39
39
  "zod": "^3.25.0",
@@ -1,502 +0,0 @@
1
- /**
2
- * TEST 1-15: Individual tool comparisons (atomic benchmarks).
3
- *
4
- * Each test compares "agent without hex-line" vs "agent with hex-line"
5
- * for a single tool or error-recovery scenario.
6
- */
7
-
8
- import { readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
9
- import { resolve } from "node:path";
10
- import { tmpdir } from "node:os";
11
- import { performance } from "node:perf_hooks";
12
- import { fnv1a, lineTag, rangeChecksum } from "../lib/hash.mjs";
13
- import { readFile } from "../lib/read.mjs";
14
- import { directoryTree } from "../lib/tree.mjs";
15
- import { fileInfo } from "../lib/info.mjs";
16
- import { verifyChecksums } from "../lib/verify.mjs";
17
- import { fileChanges } from "../lib/changes.mjs";
18
- import {
19
- getFileLines,
20
- simBuiltInReadFull, simBuiltInOutlineFull, simBuiltInGrep,
21
- simBuiltInLsR, simBuiltInStat, simBuiltInWrite, simBuiltInEdit, simBuiltInVerify,
22
- simHexLineOutlinePlusRead, simHexLineGrep, simHexLineWrite, simHexLineEditDiff,
23
- runN, pctSavings,
24
- } from "../lib/benchmark-helpers.mjs";
25
-
26
- /**
27
- * Run TEST 1-15 atomic benchmarks.
28
- *
29
- * @param {object} config
30
- * @param {string[]} config.allFiles - All discovered code files
31
- * @param {object} config.cats - Categorized files { small, medium, large, xl }
32
- * @param {string} config.tmpPath - Path to temp benchmark file
33
- * @param {string} config.tmpContent - Content of temp file
34
- * @param {string[]} config.tmpLines - Lines of temp file
35
- * @param {string} config.repoRoot - Repository root path
36
- * @param {number} config.ts - Timestamp for unique temp file names
37
- * @returns {Promise<object[]>} Array of result objects
38
- */
39
- export async function runAtomic(config) {
40
- const { allFiles, cats, tmpPath, tmpContent, tmpLines, repoRoot, ts } = config;
41
- const results = [];
42
-
43
- // ===================================================================
44
- // TEST 1: Read full file
45
- // ===================================================================
46
- for (const [cat, files] of Object.entries(cats)) {
47
- if (files.length === 0) continue;
48
- const withoutArr = [];
49
- const withArr = [];
50
-
51
- for (const f of files) {
52
- const lines = getFileLines(f);
53
- if (!lines) continue;
54
- withoutArr.push(runN(() => simBuiltInReadFull(f, lines).length));
55
- withArr.push(runN(() => readFile(f).length));
56
- }
57
-
58
- if (withoutArr.length === 0) continue;
59
- const avgWithout = Math.round(withoutArr.reduce((a, b) => a + b.value, 0) / withoutArr.length);
60
- const avgWith = Math.round(withArr.reduce((a, b) => a + b.value, 0) / withArr.length);
61
- const avgMsWithout = parseFloat((withoutArr.reduce((a, b) => a + b.ms, 0) / withoutArr.length).toFixed(1));
62
- const avgMsWith = parseFloat((withArr.reduce((a, b) => a + b.ms, 0) / withArr.length).toFixed(1));
63
-
64
- const label = { small: "<50L", medium: "50-200L", large: "200-500L", xl: "500L+" }[cat];
65
- results.push({
66
- num: 1, scenario: `Read full (${label})`,
67
- without: avgWithout, withSL: avgWith,
68
- savings: pctSavings(avgWithout, avgWith),
69
- latencyWithout: avgMsWithout, latencyWith: avgMsWith,
70
- });
71
- }
72
-
73
- // ===================================================================
74
- // TEST 2: Read with outline — full read vs outline + targeted read
75
- // ===================================================================
76
- for (const cat of ["large", "xl"]) {
77
- const files = cats[cat] || [];
78
- if (files.length === 0) continue;
79
- const withoutArr = [];
80
- const withArr = [];
81
-
82
- for (const f of files) {
83
- const lines = getFileLines(f);
84
- if (!lines) continue;
85
- withoutArr.push(runN(() => simBuiltInOutlineFull(f, lines).length));
86
- withArr.push(runN(() => simHexLineOutlinePlusRead(f, lines).length));
87
- }
88
-
89
- if (withoutArr.length === 0) continue;
90
- const avgWithout = Math.round(withoutArr.reduce((a, b) => a + b.value, 0) / withoutArr.length);
91
- const avgWith = Math.round(withArr.reduce((a, b) => a + b.value, 0) / withArr.length);
92
- const avgMsWithout = parseFloat((withoutArr.reduce((a, b) => a + b.ms, 0) / withoutArr.length).toFixed(1));
93
- const avgMsWith = parseFloat((withArr.reduce((a, b) => a + b.ms, 0) / withArr.length).toFixed(1));
94
-
95
- const label = cat === "large" ? "200-500L" : "500L+";
96
- results.push({
97
- num: 2, scenario: `Outline+read (${label})`,
98
- without: avgWithout, withSL: avgWith,
99
- savings: pctSavings(avgWithout, avgWith),
100
- latencyWithout: avgMsWithout, latencyWith: avgMsWith,
101
- });
102
- }
103
-
104
- // ===================================================================
105
- // TEST 3: Grep search
106
- // ===================================================================
107
- {
108
- const grepFiles = [...(cats.medium || []), ...(cats.large || []), ...(cats.xl || [])].slice(0, 3);
109
- if (grepFiles.length > 0) {
110
- const withoutArr = [];
111
- const withArr = [];
112
-
113
- for (const f of grepFiles) {
114
- const lines = getFileLines(f);
115
- if (!lines) continue;
116
- const pattern = "function|class|const";
117
- withoutArr.push(runN(() => simBuiltInGrep(pattern, f).length));
118
- withArr.push(runN(() => simHexLineGrep(f, lines, pattern).length));
119
- }
120
-
121
- if (withoutArr.length > 0) {
122
- const avgWithout = Math.round(withoutArr.reduce((a, b) => a + b.value, 0) / withoutArr.length);
123
- const avgWith = Math.round(withArr.reduce((a, b) => a + b.value, 0) / withArr.length);
124
- const avgMsWithout = parseFloat((withoutArr.reduce((a, b) => a + b.ms, 0) / withoutArr.length).toFixed(1));
125
- const avgMsWith = parseFloat((withArr.reduce((a, b) => a + b.ms, 0) / withArr.length).toFixed(1));
126
- results.push({
127
- num: 3, scenario: "Grep search",
128
- without: avgWithout, withSL: avgWith,
129
- savings: pctSavings(avgWithout, avgWith),
130
- latencyWithout: avgMsWithout, latencyWith: avgMsWith,
131
- });
132
- }
133
- }
134
- }
135
-
136
- // ===================================================================
137
- // TEST 4: Directory tree
138
- // ===================================================================
139
- {
140
- const { value: without, ms: withoutMs } = runN(() => simBuiltInLsR(repoRoot, 0, 3).length);
141
- const { value: withSL, ms: withMs } = runN(() => directoryTree(repoRoot, { max_depth: 3 }).length);
142
- results.push({
143
- num: 4, scenario: "Directory tree",
144
- without, withSL,
145
- savings: pctSavings(without, withSL),
146
- latencyWithout: withoutMs, latencyWith: withMs,
147
- });
148
- }
149
-
150
- // ===================================================================
151
- // TEST 5: File info
152
- // ===================================================================
153
- {
154
- const infoFile = allFiles[Math.floor(allFiles.length / 2)] || allFiles[0];
155
- const { value: without, ms: withoutMs } = runN(() => simBuiltInStat(infoFile).length);
156
- const { value: withSL, ms: withMs } = runN(() => fileInfo(infoFile).length);
157
- results.push({
158
- num: 5, scenario: "File info",
159
- without, withSL,
160
- savings: pctSavings(without, withSL),
161
- latencyWithout: withoutMs, latencyWith: withMs,
162
- });
163
- }
164
-
165
- // ===================================================================
166
- // TEST 6: Create file (write)
167
- // ===================================================================
168
- {
169
- const { value: without, ms: withoutMs } = runN(() => simBuiltInWrite(tmpPath, tmpContent).length);
170
- const { value: withSL, ms: withMs } = runN(() => simHexLineWrite(tmpPath, tmpContent).length);
171
- results.push({
172
- num: 6, scenario: "Create file (200L)",
173
- without, withSL,
174
- savings: pctSavings(without, withSL),
175
- latencyWithout: withoutMs, latencyWith: withMs,
176
- });
177
- }
178
-
179
- // ===================================================================
180
- // TEST 7: Edit x5 sequential
181
- // ===================================================================
182
- {
183
- const editTargets = [
184
- { line: 13, new: ' this.configPath = resolve(configPath || ".");' },
185
- { line: 55, new: " const { retries = MAX_RETRIES, delay = 200, backoff = 3 } = options;" },
186
- { line: 75, new: " this.timeout = options.timeout ?? DEFAULT_TIMEOUT;" },
187
- { line: 116, new: " return this; // chainable" },
188
- { line: 148, new: " /** @type {string[]} */\n const errors = [];" },
189
- ];
190
-
191
- let totalWithout = 0;
192
- let totalWith = 0;
193
- let totalMsWithout = 0;
194
- let totalMsWith = 0;
195
-
196
- for (const edit of editTargets) {
197
- const origLines = [...tmpLines];
198
- const newLines = [...tmpLines];
199
- const idx = edit.line - 1;
200
- if (idx < newLines.length) {
201
- newLines[idx] = edit.new;
202
- }
203
-
204
- const rW = runN(() => simBuiltInEdit(tmpPath, origLines, newLines).length);
205
- const rH = runN(() => simHexLineEditDiff(origLines, newLines).length);
206
- totalWithout += rW.value;
207
- totalWith += rH.value;
208
- totalMsWithout += rW.ms;
209
- totalMsWith += rH.ms;
210
- }
211
-
212
- results.push({
213
- num: 7, scenario: "Edit x5 sequential",
214
- without: totalWithout, withSL: totalWith,
215
- savings: pctSavings(totalWithout, totalWith),
216
- latencyWithout: parseFloat(totalMsWithout.toFixed(1)), latencyWith: parseFloat(totalMsWith.toFixed(1)),
217
- });
218
- }
219
-
220
- // ===================================================================
221
- // TEST 8: Verify checksums
222
- // ===================================================================
223
- {
224
- const fileLines = readFileSync(tmpPath, "utf-8").replace(/\r\n/g, "\n").split("\n");
225
- const hashes = fileLines.map(l => fnv1a(l));
226
- const cs1 = rangeChecksum(hashes.slice(0, 50), 1, 50);
227
- const cs2 = rangeChecksum(hashes.slice(50, 100), 51, 100);
228
- const cs3 = rangeChecksum(hashes.slice(100, 150), 101, 150);
229
- const cs4 = rangeChecksum(hashes.slice(150, 200), 151, 200);
230
- const checksums = [cs1, cs2, cs3, cs4];
231
-
232
- const { value: without, ms: withoutMs } = runN(() => simBuiltInVerify(tmpPath, fileLines).length);
233
- const { value: withSL, ms: withMs } = runN(() => verifyChecksums(tmpPath, checksums).length);
234
-
235
- results.push({
236
- num: 8, scenario: "Verify checksums (4 ranges)",
237
- without, withSL,
238
- savings: pctSavings(without, withSL),
239
- latencyWithout: withoutMs, latencyWith: withMs,
240
- });
241
- }
242
-
243
- // ===================================================================
244
- // TEST 9: Multi-file read (batch)
245
- // ===================================================================
246
- {
247
- const batchFiles = (cats.small || []).slice(0, 3);
248
- if (batchFiles.length >= 2) {
249
- // Without hex-line: N separate Read calls
250
- const { value: without, ms: withoutMs } = runN(() => {
251
- let total = 0;
252
- for (const f of batchFiles) {
253
- const lines = getFileLines(f);
254
- if (lines) total += simBuiltInReadFull(f, lines).length;
255
- }
256
- return total;
257
- });
258
-
259
- // With hex-line: 1 read_file call with paths:[] — concatenated output
260
- const { value: withSL, ms: withMs } = runN(() => {
261
- const parts = [];
262
- for (const f of batchFiles) {
263
- parts.push(readFile(f));
264
- }
265
- return parts.join("\n\n---\n\n").length;
266
- });
267
-
268
- results.push({
269
- num: 9, scenario: `Multi-file read (${batchFiles.length} files)`,
270
- without, withSL,
271
- savings: pctSavings(without, withSL),
272
- latencyWithout: withoutMs, latencyWith: withMs,
273
- });
274
- }
275
- }
276
-
277
- // ===================================================================
278
- // TEST 10: bulk_replace dry_run
279
- // ===================================================================
280
- {
281
- const bulkTmpPaths = [];
282
- for (let i = 0; i < 5; i++) {
283
- const p = resolve(tmpdir(), `hex-line-bulk-${ts}-${i}.js`);
284
- writeFileSync(p, tmpContent, "utf-8");
285
- bulkTmpPaths.push(p);
286
- }
287
-
288
- const editLine = 13;
289
- const editNew = ' this.configPath = resolve(configPath || ".");';
290
-
291
- // Without hex-line: 5 separate edit_file calls
292
- const { value: without, ms: withoutMs } = runN(() => {
293
- let total = 0;
294
- for (const p of bulkTmpPaths) {
295
- const origLines = [...tmpLines];
296
- const newLines = [...tmpLines];
297
- newLines[editLine - 1] = editNew;
298
- total += simBuiltInEdit(p, origLines, newLines).length;
299
- }
300
- return total;
301
- });
302
-
303
- // With hex-line: 1 bulk_replace — summary + per-file compact diff
304
- const { value: withSL, ms: withMs } = runN(() => {
305
- let response = "5 files changed, 0 errors\n";
306
- for (let i = 0; i < bulkTmpPaths.length; i++) {
307
- const origLines = [...tmpLines];
308
- const newLines = [...tmpLines];
309
- newLines[editLine - 1] = editNew;
310
- response += simHexLineEditDiff(origLines, newLines) + "\n";
311
- }
312
- return response.length;
313
- });
314
-
315
- results.push({
316
- num: 10, scenario: "bulk_replace dry_run (5 files)",
317
- without, withSL,
318
- savings: pctSavings(without, withSL),
319
- latencyWithout: withoutMs, latencyWith: withMs,
320
- });
321
-
322
- for (const p of bulkTmpPaths) {
323
- try { unlinkSync(p); } catch { /* ok */ }
324
- }
325
- }
326
-
327
- // ===================================================================
328
- // TEST 11: changes (semantic diff)
329
- // ===================================================================
330
- {
331
- // Without hex-line: raw unified diff output
332
- const { value: without, ms: withoutMs } = runN(() => {
333
- const diffLines = [
334
- `diff --git a/benchmark-target.js b/benchmark-target.js`,
335
- `index abc1234..def5678 100644`,
336
- `--- a/benchmark-target.js`,
337
- `+++ b/benchmark-target.js`,
338
- `@@ -10,6 +10,12 @@ const DEFAULT_TIMEOUT = 5000;`,
339
- ];
340
- // Simulate ~15 context + change lines typical of a small diff
341
- for (let i = 0; i < 5; i++) {
342
- diffLines.push(` ${tmpLines[i + 5] || " // context line"}`); // context
343
- }
344
- diffLines.push(`-${tmpLines[12] || " old line"}`);
345
- diffLines.push(`+ this.configPath = resolve(configPath || ".");`);
346
- for (let i = 0; i < 5; i++) {
347
- diffLines.push(` ${tmpLines[i + 14] || " // context line"}`); // context
348
- }
349
- // Second hunk — added function
350
- diffLines.push(`@@ -195,0 +201,8 @@`);
351
- for (let i = 0; i < 3; i++) {
352
- diffLines.push(` ${tmpLines[i + 150] || " // context"}`);
353
- }
354
- for (let i = 0; i < 5; i++) {
355
- diffLines.push(`+ // new function line ${i}`);
356
- }
357
- for (let i = 0; i < 3; i++) {
358
- diffLines.push(` ${tmpLines[i + 155] || " // context"}`);
359
- }
360
- return diffLines.join("\n").length;
361
- });
362
-
363
- // With hex-line: real fileChanges() semantic diff (async, called once — deterministic)
364
- let withSL;
365
- let withMs = 0;
366
- try {
367
- const t0 = performance.now();
368
- const changesOut = await fileChanges(allFiles[0]);
369
- withMs = parseFloat((performance.now() - t0).toFixed(1));
370
- withSL = changesOut.length;
371
- } catch {
372
- withSL = 133; // fallback if no git history
373
- }
374
-
375
- results.push({
376
- num: 11, scenario: "Changes (semantic diff)",
377
- without, withSL,
378
- savings: pctSavings(without, withSL),
379
- latencyWithout: withoutMs, latencyWith: withMs,
380
- });
381
- }
382
-
383
- // ===================================================================
384
- // TEST 12: FILE_NOT_FOUND recovery
385
- // ===================================================================
386
- {
387
- const missingPath = resolve(repoRoot, "src/utils/halper.js");
388
- const parentDir = resolve(repoRoot, "src/utils");
389
-
390
- // Without hex-line: 3 round-trips (error → ls → retry)
391
- const { value: without, ms: withoutMs } = runN(() => {
392
- // Round 1: real ENOENT error
393
- let r1;
394
- try { readFileSync(missingPath, "utf-8"); r1 = ""; } catch (e) { r1 = e.message; }
395
- // Round 2: real directory listing to find correct name
396
- let r2;
397
- try { r2 = readdirSync(parentDir).join("\n"); } catch { r2 = `${parentDir}: directory not found`; }
398
- // Round 3: agent re-reads correct file (small file ~30 lines)
399
- const r3 = simBuiltInReadFull(missingPath, tmpLines.slice(0, 30));
400
- return (r1 + r2 + r3).length;
401
- });
402
-
403
- // With hex-line: real readFile() on nonexistent path — returns error + parent dir listing
404
- const { value: withSL, ms: withMs } = runN(() => {
405
- try {
406
- return readFile(missingPath).length;
407
- } catch (e) {
408
- return e.message.length;
409
- }
410
- });
411
-
412
- results.push({
413
- num: 12, scenario: "FILE_NOT_FOUND recovery*",
414
- without, withSL,
415
- savings: pctSavings(without, withSL),
416
- latencyWithout: withoutMs, latencyWith: withMs,
417
- });
418
- }
419
-
420
- // ===================================================================
421
- // TEST 13: Hash mismatch recovery
422
- // ===================================================================
423
- {
424
- // Without hex-line: 3 round-trips (stale error → re-read full → retry edit)
425
- const { value: without, ms: withoutMs } = runN(() => {
426
- // Round 1: error
427
- const r1 = 'Error: file content has changed (stale). Please re-read the file.';
428
- // Round 2: full re-read
429
- const r2 = simBuiltInReadFull(tmpPath, tmpLines);
430
- // Round 3: retry edit response
431
- const origLines = [...tmpLines];
432
- const newLines = [...tmpLines];
433
- newLines[12] = ' this.configPath = resolve(configPath || ".");';
434
- const r3 = simBuiltInEdit(tmpPath, origLines, newLines);
435
- return (r1 + r2 + r3).length;
436
- });
437
-
438
- // With hex-line: 1 round-trip (error + fresh snippet +/-5 lines around target)
439
- const { value: withSL, ms: withMs } = runN(() => {
440
- const targetLine = 13;
441
- const snippetStart = Math.max(0, targetLine - 6);
442
- const snippetEnd = Math.min(tmpLines.length, targetLine + 5);
443
- const snippet = tmpLines.slice(snippetStart, snippetEnd);
444
- const annotated = snippet.map((l, i) => {
445
- const lineNum = snippetStart + i + 1;
446
- const tag = lineTag(fnv1a(l));
447
- return `${tag}.${lineNum}\t${l}`;
448
- }).join("\n");
449
- const response = `HASH_MISMATCH at line ${targetLine}. Fresh snippet:\n\`\`\`\n${annotated}\n\`\`\``;
450
- return response.length;
451
- });
452
-
453
- results.push({
454
- num: 13, scenario: "Hash mismatch recovery*",
455
- without, withSL,
456
- savings: pctSavings(without, withSL),
457
- latencyWithout: withoutMs, latencyWith: withMs,
458
- });
459
- }
460
-
461
- // ===================================================================
462
- // TEST 14: Bash redirect savings
463
- // ===================================================================
464
- {
465
- const infoFile = allFiles[Math.floor(allFiles.length / 2)] || allFiles[0];
466
- const infoLines = getFileLines(infoFile);
467
- if (infoLines) {
468
- // Sub-test A: cat vs read_file
469
- const catW = runN(() => {
470
- // cat output: raw lines, no line numbers (agent redirect)
471
- return infoLines.join("\n").length;
472
- });
473
- const catH = runN(() => readFile(infoFile).length);
474
-
475
- // Sub-test B: ls -la vs directory_tree
476
- const dirTarget = resolve(repoRoot);
477
- const lsW = runN(() => simBuiltInLsR(dirTarget, 0, 1).length);
478
- const lsH = runN(() => directoryTree(dirTarget, { max_depth: 1 }).length);
479
-
480
- // Sub-test C: stat vs get_file_info
481
- const stW = runN(() => simBuiltInStat(infoFile).length);
482
- const stH = runN(() => fileInfo(infoFile).length);
483
-
484
- // Combined: without = raw outputs (no follow-up possible)
485
- // With = structured output (enables follow-up without extra calls)
486
- const totalWithout = catW.value + lsW.value + stW.value;
487
- const totalWith = catH.value + lsH.value + stH.value;
488
- const totalMsWithout = catW.ms + lsW.ms + stW.ms;
489
- const totalMsWith = catH.ms + lsH.ms + stH.ms;
490
-
491
- results.push({
492
- num: 14, scenario: "Bash redirects (cat+ls+stat)",
493
- without: totalWithout, withSL: totalWith,
494
- savings: pctSavings(totalWithout, totalWith),
495
- latencyWithout: parseFloat(totalMsWithout.toFixed(1)), latencyWith: parseFloat(totalMsWith.toFixed(1)),
496
- });
497
- }
498
- }
499
-
500
-
501
- return results;
502
- }
@@ -1,80 +0,0 @@
1
- /**
2
- * TEST 16-18: Graph enrichment benchmarks (--with-graph only).
3
- *
4
- * Both sides use hex-line; difference is whether .codegraph/index.db exists.
5
- * Requires hex-graph index_project to have been run first.
6
- */
7
-
8
- import { writeFileSync, unlinkSync } from "node:fs";
9
- import { resolve } from "node:path";
10
- import { tmpdir } from "node:os";
11
- import { readFile } from "../lib/read.mjs";
12
- import { editFile } from "../lib/edit.mjs";
13
- import { grepSearch } from "../lib/search.mjs";
14
- import { fnv1a, lineTag } from "../lib/hash.mjs";
15
- import { getFileLines, fmt, pctSavings } from "../lib/benchmark-helpers.mjs";
16
-
17
- /**
18
- * Run TEST 16-18 graph enrichment benchmarks.
19
- *
20
- * @param {object} config
21
- * @param {string[]} config.allFiles - All discovered code files
22
- * @param {string[]} config.largeFiles - Top 3 largest code files
23
- * @param {string} config.repoRoot - Repository root path
24
- * @returns {Promise<string[]>} Array of pre-formatted table row strings
25
- */
26
- export async function runGraph(config) {
27
- const { allFiles, largeFiles, repoRoot } = config;
28
- const graphOut = [];
29
-
30
- const { getGraphDB } = await import("../lib/graph-enrich.mjs");
31
- const db = getGraphDB(resolve(repoRoot, "server.mjs"));
32
- if (!db) {
33
- console.error("--with-graph: .codegraph/index.db not found. Run hex-graph index_project first.");
34
- return graphOut;
35
- }
36
-
37
- const graphFile = largeFiles[0] || allFiles[0];
38
- const graphLines = getFileLines(graphFile);
39
-
40
- if (!graphLines) return graphOut;
41
-
42
- // TEST 16: Read with/without Graph header
43
- {
44
- const withGraphResult = readFile(graphFile);
45
- const noGraphResult = withGraphResult.replace(/\nGraph:.*\n/, "\n");
46
- const savings = pctSavings(noGraphResult.length, withGraphResult.length);
47
- graphOut.push(`| 16 | Graph: Read (${graphLines.length}L) | ${fmt(noGraphResult.length)} chars | ${fmt(withGraphResult.length)} chars | ${savings} | 2\u21921 | 2\u21921 |`);
48
- }
49
-
50
- // TEST 17: Edit with/without call impact
51
- {
52
- const editTmpPath = resolve(tmpdir(), `hex-bench-edit-${Date.now()}.js`);
53
- writeFileSync(editTmpPath, graphLines.join("\n"), "utf-8");
54
- try {
55
- const tag = lineTag(fnv1a(graphLines[5]));
56
- const editResult = editFile(editTmpPath, [{ set_line: { anchor: `${tag}.6`, new_text: graphLines[5] + " // modified" } }]);
57
- const noBlastOut = editResult.replace(/\n.*Call impact.*$/s, "");
58
- const savings = pctSavings(noBlastOut.length, editResult.length);
59
- graphOut.push(`| 17 | Graph: Edit + call impact | ${fmt(noBlastOut.length)} chars | ${fmt(editResult.length)} chars | ${savings} | 2\u21921 | 2\u21921 |`);
60
- } catch {
61
- graphOut.push(`| 17 | Graph: Edit + call impact | \u2014 | \u2014 | \u2014 | | |`);
62
- }
63
- try { unlinkSync(editTmpPath); } catch {}
64
- }
65
-
66
- // TEST 18: Grep with/without annotations
67
- {
68
- try {
69
- const grepResult = await grepSearch("function", { path: resolve(repoRoot), glob: "*.mjs", limit: 10 });
70
- const noAnnoResult = grepResult.replace(/ \[[^\]]+\]/g, "");
71
- const savings = pctSavings(noAnnoResult.length, grepResult.length);
72
- const annoCount = (grepResult.match(/\[[^\]]+\]/g) || []).length;
73
- graphOut.push(`| 18 | Graph: Grep + ${annoCount} annotations | ${fmt(noAnnoResult.length)} chars | ${fmt(grepResult.length)} chars | ${savings} | 6\u21921 | 6\u21921 |`);
74
- } catch {
75
- graphOut.push(`| 18 | Graph: Grep + context | \u2014 | \u2014 | \u2014 | | |`);
76
- }
77
- }
78
-
79
- return graphOut;
80
- }