sigmap 8.17.0 → 8.19.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.
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const { lineAt, withAnchor } = require('./line-anchor');
4
+
3
5
  /**
4
6
  * Extract signatures from Dart source code.
7
+ * Signatures carry `:start-end` line anchors (Surgical Context); the comment
8
+ * strip below is newline-preserving so anchor lines match the original file.
5
9
  * @param {string} src - Raw file content
6
10
  * @returns {string[]} Array of signature strings
7
11
  */
@@ -11,21 +15,37 @@ function extract(src) {
11
15
 
12
16
  const stripped = src
13
17
  .replace(/\/\/.*$/gm, '')
14
- .replace(/\/\*[\s\S]*?\*\//g, '');
18
+ .replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, ' '));
19
+
20
+ // Anchor range: scan past same-line trivia (`async`, `=>` stops) to a body `{`.
21
+ const rangeFor = (declIdx, afterIdx) => {
22
+ let k = afterIdx;
23
+ while (k < stripped.length && /[ \tA-Za-z0-9_]/.test(stripped[k])) k++;
24
+ if (stripped[k] === '{') {
25
+ const end = k + 1 + extractBlock(stripped, k + 1).length;
26
+ return [lineAt(stripped, declIdx), lineAt(stripped, end)];
27
+ }
28
+ const line = lineAt(stripped, declIdx);
29
+ return [line, line];
30
+ };
15
31
 
16
32
  // Classes and abstract classes
