sigmap 6.14.0 → 6.15.0

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,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "6.14.0",
3
+ "version": "6.15.0",
4
4
  "description": "Zero-dependency AI context engine — 97% token reduction. No npm install. Runs on Node 18+.",
5
5
  "main": "gen-context.js",
6
6
  "exports": {
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "test": "node test/run.js && node test/r-language.test.js",
18
- "test:integration": "node test/integration/strategy.test.js && node test/integration/secret-scan.test.js && node test/integration/token-budget.test.js && node test/integration/auto-budget.test.js && node test/integration/mcp-server.test.js",
18
+ "test:integration": "node test/integration/strategy.test.js && node test/integration/secret-scan.test.js && node test/integration/token-budget.test.js && node test/integration/auto-budget.test.js && node test/integration/mcp/server.test.js && node test/integration/verify-ai-output.test.js && node test/integration/memory-tools.test.js",
19
19
  "test:integration:all": "node test/integration/all.js",
20
20
  "test:all": "node test/run.js && node test/r-language.test.js && node test/integration/strategy.test.js && node test/integration/secret-scan.test.js",
21
21
  "generate": "node gen-context.js",
@@ -25,6 +25,7 @@
25
25
  "report": "node gen-context.js --report",
26
26
  "audit:strategies": "node scripts/run-strategy-audit.mjs",
27
27
  "benchmark:matrix": "node scripts/run-benchmark-matrix.mjs --save --skip-clone",
28
+ "benchmark:verify": "node scripts/run-verify-benchmark.mjs",
28
29
  "health": "node gen-context.js --health",
29
30
  "map": "node gen-project-map.js",
30
31
  "mcp": "node gen-context.js --mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "6.14.0",
3
+ "version": "6.15.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "6.14.0",
3
+ "version": "6.15.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Hallucination Guard report view (Surface A, v6.15.0).
5
+ *
6
+ * Turns the `verify-ai-output --json` result into a standalone, self-contained
7
+ * HTML report — red/amber/green per issue, with closest-match suggestions
8
+ * inline. The visual language deliberately mirrors the planned PR-comment
9
+ * styling so a single screenshot is reusable across docs and CI (plan proof #5).
10
+ *
11
+ * Zero dependencies, inline CSS/SVG, no external assets. Also exports a compact
12
+ * Markdown renderer for CI / PR comments that shares the same structure.
13
+ */
14
+
15
+ const TYPE_META = {
16
+ 'fake-file': { label: 'Fake file', tone: 'red', icon: '✕' },
17
+ 'fake-test-file': { label: 'Fake test file', tone: 'red', icon: '✕' },
18
+ 'fake-import': { label: 'Fake import', tone: 'red', icon: '✕' },
19
+ 'fake-npm-script': { label: 'Fake npm script', tone: 'red', icon: '✕' },
20
+ 'fake-symbol': { label: 'Fake symbol', tone: 'amber', icon: '!' },
21
+ };
22
+
23
+ function escapeHtml(value) {
24
+ return String(value == null ? '' : value)
25
+ .replace(/&/g, '&')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;');
29
+ }
30
+
31
+ function toneFor(issue) {
32
+ const meta = TYPE_META[issue.type];
33
+ if (meta) return meta.tone;
34
+ return issue.confidence === 'high' ? 'red' : 'amber';
35
+ }
36
+
37
+ function labelFor(issue) {
38
+ return (TYPE_META[issue.type] && TYPE_META[issue.type].label) || issue.type;
39
+ }
40
+
41
+ /**
42
+ * Render the verify result to a full HTML document.
43
+ * @param {{ file?: string, issues: object[], summary: object }} result
44
+ * @param {object} [opts]
45
+ * @param {string} [opts.title]
46
+ * @returns {string} HTML
47
+ */
48
+ function renderReportHtml(result, opts = {}) {
49
+ const issues = Array.isArray(result.issues) ? result.issues : [];
50
+ const summary = result.summary || { total: issues.length, byType: {}, clean: issues.length === 0 };
51
+ const file = result.file || opts.file || 'AI answer';
52
+ const title = opts.title || 'SigMap — Hallucination Guard report';
53
+ const clean = summary.clean || issues.length === 0;
54
+ const byType = summary.byType || {};
55
+
56
+ const chips = Object.keys(TYPE_META)
57
+ .filter((t) => byType[t])
58
+ .map((t) => `<span class="chip chip-${TYPE_META[t].tone}">${escapeHtml(TYPE_META[t].label)}: ${byType[t]}</span>`)
59
+ .join('');
60
+
61
+ const banner = clean
62
+ ? `<div class="banner banner-green"><span class="dot"></span> No hallucinations detected — ${escapeHtml(String(summary.symbolsIndexed || 0))} symbols indexed</div>`
63
+ : `<div class="banner banner-red"><span class="dot"></span> ${issues.length} issue${issues.length === 1 ? '' : 's'} found in <code>${escapeHtml(file)}</code></div>`;
64
+
65
+ const rows = issues.map((issue) => {
66
+ const tone = toneFor(issue);
67
+ const sugg = issue.suggestion
68
+ ? `<div class="suggestion">↳ ${escapeHtml(issue.suggestion)} <span class="conf">heuristic</span></div>`
69
+ : '';
70
+ return [
71
+ `<li class="issue issue-${tone}">`,
72
+ ` <div class="issue-head">`,
73
+ ` <span class="badge badge-${tone}">${escapeHtml(labelFor(issue))}</span>`,
74
+ ` <span class="loc">${escapeHtml(issue.location || ('L' + issue.line))}</span>`,
75
+ ` <span class="conf conf-${escapeHtml(issue.confidence || 'high')}">${escapeHtml(issue.confidence || 'high')} confidence</span>`,
76
+ ` </div>`,
77
+ ` <div class="msg">${escapeHtml(issue.message || issue.value)}</div>`,
78
+ ` ${sugg}`,
79
+ `</li>`,
80
+ ].join('\n');
81
+ }).join('\n');
82
+
83
+ const list = clean
84
+ ? '<p class="empty">Nothing to report — every file, import, symbol, and script in the answer resolves against the repository.</p>'
85
+ : `<ul class="issues">${rows}</ul>`;
86
+
87
+ return `<!DOCTYPE html>
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="utf-8">
91
+ <meta name="viewport" content="width=device-width, initial-scale=1">
92
+ <title>${escapeHtml(title)}</title>
93
+ <style>
94
+ :root { color-scheme: light dark; }
95
+ * { box-sizing: border-box; }
96
+ body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; background: #0d1117; color: #e6edf3; }
97
+ .wrap { max-width: 880px; margin: 0 auto; padding: 32px 20px 64px; }
98
+ h1 { font-size: 20px; margin: 0 0 4px; }
99
+ .sub { color: #8b949e; margin: 0 0 20px; font-size: 13px; }
100
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: #161b22; padding: 1px 5px; border-radius: 4px; font-size: 12.5px; }
101
+ .banner { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-radius: 8px; font-weight: 600; margin-bottom: 16px; }
102
+ .banner-green { background: rgba(46,160,67,.15); border: 1px solid rgba(46,160,67,.4); color: #3fb950; }
103
+ .banner-red { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.4); color: #f85149; }
104
+ .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; }
105
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; }
106
+ .chip { font-size: 12px; padding: 3px 9px; border-radius: 999px; font-weight: 600; }
107
+ .chip-red { background: rgba(248,81,73,.15); color: #f85149; }
108
+ .chip-amber { background: rgba(210,153,34,.18); color: #d29922; }
109
+ ul.issues { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
110
+ .issue { border: 1px solid #30363d; border-left-width: 4px; border-radius: 8px; padding: 12px 14px; background: #0f141a; }
111
+ .issue-red { border-left-color: #f85149; }
112
+ .issue-amber { border-left-color: #d29922; }
113
+ .issue-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
114
+ .badge { font-size: 11.5px; font-weight: 700; padding: 2px 8px; border-radius: 5px; text-transform: uppercase; letter-spacing: .03em; }
115
+ .badge-red { background: rgba(248,81,73,.18); color: #f85149; }
116
+ .badge-amber { background: rgba(210,153,34,.2); color: #d29922; }
117
+ .loc { font-family: ui-monospace, monospace; color: #8b949e; font-size: 12px; }
118
+ .conf { font-size: 11px; color: #8b949e; }
119
+ .conf-high { color: #f85149; }
120
+ .conf-medium { color: #d29922; }
121
+ .msg { margin-top: 7px; font-family: ui-monospace, monospace; font-size: 12.5px; color: #e6edf3; }
122
+ .suggestion { margin-top: 6px; font-size: 12.5px; color: #3fb950; }
123
+ .suggestion .conf { margin-left: 6px; }
124
+ .empty { color: #8b949e; }
125
+ footer { margin-top: 28px; color: #6e7681; font-size: 12px; }
126
+ a { color: #58a6ff; }
127
+ </style>
128
+ </head>
129
+ <body>
130
+ <div class="wrap">
131
+ <h1>Hallucination Guard report</h1>
132
+ <p class="sub">Deterministic verification of an AI answer against the real repository — <code>sigmap verify-ai-output</code></p>
133
+ ${banner}
134
+ ${chips ? `<div class="chips">${chips}</div>` : ''}
135
+ ${list}
136
+ <footer>Generated by <a href="https://github.com/manojmallick/sigmap">SigMap</a> · offline, no LLM · suggestions are heuristic (closest-match)</footer>
137
+ </div>
138
+ </body>
139
+ </html>
140
+ `;
141
+ }
142
+
143
+ /** Compact Markdown rendering of the same result (CI / PR comments). */
144
+ function renderReportMarkdown(result) {
145
+ const issues = Array.isArray(result.issues) ? result.issues : [];
146
+ const summary = result.summary || {};
147
+ const file = result.file || 'AI answer';
148
+ if (summary.clean || issues.length === 0) {
149
+ return `### ✅ Hallucination Guard — clean\n\nNo fabricated files, imports, symbols, or scripts in \`${file}\`.`;
150
+ }
151
+ const lines = [
152
+ `### ❌ Hallucination Guard — ${issues.length} issue${issues.length === 1 ? '' : 's'} in \`${file}\``,
153
+ '',
154
+ '| Type | Location | Detail | Suggestion |',
155
+ '| --- | --- | --- | --- |',
156
+ ];
157
+ for (const i of issues) {
158
+ const sugg = i.suggestion ? i.suggestion.replace(/\|/g, '\\|') : '—';
159
+ lines.push(`| ${labelFor(i)} | ${i.location || ('L' + i.line)} | \`${String(i.value).replace(/\|/g, '\\|')}\` | ${sugg} |`);
160
+ }
161
+ return lines.join('\n');
162
+ }
163
+
164
+ module.exports = { renderReportHtml, renderReportMarkdown, escapeHtml };
@@ -505,4 +505,47 @@ function getLines(args, cwd) {
505
505
  ].join('\n');
506
506
  }
507
507
 
508
- module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines };
508
+ /**
509
+ * read_memory({ limit? }) → string
510
+ *
511
+ * Recall the cross-session decision log (notes) plus the last ranking-session
512
+ * focus, formatted for an agent to consume at the start of a task.
513
+ */
514
+ function readMemory(args, cwd) {
515
+ let limit = parseInt(args && args.limit, 10);
516
+ if (!Number.isFinite(limit) || limit <= 0) limit = 10;
517
+ limit = Math.min(limit, 50);
518
+
519
+ const out = ['# SigMap memory'];
520
+
521
+ let notes = [];
522
+ try {
523
+ const { readNotes, formatNotes } = require('../session/notes');
524
+ notes = readNotes(cwd, limit);
525
+ out.push('');
526
+ out.push(`## Recent notes (${notes.length})`);
527
+ // Most recent first for quick scanning.
528
+ out.push(formatNotes(notes.slice().reverse()));
529
+ } catch (_) {
530
+ out.push('');
531
+ out.push('_No notes available._');
532
+ }
533
+
534
+ // Last ranking-session focus (if any) — extends src/session/memory.js.
535
+ try {
536
+ const { loadSession } = require('../session/memory');
537
+ const s = loadSession(cwd);
538
+ if (s && (s.lastQuery || (s.topFiles && s.topFiles.length))) {
539
+ out.push('');
540
+ out.push('## Last session');
541
+ if (s.lastQuery) out.push(`**Last query:** ${s.lastQuery}`);
542
+ if (s.topFiles && s.topFiles.length) {
543
+ out.push(`**Focus files:** ${s.topFiles.map((f) => f.file).slice(0, 5).join(', ')}`);
544
+ }
545
+ }
546
+ } catch (_) { /* session optional */ }
547
+
548
+ return out.join('\n');
549
+ }
550
+
551
+ module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines, readMemory };
package/src/mcp/server.js CHANGED
@@ -8,17 +8,17 @@
8
8
  *
