sigmap 6.15.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,28 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Canonical "how to use SigMap" guidance block (v6.16/v7.0).
5
+ *
6
+ * Every adapter emits this one identical block so all generated context files
7
+ * (CLAUDE.md, AGENTS.md, .github/copilot-instructions.md, GEMINI.md, .cursorrules,
8
+ * …) carry the same, single usage section — instead of each adapter inventing
9
+ * its own wording (and codex emitting a redundant second JSON block).
10
+ */
11
+
12
+ function usageBlock() {
13
+ return [
14
+ '## SigMap commands',
15
+ '',
16
+ '| When | Command |',
17
+ '|------|---------|',
18
+ '| Before answering a question about code | `sigmap ask "<your question>"` |',
19
+ '| To rank files by topic | `sigmap --query "<topic>"` |',
20
+ '| After changing config or source dirs | `sigmap validate` |',
21
+ '| To verify an AI answer is grounded | `sigmap judge --response <file>` |',
22
+ '',
23
+ 'Always run `sigmap ask` (or `sigmap --query`) before searching for files relevant to a task.',
24
+ '',
25
+ ].join('\n');
26
+ }
27
+
28
+ module.exports = { usageBlock };
package/src/mcp/server.js CHANGED
@@ -18,7 +18,7 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '6.15.0',
21
+ version: '7.0.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
package/src/nudge.js ADDED
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Star nudge + usage tracking (v7.0.0).
5
+ *
6
+ * Records run counts in `.context/usage.json` and shows a one-time GitHub-star
7
+ * message after the tool has been genuinely useful (≥10 runs, ≥8 successes).
8
+ * Shown exactly once per machine — even under concurrent runs (an `wx` lock
9
+ * file makes the show race-safe). Wired into `ask` (and the `squeeze` path).
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const crypto = require('crypto');
16
+
17
+ const RUN_THRESHOLD = 10;
18
+ const SUCCESS_THRESHOLD = 8;
19
+
20
+ function usagePath(cwd) { return path.join(cwd, '.context', 'usage.json'); }
21
+
22
+ function defaultUsage() {
23
+ return {
24
+ totalRuns: 0, successfulRuns: 0, squeezeOffered: 0, squeezeAccepted: 0,
25
+ starNudgeShown: false, machineId: '', firstRunDate: null, lastRunDate: null,
26
+ };
27
+ }
28
+
29
+ function readUsage(cwd) {
30
+ try { return { ...defaultUsage(), ...JSON.parse(fs.readFileSync(usagePath(cwd), 'utf8')) }; }
31
+ catch (_) { return defaultUsage(); }
32
+ }
33
+
34
+ function writeUsageAtomic(cwd, usage) {
35
+ const p = usagePath(cwd);
36
+ fs.mkdirSync(path.dirname(p), { recursive: true });
37
+ const tmp = `${p}.${process.pid}.tmp`;
38
+ fs.writeFileSync(tmp, JSON.stringify(usage, null, 2));
39
+ fs.renameSync(tmp, p); // atomic on POSIX
40
+ }
41
+
42
+ const STAR_MESSAGE = [
43
+ '─────────────────────────────────────────────────────────',
44
+ ' SigMap has helped you 10 times now.',
45
+ '',
46
+ " If it's been useful, a GitHub star takes 5 seconds and",
47
+ ' helps other developers find it:',
48
+ ' → github.com/manojmallick/sigmap',
49
+ '',
50
+ " (Won't ask again. Press Enter to continue.)",
51
+ '─────────────────────────────────────────────────────────',
52
+ ].join('\n');
53
+
54
+ function showStarNudge(write) {
55
+ (write || ((s) => process.stderr.write(s)))('\n' + STAR_MESSAGE + '\n');
56
+ }
57
+
58
+ /**
59
+ * Record one run and, when the thresholds are first met, show the star nudge.
60
+ * @param {string} cwd
61
+ * @param {boolean} runSuccess
62
+ * @param {object} [opts]
63
+ * @param {boolean} [opts.silent] record only — never print
64
+ * @param {function} [opts.write] sink for the message (default stderr)
65
+ * @param {string} [opts.today] override date (testing)
66
+ * @param {object} [opts.bump] counter deltas to merge (e.g. { squeezeAccepted: 1 })
67
+ * @returns {{ usage, nudged }}
68
+ */
69
+ function checkStarNudge(cwd, runSuccess, opts = {}) {
70
+ const usage = readUsage(cwd);
71
+ usage.totalRuns += 1;
72
+ if (runSuccess) usage.successfulRuns += 1;
73
+ if (opts.bump) for (const k of Object.keys(opts.bump)) usage[k] = (usage[k] || 0) + opts.bump[k];
74
+
75
+ const today = opts.today || new Date().toISOString().slice(0, 10);
76
+ if (!usage.firstRunDate) usage.firstRunDate = today;
77
+ usage.lastRunDate = today;
78
+ if (!usage.machineId) {
79
+ try { usage.machineId = 'sha256-' + crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 16); } catch (_) {}
80
+ }
81
+
82
+ let nudged = false;
83
+ if (!usage.starNudgeShown && usage.totalRuns >= RUN_THRESHOLD && usage.successfulRuns >= SUCCESS_THRESHOLD) {
84
+ // Race-safe single-show: only the process that creates the lock prints.
85
+ let won = false;
86
+ try { fs.closeSync(fs.openSync(usagePath(cwd) + '.nudge.lock', 'wx')); won = true; }
87
+ catch (_) { won = false; }
88
+ if (won && !opts.silent) showStarNudge(opts.write);
89
+ nudged = won;
90
+ usage.starNudgeShown = true;
91
+ }
92
+
93
+ writeUsageAtomic(cwd, usage);
94
+ return { usage, nudged };
95
+ }
96
+
97
+ module.exports = { checkStarNudge, readUsage, usagePath, showStarNudge, RUN_THRESHOLD, SUCCESS_THRESHOLD };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CI / build-log squeeze (v7.0.0).
5
+ *
6
+ * Strips timestamps, progress bars, and repeated noise; keeps every error line
7
+ * plus a small context window around it. Never returns empty — when there are
8
+ * no errors it falls back to a head/tail summary. Also reused by the stacktrace
9
+ * squeezer to clean noise surrounding a trace.
10
+ */
11
+
12
+ const TS_PREFIX_RE = /^\s*(?:\[?\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?Z?\]?\s*|\[?\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?\]?\s*)+/;
13
+ const PROGRESS_LINE_RE = /(?:^|\s)(?:\d{1,3}%|Downloading|Receiving objects|Resolving deltas|Compressing objects|npm (?:WARN|notice|http|sill|verb)|##\[(?:group|endgroup|command|section)\]|\[\d+\/\d+\]|ETA[: ]|█|━|▕|⣿)/;
14
+ const ERROR_RE = /\b(?:error|err!|fail(?:ed|ure)?|exception|panic|fatal|traceback|ERR_|E[A-Z]{3,})\b|✗|❌/i;
15
+
16
+ /** Remove a leading timestamp prefix from a single line. */
17
+ function stripTimestamp(line) {
18
+ return line.replace(TS_PREFIX_RE, '');
19
+ }
20
+
21
+ /**
22
+ * @param {string} input
23
+ * @param {object} [opts]
24
+ * @param {number} [opts.context=2] context lines kept around each error
25
+ * @returns {{ squeezed: string, kept: string[], stripped: string[] }}
26
+ */
27
+ function squeezeCiLog(input, opts = {}) {
28
+ const ctx = opts.context != null ? opts.context : 2;
29
+ const lines = input.split('\n');
30
+ const keep = new Set();
31
+ const errorIdx = [];
32
+
33
+ for (let i = 0; i < lines.length; i++) {
34
+ if (ERROR_RE.test(lines[i])) {
35
+ errorIdx.push(i);
36
+ for (let j = Math.max(0, i - ctx); j <= Math.min(lines.length - 1, i + ctx); j++) keep.add(j);
37
+ }
38
+ }
39
+
40
+ let body;
41
+ let keptDesc;
42
+ if (errorIdx.length === 0) {
43
+ const head = lines.slice(0, 10).map(stripTimestamp);
44
+ const tail = lines.length > 20 ? lines.slice(-10).map(stripTimestamp) : [];
45
+ body = head.slice();
46
+ if (tail.length) body.push(`… (${lines.length - 20} lines omitted) …`, ...tail);
47
+ keptDesc = ['head/tail summary (no error lines found)'];
48
+ } else {
49
+ const sorted = [...keep].sort((a, b) => a - b);
50
+ body = [];
51
+ let prev = -2;
52
+ for (const idx of sorted) {
53
+ // Drop pure progress/noise lines unless they are themselves errors.
54
+ const line = stripTimestamp(lines[idx]);
55
+ if (PROGRESS_LINE_RE.test(line) && !ERROR_RE.test(line)) continue;
56
+ if (idx > prev + 1) body.push(`… (${idx - prev - 1} lines) …`);
57
+ body.push(line);
58
+ prev = idx;
59
+ }
60
+ if (body.length === 0) body = errorIdx.map((i) => stripTimestamp(lines[i])); // safety net
61
+ keptDesc = [`${errorIdx.length} error line(s) + ${ctx}-line context`];
62
+ }
63
+
64
+ return {
65
+ squeezed: body.join('\n'),
66
+ kept: keptDesc,
67
+ stripped: [`${Math.max(0, lines.length - body.length)} timestamp/progress/noise line(s)`],
68
+ };
69
+ }
70
+
71
+ module.exports = { squeezeCiLog, stripTimestamp, ERROR_RE };
@@ -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 };