@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.
@@ -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
- * Provides symbol annotations for outline, read_file, grep_search, edit_file.
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
- * Lazy singleton: DB opened once per session, reused across calls.
8
- * Graceful fallback: if better-sqlite3 or DB missing → returns null silently.
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
- let _db = null;
16
-
17
- let _unavailable = false;
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 better-sqlite3 not installed.
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 (_unavailable) return null;
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
- _db = new Database(dbPath, { readonly: true });
39
-
40
- return _db;
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
- _unavailable = true;
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 id FROM nodes WHERE file = ? AND name = ? AND kind != 'import' LIMIT 1"
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
- const callees = db.prepare(
62
- "SELECT COUNT(*) as c FROM edges WHERE source_id = ? AND kind = 'calls'"
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
- "SELECT id, name, kind FROM nodes WHERE file = ? AND kind != 'import' ORDER BY line_start"
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
- const result = [];
88
- for (const node of nodes) {
89
- const callees = db.prepare(
90
- "SELECT COUNT(*) as c FROM edges WHERE source_id = ? AND kind = 'calls'"
91
- ).get(node.id).c;
92
- const callers = db.prepare(
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
- * Blast radius: symbols affected by changes in given line range.
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 blastRadius(db, file, startLine, endLine) {
132
+ export function callImpact(db, file, startLine, endLine) {
117
133
  try {
118
134
  const modified = db.prepare(
119
- "SELECT id, name FROM nodes WHERE file = ? AND kind != 'import' AND line_start <= ? AND line_end >= ?"
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
- "SELECT n.name, n.file, n.line_start FROM edges e JOIN nodes n ON n.id = e.source_id WHERE e.target_id = ? AND e.kind = 'calls'"
130
- ).all(node.id);
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.line_start });
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
- "SELECT id, name, kind FROM nodes WHERE file = ? AND kind != 'import' AND line_start <= ? AND line_end >= ? LIMIT 1"
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
- const callees = db.prepare(
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: ${filePath}`,
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 95% token reduction.
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 { readFileSync } from "node:fs";
10
- import { resolve, extname } from "node:path";
11
- import { validatePath } from "./security.mjs";
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": { grammar: "javascript", outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
17
- ".mjs": { grammar: "javascript", outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
18
- ".jsx": { grammar: "javascript", outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
19
- ".ts": { grammar: "typescript", outline: ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
20
- ".tsx": { grammar: "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"] },
21
- ".py": { grammar: "python", outline: ["function_definition", "class_definition", "decorated_definition"], skip: ["import_statement", "import_from_statement"], recurse: ["class_body", "block"] },
22
- ".go": { grammar: "go", outline: ["function_declaration", "method_declaration", "type_declaration"], skip: ["import_declaration"], recurse: [] },
23
- ".rs": { grammar: "rust", outline: ["function_item", "struct_item", "enum_item", "impl_item", "trait_item", "const_item", "static_item"], skip: ["use_declaration"], recurse: ["impl_item"] },
24
- ".java": { grammar: "java", outline: ["class_declaration", "interface_declaration", "method_declaration", "enum_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
25
- ".c": { grammar: "c", outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
26
- ".h": { grammar: "c", outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
27
- ".cpp": { grammar: "cpp", outline: ["function_definition", "class_specifier", "struct_specifier", "namespace_definition"], skip: ["preproc_include"], recurse: ["class_specifier"] },
28
- ".cs": { grammar: "c_sharp", outline: ["class_declaration", "interface_declaration", "method_declaration", "namespace_declaration"], skip: ["using_directive"], recurse: ["class_body"] },
29
- ".rb": { grammar: "ruby", outline: ["method", "class", "module"], skip: ["require", "require_relative"], recurse: ["class", "module"] },
30
- ".php": { grammar: "php", outline: ["function_definition", "class_declaration", "method_declaration"], skip: ["namespace_use_declaration"], recurse: ["class_body"] },
31
- ".kt": { grammar: "kotlin", outline: ["function_declaration", "class_declaration", "object_declaration"], skip: ["import_header"], recurse: ["class_body"] },
32
- ".swift": { grammar: "swift", outline: ["function_declaration", "class_declaration", "struct_declaration", "protocol_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
33
- ".sh": { grammar: "bash", outline: ["function_definition"], skip: [], recurse: [] },
34
- ".bash": { grammar: "bash", outline: ["function_definition"], skip: [], recurse: [] },
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
- if (!config) return null;
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(config.grammar);
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: ${Object.keys(LANG_CONFIGS).join(", ")}`;
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 = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
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;