@levnikolaevich/hex-line-mcp 1.3.1 → 1.3.3
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/README.md +120 -47
- package/benchmark/atomic.mjs +502 -0
- package/benchmark/graph.mjs +80 -0
- package/benchmark/index.mjs +144 -0
- package/benchmark/workflows.mjs +350 -0
- package/hook.mjs +48 -15
- package/lib/benchmark-helpers.mjs +1 -1
- package/lib/changes.mjs +2 -1
- package/lib/coerce.mjs +1 -42
- package/lib/edit.mjs +258 -248
- package/lib/graph-enrich.mjs +76 -58
- package/lib/hash.mjs +1 -109
- package/lib/info.mjs +1 -1
- package/lib/normalize.mjs +1 -106
- package/lib/outline.mjs +32 -87
- package/lib/read.mjs +8 -5
- package/lib/revisions.mjs +238 -0
- package/lib/search.mjs +6 -7
- package/lib/security.mjs +4 -4
- package/lib/setup.mjs +7 -20
- package/lib/update-check.mjs +1 -56
- package/lib/verify.mjs +32 -16
- package/output-style.md +21 -6
- package/package.json +18 -6
- package/server.mjs +35 -43
- package/benchmark.mjs +0 -1106
package/lib/graph-enrich.mjs
CHANGED
|
@@ -1,30 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Graph enrichment for hex-line tools.
|
|
3
3
|
*
|
|
4
|
-
* Reads .codegraph/index.db (created by hex-graph-mcp) in readonly mode
|
|
5
|
-
*
|
|
4
|
+
* Reads .codegraph/index.db (created by hex-graph-mcp) in readonly mode via a
|
|
5
|
+
* small explicit compatibility contract:
|
|
6
|
+
* - hex_line_contract
|
|
7
|
+
* - hex_line_symbol_annotations
|
|
8
|
+
* - hex_line_call_edges
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
10
|
+
* Graceful fallback: if better-sqlite3, contract views, or DB are missing,
|
|
11
|
+
* enrichment is disabled silently for that project.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import { existsSync } from "node:fs";
|
|
12
15
|
import { join, dirname, relative } from "node:path";
|
|
13
16
|
import { createRequire } from "node:module";
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
let
|
|
18
|
+
const HEX_LINE_CONTRACT_VERSION = 1;
|
|
19
|
+
const _dbs = new Map();
|
|
20
|
+
let _driverUnavailable = false;
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Get readonly graph DB for a project root.
|
|
21
|
-
* Returns null if DB missing or
|
|
24
|
+
* Returns null if DB missing or contract unavailable.
|
|
22
25
|
* @param {string} filePath - any file path inside the project
|
|
23
26
|
* @returns {object|null} better-sqlite3 Database instance or null
|
|
24
27
|
*/
|
|
25
28
|
export function getGraphDB(filePath) {
|
|
26
|
-
if (
|
|
27
|
-
if (_db) return _db;
|
|
29
|
+
if (_driverUnavailable) return null;
|
|
28
30
|
|
|
29
31
|
try {
|
|
30
32
|
const projectRoot = findProjectRoot(filePath);
|
|
@@ -32,18 +34,46 @@ export function getGraphDB(filePath) {
|
|
|
32
34
|
|
|
33
35
|
const dbPath = join(projectRoot, ".codegraph", "index.db");
|
|
34
36
|
if (!existsSync(dbPath)) return null;
|
|
37
|
+
if (_dbs.has(dbPath)) return _dbs.get(dbPath);
|
|
35
38
|
|
|
36
39
|
const require = createRequire(import.meta.url);
|
|
37
40
|
const Database = require("better-sqlite3");
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
const db = new Database(dbPath, { readonly: true });
|
|
42
|
+
if (!validateHexLineContract(db)) {
|
|
43
|
+
db.close();
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
_dbs.set(dbPath, db);
|
|
47
|
+
return db;
|
|
41
48
|
} catch {
|
|
42
|
-
|
|
49
|
+
_driverUnavailable = true;
|
|
43
50
|
return null;
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Test helper: close cached DB handles so each test can start clean.
|
|
56
|
+
*/
|
|
57
|
+
export function _resetGraphDBCache() {
|
|
58
|
+
for (const db of _dbs.values()) {
|
|
59
|
+
try { db.close(); } catch { /* ignore */ }
|
|
60
|
+
}
|
|
61
|
+
_dbs.clear();
|
|
62
|
+
_driverUnavailable = false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateHexLineContract(db) {
|
|
66
|
+
try {
|
|
67
|
+
const contract = db.prepare("SELECT contract_version FROM hex_line_contract LIMIT 1").get();
|
|
68
|
+
if (!contract || contract.contract_version !== HEX_LINE_CONTRACT_VERSION) return false;
|
|
69
|
+
db.prepare("SELECT node_id, file, line_start, line_end, display_name, kind, callees, callers FROM hex_line_symbol_annotations LIMIT 1").all();
|
|
70
|
+
db.prepare("SELECT source_id, target_id, source_file, source_line, source_display_name, target_file, target_line, target_display_name FROM hex_line_call_edges LIMIT 1").all();
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
/**
|
|
48
78
|
* Get [N↓ M↑] annotation for a symbol.
|
|
49
79
|
* @param {object} db - better-sqlite3 instance
|
|
@@ -54,19 +84,12 @@ export function getGraphDB(filePath) {
|
|
|
54
84
|
export function symbolAnnotation(db, file, name) {
|
|
55
85
|
try {
|
|
56
86
|
const node = db.prepare(
|
|
57
|
-
"SELECT
|
|
87
|
+
"SELECT callees, callers FROM hex_line_symbol_annotations WHERE file = ? AND name = ? LIMIT 1"
|
|
58
88
|
).get(file, name);
|
|
59
89
|
if (!node) return null;
|
|
60
90
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
).get(node.id).c;
|
|
64
|
-
const callers = db.prepare(
|
|
65
|
-
"SELECT COUNT(*) as c FROM edges WHERE target_id = ? AND kind = 'calls'"
|
|
66
|
-
).get(node.id).c;
|
|
67
|
-
|
|
68
|
-
if (callees === 0 && callers === 0) return null;
|
|
69
|
-
return `[${callees}\u2193 ${callers}\u2191]`;
|
|
91
|
+
if (node.callees === 0 && node.callers === 0) return null;
|
|
92
|
+
return `[${node.callees}\u2193 ${node.callers}\u2191]`;
|
|
70
93
|
} catch {
|
|
71
94
|
return null;
|
|
72
95
|
}
|
|
@@ -81,42 +104,39 @@ export function symbolAnnotation(db, file, name) {
|
|
|
81
104
|
export function fileAnnotations(db, file) {
|
|
82
105
|
try {
|
|
83
106
|
const nodes = db.prepare(
|
|
84
|
-
|
|
107
|
+
`SELECT display_name, kind, callees, callers
|
|
108
|
+
FROM hex_line_symbol_annotations
|
|
109
|
+
WHERE file = ?
|
|
110
|
+
ORDER BY line_start`
|
|
85
111
|
).all(file);
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"SELECT COUNT(*) as c FROM edges WHERE target_id = ? AND kind = 'calls'"
|
|
94
|
-
).get(node.id).c;
|
|
95
|
-
result.push({
|
|
96
|
-
name: node.name,
|
|
97
|
-
kind: node.kind,
|
|
98
|
-
callees,
|
|
99
|
-
callers,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
return result;
|
|
113
|
+
return nodes.map((node) => ({
|
|
114
|
+
name: node.display_name,
|
|
115
|
+
kind: node.kind,
|
|
116
|
+
callees: node.callees,
|
|
117
|
+
callers: node.callers,
|
|
118
|
+
}));
|
|
103
119
|
} catch {
|
|
104
120
|
return [];
|
|
105
121
|
}
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
/**
|
|
109
|
-
*
|
|
125
|
+
* Call impact: callers affected by changes in given line range.
|
|
110
126
|
* @param {object} db
|
|
111
127
|
* @param {string} file - relative file path
|
|
112
128
|
* @param {number} startLine
|
|
113
129
|
* @param {number} endLine
|
|
114
130
|
* @returns {Array<{name, file, line}>} affected symbols (max 10)
|
|
115
131
|
*/
|
|
116
|
-
export function
|
|
132
|
+
export function callImpact(db, file, startLine, endLine) {
|
|
117
133
|
try {
|
|
118
134
|
const modified = db.prepare(
|
|
119
|
-
|
|
135
|
+
`SELECT node_id
|
|
136
|
+
FROM hex_line_symbol_annotations
|
|
137
|
+
WHERE file = ?
|
|
138
|
+
AND line_start <= ?
|
|
139
|
+
AND line_end >= ?`
|
|
120
140
|
).all(file, endLine, startLine);
|
|
121
141
|
|
|
122
142
|
if (modified.length === 0) return [];
|
|
@@ -126,14 +146,16 @@ export function blastRadius(db, file, startLine, endLine) {
|
|
|
126
146
|
|
|
127
147
|
for (const node of modified) {
|
|
128
148
|
const dependents = db.prepare(
|
|
129
|
-
|
|
130
|
-
|
|
149
|
+
`SELECT source_display_name AS name, source_file AS file, source_line AS line
|
|
150
|
+
FROM hex_line_call_edges
|
|
151
|
+
WHERE target_id = ?`
|
|
152
|
+
).all(node.node_id);
|
|
131
153
|
|
|
132
154
|
for (const dep of dependents) {
|
|
133
155
|
const key = `${dep.file}:${dep.name}`;
|
|
134
156
|
if (!seen.has(key) && dep.file !== file) {
|
|
135
157
|
seen.add(key);
|
|
136
|
-
affected.push({ name: dep.name, file: dep.file, line: dep.
|
|
158
|
+
affected.push({ name: dep.name, file: dep.file, line: dep.line });
|
|
137
159
|
}
|
|
138
160
|
}
|
|
139
161
|
}
|
|
@@ -154,21 +176,17 @@ export function blastRadius(db, file, startLine, endLine) {
|
|
|
154
176
|
export function matchAnnotation(db, file, line) {
|
|
155
177
|
try {
|
|
156
178
|
const node = db.prepare(
|
|
157
|
-
|
|
179
|
+
`SELECT display_name, kind, callees, callers
|
|
180
|
+
FROM hex_line_symbol_annotations
|
|
181
|
+
WHERE file = ? AND line_start <= ? AND line_end >= ?
|
|
182
|
+
ORDER BY line_start DESC
|
|
183
|
+
LIMIT 1`
|
|
158
184
|
).get(file, line, line);
|
|
159
185
|
if (!node) return null;
|
|
160
186
|
|
|
161
187
|
const kindShort = { function: "fn", class: "cls", method: "mtd", variable: "var" }[node.kind] || node.kind;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"SELECT COUNT(*) as c FROM edges WHERE source_id = ? AND kind = 'calls'"
|
|
165
|
-
).get(node.id).c;
|
|
166
|
-
const callers = db.prepare(
|
|
167
|
-
"SELECT COUNT(*) as c FROM edges WHERE target_id = ? AND kind = 'calls'"
|
|
168
|
-
).get(node.id).c;
|
|
169
|
-
|
|
170
|
-
if (callees === 0 && callers === 0) return `[${kindShort}]`;
|
|
171
|
-
return `[${kindShort} ${callees}\u2193 ${callers}\u2191]`;
|
|
188
|
+
if (node.callees === 0 && node.callers === 0) return `[${kindShort}]`;
|
|
189
|
+
return `[${kindShort} ${node.callees}\u2193 ${node.callers}\u2191]`;
|
|
172
190
|
} catch {
|
|
173
191
|
return null;
|
|
174
192
|
}
|
package/lib/hash.mjs
CHANGED
|
@@ -1,109 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* FNV-1a hashing for hash-verified file editing.
|
|
3
|
-
*
|
|
4
|
-
* 2-char tags from 32-symbol alphabet,
|
|
5
|
-
* range checksums as FNV-1a accumulator over line hashes.
|
|
6
|
-
*
|
|
7
|
-
* Line format: {tag}.{lineNum}\t{content}
|
|
8
|
-
* Range checksum: checksum: {startLine}-{endLine}:{8hex}
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const FNV_OFFSET = 0x811c9dc5;
|
|
12
|
-
const FNV_PRIME = 0x01000193;
|
|
13
|
-
|
|
14
|
-
// 32 symbols — bitwise & 0x1f (power of 2, no division)
|
|
15
|
-
const TAG_CHARS = "abcdefghijklmnopqrstuvwxyz234567";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* FNV-1a 32-bit hash of a string (UTF-8 encoded).
|
|
19
|
-
* Whitespace is normalized (collapsed) before hashing.
|
|
20
|
-
*/
|
|
21
|
-
export function fnv1a(str) {
|
|
22
|
-
// Normalize: strip trailing \r, collapse whitespace
|
|
23
|
-
const normalized = str.replace(/\r$/, "").replace(/\s+/g, "");
|
|
24
|
-
let h = FNV_OFFSET;
|
|
25
|
-
for (let i = 0; i < normalized.length; i++) {
|
|
26
|
-
let code = normalized.charCodeAt(i);
|
|
27
|
-
// Handle surrogate pairs for codepoints > U+FFFF
|
|
28
|
-
if (code >= 0xd800 && code <= 0xdbff && i + 1 < normalized.length) {
|
|
29
|
-
const lo = normalized.charCodeAt(i + 1);
|
|
30
|
-
if (lo >= 0xdc00 && lo <= 0xdfff) {
|
|
31
|
-
code = ((code - 0xd800) << 10) + (lo - 0xdc00) + 0x10000;
|
|
32
|
-
i++;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
// Encode as UTF-8 bytes and feed to FNV-1a
|
|
36
|
-
if (code < 0x80) {
|
|
37
|
-
h = Math.imul(h ^ code, FNV_PRIME) >>> 0;
|
|
38
|
-
} else if (code < 0x800) {
|
|
39
|
-
h = Math.imul(h ^ (0xc0 | (code >> 6)), FNV_PRIME) >>> 0;
|
|
40
|
-
h = Math.imul(h ^ (0x80 | (code & 0x3f)), FNV_PRIME) >>> 0;
|
|
41
|
-
} else if (code < 0x10000) {
|
|
42
|
-
h = Math.imul(h ^ (0xe0 | (code >> 12)), FNV_PRIME) >>> 0;
|
|
43
|
-
h = Math.imul(h ^ (0x80 | ((code >> 6) & 0x3f)), FNV_PRIME) >>> 0;
|
|
44
|
-
h = Math.imul(h ^ (0x80 | (code & 0x3f)), FNV_PRIME) >>> 0;
|
|
45
|
-
} else {
|
|
46
|
-
h = Math.imul(h ^ (0xf0 | (code >> 18)), FNV_PRIME) >>> 0;
|
|
47
|
-
h = Math.imul(h ^ (0x80 | ((code >> 12) & 0x3f)), FNV_PRIME) >>> 0;
|
|
48
|
-
h = Math.imul(h ^ (0x80 | ((code >> 6) & 0x3f)), FNV_PRIME) >>> 0;
|
|
49
|
-
h = Math.imul(h ^ (0x80 | (code & 0x3f)), FNV_PRIME) >>> 0;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return h;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* 2-character tag from 32-bit hash.
|
|
57
|
-
* Uses bits 0-4 and 8-12 for two characters from TAG_CHARS.
|
|
58
|
-
*/
|
|
59
|
-
export function lineTag(hash32) {
|
|
60
|
-
return TAG_CHARS[hash32 & 0x1f] + TAG_CHARS[(hash32 >>> 8) & 0x1f];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Compute tag for a line's content.
|
|
65
|
-
*/
|
|
66
|
-
export function hashLine(content) {
|
|
67
|
-
return lineTag(fnv1a(content));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Format a line with hash prefix: {tag}.{lineNum}\t{content}
|
|
72
|
-
*/
|
|
73
|
-
export function formatLine(lineNum, content) {
|
|
74
|
-
return `${hashLine(content)}.${lineNum}\t${content}`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Range checksum: FNV-1a accumulator over line hashes (little-endian bytes).
|
|
79
|
-
* Returns "{startLine}-{endLine}:{8hex}".
|
|
80
|
-
*/
|
|
81
|
-
export function rangeChecksum(lineHashes, startLine, endLine) {
|
|
82
|
-
let acc = FNV_OFFSET;
|
|
83
|
-
for (const h of lineHashes) {
|
|
84
|
-
// Feed 4 bytes of each 32-bit hash in little-endian order
|
|
85
|
-
acc = Math.imul(acc ^ (h & 0xff), FNV_PRIME) >>> 0;
|
|
86
|
-
acc = Math.imul(acc ^ ((h >>> 8) & 0xff), FNV_PRIME) >>> 0;
|
|
87
|
-
acc = Math.imul(acc ^ ((h >>> 16) & 0xff), FNV_PRIME) >>> 0;
|
|
88
|
-
acc = Math.imul(acc ^ ((h >>> 24) & 0xff), FNV_PRIME) >>> 0;
|
|
89
|
-
}
|
|
90
|
-
return `${startLine}-${endLine}:${acc.toString(16).padStart(8, "0")}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Parse a line reference: "ab.12" → { tag: "ab", line: 12 }
|
|
95
|
-
*/
|
|
96
|
-
export function parseRef(ref) {
|
|
97
|
-
const m = ref.trim().match(/^([a-z2-7]{2})\.(\d+)$/);
|
|
98
|
-
if (!m) throw new Error(`Bad ref: "${ref}". Expected "ab.12" (tag.lineNum)`);
|
|
99
|
-
return { tag: m[1], line: parseInt(m[2], 10) };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Parse a range checksum: "1-50:f7e2a1b0" → { start: 1, end: 50, hex: "f7e2a1b0" }
|
|
104
|
-
*/
|
|
105
|
-
export function parseChecksum(cs) {
|
|
106
|
-
const m = cs.trim().match(/^(\d+)-(\d+):([0-9a-f]{8})$/);
|
|
107
|
-
if (!m) throw new Error(`Bad checksum: "${cs}". Expected "1-50:f7e2a1b0"`);
|
|
108
|
-
return { start: parseInt(m[1], 10), end: parseInt(m[2], 10), hex: m[3] };
|
|
109
|
-
}
|
|
1
|
+
export * from "@levnikolaevich/hex-common/text-protocol/hash";
|
package/lib/info.mjs
CHANGED
|
@@ -82,7 +82,7 @@ export function fileInfo(filePath) {
|
|
|
82
82
|
const timeStr = `Modified: ${mtime.toISOString().replace("T", " ").slice(0, 19)} (${relativeTime(mtime)})`;
|
|
83
83
|
|
|
84
84
|
return [
|
|
85
|
-
`File: ${
|
|
85
|
+
`File: ${normalized}`,
|
|
86
86
|
sizeStr,
|
|
87
87
|
timeStr,
|
|
88
88
|
`Type: ${typeName}`,
|
package/lib/normalize.mjs
CHANGED
|
@@ -1,106 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Output normalization and smart truncation.
|
|
3
|
-
* Based on shared/references/output_normalization.md patterns.
|
|
4
|
-
*
|
|
5
|
-
* - Normalizes runtime values (IPs, timestamps, UUIDs, large numbers)
|
|
6
|
-
* - Deduplicates identical normalized lines with (xN) counts
|
|
7
|
-
* - Smart truncation: first N + last N lines with gap indicator
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
// Normalization patterns (applied in order)
|
|
11
|
-
const NORM_RULES = [
|
|
12
|
-
[/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<UUID>"],
|
|
13
|
-
[/\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}/g, "<TS>"],
|
|
14
|
-
[/\d{2}-\d{2}-\d{4}\s\d{2}:\d{2}:\d{2}/g, "<TS>"],
|
|
15
|
-
[/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?/g, "<IP>"],
|
|
16
|
-
[/\/[0-9a-f]{8,}/gi, "/<ID>"],
|
|
17
|
-
[/\b\d{3,}(?=\b|[a-zA-Z])/g, "<N>"],
|
|
18
|
-
[/trace_id=[0-9a-fA-F]{1,8}/g, "trace_id=<TRACE>"],
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Normalize a single line by replacing runtime-specific values.
|
|
23
|
-
*/
|
|
24
|
-
function normalizeLine(line) {
|
|
25
|
-
let result = line;
|
|
26
|
-
for (const [rx, repl] of NORM_RULES) {
|
|
27
|
-
result = result.replace(rx, repl);
|
|
28
|
-
}
|
|
29
|
-
return result;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Deduplicate lines: group identical normalized lines, append (xN).
|
|
34
|
-
*
|
|
35
|
-
* @param {string[]} lines - raw output lines
|
|
36
|
-
* @returns {string[]} deduplicated lines
|
|
37
|
-
*/
|
|
38
|
-
export function deduplicateLines(lines) {
|
|
39
|
-
const groups = new Map();
|
|
40
|
-
const order = [];
|
|
41
|
-
|
|
42
|
-
for (const line of lines) {
|
|
43
|
-
const norm = normalizeLine(line);
|
|
44
|
-
if (groups.has(norm)) {
|
|
45
|
-
groups.get(norm).count++;
|
|
46
|
-
} else {
|
|
47
|
-
const entry = { representative: line, count: 1 };
|
|
48
|
-
groups.set(norm, entry);
|
|
49
|
-
order.push(norm);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Sort by count descending (stable within same count)
|
|
54
|
-
order.sort((a, b) => groups.get(b).count - groups.get(a).count);
|
|
55
|
-
|
|
56
|
-
return order.map((norm) => {
|
|
57
|
-
const { representative, count } = groups.get(norm);
|
|
58
|
-
return count > 1 ? `${representative} (x${count})` : representative;
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Smart truncation: keep first N + last N lines with gap indicator.
|
|
64
|
-
*
|
|
65
|
-
* @param {string} text - full output text
|
|
66
|
-
* @param {number} [headLines=40] - lines to keep from start
|
|
67
|
-
* @param {number} [tailLines=20] - lines to keep from end
|
|
68
|
-
* @returns {string} truncated text
|
|
69
|
-
*/
|
|
70
|
-
export function smartTruncate(text, headLines = 40, tailLines = 20) {
|
|
71
|
-
const lines = text.split("\n");
|
|
72
|
-
const total = lines.length;
|
|
73
|
-
const maxLines = headLines + tailLines;
|
|
74
|
-
|
|
75
|
-
if (total <= maxLines) return text;
|
|
76
|
-
|
|
77
|
-
const head = lines.slice(0, headLines);
|
|
78
|
-
const tail = lines.slice(total - tailLines);
|
|
79
|
-
const skipped = total - maxLines;
|
|
80
|
-
|
|
81
|
-
return [
|
|
82
|
-
...head,
|
|
83
|
-
`\n--- ${skipped} lines omitted ---\n`,
|
|
84
|
-
...tail,
|
|
85
|
-
].join("\n");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Full normalization pipeline: normalize -> deduplicate -> truncate.
|
|
90
|
-
*
|
|
91
|
-
* @param {string} text - raw output
|
|
92
|
-
* @param {object} [opts]
|
|
93
|
-
* @param {boolean} [opts.deduplicate=true]
|
|
94
|
-
* @param {number} [opts.headLines=40]
|
|
95
|
-
* @param {number} [opts.tailLines=20]
|
|
96
|
-
* @returns {string}
|
|
97
|
-
*/
|
|
98
|
-
export function normalizeOutput(text, opts = {}) {
|
|
99
|
-
const { deduplicate = true, headLines = 40, tailLines = 20 } = opts;
|
|
100
|
-
const lines = text.split("\n");
|
|
101
|
-
|
|
102
|
-
const processed = deduplicate ? deduplicateLines(lines) : lines;
|
|
103
|
-
const result = processed.join("\n");
|
|
104
|
-
|
|
105
|
-
return smartTruncate(result, headLines, tailLines);
|
|
106
|
-
}
|
|
1
|
+
export * from "@levnikolaevich/hex-common/output/normalize";
|
package/lib/outline.mjs
CHANGED
|
@@ -2,74 +2,39 @@
|
|
|
2
2
|
* AST-based file outline via tree-sitter WASM.
|
|
3
3
|
*
|
|
4
4
|
* Returns structural overview: functions, classes, interfaces with line ranges.
|
|
5
|
-
* 10-20 lines instead of 500
|
|
5
|
+
* 10-20 lines instead of 500 -> 95% token reduction.
|
|
6
6
|
* Output maps directly to read_file ranges.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { extname } from "node:path";
|
|
10
|
+
import { getParser, getLanguage } from "@levnikolaevich/hex-common/parser/tree-sitter";
|
|
11
|
+
import { grammarForExtension, supportedExtensions } from "@levnikolaevich/hex-common/parser/languages";
|
|
12
|
+
import { readUtf8Normalized } from "@levnikolaevich/hex-common/text/file-text";
|
|
13
|
+
import { validatePath, normalizePath } from "./security.mjs";
|
|
12
14
|
import { getGraphDB, symbolAnnotation, getRelativePath } from "./graph-enrich.mjs";
|
|
13
15
|
|
|
14
|
-
// Language configs: extension → { grammar, outline, skip, recurse }
|
|
15
16
|
const LANG_CONFIGS = {
|
|
16
|
-
".js": {
|
|
17
|
-
".mjs": {
|
|
18
|
-
".jsx": {
|
|
19
|
-
".ts": {
|
|
20
|
-
".tsx": {
|
|
21
|
-
".py": {
|
|
22
|
-
".go": {
|
|
23
|
-
".rs": {
|
|
24
|
-
".java": {
|
|
25
|
-
".c": {
|
|
26
|
-
".h": {
|
|
27
|
-
".cpp": {
|
|
28
|
-
".cs": {
|
|
29
|
-
".rb": {
|
|
30
|
-
".php": {
|
|
31
|
-
".kt": {
|
|
32
|
-
".swift": {
|
|
33
|
-
".sh": {
|
|
34
|
-
".bash": {
|
|
17
|
+
".js": { outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
18
|
+
".mjs": { outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
19
|
+
".jsx": { outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
20
|
+
".ts": { outline: ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
21
|
+
".tsx": { outline: ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
22
|
+
".py": { outline: ["function_definition", "class_definition", "decorated_definition"], skip: ["import_statement", "import_from_statement"], recurse: ["class_body", "block"] },
|
|
23
|
+
".go": { outline: ["function_declaration", "method_declaration", "type_declaration"], skip: ["import_declaration"], recurse: [] },
|
|
24
|
+
".rs": { outline: ["function_item", "struct_item", "enum_item", "impl_item", "trait_item", "const_item", "static_item"], skip: ["use_declaration"], recurse: ["impl_item"] },
|
|
25
|
+
".java": { outline: ["class_declaration", "interface_declaration", "method_declaration", "enum_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
|
|
26
|
+
".c": { outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
|
|
27
|
+
".h": { outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
|
|
28
|
+
".cpp": { outline: ["function_definition", "class_specifier", "struct_specifier", "namespace_definition"], skip: ["preproc_include"], recurse: ["class_specifier"] },
|
|
29
|
+
".cs": { outline: ["class_declaration", "interface_declaration", "method_declaration", "namespace_declaration"], skip: ["using_directive"], recurse: ["class_body"] },
|
|
30
|
+
".rb": { outline: ["method", "class", "module"], skip: ["require", "require_relative"], recurse: ["class", "module"] },
|
|
31
|
+
".php": { outline: ["function_definition", "class_declaration", "method_declaration"], skip: ["namespace_use_declaration"], recurse: ["class_body"] },
|
|
32
|
+
".kt": { outline: ["function_declaration", "class_declaration", "object_declaration"], skip: ["import_header"], recurse: ["class_body"] },
|
|
33
|
+
".swift": { outline: ["function_declaration", "class_declaration", "struct_declaration", "protocol_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
|
|
34
|
+
".sh": { outline: ["function_definition"], skip: [], recurse: [] },
|
|
35
|
+
".bash": { outline: ["function_definition"], skip: [], recurse: [] },
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
// Parser cache (init once)
|
|
38
|
-
let _parser = null;
|
|
39
|
-
const _langCache = new Map();
|
|
40
|
-
|
|
41
|
-
async function getParser() {
|
|
42
|
-
if (_parser) return _parser;
|
|
43
|
-
try {
|
|
44
|
-
const { Parser } = await import("web-tree-sitter");
|
|
45
|
-
await Parser.init();
|
|
46
|
-
_parser = new Parser();
|
|
47
|
-
return _parser;
|
|
48
|
-
} catch (e) {
|
|
49
|
-
throw new Error(`tree-sitter init failed: ${e.message}. Run: cd mcp/hex-line-mcp && npm install`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function getLanguage(grammar) {
|
|
54
|
-
if (_langCache.has(grammar)) return _langCache.get(grammar);
|
|
55
|
-
await getParser(); // ensure init
|
|
56
|
-
try {
|
|
57
|
-
const { Language } = await import("web-tree-sitter");
|
|
58
|
-
const { createRequire } = await import("node:module");
|
|
59
|
-
const require = createRequire(import.meta.url);
|
|
60
|
-
// Absolute path for Windows compatibility (Gemini finding #3)
|
|
61
|
-
const wasmPath = resolve(require.resolve("tree-sitter-wasms/package.json"), "..", "out", `tree-sitter-${grammar}.wasm`);
|
|
62
|
-
const lang = await Language.load(wasmPath);
|
|
63
|
-
_langCache.set(grammar, lang);
|
|
64
|
-
return lang;
|
|
65
|
-
} catch (e) {
|
|
66
|
-
throw new Error(`Language "${grammar}" not available: ${e.message}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Extract structural outline entries from AST.
|
|
72
|
-
*/
|
|
73
38
|
function extractOutline(rootNode, config, sourceLines) {
|
|
74
39
|
const entries = [];
|
|
75
40
|
const skipTypes = new Set(config.skip);
|
|
@@ -87,7 +52,6 @@ function extractOutline(rootNode, config, sourceLines) {
|
|
|
87
52
|
|
|
88
53
|
if (outlineTypes.has(type)) {
|
|
89
54
|
const firstLine = sourceLines[startLine - 1] || "";
|
|
90
|
-
// Extract symbol name for graph annotation
|
|
91
55
|
const nameMatch = firstLine.match(/(?:function|class|interface|type|enum|struct|def|fn|pub\s+fn)\s+(\w+)|(?:const|let|var|export\s+(?:const|let|var|function|class))\s+(\w+)/);
|
|
92
56
|
const name = nameMatch ? (nameMatch[1] || nameMatch[2]) : null;
|
|
93
57
|
|
|
@@ -99,18 +63,14 @@ function extractOutline(rootNode, config, sourceLines) {
|
|
|
99
63
|
name,
|
|
100
64
|
});
|
|
101
65
|
|
|
102
|
-
// Recurse into class/struct bodies
|
|
103
66
|
for (let j = 0; j < child.childCount; j++) {
|
|
104
67
|
const sub = child.child(j);
|
|
105
|
-
if (recurseTypes.has(sub.type))
|
|
106
|
-
walk(sub, depth + 1);
|
|
107
|
-
}
|
|
68
|
+
if (recurseTypes.has(sub.type)) walk(sub, depth + 1);
|
|
108
69
|
}
|
|
109
70
|
}
|
|
110
71
|
}
|
|
111
72
|
}
|
|
112
73
|
|
|
113
|
-
// Collect skipped ranges for summary
|
|
114
74
|
const skippedRanges = [];
|
|
115
75
|
for (let i = 0; i < rootNode.childCount; i++) {
|
|
116
76
|
const child = rootNode.child(i);
|
|
@@ -126,23 +86,16 @@ function extractOutline(rootNode, config, sourceLines) {
|
|
|
126
86
|
return { entries, skippedRanges };
|
|
127
87
|
}
|
|
128
88
|
|
|
129
|
-
/**
|
|
130
|
-
* Parse content string into outline entries.
|
|
131
|
-
* Reusable core — no file I/O, no validatePath.
|
|
132
|
-
*
|
|
133
|
-
* @param {string} content Source text (LF-normalized)
|
|
134
|
-
* @param {string} ext Lowercase extension including dot (e.g. ".mjs")
|
|
135
|
-
* @returns {Promise<{entries: Array, skippedRanges: Array} | null>} null if unsupported ext
|
|
136
|
-
*/
|
|
137
89
|
export async function outlineFromContent(content, ext) {
|
|
138
90
|
const config = LANG_CONFIGS[ext];
|
|
139
|
-
|
|
91
|
+
const grammar = grammarForExtension(ext);
|
|
92
|
+
if (!config || !grammar) return null;
|
|
140
93
|
|
|
141
94
|
const sourceLines = content.split("\n");
|
|
142
95
|
|
|
143
96
|
let lang;
|
|
144
97
|
try {
|
|
145
|
-
lang = await getLanguage(
|
|
98
|
+
lang = await getLanguage(grammar);
|
|
146
99
|
} catch (e) {
|
|
147
100
|
throw new Error(`Outline error: ${e.message}`);
|
|
148
101
|
}
|
|
@@ -153,9 +106,6 @@ export async function outlineFromContent(content, ext) {
|
|
|
153
106
|
return extractOutline(tree.rootNode, config, sourceLines);
|
|
154
107
|
}
|
|
155
108
|
|
|
156
|
-
/**
|
|
157
|
-
* Format outline entries into display string.
|
|
158
|
-
*/
|
|
159
109
|
function formatOutline(entries, skippedRanges, sourceLineCount, db, relFile) {
|
|
160
110
|
const lines = [];
|
|
161
111
|
|
|
@@ -178,21 +128,16 @@ function formatOutline(entries, skippedRanges, sourceLineCount, db, relFile) {
|
|
|
178
128
|
return lines.join("\n");
|
|
179
129
|
}
|
|
180
130
|
|
|
181
|
-
/**
|
|
182
|
-
* Generate file outline.
|
|
183
|
-
*
|
|
184
|
-
* @param {string} filePath
|
|
185
|
-
* @returns {Promise<string>} formatted outline
|
|
186
|
-
*/
|
|
187
131
|
export async function fileOutline(filePath) {
|
|
132
|
+
filePath = normalizePath(filePath);
|
|
188
133
|
const real = validatePath(filePath);
|
|
189
134
|
const ext = extname(real).toLowerCase();
|
|
190
135
|
|
|
191
136
|
if (!LANG_CONFIGS[ext]) {
|
|
192
|
-
return `Outline unavailable for ${ext} files. Use read_file directly for non-code files (markdown, config, text). Supported code extensions: ${
|
|
137
|
+
return `Outline unavailable for ${ext} files. Use read_file directly for non-code files (markdown, config, text). Supported code extensions: ${supportedExtensions().join(", ")}`;
|
|
193
138
|
}
|
|
194
139
|
|
|
195
|
-
const content =
|
|
140
|
+
const content = readUtf8Normalized(real);
|
|
196
141
|
const result = await outlineFromContent(content, ext);
|
|
197
142
|
const db = getGraphDB(real);
|
|
198
143
|
const relFile = db ? getRelativePath(real) : null;
|