sigmap 8.19.0 → 8.21.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/llms-full.txt CHANGED
@@ -11,20 +11,20 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.19.0 | Benchmark: sigmap-v8.19-main (2026-07-19)
14
+ # Version: 8.21.0 | Benchmark: sigmap-v8.21-main (2026-07-19)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
18
18
  ---
19
19
 
20
- ## Core metrics (benchmark: sigmap-v8.19-main, 2026-07-19)
20
+ ## Core metrics (benchmark: sigmap-v8.21-main, 2026-07-19)
21
21
 
22
22
  | Metric | Without SigMap | With SigMap |
23
23
  |--------|----------------|-------------|
24
- | Retrieval hit@5 | 42.7% (single-shot grep) | 86.7% (2.02× lift) |
25
- | Token reduction | — | 96.9% average |
26
- | Task-success proxy (modeled) | — | 68.9% |
27
- | Prompts per task | 2.84 | 1.44 (49.2% fewer) |
24
+ | Retrieval hit@5 | 42.7% (single-shot grep) | 85.6% (2.00× lift) |
25
+ | Token reduction | — | 96.8% average |
26
+ | Task-success proxy (modeled) | — | 66.7% |
27
+ | Prompts per task | 2.84 | 1.48 (48.0% fewer) |
28
28
  | Supported languages | — | 33 |
29
29
  | MCP tools | — | 20 |
30
30
  | npm runtime dependencies | — | 0 |
@@ -123,6 +123,8 @@ sigmap ask "<query>" --squeeze-threshold N Min reduction %% to prompt (default
123
123
  sigmap evidence "<query>" Build a deterministic Evidence Pack (JSON) → .context/evidence-pack.json
124
124
  sigmap evidence "<query>" --markdown Emit the Markdown handoff rendering to stdout
125
125
  sigmap evidence "<query>" --top <n> --budget <n> --out <path> Tune ranked files / token budget / write rendered output
126
+ sigmap memory List cross-session stores (.context/) — entries, size, age
127
+ sigmap memory --clear <store> Clear one store: session|notes|weights|evidence|all (--json supported)
126
128
  sigmap note "<text>" Append a note to the cross-session decision log
127
129
  sigmap note List recent notes (also: note --list <N>)
128
130
  sigmap status Show repo state — branch, dirty files, index freshness, notes
@@ -343,7 +345,7 @@ testCoverage = false
343
345
  testDirs = ["tests","test","__tests__","spec"]
344
346
  sigCache = false
345
347
  impactRadius = false
346
- retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false,"surfaceEnrichment":false}
348
+ retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false,"centralityBlend":false,"surfaceEnrichment":false}
347
349
  impact = {"depth":3,"includeSigs":true}
