sigmap 8.20.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/CHANGELOG.md CHANGED
@@ -10,6 +10,19 @@ Format: [Semantic Versioning](https://semver.org/)
10
10
 
11
11
  ---
12
12
 
13
+ ## [8.21.0] — 2026-07-19
14
+
15
+ Minor release — **"Semantic Bridge II" (v8.21)**: doc-comment hints reach Go, Rust, and Java, and the import graph gains a principled centrality prior for ranking — flag-gated and measured.
16
+
17
+ ### Added
18
+ - **Go/Rust/Java doc-comment hints (#501, PR #502):** `buildDocHints` in the Go extractor (godoc `//` blocks above top-level `func`/`type`, compiler directives `//go:`/`nolint` skipped), the Rust extractor (`///` blocks above `pub fn`/`struct`/`enum`/`trait` and impl methods, `#[attr]` lines between doc and declaration tolerated), and the Java extractor (Javadoc on type declarations **and** public/protected members; tag-only blocks produce no hint). First prose sentence, 60-char cap, appended after the line anchor as ` # <hint>` — byte-format identical to the Python/JS/TS hints. Hints are mined from the original source since `extract()` strips comments before matching; undocumented signatures are byte-identical to before.
19
+ - **Centrality rank blend (#501, PR #502, opt-in `retrieval.centralityBlend`):** new `src/graph/centrality.js` — zero-dependency power iteration over the forward import graph (damping 0.85, 20 iterations, sorted nodes, dangling mass redistributed; deterministic), max-normalized to (0,1]. `rank()` blends `0.3 × centrality` as a small additive prior onto **positively-scored files only** (`signals.centrality`) — a tie-breaker among matches, never a way to surface non-matches. Wired like `callGraphBoost`: MCP `query_context` + CLI `ask`/`--query`, all non-fatal. New A/B measure gate `scripts/run-centrality-blend-benchmark.mjs` (`npm run benchmark:centrality-blend`).
20
+
21
+ ### Changed
22
+ - **Measured and gated off by default:** the centrality A/B over 90 tasks / 18 repos scored both arms at 77.8% hit@5 (+0 tasks) — non-regressing but neutral on the lexical-favoring corpus, so `retrieval.centralityBlend` ships **off** per the measure gate; the v8.22 hard-split corpus (A3) is the next chance to show a real delta. 11 new integration tests (127 test files).
23
+
24
+ ---
25
+
13
26
  ## [8.20.0] — 2026-07-19
14
27
 
15
28
  Minor release — **"Semantic Bridge I" (v8.20)**: the JS/TS extractors gain the same doc-comment hints Python has carried for releases, and the cross-session stores get a single inspect/prune surface.
package/README.md CHANGED
@@ -61,8 +61,8 @@ That map is exactly what agentic grep is worst at: reproducible, auditable conte
61
61
  <!--SM:whyMetrics-->
62
62
  - **85.6% hit@5** — right file in top 5 results (vs 42.7% single-shot grep baseline — 2.00× lift)
63
63
  - **96.8% token reduction** — average across 21 real repos
64
- - **67.8% task-success proxy** — modeled from retrieval tiers, not measured LLM sessions
65
- - **1.47 prompts per task** — down from 2.84 (48.4% fewer retries, modeled)
64
+ - **66.7% task-success proxy** — modeled from retrieval tiers, not measured LLM sessions
65
+ - **1.48 prompts per task** — down from 2.84 (48.0% fewer retries, modeled)
66
66
  <!--/SM:whyMetrics-->
67
67
  - **<!--SM:languages-->33<!--/SM:languages--> languages supported** — TypeScript, Python, Go, Rust, Java, R, and more
68
68
  - **No vendor lock-in** — works with any AI assistant or local LLM
@@ -122,13 +122,13 @@ Ask → Rank → Context → Validate → Judge → Learn
122
122
 
123
123
  <!--SM:benchmarkBlock-->
124
124
  ```
125
- Benchmark : sigmap-v8.20-main (21 repositories, including R language)
125
+ Benchmark : sigmap-v8.21-main (21 repositories, including R language)
126
126
  Date : 2026-07-19
127
127
 
128
128
  Hit@5 : 85.6% (grep-agent baseline 42.7% — 2.00× lift)
129
129
  Token reduction: 96.8% (across 21 repos)
130
- Prompt reduction : 48.4% (2.84 → 1.47 prompts per task, modeled)
131
- Task success : 67.8% (proxy — modeled from retrieval tiers)
130
+ Prompt reduction : 48.0% (2.84 → 1.48 prompts per task, modeled)
131
+ Task success : 66.7% (proxy — modeled from retrieval tiers)
132
132
  Repos tested : 21 (JavaScript, Python, Go, Rust, Java, R, C++, C#, Dart, Swift, Ruby, PHP, Scala, Kotlin, and more)
133
133
  ```
134
134
  <!--/SM:benchmarkBlock-->
package/gen-context.js CHANGED
@@ -1533,6 +1533,8 @@ __factories["./src/config/defaults"] = function(module, exports) {
1533
1533
  recencyBoost: 1.5,
1534
1534
  // Boost files call-graph-connected to query matches (opt-in, measure-gated)
1535
1535
  callGraphBoost: false,
1536
+ // Blend import-graph centrality into ranking as a small prior (opt-in, measure-gated)
1537
+ centralityBlend: false,
1536
1538
  // Append route pseudo-signatures to the rankable index (opt-in, measure-gated)
1537
1539
  surfaceEnrichment: false,
1538
1540
  },
@@ -5928,6 +5930,10 @@ __factories["./src/extractors/go"] = function(module, exports) {
5928
5930
  function extract(src) {
5929
5931
  if (!src || typeof src !== 'string') return [];
5930
5932
  const sigs = [];
5933
+ const docHints = buildDocHints(src);
5934
+ // Append the godoc hint after the anchor as ` # <hint>` — same convention
5935
+ // as the Python/JS extractors' doc hints.
5936
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
5931
5937
 
5932
5938
  const stripped = src
5933
5939
  .replace(/\/\/.*$/gm, '')
@@ -5939,14 +5945,14 @@ __factories["./src/extractors/go"] = function(module, exports) {
5939
5945
  // Structs
5940
5946
  for (const m of stripped.matchAll(/^type\s+(\w+)\s+struct\s*\{/gm)) {
5941
5947
  const end = blockEndIdx(m.index + m[0].length);
5942
- sigs.push(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)));
5948
+ sigs.push(hinted(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)), m[1]));
5943
5949
  }
5944
5950
 
5945
5951
  // Interfaces
5946
5952
  for (const m of stripped.matchAll(/^type\s+(\w+)\s+interface\s*\{/gm)) {
5947
5953
  const bodyStart = m.index + m[0].length;
5948
5954
  const block = extractBlock(stripped, bodyStart);
5949
- sigs.push(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
5955
+ sigs.push(hinted(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[1]));
5950
5956
  for (const meth of extractInterfaceMethods(block)) {
5951
5957
  sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
5952
5958
  }
@@ -5958,7 +5964,7 @@ __factories["./src/extractors/go"] = function(module, exports) {
5958
5964
  const retType = m[4] ? m[4].trim().replace(/\s+/g, ' ') : '';
5959
5965
  const retStr = retType ? ` → ${retType.slice(0, 30)}` : '';
5960
5966
  const end = blockEndIdx(m.index + m[0].length);
5961
- sigs.push(withAnchor(`func ${receiver}${m[2]}(${normalizeParams(m[3])})${retStr}`, lineAt(stripped, m.index), lineAt(stripped, end)));
5967
+ sigs.push(hinted(withAnchor(`func ${receiver}${m[2]}(${normalizeParams(m[3])})${retStr}`, lineAt(stripped, m.index), lineAt(stripped, end)), m[2]));
5962
5968
  }
5963
5969
 
5964
5970
  return sigs.slice(0, 25);
@@ -5994,6 +6000,30 @@ __factories["./src/extractors/go"] = function(module, exports) {
5994
6000
  return params.trim().replace(/\s+/g, ' ');
5995
6001
  }
5996
6002
 
6003
+ // Godoc: the `//` comment block directly above a top-level func/type/method
6004
+ // declaration → first prose sentence, 60-char cap. Runs on the ORIGINAL src
6005
+ // (extract strips comments before matching). Compiler directives (`//go:...`)
6006
+ // carry no prose and are skipped.
6007
+ function buildDocHints(src) {
6008
+ const hints = new Map();
6009
+ const re = /((?:^\/\/[^\n]*\n)+)(?:func\s+(?:\(\w+\s+[\w*]+\)\s+)?(\w+)\s*\(|type\s+(\w+)\s+(?:struct|interface)\b)/gm;
6010
+ for (const m of src.matchAll(re)) {
6011
+ const name = m[2] || m[3];
6012
+ const hint = firstDocSentence(m[1]);
6013
+ if (hint && !hints.has(name)) hints.set(name, hint);
6014
+ }
6015
+ return hints;
6016
+ }
6017
+
6018
+ // First non-directive prose line of a `//` block → first sentence, 60-char cap.
6019
+ function firstDocSentence(block) {
6020
+ const line = String(block).split('\n')
6021
+ .map((l) => l.replace(/^\/\/\s?/, '').trim())
6022
+ .find((l) => l && !l.startsWith('go:') && !l.startsWith('nolint'));
6023
+ if (!line) return '';
6024
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
6025
+ }
6026
+
5997
6027
  module.exports = { extract };
5998
6028
 
5999
6029
  };
@@ -6126,6 +6156,10 @@ __factories["./src/extractors/java"] = function(module, exports) {
6126
6156
  function extract(src) {
6127
6157
  if (!src || typeof src !== 'string') return [];
6128
6158
  const sigs = [];
6159
+ const docHints = buildDocHints(src);
6160
+ // Append the Javadoc hint after the anchor as ` # <hint>` — same convention
6161
+ // as the Python/JS extractors' doc hints.
6162
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
6129
6163
 
6130
6164
  const stripped = src
6131
6165
  .replace(/\/\/.*$/gm, '')
@@ -6136,9 +6170,9 @@ __factories["./src/extractors/java"] = function(module, exports) {
6136
6170
  for (const m of stripped.matchAll(typeRegex)) {
6137
6171
  const bodyStart = m.index + m[0].length;
6138
6172
  const block = extractBlock(stripped, bodyStart);
6139
- sigs.push(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
6173
+ sigs.push(hinted(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[2]));
6140
6174
  for (const meth of extractMembers(block)) {
6141
- sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
6175
+ sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)), meth.name));
6142
6176
  }
6143
6177
  }
6144
6178
 
@@ -6165,6 +6199,7 @@ __factories["./src/extractors/java"] = function(module, exports) {
6165
6199
  const retStr = ret ? ` → ${ret}` : '';
6166
6200
  members.push({
6167
6201
  text: `${m[2]}(${normalizeParams(m[3])})${retStr}`,
6202
+ name: m[2],
6168
6203
  declIdx: m.index + (m[0].length - m[0].trimStart().length),
6169
6204
  endIdx: m.index + m[0].length,
6170
6205
  });
@@ -6182,6 +6217,36 @@ __factories["./src/extractors/java"] = function(module, exports) {
6182
6217
  return type.trim().replace(/\s+/g, ' ').slice(0, 30);
6183
6218
  }
6184
6219
 
6220
+ // Javadoc: the `/** ... */` block directly above a type or public/protected
6221
+ // member declaration → first prose sentence, 60-char cap. Runs on the
6222
+ // ORIGINAL src (extract strips comments before matching). Annotation lines
6223
+ // (`@Override` etc.) between the doc block and the declaration are tolerated.
6224
+ // Body may not contain `*/` so a failed adjacency check can't expand across
6225
+ // code to the next comment block and misattribute the hint.
6226
+ function buildDocHints(src) {
6227
+ const hints = new Map();
6228
+ const patterns = [
6229
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public\s+|protected\s+)?(?:abstract\s+|final\s+)?(?:class|interface|enum)\s+(\w+)/g,
6230
+ /\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public|protected)\s+(?:static\s+)?(?:final\s+)?(?:synchronized\s+)?(?:<[^>]+>\s+)?[\w<>\[\], ?.]+\s+(\w+)\s*\(/g,
6231
+ ];
6232
+ for (const re of patterns) {
6233
+ for (const m of src.matchAll(re)) {
6234
+ const hint = firstDocSentence(m[1]);
6235
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
6236
+ }
6237
+ }
6238
+ return hints;
6239
+ }
6240
+
6241
+ // First non-tag prose line of a Javadoc body → first sentence, 60-char cap.
6242
+ function firstDocSentence(body) {
6243
+ const line = String(body).split('\n')
6244
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
6245
+ .find((l) => l && !l.startsWith('@'));
6246
+ if (!line) return '';
6247
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
6248
+ }
6249
+
6185
6250
  module.exports = { extract };
6186
6251
 
6187
6252
  };
@@ -7680,6 +7745,10 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7680
7745
  function extract(src) {
7681
7746
  if (!src || typeof src !== 'string') return [];
7682
7747
  const sigs = [];
7748
+ const docHints = buildDocHints(src);
7749
+ // Append the doc-comment hint after the anchor as ` # <hint>` — same
7750
+ // convention as the Python/JS extractors' doc hints.
7751
+ const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
7683
7752
 
7684
7753
  const stripped = src
7685
7754
  .replace(/\/\/.*$/gm, '')
@@ -7701,19 +7770,19 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7701
7770
  // Structs
7702
7771
  for (const m of stripped.matchAll(/^pub\s+struct\s+(\w+)(?:<[^{]*>)?/gm)) {
7703
7772
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
7704
- sigs.push(withAnchor(`pub struct ${m[1]}`, s, e));
7773
+ sigs.push(hinted(withAnchor(`pub struct ${m[1]}`, s, e), m[1]));
7705
7774
  }
7706
7775
 
7707
7776
  // Enums
7708
7777
  for (const m of stripped.matchAll(/^pub\s+enum\s+(\w+)(?:<[^{]*>)?/gm)) {
7709
7778
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
7710
- sigs.push(withAnchor(`pub enum ${m[1]}`, s, e));
7779
+ sigs.push(hinted(withAnchor(`pub enum ${m[1]}`, s, e), m[1]));
7711
7780
  }
7712
7781
 
7713
7782
  // Traits
7714
7783
  for (const m of stripped.matchAll(/^pub\s+trait\s+(\w+)(?:<[^{]*>)?/gm)) {
7715
7784
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
7716
- sigs.push(withAnchor(`pub trait ${m[1]}`, s, e));
7785
+ sigs.push(hinted(withAnchor(`pub trait ${m[1]}`, s, e), m[1]));
7717
7786
  }
7718
7787
 
7719
7788
  // impl blocks
@@ -7722,7 +7791,7 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7722
7791
  const block = extractBlock(stripped, bodyStart);
7723
7792
  sigs.push(withAnchor(`impl ${m[1]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
7724
7793
  for (const fn of extractMethods(block)) {
7725
- sigs.push(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)));
7794
+ sigs.push(hinted(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)), fn.name));
7726
7795
  }
7727
7796
  }
7728
7797
 
@@ -7731,7 +7800,7 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7731
7800
  const asyncKw = m[0].includes('async') ? 'async ' : '';
7732
7801
  const retStr = extractReturnType(m[3]);
7733
7802
  const [s, e] = rangeFor(m.index, m.index + m[0].length);
7734
- sigs.push(withAnchor(`pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e));
7803
+ sigs.push(hinted(withAnchor(`pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e), m[1]));
7735
7804
  }
7736
7805
 
7737
7806
  return sigs.slice(0, 25);
@@ -7755,6 +7824,7 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7755
7824
  const retStr = extractReturnType(m[3]);
7756
7825
  methods.push({
7757
7826
  text: `pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`,
7827
+ name: m[1],
7758
7828
  declIdx: m.index + (m[0].length - m[0].trimStart().length),
7759
7829
  endIdx: m.index + m[0].length,
7760
7830
  });
@@ -7775,6 +7845,29 @@ __factories["./src/extractors/rust"] = function(module, exports) {
7775
7845
  return ` → ${rt.length > 30 ? rt.slice(0, 27) + '...' : rt}`;
7776
7846
  }
7777
7847
 
7848
+ // Rustdoc: the `///` block directly above a declaration → first prose
7849
+ // sentence, 60-char cap. Runs on the ORIGINAL src (extract strips comments
7850
+ // before matching). Attribute lines (`#[...]`) between the doc block and the
7851
+ // declaration are tolerated.
7852
+ function buildDocHints(src) {
7853
+ const hints = new Map();
7854
+ const re = /((?:^[ \t]*\/\/\/[^\n]*\n)+)(?:[ \t]*#\[[^\n]*\n)*[ \t]*pub(?:\s+async)?\s+(?:fn|struct|enum|trait)\s+(\w+)/gm;
7855
+ for (const m of src.matchAll(re)) {
7856
+ const hint = firstDocSentence(m[1]);
7857
+ if (hint && !hints.has(m[2])) hints.set(m[2], hint);
7858
+ }
7859
+ return hints;
7860
+ }
7861
+
7862
+ // First prose line of a `///` block → first sentence, 60-char cap.
7863
+ function firstDocSentence(block) {
7864
+ const line = String(block).split('\n')
7865
+ .map((l) => l.replace(/^[ \t]*\/\/\/\s?/, '').trim())
7866
+ .find((l) => l);
7867
+ if (!line) return '';
7868
+ return line.split(/[.!?]/)[0].trim().slice(0, 60);
7869
+ }
7870
+
7778
7871
  module.exports = { extract };
7779
7872
 
7780
7873
  };
@@ -11806,6 +11899,71 @@ __factories["./src/graph/call-graph"] = function(module, exports) {
11806
11899
 
11807
11900
  };
11808
11901
 
11902
+ // ── ./src/graph/centrality ──
11903
+ __factories["./src/graph/centrality"] = function(module, exports) {
11904
+
11905
+ /**
11906
+ * Zero-dependency import-graph centrality (Semantic Bridge II, B3).
11907
+ *
11908
+ * Power iteration over the forward dependency graph: rank flows from each
11909
+ * importer to the files it imports, so heavily-referenced files accumulate
11910
+ * centrality and one-off helpers do not. Deterministic — fixed damping,
11911
+ * fixed iteration count, nodes processed in sorted order.
11912
+ *
11913
+ * The result feeds the opt-in `retrieval.centralityBlend` ranking prior
11914
+ * (see src/retrieval/ranker.js) — a principled deepening of the existing
11915
+ * graph-boost idea, not a replacement for query relevance.
11916
+ */
11917
+
11918
+ const DAMPING = 0.85;
11919
+ const ITERATIONS = 20;
11920
+
11921
+ /**
11922
+ * Compute a normalized centrality score for every file in a dependency graph.
11923
+ *
11924
+ * @param {{ forward: Map<string, string[]> }} graph - forward dependency graph
11925
+ * (file → files it imports), as built by src/graph/builder.js
11926
+ * @returns {Map<string, number>} file → centrality in (0, 1], max-normalized;
11927
+ * empty Map when the graph is missing or empty
11928
+ */
11929
+ function computeCentrality(graph) {
11930
+ if (!graph || !(graph.forward instanceof Map) || graph.forward.size === 0) return new Map();
11931
+
11932
+ const nodes = new Set(graph.forward.keys());
11933
+ for (const deps of graph.forward.values()) {
11934
+ for (const dep of deps || []) nodes.add(dep);
11935
+ }
11936
+ const nodeList = [...nodes].sort();
11937
+ const n = nodeList.length;
11938
+ const indexOf = new Map(nodeList.map((file, i) => [file, i]));
11939
+ const outLinks = nodeList.map((file) =>
11940
+ (graph.forward.get(file) || []).map((dep) => indexOf.get(dep)).filter((i) => i !== undefined));
11941
+
11942
+ let ranks = new Array(n).fill(1 / n);
11943
+ for (let iter = 0; iter < ITERATIONS; iter++) {
11944
+ const next = new Array(n).fill((1 - DAMPING) / n);
11945
+ let dangling = 0;
11946
+ for (let i = 0; i < n; i++) {
11947
+ if (outLinks[i].length === 0) { dangling += ranks[i]; continue; }
11948
+ const share = (DAMPING * ranks[i]) / outLinks[i].length;
11949
+ for (const j of outLinks[i]) next[j] += share;
11950
+ }
11951
+ // Dangling mass (files that import nothing) is redistributed uniformly.
11952
+ const danglingShare = (DAMPING * dangling) / n;
11953
+ for (let i = 0; i < n; i++) next[i] += danglingShare;
11954
+ ranks = next;
11955
+ }
11956
+
11957
+ const max = Math.max(...ranks) || 1;
11958
+ const result = new Map();
11959
+ for (let i = 0; i < n; i++) result.set(nodeList[i], ranks[i] / max);
11960
+ return result;
11961
+ }
11962
+
11963
+ module.exports = { computeCentrality, DAMPING, ITERATIONS };
11964
+
11965
+ };
11966
+
11809
11967
  // ── ./src/graph/impact ──
11810
11968
  __factories["./src/graph/impact"] = function(module, exports) {
11811
11969
 
@@ -13963,8 +14121,9 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
13963
14121
  // Build dependency graph for neighbor boost — non-fatal if it fails
13964
14122
  let graph = null;
13965
14123
  try { graph = buildFromCwd(cwd); } catch (_) {}
13966
- // Opt-in call-graph neighbor boost + surface enrichment — non-fatal
14124
+ // Opt-in call-graph neighbor boost + surface enrichment + centrality blend — non-fatal
13967
14125
  let callGraph = null;
14126
+ let centrality = null;
13968
14127
  try {
13969
14128
  const { loadConfig } = __require('./src/config/loader');
13970
14129
  const retrieval = loadConfig(cwd).retrieval;
@@ -13974,8 +14133,11 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
13974
14133
  if (retrieval && retrieval.surfaceEnrichment) {
13975
14134
  __require('./src/retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd);
13976
14135
  }
14136
+ if (retrieval && retrieval.centralityBlend && graph) {
14137
+ centrality = __require('./src/graph/centrality').computeCentrality(graph);
14138
+ }
13977
14139
  } catch (_) {}
13978
- const results = rank(args.query, index, { topK, cwd, graph, callGraph });
14140
+ const results = rank(args.query, index, { topK, cwd, graph, callGraph, centrality });
13979
14141
  return formatRankTable(results, args.query);
13980
14142
  } catch (err) {
13981
14143
  return `_query_context failed: ${err.message}_`;
@@ -14688,7 +14850,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
14688
14850
 
14689
14851
  const SERVER_INFO = {
14690
14852
  name: 'sigmap',
14691
- version: '8.20.0',
14853
+ version: '8.21.0',
14692
14854
  description: 'SigMap MCP server — code signatures on demand',
14693
14855
  };
14694
14856
 
@@ -15818,6 +15980,9 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
15818
15980
  callHop: 0.30, // call-graph file neighbor (opt-in retrieval.callGraphBoost)
15819
15981
  };
15820
15982
 
15983
+ // Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
15984
+ const CENTRALITY_BLEND_WEIGHT = 0.3;
15985
+
15821
15986
  // Intent-specific weight adjustments
15822
15987
  const INTENT_WEIGHTS = {
15823
15988
  search: DEFAULT_WEIGHTS,
@@ -15950,6 +16115,8 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
15950
16115
  * @param {{ forward: Map<string,string[]> }} [opts.graph] - dependency graph for neighbor boost
15951
16116
  * @param {{ forward: Map<string,string[]> }} [opts.callGraph] - file-level call-graph edges
15952
16117
  * (from buildCallFileGraph) for the opt-in call-neighbor boost
16118
+ * @param {Map<string,number>} [opts.centrality] - absolute file → normalized
16119
+ * centrality (from computeCentrality) for the opt-in centrality blend
15953
16120
  * @returns {{ file: string, score: number, sigs: string[], tokens: number, intent: string, signals: object }[]}
15954
16121
  */
15955
16122
  function rank(query, sigIndex, opts) {
@@ -16098,6 +16265,26 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16098
16265
  }
16099
16266
  }
16100
16267
 
16268
+ // Centrality blend (opt-in via retrieval.centralityBlend): a small additive
16269
+ // prior from import-graph centrality so heavily-referenced files rank above
16270
+ // one-off helpers on ambiguous queries. Applied only to positively-scored
16271
+ // files — a tie-breaker among matches, never a way to surface non-matches.
16272
+ const centrality = (opts && opts.centrality instanceof Map && opts.centrality.size > 0) ? opts.centrality : null;
16273
+ if (centrality && cwd) {
16274
+ const path = require('path');
16275
+ for (const entry of scored) {
16276
+ if (entry.score <= 0) continue;
16277
+ const abs = path.resolve(cwd, entry.file);
16278
+ // The graph builder lowercases paths (normalizePath) — probe both forms.
16279
+ const c = centrality.get(abs) || centrality.get(abs.toLowerCase());
16280
+ if (c) {
16281
+ const bonus = CENTRALITY_BLEND_WEIGHT * c;
16282
+ entry.score += bonus;
16283
+ entry.signals.centrality = bonus;
16284
+ }
16285
+ }
16286
+ }
16287
+
16101
16288
  // Compute confidence levels based on score distribution
16102
16289
  if (scored.length > 0) {
16103
16290
  const scores = scored.map(s => s.score);
@@ -16373,7 +16560,7 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16373
16560
  return 'search';
16374
16561
  }
16375
16562
 
16376
- module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, detectIntent };
16563
+ module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
16377
16564
 
16378
16565
  };
16379
16566
 
@@ -19881,7 +20068,7 @@ function __tryGit(args, opts = {}) {
19881
20068
  catch (_) { return ''; }
19882
20069
  }
19883
20070
 
19884
- const VERSION = '8.20.0';
20071
+ const VERSION = '8.21.0';
19885
20072
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
19886
20073
 
19887
20074
  function requireSourceOrBundled(key) {
@@ -22215,8 +22402,16 @@ function main() {
22215
22402
  if (config && config.retrieval && config.retrieval.surfaceEnrichment) {
22216
22403
  try { requireSourceOrBundled('./src/retrieval/enrich-from-maps').enrichWithSurfaces(sigIndex, cwd); } catch (_) {}
22217
22404
  }
22405
+ // Opt-in import-graph centrality blend (retrieval.centralityBlend) — non-fatal
22406
+ let askCentrality = null;
22407
+ if (config && config.retrieval && config.retrieval.centralityBlend) {
22408
+ try {
22409
+ const askCentralityGraph = requireSourceOrBundled('./src/graph/builder').buildFromCwd(cwd);
22410
+ askCentrality = requireSourceOrBundled('./src/graph/centrality').computeCentrality(askCentralityGraph);
22411
+ } catch (_) {}
22412
+ }
22218
22413
 
22219
- let ranked = rank(query, sigIndex, { topK: 5, weights: intentWeights, cwd, callGraph: askCallGraph });
22414
+ let ranked = rank(query, sigIndex, { topK: 5, weights: intentWeights, cwd, callGraph: askCallGraph, centrality: askCentrality });
22220
22415
 
22221
22416
  // v6.10: Workspace scoping — infer package from query and apply boost
22222
22417
  const workspaces = detectWorkspaces(cwd);
@@ -24447,7 +24642,15 @@ function main() {
24447
24642
  if (config && config.retrieval && config.retrieval.surfaceEnrichment) {
24448
24643
  try { requireSourceOrBundled('./src/retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd); } catch (_) {}
24449
24644
  }
24450
- const results = rank(query, index, { topK, recencyBoost, cwd, callGraph: queryCallGraph });
24645
+ // Opt-in import-graph centrality blend (retrieval.centralityBlend) non-fatal
24646
+ let queryCentrality = null;
24647
+ if (config && config.retrieval && config.retrieval.centralityBlend) {
24648
+ try {
24649
+ const centralityGraph = requireSourceOrBundled('./src/graph/builder').buildFromCwd(cwd);
24650
+ queryCentrality = requireSourceOrBundled('./src/graph/centrality').computeCentrality(centralityGraph);
24651
+ } catch (_) {}
24652
+ }
24653
+ const results = rank(query, index, { topK, recencyBoost, cwd, callGraph: queryCallGraph, centrality: queryCentrality });
24451
24654
  if (args.includes('--context')) {
24452
24655
  const miniCtx = buildMiniContext(results, cwd);
24453
24656
  const ctxOut = path.join(cwd, '.context', 'query-context.md');
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.20.0 | Benchmark: sigmap-v8.20-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.20-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
24
  | Retrieval hit@5 | 42.7% (single-shot grep) | 85.6% (2.00× lift) |
25
25
  | Token reduction | — | 96.8% average |
26
- | Task-success proxy (modeled) | — | 67.8% |
27
- | Prompts per task | 2.84 | 1.47 (48.4% fewer) |
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 |
@@ -345,7 +345,7 @@ testCoverage = false
345
345
  testDirs = ["tests","test","__tests__","spec"]
346
346
  sigCache = false
347
347
  impactRadius = false
348
- retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false,"surfaceEnrichment":false}
348
+ retrieval = {"topK":10,"recencyBoost":1.5,"callGraphBoost":false,"centralityBlend":false,"surfaceEnrichment":false}
349
349
  impact = {"depth":3,"includeSigs":true}
350
350
  ```
351
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.20.0 | Benchmark: sigmap-v8.20-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.20-main, 2026-07-19)
26
+ ## Core metrics (benchmark: sigmap-v8.21-main, 2026-07-19)
27
27
 
28
28
  - hit@5 retrieval: 85.6% vs 42.7% single-shot grep baseline (2.00× lift)
29
29
  - Token reduction: 96.8% average across benchmark repos
30
- - Task-success proxy: 67.8% (modeled from retrieval tiers, not measured LLM sessions)
31
- - Prompts per task: 1.47 vs 2.84 baseline (48.4% fewer, modeled)
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.20.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.20.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.20.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 };
@@ -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 };
@@ -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.20.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 };