9
9
  * Supported methods:
10
10
  * initialize → serverInfo + capabilities
11
- * tools/list → 10 tool definitions
11
+ * tools/list → 11 tool definitions
12
12
  * tools/call → dispatch to handler, return result
13
13
  */
14
14
 
15
15
  const readline = require('readline');
16
16
  const { TOOLS } = require('./tools');
17
- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines } = require('./handlers');
17
+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines, readMemory } = require('./handlers');
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '6.14.0',
21
+ version: '6.15.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -76,6 +76,7 @@ function dispatch(msg, cwd) {
76
76
  else if (name === 'query_context') text = queryContext(args, cwd);
77
77
  else if (name === 'get_impact') text = getImpact(args, cwd);
78
78
  else if (name === 'get_lines') text = getLines(args, cwd);
79
+ else if (name === 'read_memory') text = readMemory(args, cwd);
79
80
  else {
80
81
  respondError(id, -32601, `Unknown tool: ${name}`);
81
82
  return;
package/src/mcp/tools.js CHANGED
@@ -1,9 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * MCP tool definitions for SigMap (10 tools).
4
+ * MCP tool definitions for SigMap (11 tools).
5
5
  * read_context, search_signatures, get_map, create_checkpoint, get_routing,
6
- * explain_file, list_modules, query_context, get_impact, get_lines.
6
+ * explain_file, list_modules, query_context, get_impact, get_lines, read_memory.
7
7
  */
8
8
 
9
9
  const TOOLS = [
@@ -197,6 +197,24 @@ const TOOLS = [
197
197
  required: ['file', 'start', 'end'],
198
198
  },
199
199
  },
200
+ {
201
+ name: 'read_memory',
202
+ description:
203
+ 'Recall the project decision log — recent notes left by humans or agents ' +
204
+ 'across sessions (via `sigmap note`), plus the last ranking-session focus. ' +
205
+ 'Call this at the start of a task to kill cold-start: it answers ' +
206
+ '"what were we doing and why" without re-reading the whole codebase.',
207
+ inputSchema: {
208
+ type: 'object',
209
+ properties: {
210
+ limit: {
211
+ type: 'number',
212
+ description: 'How many of the most recent notes to return (default: 10, max: 50).',
213
+ },
214
+ },
215
+ required: [],
216
+ },
217
+ },
200
218
  ];