348
350
  ```
349
351
 
package/llms.txt CHANGED
@@ -11,7 +11,7 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.19.0 | Benchmark: sigmap-v8.19-main (2026-07-19)
14
+ # Version: 8.21.0 | Benchmark: sigmap-v8.21-main (2026-07-19)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
@@ -23,12 +23,12 @@ Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
23
23
  - No blast-radius awareness before editing a hub file — `--impact` shows every file a change touches.
24
24
  - Pasted stack traces, CI logs, and JSON bloat the prompt — `squeeze` minimizes them and enriches the top frame from the symbol index.
25
25
 
26
- ## Core metrics (benchmark: sigmap-v8.19-main, 2026-07-19)
26
+ ## Core metrics (benchmark: sigmap-v8.21-main, 2026-07-19)
27
27
 
28
- - hit@5 retrieval: 86.7% vs 42.7% single-shot grep baseline (2.02× lift)
29
- - Token reduction: 96.9% average across benchmark repos
30
- - Task-success proxy: 68.9% (modeled from retrieval tiers, not measured LLM sessions)
31
- - Prompts per task: 1.44 vs 2.84 baseline (49.2% fewer, modeled)
28
+ - hit@5 retrieval: 85.6% vs 42.7% single-shot grep baseline (2.00× lift)
29
+ - Token reduction: 96.8% average across benchmark repos
30
+ - Task-success proxy: 66.7% (modeled from retrieval tiers, not measured LLM sessions)
31
+ - Prompts per task: 1.48 vs 2.84 baseline (48.0% fewer, modeled)
32
32
  - Languages: 33 supported · MCP tools: 20
33
33
  - Dependencies: zero npm runtime dependencies · fully offline
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "8.19.0",
3
+ "version": "8.21.0",
4
4
  "description": "The deterministic, verifiable grounding layer for AI code work — a zero-dependency signature-and-evidence map that grounds Claude, Cursor, Copilot, Aider, Windsurf, local LLMs & MCP agents against your real code (repo + installed libraries) so they stop hallucinating files, imports & APIs. Runs offline via npx; byte-stable output; ~97% token reduction as proof.",
5
5
  "main": "packages/core/index.js",
6
6
  "exports": {
@@ -31,6 +31,7 @@
31
31
  "benchmark:test-discovery": "node scripts/run-test-discovery-benchmark.mjs --save",
32
32
  "benchmark:terse": "node scripts/run-terse-benchmark.mjs --save",
33
33
  "benchmark:callgraph-boost": "node scripts/run-callgraph-boost-benchmark.mjs --save",
34
+ "benchmark:centrality-blend": "node scripts/run-centrality-blend-benchmark.mjs --save",
34
35
  "benchmark:surface-enrichment": "node scripts/run-surface-enrichment-benchmark.mjs --save",
35
36
  "validate:squeeze": "node scripts/run-squeeze-benchmark.mjs --gate",
36
37
  "health": "node gen-context.js --health",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "8.19.0",
3
+ "version": "8.21.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "8.19.0",
3
+ "version": "8.21.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -149,6 +149,8 @@ const DEFAULTS = {
149
149
  recencyBoost: 1.5,
150
150
  // Boost files call-graph-connected to query matches (opt-in, measure-gated)
151
151
  callGraphBoost: false,
152
+ // Blend import-graph centrality into ranking as a small prior (opt-in, measure-gated)
153
+ centralityBlend: false,
152
154
  // Append route pseudo-signatures to the rankable index (opt-in, measure-gated)
153
155
  surfaceEnrichment: false,
154
156
  },
@@ -12,6 +12,10 @@ const { lineAt, withAnchor } = require('./line-anchor');
12
12
  function extract(src) {
13
13
  if (!src || typeof src !== 'string') return [];
14
14
  const sigs = [];
15
+ const docHints = buildDocHints(src);
16
+ // Append the godoc hint after the anchor as ` # <hint>` — same convention
17
+ // as the Python/JS extractors' doc hints.
18
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
15
19
 
16
20
  const stripped = src
17
21
  .replace(/\/\/.*$/gm, '')
@@ -23,14 +27,14 @@ function extract(src) {
23
27
  // Structs
24
28
  for (const m of stripped.matchAll(/^type\s+(\w+)\s+struct\s*\{/gm)) {
25
29
  const end = blockEndIdx(m.index + m[0].length);
26
- sigs.push(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)));
30
+ sigs.push(hinted(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)), m[1]));
27
31
  }
28
32
 
29
33
  // Interfaces
