sigmap 6.11.1 → 6.13.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/CHANGELOG.md +37 -0
- package/README.md +3 -3
- package/gen-context.js +449 -88
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/extractors/javascript.js +44 -7
- package/src/extractors/typescript.js +22 -6
- package/src/format/dashboard.js +105 -0
- package/src/mcp/handlers.js +58 -1
- package/src/mcp/server.js +4 -3
- package/src/mcp/tools.js +31 -2
package/package.json
CHANGED
|
@@ -1,57 +1,88 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { lineAt, withAnchor } = require('./line-anchor');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Extract signatures from JavaScript source code.
|
|
7
|
+
* Top-level declarations and class members carry a `:start-end` line anchor
|
|
8
|
+
* (see line-anchor.js); kept parallel to `sigs` and applied once at return.
|
|
5
9
|
* @param {string} src - Raw file content
|
|
6
10
|
* @returns {string[]} Array of signature strings
|
|
7
11
|
*/
|
|
8
12
|
function extract(src) {
|
|
9
13
|
if (!src || typeof src !== 'string') return [];
|
|
10
14
|
const sigs = [];
|
|
15
|
+
const anchors = [];
|
|
11
16
|
const returnHints = buildReturnHints(src);
|
|
12
17
|
|
|
18
|
+
// Block comments are blanked newline-by-newline (non-newline chars → spaces)
|
|
19
|
+
// so character offsets AND line numbers stay exact for anchors.
|
|
13
20
|
const stripped = src
|
|
14
21
|
.replace(/\/\/.*$/gm, '')
|
|
15
|
-
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
22
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
23
|
+
|
|
24
|
+
const blockEndIdx = (bodyStart) => bodyStart + extractBlock(stripped, bodyStart).length;
|
|
25
|
+
// End line for a function whose match ends at `matchEnd` (before its body brace).
|
|
26
|
+
const fnEndLine = (matchEnd, startLn) => {
|
|
27
|
+
const brace = stripped.indexOf('{', matchEnd);
|
|
28
|
+
return brace !== -1 ? lineAt(stripped, blockEndIdx(brace + 1)) : startLn;
|
|
29
|
+
};
|
|
16
30
|
|
|
17
31
|
// Classes
|
|
18
32
|
const classRegex = /^(export\s+(?:default\s+)?)?class\s+(\w+)(?:\s+extends\s+[\w.]+)?\s*\{/gm;
|
|
19
33
|
for (const m of stripped.matchAll(classRegex)) {
|
|
20
34
|
const prefix = m[1] ? m[1].trim() + ' ' : '';
|
|
35
|
+
const bodyStart = m.index + m[0].length;
|
|
21
36
|
sigs.push(`${prefix}class ${m[2]}`);
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
38
|
+
const block = extractBlock(stripped, bodyStart);
|
|
39
|
+
for (const meth of extractClassMembers(block, returnHints)) {
|
|
40
|
+
sigs.push(` ${meth.text}`);
|
|
41
|
+
anchors.push([lineAt(stripped, bodyStart + meth.start), lineAt(stripped, bodyStart + meth.end)]);
|
|
42
|
+
}
|
|
24
43
|
}
|
|
25
44
|
|
|
26
45
|
// Exported named functions
|
|
27
46
|
for (const m of stripped.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/gm)) {
|
|
28
47
|
const asyncKw = /export\s+async/.test(m[0]) ? 'async ' : '';
|
|
29
48
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
49
|
+
const startLn = lineAt(stripped, m.index);
|
|
30
50
|
sigs.push(`export ${asyncKw}function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
|
|
51
|
+
anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
// Exported arrow functions
|
|
34
55
|
for (const m of stripped.matchAll(/^export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*=>/gm)) {
|
|
35
56
|
const asyncKw = m[0].includes('async') ? 'async ' : '';
|
|
36
57
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
58
|
+
const startLn = lineAt(stripped, m.index);
|
|
37
59
|
sigs.push(`export const ${m[1]} = ${asyncKw}(${normalizeParams(m[2])}) =>${retStr}`);
|
|
60
|
+
anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
|
|
38
61
|
}
|
|
39
62
|
|
|
40
63
|
// module.exports = { ... }
|
|
41
64
|
const moduleExports = stripped.match(/^module\.exports\s*=\s*\{([^}]+)\}/m);
|
|
42
65
|
if (moduleExports) {
|
|
43
66
|
const names = moduleExports[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
44
|
-
if (names.length > 0)
|
|
67
|
+
if (names.length > 0) {
|
|
68
|
+
const startLn = lineAt(stripped, moduleExports.index);
|
|
69
|
+
sigs.push(`module.exports = { ${names.join(', ')} }`);
|
|
70
|
+
anchors.push([startLn, lineAt(stripped, moduleExports.index + moduleExports[0].length)]);
|
|
71
|
+
}
|
|
45
72
|
}
|
|
46
73
|
|
|
47
74
|
// Top-level named functions (non-exported)
|
|
48
75
|
for (const m of stripped.matchAll(/^(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/gm)) {
|
|
49
76
|
const asyncKw = m[0].startsWith('async') ? 'async ' : '';
|
|
50
77
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
78
|
+
const startLn = lineAt(stripped, m.index);
|
|
51
79
|
sigs.push(`${asyncKw}function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
|
|
80
|
+
anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
|
|
52
81
|
}
|
|
53
82
|
|
|
54
|
-
return sigs
|
|
83
|
+
return sigs
|
|
84
|
+
.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s))
|
|
85
|
+
.slice(0, 25);
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
function extractBlock(src, startIndex) {
|
|
@@ -66,15 +97,21 @@ function extractBlock(src, startIndex) {
|
|
|
66
97
|
return src.slice(startIndex, i - 1);
|
|
67
98
|
}
|
|
68
99
|
|
|
100
|
+
// Returns members as { text, start, end } where start/end are char offsets
|
|
101
|
+
// WITHIN `block` (end = the method's closing brace), so the caller can resolve
|
|
102
|
+
// per-method line anchors that span the method body.
|
|
69
103
|
function extractClassMembers(block, returnHints) {
|
|
70
104
|
const members = [];
|
|
71
105
|
for (const m of block.matchAll(/^\s+(?:static\s+|async\s+|get\s+|set\s+)*(\w+)\s*\(([^)]*)\)\s*\{/gm)) {
|
|
72
106
|
if (/^_/.test(m[1])) continue;
|
|
73
|
-
|
|
107
|
+
const bodyStart = m.index + m[0].length; // just past the opening brace
|
|
108
|
+
const end = bodyStart + extractBlock(block, bodyStart).length;
|
|
109
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
110
|
+
if (m[1] === 'constructor') { members.push({ text: `constructor(${normalizeParams(m[2])})`, start, end }); continue; }
|
|
74
111
|
const isAsync = m[0].includes('async ') ? 'async ' : '';
|
|
75
112
|
const isStatic = m[0].includes('static ') ? 'static ' : '';
|
|
76
113
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
77
|
-
members.push(`${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}
|
|
114
|
+
members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
|
|
78
115
|
}
|
|
79
116
|
return members.slice(0, 8);
|
|
80
117
|
}
|
|
@@ -35,7 +35,10 @@ function extract(src) {
|
|
|
35
35
|
// Collect members
|
|
36
36
|
const block = extractBlock(stripped, bodyStart);
|
|
37
37
|
const members = extractInterfaceMembers(block);
|
|
38
|
-
for (const mem of members) {
|
|
38
|
+
for (const mem of members) {
|
|
39
|
+
sigs.push(` ${mem.text}`);
|
|
40
|
+
anchors.push([lineAt(stripped, bodyStart + mem.start), lineAt(stripped, bodyStart + mem.end)]);
|
|
41
|
+
}
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
// Exported type aliases
|
|
@@ -61,7 +64,10 @@ function extract(src) {
|
|
|
61
64
|
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
62
65
|
const block = extractBlock(stripped, bodyStart);
|
|
63
66
|
const methods = extractClassMembers(block);
|
|
64
|
-
for (const meth of methods) {
|
|
67
|
+
for (const meth of methods) {
|
|
68
|
+
sigs.push(` ${meth.text}`);
|
|
69
|
+
anchors.push([lineAt(stripped, bodyStart + meth.start), lineAt(stripped, bodyStart + meth.end)]);
|
|
70
|
+
}
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
// Exported top-level functions (not methods)
|
|
@@ -163,22 +169,29 @@ function extractBlock(src, startIndex) {
|
|
|
163
169
|
return src.slice(startIndex, i - 1);
|
|
164
170
|
}
|
|
165
171
|
|
|
172
|
+
// Returns members as { text, start, end } where start/end are char offsets
|
|
173
|
+
// WITHIN `block`, so the caller can resolve member line anchors.
|
|
166
174
|
function extractInterfaceMembers(block) {
|
|
167
175
|
const members = [];
|
|
168
176
|
for (const m of block.matchAll(/^\s+(readonly\s+)?(\w+)(\??):\s*([^;]+);/gm)) {
|
|
169
177
|
const readonly = m[1] ? 'readonly ' : '';
|
|
170
178
|
const optional = m[3] ? '?' : '';
|
|
171
179
|
const typeStr = m[4].trim().replace(/\s+/g, ' ').slice(0, 35);
|
|
172
|
-
|
|
180
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
181
|
+
members.push({ text: `${readonly}${m[2]}${optional}: ${typeStr}`, start, end: m.index + m[0].length });
|
|
173
182
|
}
|
|
174
183
|
for (const m of block.matchAll(/^\s+(\w+)\s*(?:<[^(]*>)?\s*\(([^)]*)\)\s*:/gm)) {
|
|
175
|
-
|
|
184
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
185
|
+
members.push({ text: `${m[1]}(${normalizeParams(m[2])})`, start, end: m.index + m[0].length });
|
|
176
186
|
}
|
|
177
187
|
return members.slice(0, 8);
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
const _CTRL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'do', 'try', 'catch', 'finally', 'else', 'return']);
|
|
181
191
|
|
|
192
|
+
// Returns members as { text, start, end } where start/end are char offsets
|
|
193
|
+
// WITHIN `block` (end = the method's closing brace), so the caller can resolve
|
|
194
|
+
// per-method line anchors that span the method body.
|
|
182
195
|
function extractClassMembers(block) {
|
|
183
196
|
const members = [];
|
|
184
197
|
// Public methods (skip private/protected/_ prefixed and control-flow keywords)
|
|
@@ -186,13 +199,16 @@ function extractClassMembers(block) {
|
|
|
186
199
|
for (const m of block.matchAll(methodRe)) {
|
|
187
200
|
if (_CTRL_KEYWORDS.has(m[1])) continue;
|
|
188
201
|
if (/^(private|protected|_)/.test(m[1])) continue;
|
|
189
|
-
|
|
202
|
+
const bodyStart = m.index + m[0].length; // just past the opening brace
|
|
203
|
+
const end = bodyStart + extractBlock(block, bodyStart).length;
|
|
204
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
205
|
+
if (m[1] === 'constructor') { members.push({ text: `constructor(${normalizeParams(m[2])})`, start, end }); continue; }
|
|
190
206
|
const isAsync = m[0].includes('async ') ? 'async ' : '';
|
|
191
207
|
const isStatic = m[0].includes('static ') ? 'static ' : '';
|
|
192
208
|
const retMatch = m[0].match(/\)\s*:\s*([^{;]+)\s*\{/);
|
|
193
209
|
const retType = retMatch ? retMatch[1].trim().replace(/\s+/g, ' ').slice(0, 20) : '';
|
|
194
210
|
const retStr = retType ? ` → ${retType}` : '';
|
|
195
|
-
members.push(`${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}
|
|
211
|
+
members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
|
|
196
212
|
}
|
|
197
213
|
return members.slice(0, 8);
|
|
198
214
|
}
|
package/src/format/dashboard.js
CHANGED
|
@@ -304,6 +304,106 @@ function sparkline(values) {
|
|
|
304
304
|
}).join('');
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
function escapeAttr(s) {
|
|
308
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
|
309
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
310
|
+
));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Surgical Context (v6.12.0): read the published token-reduction benchmark and
|
|
314
|
+
// aggregate it for the dashboard panel. Numbers are never hand-typed — they come
|
|
315
|
+
// straight from benchmarks/reports/token-reduction.json.
|
|
316
|
+
function readTokenReduction(cwd) {
|
|
317
|
+
const p = path.join(cwd, 'benchmarks', 'reports', 'token-reduction.json');
|
|
318
|
+
let data;
|
|
319
|
+
try { data = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
|
|
320
|
+
const repos = Array.isArray(data.repos) ? data.repos : [];
|
|
321
|
+
if (repos.length === 0) return null;
|
|
322
|
+
|
|
323
|
+
let baseline = 0, signatures = 0, surgical = 0, hasSurgical = false;
|
|
324
|
+
for (const r of repos) {
|
|
325
|
+
baseline += toNumber(r.rawTokens) || 0;
|
|
326
|
+
signatures += toNumber(r.finalTokens) || 0;
|
|
327
|
+
const s = toNumber(r.surgicalTokens);
|
|
328
|
+
if (s !== null) { surgical += s; hasSurgical = true; }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const out = {
|
|
332
|
+
version: data.version || null,
|
|
333
|
+
repoCount: repos.length,
|
|
334
|
+
baseline,
|
|
335
|
+
signatures,
|
|
336
|
+
savedPct: baseline > 0 ? Math.round((1 - signatures / baseline) * 1000) / 10 : 0,
|
|
337
|
+
perRepo: repos.map((r) => ({
|
|
338
|
+
repo: r.repo,
|
|
339
|
+
language: r.language,
|
|
340
|
+
rawTokens: toNumber(r.rawTokens) || 0,
|
|
341
|
+
finalTokens: toNumber(r.finalTokens) || 0,
|
|
342
|
+
reductionPct: toNumber(r.reductionPct) || 0,
|
|
343
|
+
})),
|
|
344
|
+
};
|
|
345
|
+
if (hasSurgical) {
|
|
346
|
+
out.surgical = surgical;
|
|
347
|
+
out.surgicalSavedPct = baseline > 0 ? Math.round((1 - surgical / baseline) * 1000) / 10 : 0;
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function tokenReductionPanelHtml(tr) {
|
|
353
|
+
if (!tr) {
|
|
354
|
+
return '<div class="panel"><div class="label">Token Reduction</div>' +
|
|
355
|
+
'<div class="value" style="font-size:13px">No token-reduction benchmark found — run the token benchmark to populate this panel.</div></div>';
|
|
356
|
+
}
|
|
357
|
+
const fmt = (n) => Number(n).toLocaleString('en-US');
|
|
358
|
+
const tiers = [
|
|
359
|
+
{ label: 'Whole-file baseline', value: fmt(tr.baseline) + ' tok' },
|
|
360
|
+
{ label: 'Ranked signatures (ask)', value: fmt(tr.signatures) + ' tok' },
|
|
361
|
+
];
|
|
362
|
+
if (tr.surgical != null) {
|
|
363
|
+
tiers.push({ label: 'Surgical (index + delta)', value: fmt(tr.surgical) + ' tok' });
|
|
364
|
+
tiers.push({ label: 'Saved (surgical)', value: tr.surgicalSavedPct + '%' });
|
|
365
|
+
} else {
|
|
366
|
+
tiers.push({ label: 'Saved', value: tr.savedPct + '%' });
|
|
367
|
+
}
|
|
368
|
+
const tierHtml = tiers.map((t) =>
|
|
369
|
+
`<div class="card"><div class="label">${escapeAttr(t.label)}</div><div class="value">${escapeAttr(t.value)}</div></div>`
|
|
370
|
+
).join('');
|
|
371
|
+
|
|
372
|
+
// Proportional comparison bar (baseline = full width).
|
|
373
|
+
const sigPct = tr.baseline > 0 ? Math.max(0.4, (tr.signatures / tr.baseline) * 100) : 0;
|
|
374
|
+
const surgPct = (tr.surgical != null && tr.baseline > 0) ? Math.max(0.4, (tr.surgical / tr.baseline) * 100) : null;
|
|
375
|
+
const barRow = (label, pct, color) =>
|
|
376
|
+
`<div style="margin:4px 0;font-size:11px;color:#8ea0d9">${escapeAttr(label)}</div>` +
|
|
377
|
+
`<div style="background:#0a0f1e;border:1px solid #223056;border-radius:6px;height:14px;overflow:hidden">` +
|
|
378
|
+
`<div style="width:${pct.toFixed(1)}%;height:100%;background:${color}"></div></div>`;
|
|
379
|
+
const bars = [
|
|
380
|
+
barRow('Whole-file baseline (100%)', 100, '#3a4a78'),
|
|
381
|
+
barRow(`Ranked signatures — ${tr.savedPct}% saved`, sigPct, '#2e7d6b'),
|
|
382
|
+
surgPct != null ? barRow(`Surgical — ${tr.surgicalSavedPct}% saved`, surgPct, '#5ad1a8') : '',
|
|
383
|
+
].filter(Boolean).join('');
|
|
384
|
+
|
|
385
|
+
const rows = tr.perRepo.slice(0, 8).map((r) =>
|
|
386
|
+
`<tr><td>${escapeAttr(r.repo)}</td><td>${escapeAttr(r.language)}</td>` +
|
|
387
|
+
`<td style="text-align:right">${fmt(r.rawTokens)}</td>` +
|
|
388
|
+
`<td style="text-align:right">${fmt(r.finalTokens)}</td>` +
|
|
389
|
+
`<td style="text-align:right">${r.reductionPct}%</td></tr>`
|
|
390
|
+
).join('');
|
|
391
|
+
|
|
392
|
+
return [
|
|
393
|
+
'<div class="panel">',
|
|
394
|
+
`<div class="label">Token Reduction — ${tr.repoCount} benchmark repos${tr.version ? ' · v' + escapeAttr(tr.version) : ''}</div>`,
|
|
395
|
+
`<div class="grid" style="margin:8px 0">${tierHtml}</div>`,
|
|
396
|
+
bars,
|
|
397
|
+
'<table style="width:100%;border-collapse:collapse;margin-top:10px;font-size:12px">',
|
|
398
|
+
'<thead><tr style="color:#8ea0d9;text-align:left">',
|
|
399
|
+
'<th>Repo</th><th>Lang</th><th style="text-align:right">Baseline</th><th style="text-align:right">Signatures</th><th style="text-align:right">Saved</th>',
|
|
400
|
+
'</tr></thead>',
|
|
401
|
+
`<tbody>${rows}</tbody>`,
|
|
402
|
+
'</table>',
|
|
403
|
+
'</div>',
|
|
404
|
+
].join('');
|
|
405
|
+
}
|
|
406
|
+
|
|
307
407
|
function buildDashboardData(cwd, health) {
|
|
308
408
|
const entries = readLog(cwd);
|
|
309
409
|
const recent = entries.slice(-30);
|
|
@@ -326,15 +426,19 @@ function buildDashboardData(cwd, health) {
|
|
|
326
426
|
extractorCoverage: coverage.pct,
|
|
327
427
|
};
|
|
328
428
|
|
|
429
|
+
const tokenReduction = readTokenReduction(cwd);
|
|
430
|
+
|
|
329
431
|
return {
|
|
330
432
|
summary,
|
|
331
433
|
tokenReductionTrend,
|
|
332
434
|
hitAt5Trend,
|
|
333
435
|
coverage,
|
|
436
|
+
tokenReduction,
|
|
334
437
|
charts: {
|
|
335
438
|
tokenReductionSvg: lineChartSvg(tokenReductionTrend, 'Token reduction trend (last 30 tracked runs)', '%'),
|
|
336
439
|
hitAt5Svg: lineChartSvg(hitAt5Trend, 'hit@5 trend (last 30 benchmark runs)', ''),
|
|
337
440
|
coverageSvg: barChartSvg(coverage.perLanguage),
|
|
441
|
+
tokenSavingsPanel: tokenReductionPanelHtml(tokenReduction),
|
|
338
442
|
},
|
|
339
443
|
};
|
|
340
444
|
}
|
|
@@ -379,6 +483,7 @@ function generateDashboardHtml(cwd, health) {
|
|
|
379
483
|
'<h1>SigMap v2.10 dashboard</h1>',
|
|
380
484
|
'<div class="sub">Self-contained report. No external scripts, styles, or network calls.</div>',
|
|
381
485
|
`<div class="grid">${cardHtml}</div>`,
|
|
486
|
+
data.charts.tokenSavingsPanel,
|
|
382
487
|
`<div class="panel">${data.charts.tokenReductionSvg}</div>`,
|
|
383
488
|
`<div class="panel">${data.charts.hitAt5Svg}</div>`,
|
|
384
489
|
`<div class="panel">${data.charts.coverageSvg}</div>`,
|
package/src/mcp/handlers.js
CHANGED
|
@@ -448,4 +448,61 @@ function getImpact(args, cwd) {
|
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
450
|
|
|
451
|
-
|
|
451
|
+
/**
|
|
452
|
+
* get_lines({ file, start, end }) → string
|
|
453
|
+
*
|
|
454
|
+
* Surgical Context demand-driven fetch: returns an exact, clamped line range from a
|
|
455
|
+
* source file. The path is resolved inside the project root (no traversal escape) and
|
|
456
|
+
* the returned lines are secret-scanned via the same redactor used for signatures.
|
|
457
|
+
*/
|
|
458
|
+
function getLines(args, cwd) {
|
|
459
|
+
if (!args || !args.file) return 'Missing required argument: file';
|
|
460
|
+
|
|
461
|
+
const rel = String(args.file).replace(/\\/g, '/').replace(/^\//, '');
|
|
462
|
+
const abs = path.resolve(cwd, rel);
|
|
463
|
+
|
|
464
|
+
// Sandbox: refuse paths that resolve outside the project root.
|
|
465
|
+
const root = path.resolve(cwd);
|
|
466
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
467
|
+
return `Refused: ${rel} resolves outside the project root`;
|
|
468
|
+
}
|
|
469
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
|
470
|
+
return `File not found: ${rel}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const start = parseInt(args.start, 10);
|
|
474
|
+
const end = parseInt(args.end, 10);
|
|
475
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
|
476
|
+
return 'Arguments "start" and "end" must be numbers (1-based line numbers).';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let lines;
|
|
480
|
+
try {
|
|
481
|
+
lines = fs.readFileSync(abs, 'utf8').split('\n');
|
|
482
|
+
} catch (err) {
|
|
483
|
+
return `Could not read ${rel}: ${err.message}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const total = lines.length;
|
|
487
|
+
const from = Math.max(1, Math.min(start, end));
|
|
488
|
+
const to = Math.min(total, Math.max(start, end));
|
|
489
|
+
if (from > total) return `${rel} has only ${total} lines; requested ${start}-${end}`;
|
|
490
|
+
|
|
491
|
+
const slice = lines.slice(from - 1, to);
|
|
492
|
+
|
|
493
|
+
// Redaction scan: reuse the signature secret scanner line-by-line.
|
|
494
|
+
let safeLines = slice;
|
|
495
|
+
try {
|
|
496
|
+
const { scan } = require('../security/scanner');
|
|
497
|
+
safeLines = scan(slice, rel).safe;
|
|
498
|
+
} catch (_) {} // non-fatal: fall back to raw slice
|
|
499
|
+
|
|
500
|
+
return [
|
|
501
|
+
`# ${rel}:${from}-${to}`,
|
|
502
|
+
'```',
|
|
503
|
+
...safeLines,
|
|
504
|
+
'```',
|
|
505
|
+
].join('\n');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines };
|
package/src/mcp/server.js
CHANGED
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Supported methods:
|
|
10
10
|
* initialize → serverInfo + capabilities
|
|
11
|
-
* tools/list →
|
|
11
|
+
* tools/list → 10 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 } = require('./handlers');
|
|
17
|
+
const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines } = require('./handlers');
|
|
18
18
|
|
|
19
19
|
const SERVER_INFO = {
|
|
20
20
|
name: 'sigmap',
|
|
21
|
-
version: '6.
|
|
21
|
+
version: '6.13.0',
|
|
22
22
|
description: 'SigMap MCP server — code signatures on demand',
|
|
23
23
|
};
|
|
24
24
|
|
|
@@ -75,6 +75,7 @@ function dispatch(msg, cwd) {
|
|
|
75
75
|
else if (name === 'list_modules') text = listModules(args, cwd);
|
|
76
76
|
else if (name === 'query_context') text = queryContext(args, cwd);
|
|
77
77
|
else if (name === 'get_impact') text = getImpact(args, cwd);
|
|
78
|
+
else if (name === 'get_lines') text = getLines(args, cwd);
|
|
78
79
|
else {
|
|
79
80
|
respondError(id, -32601, `Unknown tool: ${name}`);
|
|
80
81
|
return;
|
package/src/mcp/tools.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* MCP tool definitions for SigMap.
|
|
5
|
-
*
|
|
4
|
+
* MCP tool definitions for SigMap (10 tools).
|
|
5
|
+
* read_context, search_signatures, get_map, create_checkpoint, get_routing,
|
|
6
|
+
* explain_file, list_modules, query_context, get_impact, get_lines.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
const TOOLS = [
|
|
@@ -168,6 +169,34 @@ const TOOLS = [
|
|
|
168
169
|
required: ['file'],
|
|
169
170
|
},
|
|
170
171
|
},
|
|
172
|
+
{
|
|
173
|
+
name: 'get_lines',
|
|
174
|
+
description:
|
|
175
|
+
'Fetch an exact line range from a source file on demand — the Surgical Context ' +
|
|
176
|
+
'workhorse. Signatures carry `path:start-end` anchors; call this to read just those ' +
|
|
177
|
+
'lines instead of re-opening the whole file. Lines are clamped to the file bounds and ' +
|
|
178
|
+
'secret-scanned (redacted) before return. Path is sandboxed to the project root.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
file: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description:
|
|
185
|
+
'Relative path from the project root (e.g. "src/config/loader.js"). ' +
|
|
186
|
+
'Use the path shown in a signature anchor. Use forward slashes.',
|
|
187
|
+
},
|
|
188
|
+
start: {
|
|
189
|
+
type: 'number',
|
|
190
|
+
description: '1-based start line (inclusive). Clamped to the file bounds.',
|
|
191
|
+
},
|
|
192
|
+
end: {
|
|
193
|
+
type: 'number',
|
|
194
|
+
description: '1-based end line (inclusive). Clamped to the file bounds.',
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
required: ['file', 'start', 'end'],
|
|
198
|
+
},
|
|
199
|
+
},
|
|
171
200
|
];
|
|
172
201
|
|
|
173
202
|
module.exports = { TOOLS };
|