201
219
 
202
220
  module.exports = { TOOLS };
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Decision-log notes (Memory, v6.15.0).
5
+ *
6
+ * A tiny append-only log of human/agent notes that survives across sessions,
7
+ * so an agent starting cold can recall "what were we doing / why". Stored as
8
+ * NDJSON under `.context/` to match the project's other state logs
9
+ * (usage.ndjson, benchmark-history.ndjson). Zero dependencies.
10
+ *
11
+ * Consumed by the `read_memory` MCP tool and the `sigmap note` / `sigmap status`
12
+ * commands. Complements `src/session/memory.js` (ranking session) rather than
13
+ * duplicating it.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { execSync } = require('child_process');
19
+
20
+ const NOTES_FILE = path.join('.context', 'notes.ndjson');
21
+ const MAX_TEXT = 2000;
22
+
23
+ function notesPath(cwd) {
24
+ return path.join(cwd, NOTES_FILE);
25
+ }
26
+
27
+ function _currentBranch(cwd) {
28
+ try {
29
+ return execSync('git rev-parse --abbrev-ref HEAD', {
30
+ cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
31
+ }).trim() || null;
32
+ } catch (_) {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Append a note. Returns the stored entry.
39
+ * @param {string} cwd
40
+ * @param {string} text
41
+ * @param {object} [opts]
42
+ * @param {string|null} [opts.branch] override branch (default: current git branch)
43
+ * @param {string} [opts.tag] optional category tag
44
+ */
45
+ function addNote(cwd, text, opts = {}) {
46
+ const clean = String(text == null ? '' : text).trim().slice(0, MAX_TEXT);
47
+ if (!clean) throw new Error('note text is empty');
48
+ const entry = {
49
+ ts: new Date().toISOString(),
50
+ text: clean,
51
+ branch: opts.branch !== undefined ? opts.branch : _currentBranch(cwd),
52
+ };
53
+ if (opts.tag) entry.tag = String(opts.tag).trim().slice(0, 40);
54
+ const p = notesPath(cwd);
55
+ fs.mkdirSync(path.dirname(p), { recursive: true });
56
+ fs.appendFileSync(p, JSON.stringify(entry) + '\n');
57
+ return entry;
58
+ }
59
+
60
+ /**
61
+ * Read notes in chronological order (oldest first).
62
+ * @param {string} cwd
63
+ * @param {number} [limit=0] keep only the most recent N (0 = all)
64
+ * @returns {object[]}
65
+ */
66
+ function readNotes(cwd, limit = 0) {
67
+ let raw;
68
+ try { raw = fs.readFileSync(notesPath(cwd), 'utf8'); }
69
+ catch (_) { return []; }
70
+ const out = [];
71
+ for (const line of raw.split('\n')) {
72
+ const t = line.trim();
73
+ if (!t) continue;
74
+ try { out.push(JSON.parse(t)); } catch (_) { /* skip corrupt line */ }
75
+ }
76
+ if (limit > 0 && out.length > limit) return out.slice(out.length - limit);
77
+ return out;
78
+ }
79
+
80
+ /** Format notes as a Markdown list (pass already-ordered notes). */
81
+ function formatNotes(notes) {
82
+ if (!notes || !notes.length) {
83
+ return 'No notes recorded yet. Add one with: sigmap note "<text>"';
84
+ }
85
+ return notes.map((n) => {
86
+ const when = String(n.ts || '').replace('T', ' ').slice(0, 16);
87
+ const br = n.branch ? ` (${n.branch})` : '';
88
+ const tag = n.tag ? ` #${n.tag}` : '';
89
+ return `- [${when}${br}]${tag} ${n.text}`;
90
+ }).join('\n');
91
+ }
92
+
93
+ /** Delete the notes log. Returns true if a file was removed. */
94
+ function clearNotes(cwd) {
95
+ try { fs.unlinkSync(notesPath(cwd)); return true; }
96
+ catch (_) { return false; }
97
+ }
98
+
99
+ module.exports = { notesPath, addNote, readNotes, formatNotes, clearNotes };
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Closest-match suggestions for the Hallucination Guard (v6.15.0).
5
+ *
6
+ * Heuristic layer (plan §5 labels this "Medium" confidence): given a name the
7
+ * detectors flagged as fake, find the nearest *real* candidate by Levenshtein
8
+ * distance so the report can say "Did you mean `loadConfig()` in
9
+ * src/config/loader.js:42?". Pure, deterministic, offline — no network, no LLM.
10
+ *
11
+ * All inputs are passed in (symbol/file/script candidate lists) so this module
12
+ * stays unit-testable without touching the filesystem or the SigMap index.
13
+ */
14
+
15
+ /**
16
+ * Levenshtein edit distance with an early-exit ceiling.
17
+ * Returns `max + 1` as soon as the best achievable distance exceeds `max`,
18
+ * so callers can cheaply reject far-apart strings.
19
+ */
20
+ function levenshtein(a, b, max = Infinity) {
21
+ if (a === b) return 0;
22
+ const al = a.length;
23
+ const bl = b.length;
24
+ if (al === 0) return bl;
25
+ if (bl === 0) return al;
26
+ if (Math.abs(al - bl) > max) return max + 1;
27
+
28
+ let prev = new Array(bl + 1);
29
+ let curr = new Array(bl + 1);
30
+ for (let j = 0; j <= bl; j++) prev[j] = j;
31
+
32
+ for (let i = 1; i <= al; i++) {
33
+ curr[0] = i;
34
+ let rowMin = curr[0];
35
+ const ca = a.charCodeAt(i - 1);
36
+ for (let j = 1; j <= bl; j++) {
37
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
38
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
39
+ if (curr[j] < rowMin) rowMin = curr[j];
40
+ }
41
+ if (rowMin > max) return max + 1;
42
+ const tmp = prev;
43
+ prev = curr;
44
+ curr = tmp;
45
+ }
46
+ return prev[bl];
47
+ }
48
+
49
+ /** Bucket a normalized edit distance into a confidence label (plan §5). */
50
+ function suggestionConfidence(distance, targetLen) {
51
+ const ratio = distance / Math.max(targetLen, 1);
52
+ if (distance === 0 || ratio <= 0.2) return 'high';
53
+ if (ratio <= 0.4) return 'medium';
54
+ return 'low';
55
+ }
56
+
57
+ /**
58
+ * Find the nearest candidate name to `target`.
59
+ *
60
+ * @param {string} target
61
+ * @param {Array<string | { name: string, file?: string, line?: number }>} candidates
62
+ * @param {object} [opts]
63
+ * @param {number} [opts.maxRatio=0.5] reject matches farther than ratio·len edits
64
+ * @param {number} [opts.minLen=3] skip very short targets (too noisy)
65
+ * @returns {{ name, file, line, distance, confidence } | null}
66
+ */
67
+ function closestMatch(target, candidates, opts = {}) {
68
+ const maxRatio = opts.maxRatio != null ? opts.maxRatio : 0.5;
69
+ const minLen = opts.minLen != null ? opts.minLen : 3;
70
+ if (!target || target.length < minLen) return null;
71
+ if (!candidates || candidates.length === 0) return null;
72
+
73
+ const lower = target.toLowerCase();
74
+ const cap = Math.max(1, Math.ceil(target.length * maxRatio));
75
+ let best = null;
76
+
77
+ for (const c of candidates) {
78
+ const name = typeof c === 'string' ? c : c && c.name;
79
+ if (!name || name === target) continue;
80
+ if (Math.abs(name.length - target.length) > cap) continue;
81
+ const d = levenshtein(lower, name.toLowerCase(), cap);
82
+ if (d > cap) continue;
83
+ if (!best || d < best.distance ||
84
+ (d === best.distance && name.length < best.name.length)) {
85
+ best = {
86
+ name,
87
+ file: typeof c === 'object' ? c.file : undefined,
88
+ line: typeof c === 'object' ? c.line : undefined,
89
+ distance: d,
90
+ };
91
+ if (d === 0) break; // case-only difference — can't beat it
92
+ }
93
+ }
94
+
95
+ if (!best) return null;
96
+ best.confidence = suggestionConfidence(best.distance, target.length);
97
+ return best;
98
+ }
99
+
100
+ /**
101
+ * Build `[{ name, file, line }]` symbol candidates from a SigMap signature
102
+ * index (`Map<file, string[]>` whose entries may carry a `:start-end` anchor).
103
+ */
104
+ function buildSymbolCandidates(sigIndex) {
105
+ const out = [];
106
+ const seen = new Set();
107
+ if (!sigIndex) return out;
108
+ for (const [file, sigs] of sigIndex) {
109
+ for (const sig of sigs) {
110
+ const s = String(sig);
111
+ const lineM = s.match(/:(\d+)(?:-\d+)?\s*$/);
112
+ const line = lineM ? parseInt(lineM[1], 10) : null;
113
+ const cleaned = s.replace(/\s*:\d+(?:-\d+)?\s*$/, '');
114
+ const m = cleaned.match(/\b(?:async\s+function|function|class|def|interface|type|enum|const|let|var)\s+([A-Za-z_$][\w$]*)/)
115
+ || cleaned.match(/([A-Za-z_$][\w$]*)\s*\(/)
116
+ || cleaned.match(/([A-Za-z_$][\w$]*)/);
117
+ if (!m) continue;
118
+ const name = m[1];
119
+ const key = name + '@' + file;
120
+ if (seen.has(key)) continue;
121
+ seen.add(key);
122
+ out.push({ name, file, line });
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Format a suggestion object into a human one-liner for reports/CLI. */
129
+ function formatSuggestion(match, asCall) {
130
+ if (!match) return null;
131
+ const sym = asCall ? `${match.name}()` : match.name;
132
+ let where = '';
133
+ if (match.file) {
134
+ where = match.line ? ` in ${match.file}:${match.line}` : ` in ${match.file}`;
135
+ }
136
+ return `Did you mean \`${sym}\`${where}?`;
137
+ }
138
+
139
+ module.exports = {
140
+ levenshtein,
141
+ closestMatch,
142
+ buildSymbolCandidates,
143
+ suggestionConfidence,
144
+ formatSuggestion,
145
+ };