30
34
  for (const m of stripped.matchAll(/^type\s+(\w+)\s+interface\s*\{/gm)) {
31
35
  const bodyStart = m.index + m[0].length;
32
36
  const block = extractBlock(stripped, bodyStart);
33
- sigs.push(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
37
+ sigs.push(hinted(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[1]));
34
38
  for (const meth of extractInterfaceMethods(block)) {
35
39
  sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
36
40
  }
@@ -42,7 +46,7 @@ function extract(src) {
42
46
  const retType = m[4] ? m[4].trim().replace(/\s+/g, ' ') : '';
43
47
  const retStr = retType ? ` → ${retType.slice(0, 30)}` : '';
44
48
  const end = blockEndIdx(m.index + m[0].length);
45
- sigs.push(withAnchor(`func ${receiver}${m[2]}(${normalizeParams(m[3])})${retStr}`, lineAt(stripped, m.index), lineAt(stripped, end)));
49
+ sigs.push(hinted(withAnchor(`func ${receiver}${m[2]}(${normalizeParams(m[3])})${retStr}`, lineAt(stripped, m.index), lineAt(stripped, end)), m[2]));
46
50
  }
47
51
 
48
52
  return sigs.slice(0, 25);
@@ -78,4 +82,28 @@ function normalizeParams(params) {
78
82
  return params.trim().replace(/\s+/g, ' ');
79
83
  }
80
84
 
85
+ // Godoc: the `//` comment block directly above a top-level func/type/method
86
+ // declaration → first prose sentence, 60-char cap. Runs on the ORIGINAL src
87
+ // (extract strips comments before matching). Compiler directives (`//go:...`)
88
+ // carry no prose and are skipped.
89
+ function buildDocHints(src) {
90
+ const hints = new Map();
91
+ const re = /((?:^\/\/[^\n]*\n)+)(?:func\s+(?:\(\w+\s+[\w*]+\)\s+)?(\w+)\s*\(|type\s+(\w+)\s+(?:struct|interface)\b)/gm;
92
+ for (const m of src.matchAll(re)) {
93
+ const name = m[2] || m[3];
94
+ const hint = firstDocSentence(m[1]);
95
+ if (hint && !hints.has(name)) hints.set(name, hint);
96
+ }
97
+ return hints;
98
+ }
99
+
100
+ // First non-directive prose line of a `//` block → first sentence, 60-char cap.
101
+ function firstDocSentence(block) {
102
+ const line = String(block).split('\n')
103
+ .map((l) => l.replace(/^\/\/\s?/, '').trim())
104
+ .find((l) => l && !l.startsWith('go:') && !l.startsWith('nolint'));
105
+ if (!line) return '';
106
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
107
+ }
108
+
81
109
  module.exports = { extract };
@@ -12,6 +12,10 @@ const { lineAt, withAnchor } = require('./line-anchor');
12
12
  function extract(src) {
13
13
  if (!src || typeof src !== 'string') return [];
14
14
  const sigs = [];
15
+ const docHints = buildDocHints(src);
16
+ // Append the Javadoc hint after the anchor as ` # <hint>` — same convention
17
+ // as the Python/JS extractors' doc hints.
18
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
15
19
 
16
20
  const stripped = src
17
21
  .replace(/\/\/.*$/gm, '')
@@ -22,9 +26,9 @@ function extract(src) {
22
26
  for (const m of stripped.matchAll(typeRegex)) {
23
27
  const bodyStart = m.index + m[0].length;
24
28
  const block = extractBlock(stripped, bodyStart);
25
- sigs.push(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
29
+ sigs.push(hinted(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[2]));
26
30
  for (const meth of extractMembers(block)) {
27
- sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
31
+ sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)), meth.name));
28
32
  }
29
33
  }
30
34
 
@@ -51,6 +55,7 @@ function extractMembers(block) {
51
55
  const retStr = ret ? ` → ${ret}` : '';
52
56
  members.push({
53
57
  text: `${m[2]}(${normalizeParams(m[3])})${retStr}`,
58
+ name: m[2],
54
59
  declIdx: m.index + (m[0].length - m[0].trimStart().length),
55
60
  endIdx: m.index + m[0].length,
56
61
  });
@@ -68,4 +73,34 @@ function normalizeType(type) {
68
73
  return type.trim().replace(/\s+/g, ' ').slice(0, 30);
69
74
  }
70
75
 
76
+ // Javadoc: the `/** ... */` block directly above a type or public/protected
77
+ // member declaration → first prose sentence, 60-char cap. Runs on the
78
+ // ORIGINAL src (extract strips comments before matching). Annotation lines
79
+ // (`@Override` etc.) between the doc block and the declaration are tolerated.
80
+ // Body may not contain `*/` so a failed adjacency check can't expand across
81
+ // code to the next comment block and misattribute the hint.
82
+ function buildDocHints(src) {
83
+ const hints = new Map();
84
+ const patterns = [
85
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public\s+|protected\s+)?(?:abstract\s+|final\s+)?(?:class|interface|enum)\s+(\w+)/g,
86
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public|protected)\s+(?:static\s+)?(?:final\s+)?(?:synchronized\s+)?(?:<[^>]+>\s+)?[\w<>\[\], ?.]+\s+(\w+)\s*\(/g,
87
+ ];
88
+ for (const re of patterns) {
89
+ for (const m of src.matchAll(re)) {
90
+ const hint = firstDocSentence(m[1]);
91
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
92
+ }
93
+ }
94
+ return hints;
95
+ }
96
+
97
+ // First non-tag prose line of a Javadoc body → first sentence, 60-char cap.
98
+ function firstDocSentence(body) {
99
+ const line = String(body).split('\n')
100
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
101
+ .find((l) => l && !l.startsWith('@'));
102
+ if (!line) return '';
103
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
104
+ }
105
+
71
106
  module.exports = { extract };
@@ -14,7 +14,12 @@ function extract(src) {
14
14
  if (!src || typeof src !== 'string') return [];
15
15
  const sigs = [];
16
16
  const anchors = [];
17
+ // docHintFor[i] is the doc-comment hint for sigs[i] (top-level functions
18
+ // only), appended after the anchor as ` # <hint>` — same convention as the
19
+ // Python extractor's extractDocHint.
20
+ const docHintFor = [];
17
21
  const returnHints = buildReturnHints(src);
22
+ const docHints = buildDocHints(src);
18
23
 
19
24
  // Block comments are blanked newline-by-newline (non-newline chars → spaces)
20
25
  // so character offsets AND line numbers stay exact for anchors.
@@ -49,6 +54,7 @@ function extract(src) {
49
54
  const retStr = formatReturnHint(returnHints.get(m[1]));
50
55
  const startLn = lineAt(stripped, m.index);
51
56
  sigs.push(`export ${asyncKw}function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
57
+ docHintFor[sigs.length - 1] = docHints.get(m[1]);
52
58
  anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
53
59
  }
54
60
 
@@ -58,6 +64,7 @@ function extract(src) {
58
64
  const retStr = formatReturnHint(returnHints.get(m[1]));
59
65
  const startLn = lineAt(stripped, m.index);
60
66
  sigs.push(`export const ${m[1]} = ${asyncKw}(${normalizeParams(m[2])}) =>${retStr}`);
67
+ docHintFor[sigs.length - 1] = docHints.get(m[1]);
61
68
  anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
62
69
  }
63
70
 
@@ -78,10 +85,14 @@ function extract(src) {
78
85
  const retStr = formatReturnHint(returnHints.get(m[1]));
79
86
  const startLn = lineAt(stripped, m.index);
80
87
  sigs.push(`${asyncKw}function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
88
+ docHintFor[sigs.length - 1] = docHints.get(m[1]);
81
89
  anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
82
90
  }
83
91
 
84
- const withAnchors = sigs.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s));
92
+ const withAnchors = sigs.map((s, i) => {
93
+ const anchored = anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s;
94
+ return docHintFor[i] ? `${anchored} # ${docHintFor[i]}` : anchored;
95
+ });
85
96
  return capWithNotice(withAnchors, 25, 'signatures');
86
97
  }
87
98
 
@@ -130,6 +141,36 @@ function buildReturnHints(src) {
130
141
  return hints;
131
142
  }
132
143
 
144
+ // First prose sentence of the JSDoc block immediately preceding a top-level
145
+ // function (same three shapes as buildReturnHints). Mirrors the Python
146
+ // extractor's extractDocHint: first sentence only, 60-char cap.
147
+ function buildDocHints(src) {
148
+ const hints = new Map();
149
+ // Body may not contain `*/` — otherwise a failed adjacency check would let
150
+ // the match expand across a whole function to the next comment block and
151
+ // misattribute the hint.
152
+ const patterns = [
153
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/g,
154
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(/g,
155
+ ];
156
+ for (const re of patterns) {
157
+ for (const m of src.matchAll(re)) {
158
+ const hint = firstDocSentence(m[1]);
159
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
160
+ }
161
+ }
162
+ return hints;
163
+ }
164
+
165
+ // First non-tag prose line of a JSDoc body → first sentence, 60-char cap.
166
+ function firstDocSentence(body) {
167
+ const line = String(body).split('\n')
168
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
169
+ .find((l) => l && !l.startsWith('@'));
170
+ if (!line) return '';
171
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
172
+ }
173
+
133
174
  function normalizeType(type) {
134
175
  if (!type) return '';
135
176
  return type.trim().replace(/\s+/g, ' ').slice(0, 25);
@@ -12,6 +12,10 @@ const { lineAt, withAnchor } = require('./line-anchor');
12
12
  function extract(src) {
13
13
  if (!src || typeof src !== 'string') return [];
14
14
  const sigs = [];
15
+ const docHints = buildDocHints(src);
16
+ // Append the doc-comment hint after the anchor as ` # <hint>` — same
17
+ // convention as the Python/JS extractors' doc hints.
18
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
15
19
 
16
20
  const stripped = src
17
21
  .replace(/\/\/.*$/gm, '')
@@ -33,19 +37,19 @@ function extract(src) {
33
37
  // Structs
34
38
  for (const m of stripped.matchAll(/^pub\s+struct\s+(\w+)(?:<[^{]*>)?/gm)) {
35
39
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
36
- sigs.push(withAnchor(`pub struct ${m[1]}`, s, e));
40
+ sigs.push(hinted(withAnchor(`pub struct ${m[1]}`, s, e), m[1]));
37
41
  }
38
42
 
39
43
  // Enums
40
44
  for (const m of stripped.matchAll(/^pub\s+enum\s+(\w+)(?:<[^{]*>)?/gm)) {
41
45
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
42
- sigs.push(withAnchor(`pub enum ${m[1]}`, s, e));
46
+ sigs.push(hinted(withAnchor(`pub enum ${m[1]}`, s, e), m[1]));
43
47
  }
44
48
 
45
49
  // Traits
46
50
  for (const m of stripped.matchAll(/^pub\s+trait\s+(\w+)(?:<[^{]*>)?/gm)) {
47
51
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
48
- sigs.push(withAnchor(`pub trait ${m[1]}`, s, e));
52
+ sigs.push(hinted(withAnchor(`pub trait ${m[1]}`, s, e), m[1]));
49
53
  }
50
54
 
51
55
  // impl blocks
@@ -54,7 +58,7 @@ function extract(src) {
54
58
  const block = extractBlock(stripped, bodyStart);
55
59
  sigs.push(withAnchor(`impl ${m[1]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
56
60
  for (const fn of extractMethods(block)) {
57
- sigs.push(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)));
61
+ sigs.push(hinted(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)), fn.name));
58
62
  }
59
63
  }
60
64
 
@@ -63,7 +67,7 @@ function extract(src) {
63
67
  const asyncKw = m[0].includes('async') ? 'async ' : '';
64
68
  const retStr = extractReturnType(m[3]);
65
69
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
66
- sigs.push(withAnchor(`pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e));
70
+ sigs.push(hinted(withAnchor(`pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e), m[1]));
67
71
  }
68
72
 
69
73
  return sigs.slice(0, 25);
@@ -87,6 +91,7 @@ function extractMethods(block) {
87
91
  const retStr = extractReturnType(m[3]);
88
92
  methods.push({
89
93
  text: `pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`,
94
+ name: m[1],
90
95
  declIdx: m.index + (m[0].length - m[0].trimStart().length),
91
96
  endIdx: m.index + m[0].length,
92
97
  });
@@ -107,4 +112,27 @@ function extractReturnType(afterParen) {
107
112
  return ` → ${rt.length > 30 ? rt.slice(0, 27) + '...' : rt}`;
108
113
  }
109
114
 
115
+ // Rustdoc: the `///` block directly above a declaration → first prose
116
+ // sentence, 60-char cap. Runs on the ORIGINAL src (extract strips comments
117
+ // before matching). Attribute lines (`#[...]`) between the doc block and the
118
+ // declaration are tolerated.
119
+ function buildDocHints(src) {
120
+ const hints = new Map();
121
+ const re = /((?:^[ \t]*\/\/\/[^\n]*\n)+)(?:[ \t]*#\[[^\n]*\n)*[ \t]*pub(?:\s+async)?\s+(?:fn|struct|enum|trait)\s+(\w+)/gm;
122
+ for (const m of src.matchAll(re)) {
123
+ const hint = firstDocSentence(m[1]);
124
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
125
+ }
126
+ return hints;
127
+ }
128
+
129
+ // First prose line of a `///` block → first sentence, 60-char cap.
130
+ function firstDocSentence(block) {
131
+ const line = String(block).split('\n')
132
+ .map((l) => l.replace(/^[ \t]*\/\/\/\s?/, '').trim())
133
+ .find((l) => l);
134
+ if (!line) return '';
135
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
136
+ }
137
+
110
138
  module.exports = { extract };
@@ -13,6 +13,11 @@ const { capWithNotice, capMembersWithNotice } = require('../util/truncate');
13
13
  function extract(src) {
14
14
  if (!src || typeof src !== 'string') return [];
15
15
  const sigs = [];
16
+ // docHintFor[i] is the doc-comment hint for sigs[i] (exported top-level
17
+ // functions only), appended after the anchor as ` # <hint>` — same
18
+ // convention as the Python extractor's extractDocHint.
19
+ const docHintFor = [];
20
+ const docHints = buildDocHints(src);
16
21
  // anchors[i] is [start, end] for a top-level sig, or null for an indented member.
17
22
  // Kept parallel to `sigs` so existing push/mutation logic stays untouched;
18
23
  // anchors are applied once at return.
@@ -80,6 +85,7 @@ function extract(src) {
80
85
  const retStr = retType ? ` → ${retType}` : '';
81
86
  const bodyStart = m.index + m[0].length;
82
87
  sigs.push(`export ${asyncKw}function ${m[1]}(${params})${retStr}`);
88
+ docHintFor[sigs.length - 1] = docHints.get(m[1]);
83
89
  anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
84
90
 
85
91
  // Hooks: capture compact return object shape for use* functions.
@@ -104,6 +110,7 @@ function extract(src) {
104
110
  const asyncKw = /=\s*async\s+/.test(m[0]) ? 'async ' : '';
105
111
  const params = normalizeParams(m[2]);
106
112
  sigs.push(`export const ${m[1]} = ${asyncKw}(${params}) =>`);
113
+ docHintFor[sigs.length - 1] = docHints.get(m[1]);
107
114
  const bodyStart = stripped.indexOf('{', m.index + m[0].length);
108
115
  const endLn = bodyStart !== -1
109
116
  ? lineAt(stripped, blockEndIdx(bodyStart + 1))
@@ -153,7 +160,10 @@ function extract(src) {
153
160
  }
154
161
  }
155
162
 
156
- const withAnchors = sigs.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s));
163
+ const withAnchors = sigs.map((s, i) => {
164
+ const anchored = anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s;
165
+ return docHintFor[i] ? `${anchored} # ${docHintFor[i]}` : anchored;
166
+ });
157
167
  return capWithNotice(withAnchors, 35, 'signatures');
158
168
  }
159
169
 
@@ -218,4 +228,34 @@ function normalizeParams(params) {
218
228
  return params.trim().replace(/\s+/g, ' ').replace(/:[^,)]+/g, '').trim();
219
229
  }
220
230
 
231
+ // First prose sentence of the JSDoc block immediately preceding an exported
232
+ // top-level function (function or arrow-const form). Mirrors the Python
233
+ // extractor's extractDocHint: first sentence only, 60-char cap.
234
+ function buildDocHints(src) {
235
+ const hints = new Map();
236
+ // Body may not contain `*/` — otherwise a failed adjacency check would let
237
+ // the match expand across a whole function to the next comment block and
238
+ // misattribute the hint.
239
+ const patterns = [
240
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*export\s+(?:async\s+)?function\s+(\w+)\s*[<(]/g,
241
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*export\s+const\s+(\w+)\s*[:=]/g,
242
+ ];
243
+ for (const re of patterns) {
244
+ for (const m of src.matchAll(re)) {
245
+ const hint = firstDocSentence(m[1]);
246
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
247
+ }
248
+ }
249
+ return hints;
250
+ }
251
+
252
+ // First non-tag prose line of a JSDoc body → first sentence, 60-char cap.
253
+ function firstDocSentence(body) {
254
+ const line = String(body).split('\n')
255
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
256
+ .find((l) => l && !l.startsWith('@'));
257
+ if (!line) return '';
258
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
259
+ }
260
+
221
261
  module.exports = { extract };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Zero-dependency import-graph centrality (Semantic Bridge II, B3).
5
+ *
6
+ * Power iteration over the forward dependency graph: rank flows from each
7
+ * importer to the files it imports, so heavily-referenced files accumulate
8
+ * centrality and one-off helpers do not. Deterministic — fixed damping,
9
+ * fixed iteration count, nodes processed in sorted order.
10
+ *
11
+ * The result feeds the opt-in `retrieval.centralityBlend` ranking prior
12
+ * (see src/retrieval/ranker.js) — a principled deepening of the existing
13
+ * graph-boost idea, not a replacement for query relevance.
14
+ */
15
+
16
+ const DAMPING = 0.85;
17
+ const ITERATIONS = 20;
18
+
19
+ /**
20
+ * Compute a normalized centrality score for every file in a dependency graph.
21
+ *
22
+ * @param {{ forward: Map<string, string[]> }} graph - forward dependency graph
23
+ * (file → files it imports), as built by src/graph/builder.js
24
+ * @returns {Map<string, number>} file → centrality in (0, 1], max-normalized;
25
+ * empty Map when the graph is missing or empty
26
+ */
27
+ function computeCentrality(graph) {
28
+ if (!graph || !(graph.forward instanceof Map) || graph.forward.size === 0) return new Map();
29
+
30
+ const nodes = new Set(graph.forward.keys());
31
+ for (const deps of graph.forward.values()) {
32
+ for (const dep of deps || []) nodes.add(dep);
33
+ }
34
+ const nodeList = [...nodes].sort();
35
+ const n = nodeList.length;
36
+ const indexOf = new Map(nodeList.map((file, i) => [file, i]));
37
+ const outLinks = nodeList.map((file) =>
38
+ (graph.forward.get(file) || []).map((dep) => indexOf.get(dep)).filter((i) => i !== undefined));
39
+
40
+ let ranks = new Array(n).fill(1 / n);
41
+ for (let iter = 0; iter < ITERATIONS; iter++) {
42
+ const next = new Array(n).fill((1 - DAMPING) / n);
43
+ let dangling = 0;
44
+ for (let i = 0; i < n; i++) {
45
+ if (outLinks[i].length === 0) { dangling += ranks[i]; continue; }
46
+ const share = (DAMPING * ranks[i]) / outLinks[i].length;
47
+ for (const j of outLinks[i]) next[j] += share;
48
+ }
49
+ // Dangling mass (files that import nothing) is redistributed uniformly.
50
+ const danglingShare = (DAMPING * dangling) / n;
51
+ for (let i = 0; i < n; i++) next[i] += danglingShare;
52
+ ranks = next;
53
+ }
54
+
55
+ const max = Math.max(...ranks) || 1;
56
+ const result = new Map();
57
+ for (let i = 0; i < n; i++) result.set(nodeList[i], ranks[i] / max);
58
+ return result;
59
+ }
60
+
61
+ module.exports = { computeCentrality, DAMPING, ITERATIONS };
@@ -421,8 +421,9 @@ 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 + surface enrichment — non-fatal
424
+ // Opt-in call-graph neighbor boost + surface enrichment + centrality blend — non-fatal
425
425
  let callGraph = null;
426
+ let centrality = null;
426
427
  try {
427
428
  const { loadConfig } = require('../config/loader');
428
429
  const retrieval = loadConfig(cwd).retrieval;
@@ -432,8 +433,11 @@ function queryContext(args, cwd) {
432
433
  if (retrieval && retrieval.surfaceEnrichment) {
433
434
  require('../retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd);
434
435
  }
436
+ if (retrieval && retrieval.centralityBlend && graph) {
437
+ centrality = require('../graph/centrality').computeCentrality(graph);
438
+ }
435
439
  } catch (_) {}
436
- const results = rank(args.query, index, { topK, cwd, graph, callGraph });
440
+ const results = rank(args.query, index, { topK, cwd, graph, callGraph, centrality });
437
441
  return formatRankTable(results, args.query);
438
442
  } catch (err) {
439
443
  return `_query_context failed: ${err.message}_`;
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.19.0',
21
+ version: '8.21.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -40,6 +40,9 @@ const GRAPH_BOOST_AMOUNTS = {
40
40
  callHop: 0.30, // call-graph file neighbor (opt-in retrieval.callGraphBoost)
41
41
  };
42
42
 
43
+ // Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
44
+ const CENTRALITY_BLEND_WEIGHT = 0.3;
45
+
43
46
  // Intent-specific weight adjustments
44
47
  const INTENT_WEIGHTS = {
45
48
  search: DEFAULT_WEIGHTS,
@@ -172,6 +175,8 @@ function scoreFile(filePath, sigs, queryTokens, weights) {
172
175
  * @param {{ forward: Map<string,string[]> }} [opts.graph] - dependency graph for neighbor boost
173
176
  * @param {{ forward: Map<string,string[]> }} [opts.callGraph] - file-level call-graph edges
174
177
  * (from buildCallFileGraph) for the opt-in call-neighbor boost
178
+ * @param {Map<string,number>} [opts.centrality] - absolute file → normalized
179
+ * centrality (from computeCentrality) for the opt-in centrality blend
175
180
  * @returns {{ file: string, score: number, sigs: string[], tokens: number, intent: string, signals: object }[]}
176
181
  */
177
182
  function rank(query, sigIndex, opts) {
@@ -320,6 +325,26 @@ function rank(query, sigIndex, opts) {
320
325
  }
321
326
  }
322
327
 
328
+ // Centrality blend (opt-in via retrieval.centralityBlend): a small additive
329
+ // prior from import-graph centrality so heavily-referenced files rank above
330
+ // one-off helpers on ambiguous queries. Applied only to positively-scored
331
+ // files — a tie-breaker among matches, never a way to surface non-matches.
332
+ const centrality = (opts && opts.centrality instanceof Map && opts.centrality.size > 0) ? opts.centrality : null;
333
+ if (centrality && cwd) {
334
+ const path = require('path');
335
+ for (const entry of scored) {
336
+ if (entry.score <= 0) continue;
337
+ const abs = path.resolve(cwd, entry.file);
338
+ // The graph builder lowercases paths (normalizePath) — probe both forms.
339
+ const c = centrality.get(abs) || centrality.get(abs.toLowerCase());
340
+ if (c) {
341
+ const bonus = CENTRALITY_BLEND_WEIGHT * c;
342
+ entry.score += bonus;
343
+ entry.signals.centrality = bonus;
344
+ }
345
+ }
346
+ }
347
+
323
348
  // Compute confidence levels based on score distribution
324
349
  if (scored.length > 0) {
325
350
  const scores = scored.map(s => s.score);
@@ -595,4 +620,4 @@ function detectIntent(query) {
595
620
  return 'search';
596
621
  }
597
622
 
598
- module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, detectIntent };
623
+ module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };