sigmap 6.14.0 → 7.0.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.
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Squeeze input classifier (v7.0.0).
5
+ *
6
+ * Deterministic, zero-dep detector that labels a pasted blob as a
7
+ * `stacktrace`, `cilog`, or `json` payload — or `null` (pass through). Pure
8
+ * regex/heuristics; runs in well under 10ms even on large input.
9
+ *
10
+ * Order matters: stack traces are highest value and a CI log often *contains*
11
+ * a trace, so stacktrace is checked first, then cilog, then json.
12
+ */
13
+
14
+ const FRAME_RE = [
15
+ /^\s*at\s+.+\(.+:\d+:\d+\)\s*$/, // JS: at fn (file:line:col)
16
+ /^\s*at\s+.+:\d+:\d+\s*$/, // JS: at file:line:col
17
+ /^\s*at\s+[\w$.<>]+\(.+\.\w+:\d+\)/, // Java/Kotlin: at pkg.Cls.m(File.java:42)
18
+ /^\s*File\s+".+",\s+line\s+\d+/, // Python frame
19
+ /^\s+\w+.*\([^)]*\.(go|rs):\d+\)/, // Go/Rust frame with file:line
20
+ /^\s*#\d+\s+0x[0-9a-f]+/, // native/gdb frame
21
+ ];
22
+
23
+ const STACK_HEADER_RE = [
24
+ /Traceback \(most recent call last\)/,
25
+ /Exception in thread/,
26
+ /\bat Object\.<anonymous>\b/,
27
+ /thread '.*' panicked at/,
28
+ /^panic:/m,
29
+ /goroutine \d+ \[/,
30
+ ];
31
+
32
+ function countFrames(lines) {
33
+ let n = 0;
34
+ for (const line of lines) {
35
+ if (FRAME_RE.some((re) => re.test(line))) n++;
36
+ }
37
+ return n;
38
+ }
39
+
40
+ function matchesStackTrace(input, lines) {
41
+ const frames = countFrames(lines);
42
+ const header = STACK_HEADER_RE.some((re) => re.test(input));
43
+ // A single explicit header (Traceback/panic) counts as strong evidence even
44
+ // with few parsed frames; otherwise require 2+ frame-like lines.
45
+ if (!header && frames < 2) return { match: false, confidence: 0, frames };
46
+ // Confidence scales with frame count; a header alone floors it at 0.6.
47
+ let confidence = Math.min(0.97, 0.15 * frames);
48
+ if (header) confidence = Math.max(confidence, 0.6 + Math.min(0.3, 0.05 * frames));
49
+ return { match: true, confidence: Number(confidence.toFixed(2)), frames };
50
+ }
51
+
52
+ const TS_RE = /(\b\d{1,2}:\d{2}:\d{2}\b)|(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})/;
53
+ const PROGRESS_RE = /(\d{1,3}%)|(\bDownloading\b)|(\bnpm (WARN|notice|http)\b)|(##\[(group|endgroup|command)\])|(\[\d+\/\d+\])|(▕|█|━|⠿)|(\bETA\b)|(\r$)/;
54
+
55
+ function matchesCiLog(input, lines) {
56
+ if (lines.length < 8) return { match: false, confidence: 0 };
57
+ let logish = 0;
58
+ const seen = new Map();
59
+ let repeats = 0;
60
+ for (const line of lines) {
61
+ if (TS_RE.test(line) || PROGRESS_RE.test(line)) logish++;
62
+ const norm = line.replace(/\d+/g, '#').trim();
63
+ if (norm) {
64
+ const c = (seen.get(norm) || 0) + 1;
65
+ seen.set(norm, c);
66
+ if (c > 1) repeats++;
67
+ }
68
+ }
69
+ const density = logish / lines.length;
70
+ const repeatRatio = repeats / lines.length;
71
+ if (density < 0.4 && repeatRatio < 0.4) return { match: false, confidence: 0 };
72
+ const confidence = Math.min(0.95, Math.max(density, repeatRatio) + 0.15);
73
+ return { match: true, confidence: Number(confidence.toFixed(2)) };
74
+ }
75
+
76
+ function matchesJsonPayload(input) {
77
+ const trimmed = input.trim();
78
+ if (/^[[{]/.test(trimmed)) {
79
+ try {
80
+ JSON.parse(trimmed);
81
+ return { match: true, confidence: 0.95 };
82
+ } catch (_) { /* not strict JSON — fall through to heuristic */ }
83
+ }
84
+ const lines = trimmed.split('\n');
85
+ if (lines.length < 4) return { match: false, confidence: 0 };
86
+ let kv = 0;
87
+ for (const line of lines) {
88
+ if (/^\s*"[^"]+"\s*:\s*.+/.test(line) || /^\s*[}\]],?\s*$/.test(line)) kv++;
89
+ }
90
+ const ratio = kv / lines.length;
91
+ if (ratio < 0.6) return { match: false, confidence: 0 };
92
+ return { match: true, confidence: Number(Math.min(0.9, ratio).toFixed(2)) };
93
+ }
94
+
95
+ /**
96
+ * @param {string} input
97
+ * @returns {{ category: 'stacktrace'|'cilog'|'json'|null, confidence: number }}
98
+ */
99
+ function classify(input) {
100
+ if (typeof input !== 'string' || !input.trim()) return { category: null, confidence: 0 };
101
+ const lines = input.split('\n');
102
+
103
+ const st = matchesStackTrace(input, lines);
104
+ if (st.match) return { category: 'stacktrace', confidence: st.confidence };
105
+
106
+ const ci = matchesCiLog(input, lines);
107
+ if (ci.match) return { category: 'cilog', confidence: ci.confidence };
108
+
109
+ const js = matchesJsonPayload(input);
110
+ if (js.match) return { category: 'json', confidence: js.confidence };
111
+
112
+ return { category: null, confidence: 0 };
113
+ }
114
+
115
+ module.exports = { classify, countFrames };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Squeeze orchestrator (v7.0.0): classify → squeeze → reduction → decision.
5
+ *
6
+ * Always-on and silent: callers run `squeeze()` on pasted input, then use
7
+ * `shouldPrompt()` to decide whether the reduction is worth interrupting for.
8
+ * Everything is deterministic and offline; the symbol index for stack-trace
9
+ * enrichment is passed through via `opts.symbolIndex`.
10
+ */
11
+
12
+ const { classify } = require('./classify');
13
+ const { squeezeStackTrace } = require('./stacktrace');
14
+ const { squeezeCiLog } = require('./cilog');
15
+ const { squeezeJsonPayload } = require('./jsonpayload');
16
+
17
+ function estimateTokens(s) { return Math.ceil(String(s || '').length / 4); }
18
+
19
+ /**
20
+ * @param {string} input
21
+ * @param {object} [opts] forwarded to the category squeezer (srcDirs, symbolIndex, …)
22
+ * @returns {{ category, confidence, original, squeezed, rawTokens, squeezedTokens, reduction, kept, stripped, enriched, applies }}
23
+ */
24
+ function squeeze(input, opts = {}) {
25
+ const { category, confidence } = classify(input);
26
+ const rawTokens = estimateTokens(input);
27
+ const base = {
28
+ category, confidence, original: input, squeezed: input,
29
+ rawTokens, squeezedTokens: rawTokens, reduction: 0,
30
+ kept: [], stripped: [], enriched: false, applies: false,
31
+ };
32
+ if (!category) return base;
33
+
34
+ let r;
35
+ if (category === 'stacktrace') r = squeezeStackTrace(input, opts);
36
+ else if (category === 'cilog') r = squeezeCiLog(input, opts);
37
+ else r = squeezeJsonPayload(input, opts);
38
+
39
+ const squeezedTokens = estimateTokens(r.squeezed);
40
+ const reduction = rawTokens > 0 ? (rawTokens - squeezedTokens) / rawTokens : 0;
41
+ return {
42
+ category, confidence,
43
+ original: input, squeezed: r.squeezed,
44
+ rawTokens, squeezedTokens,
45
+ reduction: Math.max(0, Number(reduction.toFixed(4))),
46
+ kept: r.kept || [], stripped: r.stripped || [], enriched: !!r.enriched,
47
+ applies: squeezedTokens < rawTokens,
48
+ };
49
+ }
50
+
51
+ /** True when the reduction clears the threshold (accepts 0–1 or 0–100). */
52
+ function shouldPrompt(reduction, threshold) {
53
+ const t = threshold > 1 ? threshold / 100 : threshold;
54
+ return reduction >= t;
55
+ }
56
+
57
+ /** A compact human summary of what squeeze would do (for the prompt). */
58
+ function formatSummary(result) {
59
+ const pct = Math.round(result.reduction * 100);
60
+ const lines = [
61
+ `Input: ${result.rawTokens.toLocaleString()} tokens`,
62
+ `Can reduce to ${result.squeezedTokens.toLocaleString()} tokens (${pct}% smaller):`,
63
+ ];
64
+ for (const k of result.kept) lines.push(` ✓ Kept: ${k}`);
65
+ for (const s of result.stripped) lines.push(` ✗ Stripped: ${s}`);
66
+ return lines.join('\n');
67
+ }
68
+
69
+ module.exports = { squeeze, shouldPrompt, formatSummary, estimateTokens };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * JSON-payload squeeze (v7.0.0).
5
+ *
6
+ * Collapses repeated array elements, truncates long string values, and
7
+ * preserves the schema shape at every depth — so an LLM still sees the
8
+ * structure of an API/GraphQL/validation error without the bulk.
9
+ */
10
+
11
+ const MAX_STR = 500;
12
+ const ARRAY_KEEP = 2;
13
+
14
+ function squeezeValue(v, opts) {
15
+ const maxStr = opts.maxStr;
16
+ const keep = opts.arrayKeep;
17
+ if (Array.isArray(v)) {
18
+ if (v.length <= keep + 1) return v.map((x) => squeezeValue(x, opts));
19
+ const head = v.slice(0, keep).map((x) => squeezeValue(x, opts));
20
+ head.push(`…${v.length - keep} more similar items`);
21
+ return head;
22
+ }
23
+ if (v && typeof v === 'object') {
24
+ const o = {};
25
+ for (const k of Object.keys(v)) o[k] = squeezeValue(v[k], opts);
26
+ return o;
27
+ }
28
+ if (typeof v === 'string' && v.length > maxStr) {
29
+ return v.slice(0, maxStr) + `…(${v.length} chars)`;
30
+ }
31
+ return v;
32
+ }
33
+
34
+ /**
35
+ * @param {string} input
36
+ * @param {object} [opts]
37
+ * @param {number} [opts.maxStr=500] truncate strings longer than this
38
+ * @param {number} [opts.arrayKeep=2] array items kept before collapsing
39
+ * @returns {{ squeezed, kept, stripped }}
40
+ */
41
+ function squeezeJsonPayload(input, opts = {}) {
42
+ let parsed;
43
+ try { parsed = JSON.parse(input); }
44
+ catch (_) { return { squeezed: input, kept: ['(not valid JSON — unchanged)'], stripped: [] }; }
45
+ const cfg = { maxStr: opts.maxStr != null ? opts.maxStr : MAX_STR, arrayKeep: opts.arrayKeep != null ? opts.arrayKeep : ARRAY_KEEP };
46
+ const squeezed = JSON.stringify(squeezeValue(parsed, cfg), null, 2);
47
+ return {
48
+ squeezed,
49
+ kept: ['schema shape preserved at all depths'],
50
+ stripped: ['collapsed repeated array items; truncated long string values'],
51
+ };
52
+ }
53
+
54
+ module.exports = { squeezeJsonPayload, squeezeValue };
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Stack-trace squeeze (v7.0.0) — the highest-value squeeze module.
5
+ *
6
+ * Dedupes repeated exceptions, strips vendor frames, keeps frames in the user's
7
+ * own source dirs, and — the differentiator — **enriches the top kept frame**
8
+ * with its real signature from the SigMap symbol index (`buildSigIndex`).
9
+ * Generic log summarizers can't do this; SigMap has the repo's symbol map.
10
+ *
11
+ * Pure/deterministic. The symbol index is injected via `opts.symbolIndex` so
12
+ * the module is unit-testable without touching the filesystem.
13
+ */
14
+
15
+ const path = require('path');
16
+
17
+ const VENDOR_RE = /(?:^|[\\/])(?:node_modules|vendor|site-packages|dist|build|\.venv|venv|third_party|external|\.cargo|go\/pkg\/mod)[\\/]/;
18
+
19
+ /** Parse a frame line across JS/TS, Python, Java/Kotlin, Go, Rust, native. */
20
+ function parseFrame(line) {
21
+ let m;
22
+ if ((m = line.match(/^\s*at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/))) return { fn: m[1], file: m[2], line: +m[3], raw: line };
23
+ if ((m = line.match(/^\s*at\s+(.+?):(\d+):(\d+)\s*$/))) return { fn: '', file: m[1], line: +m[2], raw: line };
24
+ if ((m = line.match(/^\s*at\s+([\w$.<>]+)\((.+?):(\d+)\)/))) return { fn: m[1], file: m[2], line: +m[3], raw: line };
25
+ if ((m = line.match(/^\s*File\s+"(.+?)",\s+line\s+(\d+)(?:,\s+in\s+(.+))?/))) return { fn: (m[3] || '').trim(), file: m[1], line: +m[2], raw: line };
26
+ if ((m = line.match(/^\s*(.+\.(?:go|rs)):(\d+)/))) return { fn: '', file: m[1], line: +m[2], raw: line };
27
+ return null;
28
+ }
29
+
30
+ function isVendor(file) { return VENDOR_RE.test(String(file).replace(/\\/g, '/')); }
31
+
32
+ function inSrcDirs(file, srcDirs) {
33
+ const f = String(file).replace(/\\/g, '/');
34
+ return srcDirs.some((d) => {
35
+ const dd = String(d).replace(/^\.\//, '').replace(/\/$/, '');
36
+ return dd && (f === dd || f.startsWith(dd + '/') || f.includes('/' + dd + '/'));
37
+ });
38
+ }
39
+
40
+ /** Look up the real signature for a frame in the SigMap symbol index. */
41
+ function enrichFrame(frame, symbolIndex) {
42
+ if (!symbolIndex || !frame) return null;
43
+ const want = String(frame.file).replace(/\\/g, '/');
44
+ const base = path.basename(want);
45
+ let key = null;
46
+ for (const k0 of symbolIndex.keys()) {
47
+ const k = String(k0).replace(/\\/g, '/');
48
+ if (k === want || want.endsWith('/' + k) || k.endsWith('/' + want)) { key = k0; break; }
49
+ if (!key && path.basename(k) === base) key = k0;
50
+ }
51
+ if (!key) return null;
52
+ const sigs = symbolIndex.get(key) || [];
53
+ const wantFn = frame.fn ? frame.fn.split('.').pop() : '';
54
+ let byLine = null, byName = null;
55
+ for (const sig of sigs) {
56
+ const s = String(sig);
57
+ const mm = s.match(/:(\d+)(?:-(\d+))?\s*$/);
58
+ if (mm) {
59
+ const a = +mm[1], b = mm[2] ? +mm[2] : a;
60
+ if (frame.line >= a && frame.line <= b) byLine = s;
61
+ }
62
+ if (wantFn && new RegExp('\\b' + wantFn.replace(/[^\w$]/g, '') + '\\b').test(s)) byName = byName || s;
63
+ }
64
+ const sig = byLine || byName;
65
+ return sig ? { file: key, sig: sig.replace(/\s*:\d+(?:-\d+)?\s*$/, '').trim() } : null;
66
+ }
67
+
68
+ /**
69
+ * @param {string} input
70
+ * @param {object} [opts]
71
+ * @param {string[]} [opts.srcDirs] user source dirs (default ['src'])
72
+ * @param {Map} [opts.symbolIndex] SigMap signature index for enrichment
73
+ * @param {number} [opts.maxFrames=8] cap on kept source frames
74
+ * @returns {{ squeezed, kept, stripped, enriched }}
75
+ */
76
+ function squeezeStackTrace(input, opts = {}) {
77
+ const srcDirs = (opts.srcDirs && opts.srcDirs.length) ? opts.srcDirs : ['src'];
78
+ const maxFrames = opts.maxFrames != null ? opts.maxFrames : 8;
79
+ const lines = input.split('\n');
80
+
81
+ const headerCount = new Map();
82
+ const headerOrder = [];
83
+ const frames = [];
84
+ for (const line of lines) {
85
+ const f = parseFrame(line);
86
+ if (f) { frames.push(f); continue; }
87
+ const t = line.trim();
88
+ if (!t) continue;
89
+ if (!headerCount.has(t)) headerOrder.push(t);
90
+ headerCount.set(t, (headerCount.get(t) || 0) + 1);
91
+ }
92
+
93
+ const seen = new Set();
94
+ let dupFrames = 0;
95
+ const nonVendor = [];
96
+ const sourceFrames = [];
97
+ let vendorCount = 0;
98
+ for (const f of frames) {
99
+ const k = f.file + ':' + f.line;
100
+ if (seen.has(k)) { dupFrames++; continue; }
101
+ seen.add(k);
102
+ if (isVendor(f.file)) { vendorCount++; continue; }
103
+ nonVendor.push(f);
104
+ if (inSrcDirs(f.file, srcDirs)) sourceFrames.push(f);
105
+ }
106
+
107
+ // Prefer source frames; never return empty (fall back to top non-vendor, then raw).
108
+ const shown = sourceFrames.length ? sourceFrames.slice(0, maxFrames)
109
+ : (nonVendor.length ? nonVendor.slice(0, 3) : frames.slice(0, 3));
110
+
111
+ const enrichment = shown.length ? enrichFrame(shown[0], opts.symbolIndex) : null;
112
+
113
+ const out = [];
114
+ for (const h of headerOrder) {
115
+ const n = headerCount.get(h);
116
+ out.push(n > 1 ? `${h} (occurred ×${n})` : h);
117
+ }
118
+ for (let i = 0; i < shown.length; i++) {
119
+ out.push(' ' + shown[i].raw.trim());
120
+ if (i === 0 && enrichment) out.push(` ↳ ${enrichment.sig} [${enrichment.file}]`);
121
+ }
122
+
123
+ return {
124
+ squeezed: out.join('\n'),
125
+ kept: [
126
+ `${headerOrder.length} unique exception(s)`,
127
+ `top ${shown.length} ${sourceFrames.length ? 'source ' : ''}frame(s)`,
128
+ ...(enrichment ? [`enriched ${path.basename(shown[0].file)}:${shown[0].line}`] : []),
129
+ ],
130
+ stripped: [`${vendorCount} vendor frame(s)`, `${dupFrames} duplicate frame(s)`],
131
+ enriched: !!enrichment,
132
+ };
133
+ }
134
+
135
+ module.exports = { squeezeStackTrace, parseFrame, isVendor, inSrcDirs, enrichFrame };
@@ -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
+ };