17
33
  for (const m of stripped.matchAll(/^(?:abstract\s+)?class\s+(\w+)(?:<[^{]*>)?(?:\s+extends\s+[\w<>, ]+)?(?:\s+(?:implements|with|on)\s+[\w<>, ]+)?\s*\{/gm)) {
18
34
  const abs = m[0].trimStart().startsWith('abstract') ? 'abstract ' : '';
19
- sigs.push(`${abs}class ${m[1]}`);
20
- const block = extractBlock(stripped, m.index + m[0].length);
21
- for (const meth of extractMembers(block)) sigs.push(` ${meth}`);
35
+ const bodyStart = m.index + m[0].length;
36
+ const block = extractBlock(stripped, bodyStart);
37
+ sigs.push(withAnchor(`${abs}class ${m[1]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
38
+ for (const meth of extractMembers(block)) {
39
+ sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
40
+ }
22
41
  }
23
42
 
24
43
  // Top-level functions — capture return type (prefix before name) and show as suffix
25
44
  for (const m of stripped.matchAll(/^((?:Future<[\w<>?,\s]*>|[\w<>?]+))\s+(\w+)\s*\(([^)]*)\)/gm)) {
26
45
  if (m[2].startsWith('_')) continue;
27
- const retStr = (m[1] && m[1] !== 'void') ? ` \u2192 ${m[1].replace(/\s+/g, '').slice(0, 25)}` : '';
28
- sigs.push(`${m[2]}(${normalizeParams(m[3])})${retStr}`);
46
+ const retStr = (m[1] && m[1] !== 'void') ? ` ${m[1].replace(/\s+/g, '').slice(0, 25)}` : '';
47
+ const [s, e] = rangeFor(m.index, m.index + m[0].length);
48
+ sigs.push(withAnchor(`${m[2]}(${normalizeParams(m[3])})${retStr}`, s, e));
29
49
  }
30
50
 
31
51
  return sigs.slice(0, 25);
@@ -46,8 +66,12 @@ function extractMembers(block) {
46
66
  const members = [];
47
67
  for (const m of block.matchAll(/^\s+(?:@override\s+)?(?:@\w+\s+)*((?:Future<[\w<>?,\s]*>|[\w<>?]+))\s+(\w+)\s*\(([^)]*)\)/gm)) {
48
68
  if (m[2].startsWith('_')) continue;
49
- const retStr = (m[1] && m[1] !== 'void') ? ` \u2192 ${m[1].replace(/\s+/g, '').slice(0, 25)}` : '';
50
- members.push(`${m[2]}(${normalizeParams(m[3])})${retStr}`);
69
+ const retStr = (m[1] && m[1] !== 'void') ? ` ${m[1].replace(/\s+/g, '').slice(0, 25)}` : '';
70
+ members.push({
71
+ text: `${m[2]}(${normalizeParams(m[3])})${retStr}`,
72
+ declIdx: m.index + (m[0].length - m[0].trimStart().length),
73
+ endIdx: m.index + m[0].length,
74
+ });
51
75
  }
52
76
  return members.slice(0, 8);
53
77
  }
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const { lineAt, withAnchor } = require('./line-anchor');
4
+
3
5
  /**
4
6
  * Extract signatures from Kotlin source code.
7
+ * Signatures carry `:start-end` line anchors (Surgical Context); the comment
8
+ * strip below is newline-preserving so anchor lines match the original file.
5
9
  * @param {string} src - Raw file content
6
10
  * @returns {string[]} Array of signature strings
7
11
  */
@@ -11,21 +15,37 @@ function extract(src) {
11
15
 
12
16
  const stripped = src
13
17
  .replace(/\/\/.*$/gm, '')
14
- .replace(/\/\*[\s\S]*?\*\//g, '');
18
+ .replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, ' '));
19
+
20
+ // Anchor range: scan past same-line modifiers to a body `{` (range) else single line.
21
+ const rangeFor = (declIdx, afterIdx) => {
22
+ let k = afterIdx;
23
+ while (k < stripped.length && /[ \tA-Za-z0-9_]/.test(stripped[k])) k++;
24
+ if (stripped[k] === '{') {
25
+ const end = k + 1 + extractBlock(stripped, k + 1).length;
26
+ return [lineAt(stripped, declIdx), lineAt(stripped, end)];
27
+ }
28
+ const line = lineAt(stripped, declIdx);
29
+ return [line, line];
30
+ };
15
31
 
16
32
  // Classes, objects, interfaces
17
33
  for (const m of stripped.matchAll(/^(?:public\s+|internal\s+)?(?:data\s+|sealed\s+|abstract\s+|open\s+)?(class|object|interface)\s+(\w+)(?:[^{]*)\{/gm)) {
18
- sigs.push(`${m[1]} ${m[2]}`);
19
- const block = extractBlock(stripped, m.index + m[0].length);
20
- for (const meth of extractMembers(block)) sigs.push(` ${meth}`);
34
+ const bodyStart = m.index + m[0].length;
35
+ const block = extractBlock(stripped, bodyStart);
36
+ sigs.push(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
37
+ for (const meth of extractMembers(block)) {
38
+ sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
39
+ }
21
40
  }
22
41
 
23
42
  // Top-level functions — capture `: RetType` after params
24
43
  for (const m of stripped.matchAll(/^(?:public\s+|internal\s+)?(?:suspend\s+)?fun\s+(\w+)\s*(?:<[^(]*>)?\s*\(([^)]*)\)(?:\s*:\s*([^\n{=]+))?/gm)) {
25
44
  const suspend = m[0].includes('suspend') ? 'suspend ' : '';
26
45
  const retType = m[3] ? m[3].trim().replace(/\s+/g, ' ') : '';
27
- const retStr = retType ? ` \u2192 ${retType.slice(0, 25)}` : '';
28
- sigs.push(`${suspend}fun ${m[1]}(${normalizeParams(m[2])})${retStr}`);
46
+ const retStr = retType ? ` ${retType.slice(0, 25)}` : '';
47
+ const [s, e] = rangeFor(m.index, m.index + m[0].length);
48
+ sigs.push(withAnchor(`${suspend}fun ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e));
29
49
  }
30
50
 
31
51
  return sigs.slice(0, 25);
@@ -48,8 +68,12 @@ function extractMembers(block) {
48
68
  if (m[1].startsWith('_')) continue;
49
69
  const suspend = m[0].includes('suspend') ? 'suspend ' : '';
50
70
  const retType = m[3] ? m[3].trim().replace(/\s+/g, ' ') : '';
51
- const retStr = retType ? ` \u2192 ${retType.slice(0, 25)}` : '';
52
- members.push(`${suspend}fun ${m[1]}(${normalizeParams(m[2])})${retStr}`);
71
+ const retStr = retType ? ` ${retType.slice(0, 25)}` : '';
72
+ members.push({
73
+ text: `${suspend}fun ${m[1]}(${normalizeParams(m[2])})${retStr}`,
74
+ declIdx: m.index + (m[0].length - m[0].trimStart().length),
75
+ endIdx: m.index + m[0].length,
76
+ });
53
77
  }
54
78
  return members.slice(0, 8);
55
79
  }
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const { lineAt, withAnchor } = require('./line-anchor');
4
+
3
5
  /**
4
6
  * Extract signatures from PHP source code.
7
+ * Signatures carry `:start-end` line anchors (Surgical Context); the comment
8
+ * strips below are newline-preserving so anchor lines match the original file.
5
9
  * @param {string} src - Raw file content
6
10
  * @returns {string[]} Array of signature strings
7
11
  */
@@ -12,23 +16,40 @@ function extract(src) {
12
16
  const stripped = src
13
17
  .replace(/\/\/.*$/gm, '')
14
18
  .replace(/#.*$/gm, '')
15
- .replace(/\/\*[\s\S]*?\*\//g, '');
19
+ .replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, ' '));
20
+
21
+ // Anchor range: scan past same-line trivia to a body `{` (range) else single line.
22
+ const rangeFor = (declIdx, afterIdx) => {
23
+ let k = afterIdx;
24
+ while (k < stripped.length && /[ \tA-Za-z0-9_:?\\]/.test(stripped[k])) k++;
25
+ if (stripped[k] === '{' || (stripped[k] === '\n' && stripped[k + 1] === '{')) {
26
+ const open = stripped[k] === '{' ? k : k + 1;
27
+ const end = open + 1 + extractBlock(stripped, open + 1).length;
28
+ return [lineAt(stripped, declIdx), lineAt(stripped, end)];
29
+ }
30
+ const line = lineAt(stripped, declIdx);
31
+ return [line, line];
32
+ };
16
33
 
17
34
  // Classes and interfaces
18
35
  const typeRe = /^(?:abstract\s+)?(?:class|interface|trait)\s+(\w+)(?:\s+extends\s+\w+)?(?:\s+implements\s+[\w, ]+)?\s*\{/gm;
19
36
  for (const m of stripped.matchAll(typeRe)) {
20
37
  const kind = m[0].trimStart().startsWith('interface') ? 'interface' :
21
38
  m[0].trimStart().startsWith('trait') ? 'trait' : 'class';
22
- sigs.push(`${kind} ${m[1]}`);
23
- const block = extractBlock(stripped, m.index + m[0].length);
24
- for (const meth of extractMembers(block)) sigs.push(` ${meth}`);
39
+ const bodyStart = m.index + m[0].length;
40
+ const block = extractBlock(stripped, bodyStart);
41
+ sigs.push(withAnchor(`${kind} ${m[1]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
42
+ for (const meth of extractMembers(block)) {
43
+ sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
44
+ }
25
45
  }
26
46
 
27
47
  // Top-level functions
28
48
  for (const m of stripped.matchAll(/^function\s+(\w+)\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/gm)) {
29
49
  const ret = normalizeType(m[3]);
30
50
  const retStr = ret ? ` → ${ret}` : '';
31
- sigs.push(`function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
51
+ const [s, e] = rangeFor(m.index, m.index + m[0].length);
52
+ sigs.push(withAnchor(`function ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e));
32
53
  }
33
54
 
34
55
  return sigs.slice(0, 25);
@@ -53,7 +74,11 @@ function extractMembers(block) {
53
74
  const isStatic = m[0].includes('static ') ? 'static ' : '';
54
75
  const ret = normalizeType(m[3]);
55
76
  const retStr = ret ? ` → ${ret}` : '';
56
- members.push(`${isStatic}function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
77
+ members.push({
78
+ text: `${isStatic}function ${m[1]}(${normalizeParams(m[2])})${retStr}`,
79
+ declIdx: m.index + (m[0].length - m[0].trimStart().length),
80
+ endIdx: m.index + m[0].length,
81
+ });
57
82
  }
58
83
  return members.slice(0, 8);
59
84
  }
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const { lineAt, withAnchor } = require('./line-anchor');
4
+
3
5
  /**
4
6
  * Extract signatures from Scala source code.
7
+ * Signatures carry `:start-end` line anchors (Surgical Context); the comment
8
+ * strip below is newline-preserving so anchor lines match the original file.
5
9
  * @param {string} src - Raw file content
6
10
  * @returns {string[]} Array of signature strings
7
11
  */
@@ -11,7 +15,7 @@ function extract(src) {
11
15
 
12
16
  const stripped = src
13
17
  .replace(/\/\/.*$/gm, '')
14
- .replace(/\/\*[\s\S]*?\*\//g, '');
18
+ .replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, ' '));
15
19
 
16
20
  // Classes, traits, objects
17
21
  const typeRe = /^(?:case\s+)?(?:class|trait|object)\s+(\w+)(?:\[[\w, ]+\])?(?:[^{]*)\{/gm;
@@ -19,9 +23,12 @@ function extract(src) {
19
23
  const kind = m[0].trimStart().startsWith('case class') ? 'case class' :
20
24
  m[0].trimStart().startsWith('trait') ? 'trait' :
21
25
  m[0].trimStart().startsWith('object') ? 'object' : 'class';
22
- sigs.push(`${kind} ${m[1]}`);
23
- const block = extractBlock(stripped, m.index + m[0].length);
24
- for (const fn of extractMembers(block)) sigs.push(` ${fn}`);
26
+ const bodyStart = m.index + m[0].length;
27
+ const block = extractBlock(stripped, bodyStart);
28
+ sigs.push(withAnchor(`${kind} ${m[1]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
29
+ for (const fn of extractMembers(block)) {
30
+ sigs.push(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)));
31
+ }
25
32
  }
26
33
 
27
34
  // Top-level defs
@@ -30,7 +37,8 @@ function extract(src) {
30
37
  const params = m[2] ? `(${normalizeParams(m[2])})` : '';
31
38
  const ret = normalizeType(m[3]);
32
39
  const retStr = ret ? ` → ${ret}` : '';
33
- sigs.push(`def ${m[1]}${params}${retStr}`);
40
+ const line = lineAt(stripped, m.index);
41
+ sigs.push(withAnchor(`def ${m[1]}${params}${retStr}`, line, line));
34
42
  }
35
43
 
36
44
  return sigs.slice(0, 25);
@@ -54,7 +62,11 @@ function extractMembers(block) {
54
62
  const params = m[2] ? `(${normalizeParams(m[2])})` : '';
55
63
  const ret = normalizeType(m[3]);
56
64
  const retStr = ret ? ` → ${ret}` : '';
57
- members.push(`def ${m[1]}${params}${retStr}`);
65
+ members.push({
66
+ text: `def ${m[1]}${params}${retStr}`,
67
+ declIdx: m.index + (m[0].length - m[0].trimStart().length),
68
+ endIdx: m.index + m[0].length,
69
+ });
58
70
  }
59
71
  return members.slice(0, 8);
60
72
  }
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const { lineAt, withAnchor } = require('./line-anchor');
4
+
3
5
  /**
4
6
  * Extract signatures from Swift source code.
7
+ * Signatures carry `:start-end` line anchors (Surgical Context); the comment
8
+ * strip below is newline-preserving so anchor lines match the original file.
5
9
  * @param {string} src - Raw file content
6
10
  * @returns {string[]} Array of signature strings
7
11
  */
@@ -11,21 +15,37 @@ function extract(src) {
11
15
 
12
16
  const stripped = src
13
17
  .replace(/\/\/.*$/gm, '')
14
- .replace(/\/\*[\s\S]*?\*\//g, '');
18
+ .replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, ' '));
19
+
20
+ // Anchor range: scan past same-line modifiers to a body `{` (range) else single line.
21
+ const rangeFor = (declIdx, afterIdx) => {
22
+ let k = afterIdx;
23
+ while (k < stripped.length && /[ \tA-Za-z0-9_>-]/.test(stripped[k])) k++;
24
+ if (stripped[k] === '{') {
25
+ const end = k + 1 + extractBlock(stripped, k + 1).length;
26
+ return [lineAt(stripped, declIdx), lineAt(stripped, end)];
27
+ }
28
+ const line = lineAt(stripped, declIdx);
29
+ return [line, line];
30
+ };
15
31
 
16
32
  // Classes, structs, protocols, enums
17
33
  const typeRe = /^(?:public\s+|internal\s+|open\s+)?(?:final\s+)?(class|struct|protocol|enum|actor)\s+(\w+)(?:<[^{]*>)?(?:\s*:\s*[\w, <>.]+)?\s*\{/gm;
18
34
  for (const m of stripped.matchAll(typeRe)) {
19
- sigs.push(`${m[1]} ${m[2]}`);
20
- const block = extractBlock(stripped, m.index + m[0].length);
21
- for (const fn of extractMembers(block)) sigs.push(` ${fn}`);
35
+ const bodyStart = m.index + m[0].length;
36
+ const block = extractBlock(stripped, bodyStart);
37
+ sigs.push(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
38
+ for (const fn of extractMembers(block)) {
39
+ sigs.push(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)));
40
+ }
22
41
  }
23
42
 
24
43
  // Top-level public functions — capture everything after ) to end of line for arrow type
25
44
  for (const m of stripped.matchAll(/^(?:public\s+|internal\s+)?(?:static\s+)?(?:async\s+)?func\s+(\w+)(?:<[^(]*>)?\s*\(([^)]*)\)([^{\n]*)/gm)) {
26
45
  const asyncKw = m[0].includes('async') ? 'async ' : '';
27
46
  const retStr = extractArrowType(m[3]);
28
- sigs.push(`${asyncKw}func ${m[1]}(${normalizeParams(m[2])})${retStr}`);
47
+ const [s, e] = rangeFor(m.index, m.index + m[0].length);
48
+ sigs.push(withAnchor(`${asyncKw}func ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e));
29
49
  }
30
50
 
31
51
  return sigs.slice(0, 25);
@@ -48,7 +68,11 @@ function extractMembers(block) {
48
68
  if (m[1].startsWith('_')) continue;
49
69
  const asyncKw = m[0].includes('async') ? 'async ' : '';
50
70
  const retStr = extractArrowType(m[3]);
51
- members.push(`${asyncKw}func ${m[1]}(${normalizeParams(m[2])})${retStr}`);
71
+ members.push({
72
+ text: `${asyncKw}func ${m[1]}(${normalizeParams(m[2])})${retStr}`,
73
+ declIdx: m.index + (m[0].length - m[0].trimStart().length),
74
+ endIdx: m.index + m[0].length,
75
+ });
52
76
  }
53
77
  return members.slice(0, 8);
54
78
  }
@@ -67,7 +91,7 @@ function extractArrowType(str) {
67
91
  const m = str.match(/->\s*([^\n{]+)/);
68
92
  if (!m) return '';
69
93
  const rt = m[1].trim().replace(/\s+/g, ' ');
70
- return ` \u2192 ${rt.length > 25 ? rt.slice(0, 22) + '...' : rt}`;
94
+ return ` ${rt.length > 25 ? rt.slice(0, 22) + '...' : rt}`;
71
95
  }
72
96
 
73
97
  module.exports = { extract };
@@ -20,7 +20,14 @@ function shouldSkipFile(rel) {
20
20
  return /(^|\/)(gen-context|gen-project-map)\.js$/.test(normalized);
21
21
  }
22
22
 
23
- function analyze(files, cwd) {
23
+ /**
24
+ * Structured route rows across the supported frameworks — the data behind
25
+ * `analyze`, exposed for retrieval surface-enrichment (#488).
26
+ * @param {string[]} files absolute paths
27
+ * @param {string} cwd
28
+ * @returns {{ method:string, path:string, file:string }[]} file is cwd-relative
29
+ */
30
+ function collectRoutes(files, cwd) {
24
31
  const routes = [];
25
32
 
26
33
  for (const filePath of files) {
@@ -112,6 +119,11 @@ function analyze(files, cwd) {
112
119
  }
113
120
  }
114
121
 
122
+ return routes;
123
+ }
124
+
125
+ function analyze(files, cwd) {
126
+ const routes = collectRoutes(files, cwd);
115
127
  if (routes.length === 0) return '';
116
128
 
117
129
  const lines = [
@@ -124,4 +136,4 @@ function analyze(files, cwd) {
124
136
  return lines.join('\n');
125
137
  }
126
138
 
127
- module.exports = { analyze };
139
+ module.exports = { analyze, collectRoutes };
@@ -421,7 +421,7 @@ function queryContext(args, cwd) {
421
421
  // Build dependency graph for neighbor boost — non-fatal if it fails
422
422
  let graph = null;
423
423
  try { graph = buildFromCwd(cwd); } catch (_) {}
424
- // Opt-in call-graph neighbor boost (retrieval.callGraphBoost) — non-fatal
424
+ // Opt-in call-graph neighbor boost + surface enrichment — non-fatal
425
425
  let callGraph = null;
426
426
  try {
427
427
  const { loadConfig } = require('../config/loader');
@@ -429,6 +429,9 @@ function queryContext(args, cwd) {
429
429
  if (retrieval && retrieval.callGraphBoost) {
430
430
  callGraph = require('../graph/call-graph').buildCallFileGraph(cwd);
431
431
  }
432
+ if (retrieval && retrieval.surfaceEnrichment) {
433
+ require('../retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd);
434
+ }
432
435
  } catch (_) {}
433
436
  const results = rank(args.query, index, { topK, cwd, graph, callGraph });
434
437
  return formatRankTable(results, args.query);
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: '8.17.0',
21
+ version: '8.19.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -159,7 +159,10 @@ function bm25rank(query, candidates) {
159
159
 
160
160
  const docs = candidates.map((c) => {
161
161
  const pathToks = tokenize(c.file || '');
162
- const toks = tokenize((c.sigs || []).join(' '));
162
+ // Ranking is anchor-invariant: `:start-end` line anchors are metadata,
163
+ // not content — strip them before tokenizing so adding anchors to an
164
+ // extractor never shifts BM25 length normalization or token counts.
165
+ const toks = tokenize((c.sigs || []).map((s) => String(s).replace(/\s*:\d+(?:-\d+)?\s*$/, '')).join(' '));
163
166
  for (let i = 0; i < PATH_BOOST; i++) toks.push(...pathToks);
164
167
  const tf = new Map();
165
168
  for (const t of toks) tf.set(t, (tf.get(t) || 0) + 1);
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Retrieval surface-enrichment (#488, §7.4 Retrieval ceiling — opt-in via
5
+ * `retrieval.surfaceEnrichment`, measure-gated).
6
+ *
7
+ * The map analyzers extract surfaces (routes) that never reach the rankable
8
+ * signature index — a query like "payment webhook route" cannot match a
9
+ * controller whose signatures never mention the route path. This module
10
+ * appends deterministic pseudo-signatures (`route GET /api/users`) to the
11
+ * defining file's signature list so the ranker's tokenizer can see them.
12
+ *
13
+ * Deterministic: rows are deduped and sorted; entries are copy-on-write so
14
+ * arrays shared with the signature cache are never mutated.
15
+ */
16
+
17
+ const path = require('path');
18
+
19
+ /**
20
+ * Enrich a signature index with route pseudo-signatures.
21
+ * @param {Map<string,string[]>} index cwd-relative file → sigs (mutated: enriched entries are replaced with fresh arrays)
22
+ * @param {string} cwd
23
+ * @returns {number} pseudo-signatures added
24
+ */
25
+ function enrichWithSurfaces(index, cwd) {
26
+ if (!(index instanceof Map) || index.size === 0) return 0;
27
+
28
+ let collectRoutes;
29
+ try { ({ collectRoutes } = require('../map/route-table')); } catch (_) { return 0; }
30
+
31
+ const rels = [...index.keys()];
32
+ const files = rels.map((rel) => path.join(cwd, rel));
33
+ let routes = [];
34
+ try { routes = collectRoutes(files, cwd) || []; } catch (_) { return 0; }
35
+
36
+ const byFile = new Map();
37
+ for (const r of routes) {
38
+ const rel = String(r.file).replace(/\\/g, '/');
39
+ if (!byFile.has(rel)) byFile.set(rel, new Set());
40
+ byFile.get(rel).add(`route ${r.method} ${r.path}`);
41
+ }
42
+
43
+ let added = 0;
44
+ for (const [rel, extras] of [...byFile.entries()].sort()) {
45
+ const sigs = index.get(rel);
46
+ if (!sigs) continue;
47
+ const fresh = [...extras].sort().filter((x) => !sigs.includes(x));
48
+ if (!fresh.length) continue;
49
+ index.set(rel, [...sigs, ...fresh]); // copy-on-write: never mutate cached arrays
50
+ added += fresh.length;
51
+ }
52
+ return added;
53
+ }
54
+
55
+ module.exports = { enrichWithSurfaces };