sigmap 8.33.0 → 8.35.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +12 -12
  3. package/gen-context.js +531 -74
  4. package/llms-full.txt +9 -9
  5. package/llms.txt +7 -7
  6. package/package.json +1 -1
  7. package/packages/cli/package.json +1 -1
  8. package/packages/core/index.js +1 -0
  9. package/packages/core/package.json +1 -1
  10. package/src/config/loader.js +1 -1
  11. package/src/config/tune.js +1 -1
  12. package/src/discovery/language-detector.js +1 -0
  13. package/src/discovery/source-root-registry.js +10 -1
  14. package/src/discovery/source-root-scorer.js +1 -1
  15. package/src/extractors/cpp.js +8 -3
  16. package/src/extractors/csharp.js +8 -3
  17. package/src/extractors/css.js +1 -1
  18. package/src/extractors/dart.js +8 -3
  19. package/src/extractors/deps.js +23 -1
  20. package/src/extractors/dispatch.js +2 -0
  21. package/src/extractors/dockerfile.js +1 -1
  22. package/src/extractors/gdscript.js +2 -2
  23. package/src/extractors/go.js +13 -5
  24. package/src/extractors/html.js +1 -1
  25. package/src/extractors/javascript.js +7 -3
  26. package/src/extractors/kotlin.js +8 -3
  27. package/src/extractors/lua.js +152 -0
  28. package/src/extractors/markdown.js +1 -1
  29. package/src/extractors/php.js +8 -3
  30. package/src/extractors/properties.js +1 -1
  31. package/src/extractors/python.js +5 -2
  32. package/src/extractors/python_dataclass.js +3 -1
  33. package/src/extractors/r.js +1 -1
  34. package/src/extractors/ruby.js +1 -1
  35. package/src/extractors/rust.js +13 -5
  36. package/src/extractors/scala.js +8 -3
  37. package/src/extractors/shell.js +1 -1
  38. package/src/extractors/svelte.js +1 -1
  39. package/src/extractors/swift.js +8 -3
  40. package/src/extractors/toml.js +1 -1
  41. package/src/extractors/typescript.js +8 -4
  42. package/src/extractors/typescript_react.js +1 -1
  43. package/src/extractors/vue_sfc.js +3 -1
  44. package/src/extractors/xml.js +3 -1
  45. package/src/extractors/yaml.js +1 -1
  46. package/src/graph/call-graph.js +81 -1
  47. package/src/mcp/server.js +46 -4
  48. package/src/retrieval/ranker.js +14 -5
@@ -3,7 +3,7 @@
3
3
  const { capWithNotice } = require('../util/truncate');
4
4
 
5
5
  // Ceiling discloses what it drops rather than truncating silently (#583).
6
- const PER_FILE_LIMIT = 50;
6
+ const PER_FILE_LIMIT = 200;
7
7
 
8
8
  /**
9
9
  * Extract signatures from .properties configuration files.
@@ -6,7 +6,10 @@ const { capWithNotice } = require('../util/truncate');
6
6
 
7
7
  // Ceiling sits above the default `maxSigsPerFile` so the configured budget
8
8
  // governs output rather than a literal buried here, and omissions are disclosed (#576).
9
- const PER_FILE_LIMIT = 30;
9
+ const PER_FILE_LIMIT = 200;
10
+
11
+ // Per-class member ceiling, disclosed via capWithNotice (#576).
12
+ const MEMBER_LIMIT = 120;
10
13
 
11
14
  /**
12
15
  * 1-based line of the last source line belonging to a top-level (indent 0)
@@ -162,7 +165,7 @@ function extractClassMethods(stripped, startIndex) {
162
165
  methods.push(`${asyncKw}def ${m[1]}(${params})${retStr}`);
163
166
  }
164
167
  }
165
- return methods.slice(0, 8);
168
+ return capWithNotice(methods, MEMBER_LIMIT, 'methods');
166
169
  }
167
170
 
168
171
  function tryExtractDataclassFields(stripped, classIndex) {
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { capWithNotice } = require('../util/truncate');
4
+
3
5
  /**
4
6
  * Extract Python dataclass, Pydantic model, and SQLAlchemy ORM metadata.
5
7
  * Focuses on model fields, validation, and relationships.
@@ -71,7 +73,7 @@ function extract(src) {
71
73
  sigs.push('config-class');
72
74
  }
73
75
 
74
- return Array.from(new Set(sigs)).slice(0, 50);
76
+ return capWithNotice(Array.from(new Set(sigs)), 200, 'signatures');
75
77
  }
76
78
 
77
79
  module.exports = { extract };
@@ -4,7 +4,7 @@ const { capWithNotice } = require('../util/truncate');
4
4
 
5
5
  // Ceiling sits above the default `maxSigsPerFile` so the configured budget
6
6
  // governs output rather than a literal buried here, and omissions are disclosed (#576).
7
- const PER_FILE_LIMIT = 30;
7
+ const PER_FILE_LIMIT = 200;
8
8
 
9
9
  /**
10
10
  * Extract signatures from R source code.
@@ -4,7 +4,7 @@ const { capWithNotice } = require('../util/truncate');
4
4
 
5
5
  // Ceiling sits above the default `maxSigsPerFile` so the configured budget
6
6
  // governs output rather than a literal buried here, and omissions are disclosed (#576).
7
- const PER_FILE_LIMIT = 25;
7
+ const PER_FILE_LIMIT = 200;
8
8
 
9
9
  /**
10
10
  * Extract signatures from Ruby source code.
@@ -1,11 +1,19 @@
1
1
  'use strict';
2
2
 
3
3
  const { lineAt, withAnchor } = require('./line-anchor');
4
- const { capWithNotice } = require('../util/truncate');
4
+ const { capWithNotice, capMembersWithNotice } = require('../util/truncate');
5
5
 
6
6
  // Ceiling sits above the default `maxSigsPerFile` so the configured budget
7
7
  // governs output rather than a literal buried here, and omissions are disclosed (#576).
8
- const PER_FILE_LIMIT = 25;
8
+ // Class bodies are scanned to this many characters. Real classes routinely
9
+ // run past the old 4KB scan window — truncating there silently hid every
10
+ // member after ~4000 chars AND anchored class end-lines short (#576). The
11
+ // ceiling only guards against pathological input (Java parity, #551).
12
+ const MAX_CLASS_BODY_CHARS = 200000;
13
+ const PER_FILE_LIMIT = 200;
14
+
15
+ // Per-impl member ceiling, disclosed via capMembersWithNotice (#576).
16
+ const MEMBER_LIMIT = 120;
9
17
 
10
18
  /**
11
19
  * Extract signatures from Rust source code.
@@ -63,7 +71,7 @@ function extract(src) {
63
71
  const block = extractBlock(stripped, bodyStart);
64
72
  sigs.push(withAnchor(`impl ${m[1]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
65
73
  for (const fn of extractMethods(block)) {
66
- sigs.push(hinted(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)), fn.name));
74
+ sigs.push(hinted(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + (fn.declIdx || 0)), lineAt(stripped, bodyStart + (fn.endIdx || 0))), fn.name));
67
75
  }
68
76
  }
69
77
 
@@ -80,7 +88,7 @@ function extract(src) {
80
88
 
81
89
  function extractBlock(src, startIndex) {
82
90
  let depth = 1, i = startIndex;
83
- const end = Math.min(src.length, startIndex + 5000);
91
+ const end = Math.min(src.length, startIndex + MAX_CLASS_BODY_CHARS);
84
92
  while (i < end && depth > 0) {
85
93
  if (src[i] === '{') depth++;
86
94
  else if (src[i] === '}') depth--;
@@ -101,7 +109,7 @@ function extractMethods(block) {
101
109
  endIdx: m.index + m[0].length,
102
110
  });
103
111
  }
104
- return methods.slice(0, 8);
112
+ return capMembersWithNotice(methods, MEMBER_LIMIT, 'methods');
105
113
  }
106
114
 
107
115
  function normalizeParams(params) {
@@ -6,8 +6,13 @@ const { capWithNotice, capMembersWithNotice } = require('../util/truncate');
6
6
  // Ceilings sit above the default `maxSigsPerFile` so the configured budget
7
7
  // governs output rather than a literal buried here, and omissions are disclosed
8
8
  // — an undisclosed cap looks like a class that simply has eight methods (#576).
9
- const MEMBER_LIMIT = 8;
10
- const PER_FILE_LIMIT = 25;
9
+ // Class bodies are scanned to this many characters. Real classes routinely
10
+ // run past the old 4KB scan window — truncating there silently hid every
11
+ // member after ~4000 chars AND anchored class end-lines short (#576). The
12
+ // ceiling only guards against pathological input (Java parity, #551).
13
+ const MAX_CLASS_BODY_CHARS = 200000;
14
+ const MEMBER_LIMIT = 120;
15
+ const PER_FILE_LIMIT = 200;
11
16
 
12
17
  /**
13
18
  * Extract signatures from Scala source code.
@@ -54,7 +59,7 @@ function extract(src) {
54
59
 
55
60
  function extractBlock(src, startIndex) {
56
61
  let depth = 1, i = startIndex;
57
- const end = Math.min(src.length, startIndex + 4000);
62
+ const end = Math.min(src.length, startIndex + MAX_CLASS_BODY_CHARS);
58
63
  while (i < end && depth > 0) {
59
64
  if (src[i] === '{') depth++;
60
65
  else if (src[i] === '}') depth--;
@@ -4,7 +4,7 @@ const { capWithNotice } = require('../util/truncate');
4
4
 
5
5
  // Ceiling sits above the default `maxSigsPerFile` so the configured budget
6
6
  // governs output rather than a literal buried here, and omissions are disclosed (#576).
7
- const PER_FILE_LIMIT = 25;
7
+ const PER_FILE_LIMIT = 200;
8
8
 
9
9
  /**
10
10
  * Extract signatures from shell scripts (bash, zsh, fish).
@@ -4,7 +4,7 @@ const { capWithNotice } = require('../util/truncate');
4
4
 
5
5
  // Ceiling sits above the default `maxSigsPerFile` so the configured budget
6
6
  // governs output rather than a literal buried here, and omissions are disclosed (#576).
7
- const PER_FILE_LIMIT = 25;
7
+ const PER_FILE_LIMIT = 200;
8
8
 
9
9
  /**
10
10
  * Extract signatures from Svelte components.
@@ -6,8 +6,13 @@ const { capWithNotice, capMembersWithNotice } = require('../util/truncate');
6
6
  // Ceilings sit above the default `maxSigsPerFile` so the configured budget
7
7
  // governs output rather than a literal buried here, and omissions are disclosed
8
8
  // — an undisclosed cap looks like a class that simply has eight methods (#576).
9
- const MEMBER_LIMIT = 8;
10
- const PER_FILE_LIMIT = 25;
9
+ // Class bodies are scanned to this many characters. Real classes routinely
10
+ // run past the old 4KB scan window — truncating there silently hid every
11
+ // member after ~4000 chars AND anchored class end-lines short (#576). The
12
+ // ceiling only guards against pathological input (Java parity, #551).
13
+ const MAX_CLASS_BODY_CHARS = 200000;
14
+ const MEMBER_LIMIT = 120;
15
+ const PER_FILE_LIMIT = 200;
11
16
 
12
17
  /**
13
18
  * Extract signatures from Swift source code.
@@ -61,7 +66,7 @@ function extract(src) {
61
66
 
62
67
  function extractBlock(src, startIndex) {
63
68
  let depth = 1, i = startIndex;
64
- const end = Math.min(src.length, startIndex + 4000);
69
+ const end = Math.min(src.length, startIndex + MAX_CLASS_BODY_CHARS);
65
70
  while (i < end && depth > 0) {
66
71
  if (src[i] === '{') depth++;
67
72
  else if (src[i] === '}') depth--;
@@ -3,7 +3,7 @@
3
3
  const { capWithNotice } = require('../util/truncate');
4
4
 
5
5
  // Ceiling discloses what it drops rather than truncating silently (#583).
6
- const PER_FILE_LIMIT = 40;
6
+ const PER_FILE_LIMIT = 200;
7
7
 
8
8
  /**
9
9
  * Extract signatures from TOML configuration files.
@@ -4,6 +4,10 @@ const { lineAt, withAnchor } = require('./line-anchor');
4
4
  const { capWithNotice, capMembersWithNotice } = require('../util/truncate');
5
5
  const { stripComments, maskCode, readBalanced } = require('./scan');
6
6
 
7
+ // Class bodies are scanned to this many characters — guard against
8
+ // pathological input only; the old 4KB window silently hid members (#576).
9
+ const MAX_CLASS_BODY_CHARS = 200000;
10
+
7
11
  /**
8
12
  * Extract signatures from TypeScript source code.
9
13
  * Top-level declarations carry a `:start-end` line anchor (see line-anchor.js);
@@ -189,13 +193,13 @@ function extract(src) {
189
193
  const anchored = anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s;
190
194
  return docHintFor[i] ? `${anchored} # ${docHintFor[i]}` : anchored;
191
195
  });
192
- return capWithNotice(withAnchors, 35, 'signatures');
196
+ return capWithNotice(withAnchors, 200, 'signatures');
193
197
  }
194
198
 
195
199
  function extractBlock(src, startIndex) {
196
200
  let depth = 1;
197
201
  let i = startIndex;
198
- const end = Math.min(src.length, startIndex + 4000);
202
+ const end = Math.min(src.length, startIndex + MAX_CLASS_BODY_CHARS);
199
203
  while (i < end && depth > 0) {
200
204
  if (src[i] === '{') depth++;
201
205
  else if (src[i] === '}') depth--;
@@ -223,7 +227,7 @@ function extractInterfaceMembers(block) {
223
227
  const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
224
228
  members.push({ text: `${m[1]}(${normalizeParams(block.slice(openIdx + 1, closeIdx))})`, start, end: closeIdx + 1 });
225
229
  }
226
- return capMembersWithNotice(members, 8, 'members');
230
+ return capMembersWithNotice(members, 120, 'members');
227
231
  }
228
232
 
229
233
  const _CTRL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'do', 'try', 'catch', 'finally', 'else', 'return']);
@@ -258,7 +262,7 @@ function extractClassMembers(block, maskedBlock) {
258
262
  const retStr = retType ? ` → ${retType}` : '';
259
263
  members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(params)})${retStr}`, start, end });
260
264
  }
261
- return capMembersWithNotice(members, 8, 'methods');
265
+ return capMembersWithNotice(members, 120, 'methods');
262
266
  }
263
267
 
264
268
  function normalizeParams(params) {
@@ -3,7 +3,7 @@
3
3
  const { capWithNotice } = require('../util/truncate');
4
4
 
5
5
  // Ceiling discloses what it drops rather than truncating silently (#583).
6
- const PER_FILE_LIMIT = 50;
6
+ const PER_FILE_LIMIT = 200;
7
7
 
8
8
  /**
9
9
  * Extract React component signatures from .tsx files.
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { capWithNotice } = require('../util/truncate');
4
+
3
5
  /**
4
6
  * Extract Vue Single-File Component (SFC) signatures from .vue files.
5
7
  * Captures component metadata: name, props, emits, slots, composables, lifecycle.
@@ -93,7 +95,7 @@ function extract(src) {
93
95
  sigs.push(`slot ${s}`);
94
96
  }
95
97
 
96
- return Array.from(new Set(sigs)).slice(0, 50);
98
+ return capWithNotice(Array.from(new Set(sigs)), 200, 'signatures');
97
99
  }
98
100
 
99
101
  module.exports = { extract };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { capWithNotice } = require('../util/truncate');
4
+
3
5
  /**
4
6
  * Lightweight XML config extractor.
5
7
  * Captures root tags, key config tags, and id/name/class attributes.
@@ -40,7 +42,7 @@ function extract(src) {
40
42
  if (cls) sigs.push(`${tag} -> ${cls[1]}`);
41
43
  }
42
44
 
43
- return Array.from(new Set(sigs)).slice(0, 50);
45
+ return capWithNotice(Array.from(new Set(sigs)), 200, 'signatures');
44
46
  }
45
47
 
46
48
  module.exports = { extract };
@@ -4,7 +4,7 @@ const { capWithNotice } = require('../util/truncate');
4
4
 
5
5
  // Ceiling sits above the default `maxSigsPerFile` so the configured budget
6
6
  // governs output rather than a literal buried here, and omissions are disclosed (#576).
7
- const PER_FILE_LIMIT = 25;
7
+ const PER_FILE_LIMIT = 200;
8
8
 
9
9
  /**
10
10
  * Extract signatures from YAML configuration files.
@@ -26,6 +26,8 @@ const PY_EXTS = new Set(['.py', '.pyw']);
26
26
  const JAVA_EXTS = new Set(['.java']);
27
27
  const GO_EXTS = new Set(['.go']);
28
28
  const RS_EXTS = new Set(['.rs']);
29
+ const KT_EXTS = new Set(['.kt', '.kts']);
30
+ const SCALA_EXTS = new Set(['.scala', '.sc']);
29
31
 
30
32
  // Tokens that look like `name(` calls or definition headers but are language
31
33
  // keywords, not user symbols — never treated as a call or a definition.
@@ -349,6 +351,81 @@ function rustDefs(masked) {
349
351
 
350
352
  // Pick the masker whose comment/string syntax matches the language.
351
353
  // Java and Go share JS syntax (Go raw strings mask like template literals).
354
+ /**
355
+ * Body range for a JVM-family member that may use either a brace body or an
356
+ * expression body (`fun f() = expr` / `def f = expr`), which Java has not.
357
+ * An expression body runs to the end of its line — enough to capture the calls
358
+ * it makes, which is all the graph needs.
359
+ * @returns {{bodyStart:number, bodyEnd:number}|null} null when neither form follows
360
+ */
361
+ function jvmBodyRange(masked, from) {
362
+ let k = from;
363
+ while (k < masked.length && masked[k] !== '{' && masked[k] !== '=' && masked[k] !== '\n' && masked[k] !== ';') k++;
364
+ if (masked[k] === '{') return { bodyStart: k, bodyEnd: matchDelim(masked, k, '{', '}') };
365
+ if (masked[k] === '=') {
366
+ // `= {` is still a brace body, just written with an assignment.
367
+ let j = k + 1;
368
+ while (j < masked.length && /\s/.test(masked[j])) j++;
369
+ if (masked[j] === '{') return { bodyStart: j, bodyEnd: matchDelim(masked, j, '{', '}') };
370
+ const eol = masked.indexOf('\n', k);
371
+ return { bodyStart: k, bodyEnd: eol === -1 ? masked.length : eol };
372
+ }
373
+ return null; // abstract member or interface declaration
374
+ }
375
+
376
+ /**
377
+ * Kotlin `fun` definitions, including extension functions (`fun Foo.bar()`,
378
+ * recorded as `bar`) and expression bodies.
379
+ */
380
+ function ktDefs(masked) {
381
+ const defs = [];
382
+ const seen = new Set();
383
+ const re = /(?:^|\n)[ \t]*((?:(?:public|private|protected|internal|open|override|abstract|final|suspend|inline|operator|infix|tailrec|external|expect|actual|inner|companion)\s+)*)fun\s+(?:<[^>\n]{0,80}>\s*)?(?:[A-Za-z_][\w.<>]*\.)?([A-Za-z_][\w]*)\s*\(/g;
384
+ let m;
385
+ while ((m = re.exec(masked)) !== null) {
386
+ const name = m[2];
387
+ if (NON_CALL.has(name)) continue;
388
+ const paren = masked.indexOf('(', m.index + m[0].length - 1);
389
+ const close = matchDelim(masked, paren, '(', ')');
390
+ if (close < 0) continue;
391
+ const range = jvmBodyRange(masked, close + 1);
392
+ const line = lineAt(masked, m.index + 1);
393
+ const key = name + ':' + (range ? range.bodyStart : line);
394
+ if (seen.has(key)) continue;
395
+ seen.add(key);
396
+ // An abstract/interface member owns no body: it can receive edges but
397
+ // never produces them, the same treatment javaDefs gives declarations.
398
+ defs.push(range ? { name, line, ...range } : { name, line, bodyStart: close, bodyEnd: close });
399
+ }
400
+ return defs;
401
+ }
402
+
403
+ /**
404
+ * Scala `def` definitions. Handles parameterless members (`def foo: Int = 1`),
405
+ * type parameters, and expression bodies.
406
+ */
407
+ function scalaDefs(masked) {
408
+ const defs = [];
409
+ const seen = new Set();
410
+ const re = /(?:^|\n)[ \t]*((?:(?:private|protected|override|implicit|final|lazy|sealed|abstract|inline)(?:\s*\[[^\]\n]{0,60}\])?\s+)*)def\s+([A-Za-z_][\w]*)\s*(?=[\[\(:=])/g;
411
+ let m;
412
+ while ((m = re.exec(masked)) !== null) {
413
+ const name = m[2];
414
+ if (NON_CALL.has(name)) continue;
415
+ // Step past an optional type-parameter list, then an optional value list.
416
+ let k = m.index + m[0].length;
417
+ if (masked[k] === '[') { const e = matchDelim(masked, k, '[', ']'); if (e < 0) continue; k = e + 1; }
418
+ while (masked[k] === '(') { const e = matchDelim(masked, k, '(', ')'); if (e < 0) break; k = e + 1; }
419
+ const range = jvmBodyRange(masked, k);
420
+ const line = lineAt(masked, m.index + 1);
421
+ const key = name + ':' + (range ? range.bodyStart : line);
422
+ if (seen.has(key)) continue;
423
+ seen.add(key);
424
+ defs.push(range ? { name, line, ...range } : { name, line, bodyStart: k, bodyEnd: k });
425
+ }
426
+ return defs;
427
+ }
428
+
352
429
  function maskFor(filePath, src) {
353
430
  const ext = path.extname(filePath).toLowerCase();
354
431
  if (PY_EXTS.has(ext)) return maskPy(src);
@@ -363,6 +440,8 @@ function extractDefs(filePath, src) {
363
440
  if (JAVA_EXTS.has(ext)) return javaDefs(maskJs(src));
364
441
  if (GO_EXTS.has(ext)) return goDefs(maskJs(src));
365
442
  if (RS_EXTS.has(ext)) return rustDefs(maskRust(src));
443
+ if (KT_EXTS.has(ext)) return ktDefs(maskJs(src));
444
+ if (SCALA_EXTS.has(ext)) return scalaDefs(maskJs(src));
366
445
  return null; // unsupported language
367
446
  }
368
447
 
@@ -452,7 +531,8 @@ function _walk(dir, excludeSet, out, depth, maxDepth) {
452
531
  if (e.isDirectory()) _walk(full, excludeSet, out, depth + 1, maxDepth);
453
532
  else if (e.isFile()) {
454
533
  const ext = path.extname(e.name).toLowerCase();
455
- if (JS_EXTS.has(ext) || PY_EXTS.has(ext) || JAVA_EXTS.has(ext) || GO_EXTS.has(ext) || RS_EXTS.has(ext)) out.push(full);
534
+ if (JS_EXTS.has(ext) || PY_EXTS.has(ext) || JAVA_EXTS.has(ext) || GO_EXTS.has(ext)
535
+ || RS_EXTS.has(ext) || KT_EXTS.has(ext) || SCALA_EXTS.has(ext)) out.push(full);
456
536
  }
457
537
  }
458
538
  }
package/src/mcp/server.js CHANGED
@@ -7,8 +7,8 @@
7
7
  * One JSON object per line on both stdin and stdout.
8
8
  *
9
9
  * Supported methods:
10
- * initialize → serverInfo + capabilities
11
- * tools/list → 19 tool definitions
10
+ * initialize → serverInfo + capabilities + negotiated protocolVersion
11
+ * tools/list → 21 tool definitions
12
12
  * tools/call → dispatch to handler, return result
13
13
  */
14
14
 
@@ -18,10 +18,22 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '8.33.0',
21
+ version: '8.35.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
25
+ // Protocol revisions this server actually speaks. The tools-only surface —
26
+ // initialize / tools/list / tools/call plus the initialized/cancelled
27
+ // notifications — is identical across these revisions, and the
28
+ // @hasmcp/mcp-spec-test suite passes every applicable 2025-11-25 case against
29
+ // it. 2026-07-28 is deliberately absent: that revision requires
30
+ // server/discover, which is not implemented. Newest first: a client offering
31
+ // a version outside this list is downgraded to [0], never echoed back —
32
+ // echoing an unspeakable version is itself a spec violation (#544), and it
33
+ // made the conformance suite believe 2026-07-28 was supported, producing six
34
+ // phantom server/discover failures on a revision never really offered (#545).
35
+ const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
36
+
25
37
  // ---------------------------------------------------------------------------
26
38
  // JSON-RPC helpers
27
39
  // ---------------------------------------------------------------------------
@@ -46,9 +58,32 @@ function dispatch(msg, cwd) {
46
58
  return;
47
59
  }
48
60
 
61
+ // server/discover (spec 2026-07-28) — session-less discovery, answerable
62
+ // before any handshake, so a client can learn the honest version list
63
+ // instead of offering versions and hoping. The response is a
64
+ // CacheableResult: deterministic, so the TTL promise of stability holds.
65
+ // Note supportedVersions does NOT include 2026-07-28 — answering discover
66
+ // is forward-compatible plumbing, not a claim to serve that revision's
67
+ // whole surface (per-result envelopes, inline _meta negotiation).
68
+ if (method === 'server/discover') {
69
+ respond(id, {
70
+ resultType: 'complete',
71
+ cacheScope: 'public',
72
+ ttlMs: 3600000,
73
+ supportedVersions: SUPPORTED_PROTOCOL_VERSIONS,
74
+ capabilities: { tools: {} },
75
+ serverInfo: SERVER_INFO,
76
+ instructions: 'SigMap serves code signatures for the working directory. Call query_context or search_signatures to rank and fetch signature blocks instead of reading whole files.',
77
+ });
78
+ return;
79
+ }
80
+
49
81
  if (method === 'initialize') {
82
+ const offered = params && params.protocolVersion;
50
83
  respond(id, {
51
- protocolVersion: (params && params.protocolVersion) || '2024-11-05',
84
+ protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(offered)
85
+ ? offered
86
+ : SUPPORTED_PROTOCOL_VERSIONS[0],
52
87
  serverInfo: SERVER_INFO,
53
88
  capabilities: { tools: {} },
54
89
  });
@@ -56,6 +91,13 @@ function dispatch(msg, cwd) {
56
91
  }
57
92
 
58
93
  if (method === 'tools/list') {
94
+ // No pagination: the full list fits one page, so any cursor a client
95
+ // presents is one this server never issued — reject it (-32602, per the
96
+ // spec's SHOULD) rather than silently restarting from page one.
97
+ if (params && params.cursor !== undefined) {
98
+ respondError(id, -32602, `Invalid cursor: ${String(params.cursor)}`);
99
+ return;
100
+ }
59
101
  respond(id, { tools: TOOLS });
60
102
  return;
61
103
  }
@@ -376,9 +376,15 @@ function rank(query, sigIndex, opts) {
376
376
  const hop1Files = new Set(); // normalised keys that received a hop1 boost
377
377
  const hop1Seeds = []; // original (un-normalised) paths, for hop-2 lookup
378
378
 
379
- // Hop 1: direct neighbors of scored files
380
- for (const entry of scored) {
381
- if (entry.score <= 0) continue;
379
+ // Hop 1: direct neighbors of scored files. Seeds snapshotted BEFORE the
380
+ // loop (as the call-graph block below already does) so boosts never
381
+ // cascade: a file whose only score is a hop-1 boost must not become a seed
382
+ // itself mid-loop, or the effective seed set — and every boost total —
383
+ // depends on index insertion order, which git history changes via the
384
+ // recent-commits hoist. That made gate scores differ between a shallow CI
385
+ // checkout and a developer clone of the same commit (#596).
386
+ const hop1SeedEntries = scored.filter((e) => e.score > 0);
387
+ for (const entry of hop1SeedEntries) {
382
388
  const neighbors = _graphGet(graph.forward, path.resolve(cwd, entry.file)) || [];
383
389
  for (const neighborAbs of neighbors) {
384
390
  const nk = path.normalize(neighborAbs);
@@ -393,7 +399,10 @@ function rank(query, sigIndex, opts) {
393
399
  }
394
400
  }
395
401
 
396
- // Hop 2: neighbors of hop1 files (only if they didn't get a direct score)
402
+ // Hop 2: neighbors of hop1 files (only if they didn't get a direct score).
403
+ // Eligibility frozen after hop-1 for the same reason: a file whose first
404
+ // score is a hop-2 boost must not become hop-2-eligible mid-loop (#596).
405
+ const hop2Eligible = scored.map((e) => e.score > 0);
397
406
  for (const hop1Key of hop1Seeds) {
398
407
  if (_graphGet(keyToIdx, hop1Key) === undefined) continue; // skip files not in index
399
408
  const neighbors = _graphGet(graph.forward, hop1Key) || [];
@@ -402,7 +411,7 @@ function rank(query, sigIndex, opts) {
402
411
  if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
403
412
  if (hop1Files.has(nk)) continue; // skip already hop1-boosted
404
413
  const idx = _graphGet(keyToIdx, nk);
405
- if (idx !== undefined && scored[idx].score > 0) {
414
+ if (idx !== undefined && hop2Eligible[idx]) {
406
415
  // Only boost files that have some baseline score (not noise)
407
416
  scored[idx].score += GRAPH_BOOST_AMOUNTS.hop2;
408
417
  scored[idx].signals.graphBoost = (scored[idx].signals.graphBoost || 0) + GRAPH_BOOST_AMOUNTS.hop2;