sigmap 8.20.0 → 8.22.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 +26 -0
- package/README.md +9 -9
- package/gen-context.js +311 -18
- package/llms-full.txt +6 -6
- package/llms.txt +5 -5
- package/package.json +2 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/config/defaults.js +2 -0
- package/src/eval/corpus.js +83 -0
- package/src/eval/runner.js +2 -1
- package/src/extractors/go.js +31 -3
- package/src/extractors/java.js +37 -2
- package/src/extractors/rust.js +33 -5
- package/src/graph/centrality.js +61 -0
- package/src/mcp/handlers.js +6 -2
- package/src/mcp/server.js +1 -1
- package/src/retrieval/ranker.js +26 -1
- package/src/tracking/pricing.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,32 @@ Format: [Semantic Versioning](https://semver.org/)
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## [8.22.0] — 2026-07-28
|
|
14
|
+
|
|
15
|
+
Minor release — **"Hard Corpus" (v8.22)**: the benchmark corpus gains a no-leakage hard split with a deterministic leakage gate, and per-repo-size buckets stop tiny repos from flattering the average. The headline retrieval number gets harder — and honest.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **Hard-split corpus + leakage gate + size buckets (#505, PR #506):** new `src/eval/corpus.js` — a task "leaks" when its BM25-tokenized query shares a stemmed token with the tokenized basenames of its expected files; `validateTasks` flags leaky `split: "hard"` tasks as violations, and `sizeBucket` groups repos at 200/1000 scanned files (tertiles of the 43-repo corpus). New CI gate `scripts/validate-task-corpus.mjs` (exit 1 on hard-split leakage). `loadTasks` carries the optional `split` field (default `easy`). `benchmark:honest` now reports hit@5/MRR per split and per size bucket — buckets use files scanned on disk, not the budget-capped context index. 15 hand-authored hard tasks (express, flask, axios, fastify, gin), all leak-free. 8 new integration tests (128 test files).
|
|
19
|
+
- **MiniMax LLM-ablation provider (PR #504)** — thanks @octo-patch: `MINIMAX_API_KEY` support in `scripts/run-llm-ablation.mjs` (OpenAI-compatible endpoint, default model MiniMax-M3, `MINIMAX_BASE_URL` override) plus a pricing entry and tests.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **Headline honesty, again:** the leakage gate measured that **90 of 110 pre-existing easy tasks leak filename tokens**, and the new hard split scores **33.3% hit@5 vs the grep baseline's 53.3%** — with leakage removed, grep currently wins; that measured vocabulary-mismatch ceiling is what B2 (repo-mined expansion, v9.0) exists to attack. Overall corpus (125 tasks): 72.8% hit@5, honest lift 1.63× (+28pt).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## [8.21.0] — 2026-07-19
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- **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.
|
|
32
|
+
- **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`).
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- **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).
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
13
39
|
## [8.20.0] — 2026-07-19
|
|
14
40
|
|
|
15
41
|
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
|
@@ -59,10 +59,10 @@ That map is exactly what agentic grep is worst at: reproducible, auditable conte
|
|
|
59
59
|
|
|
60
60
|
**Proof it pays off** (full benchmark below):
|
|
61
61
|
<!--SM:whyMetrics-->
|
|
62
|
-
- **
|
|
62
|
+
- **82.2% hit@5** — right file in top 5 results (vs 44.8% single-shot grep baseline — 1.59× lift)
|
|
63
63
|
- **96.8% token reduction** — average across 21 real repos
|
|
64
|
-
- **
|
|
65
|
-
- **1.
|
|
64
|
+
- **64.8% task-success proxy** — modeled from retrieval tiers, not measured LLM sessions
|
|
65
|
+
- **1.53 prompts per task** — down from 2.84 (46.1% 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
|
|
@@ -98,7 +98,7 @@ sigmap verify answer.md --report # standalone red/amber/green HTML report
|
|
|
98
98
|
| Without SigMap | With SigMap |
|
|
99
99
|
|---|---|
|
|
100
100
|
| ❌ Non-reproducible agent guesses | ✅ Deterministic map — same input, same output, every time |
|
|
101
|
-
| ❌ "Trust me" AI answers | ✅ Grounded — right file in context <!--SM:hitWhole-->
|
|
101
|
+
| ❌ "Trust me" AI answers | ✅ Grounded — right file in context <!--SM:hitWhole-->82%<!--/SM:hitWhole--> of the time, every symbol on a real line anchor |
|
|
102
102
|
| ❌ Embeddings / vector DB required | ✅ Zero deps, no infra, fully offline |
|
|
103
103
|
|
|
104
104
|
---
|
|
@@ -122,13 +122,13 @@ Ask → Rank → Context → Validate → Judge → Learn
|
|
|
122
122
|
|
|
123
123
|
<!--SM:benchmarkBlock-->
|
|
124
124
|
```
|
|
125
|
-
Benchmark : sigmap-v8.
|
|
126
|
-
Date : 2026-07-
|
|
125
|
+
Benchmark : sigmap-v8.22-main (21 repositories, including R language)
|
|
126
|
+
Date : 2026-07-27
|
|
127
127
|
|
|
128
|
-
Hit@5 :
|
|
128
|
+
Hit@5 : 82.2% (grep-agent baseline 44.8% — 1.59× lift)
|
|
129
129
|
Token reduction: 96.8% (across 21 repos)
|
|
130
|
-
Prompt reduction :
|
|
131
|
-
Task success :
|
|
130
|
+
Prompt reduction : 46.1% (2.84 → 1.53 prompts per task, modeled)
|
|
131
|
+
Task success : 64.8% (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
|
},
|
|
@@ -4099,6 +4101,93 @@ __factories["./src/eval/analyzer"] = function(module, exports) {
|
|
|
4099
4101
|
|
|
4100
4102
|
};
|
|
4101
4103
|
|
|
4104
|
+
// ── ./src/eval/corpus ──
|
|
4105
|
+
__factories["./src/eval/corpus"] = function(module, exports) {
|
|
4106
|
+
|
|
4107
|
+
/**
|
|
4108
|
+
* Task-corpus hygiene (A3, v8.22 "Hard Corpus").
|
|
4109
|
+
*
|
|
4110
|
+
* A benchmark query "leaks" when it shares a token with the basenames of its
|
|
4111
|
+
* expected files — hit@5 then partly measures filename matching, not
|
|
4112
|
+
* retrieval. The criterion is deterministic and reuses the production
|
|
4113
|
+
* tokenizer (identifier splitting + stemming from src/retrieval/bm25.js), so
|
|
4114
|
+
* "payments" leaks against payment.js and "InterceptorManager" leaks against
|
|
4115
|
+
* "interceptor manager" the same way the ranker would see them.
|
|
4116
|
+
*
|
|
4117
|
+
* Tasks carry an optional `split` field: 'hard' tasks MUST be leak-free
|
|
4118
|
+
* (validateTasks reports them as violations); 'easy' tasks (the default) may
|
|
4119
|
+
* leak — that is what makes them easy.
|
|
4120
|
+
*
|
|
4121
|
+
* Size buckets group repos by indexed file count so large repos stop being
|
|
4122
|
+
* averaged away by tiny ones. Thresholds are the rough tertiles of the
|
|
4123
|
+
* current benchmarks/repos corpus (43 repos, 27–3450 source files).
|
|
4124
|
+
*/
|
|
4125
|
+
|
|
4126
|
+
const { tokenize } = __require('./src/retrieval/bm25');
|
|
4127
|
+
|
|
4128
|
+
const BUCKET_LIMITS = { small: 200, medium: 1000 }; // files; large = above medium
|
|
4129
|
+
|
|
4130
|
+
/**
|
|
4131
|
+
* Stemmed tokens of a file path's basename (extension stripped).
|
|
4132
|
+
* @param {string} filePath
|
|
4133
|
+
* @returns {string[]}
|
|
4134
|
+
*/
|
|
4135
|
+
function basenameTokens(filePath) {
|
|
4136
|
+
const base = String(filePath).split('/').pop() || '';
|
|
4137
|
+
return tokenize(base.replace(/\.[^.]*$/, ''));
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
/**
|
|
4141
|
+
* Leaked tokens between a query and its expected files' basenames.
|
|
4142
|
+
* @param {string} query
|
|
4143
|
+
* @param {string[]} expectedFiles
|
|
4144
|
+
* @returns {{ leaked: string[], clean: boolean }}
|
|
4145
|
+
*/
|
|
4146
|
+
function queryLeakage(query, expectedFiles) {
|
|
4147
|
+
const qToks = new Set(tokenize(query));
|
|
4148
|
+
const leaked = new Set();
|
|
4149
|
+
for (const f of expectedFiles || []) {
|
|
4150
|
+
for (const t of basenameTokens(f)) {
|
|
4151
|
+
if (qToks.has(t)) leaked.add(t);
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
return { leaked: [...leaked].sort(), clean: leaked.size === 0 };
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
/**
|
|
4158
|
+
* Validate a task list: every task gets a leakage result; hard-split tasks
|
|
4159
|
+
* that leak are violations.
|
|
4160
|
+
* @param {Array<{id?:string, query:string, expected_files?:string[], split?:string}>} tasks
|
|
4161
|
+
* @returns {{ results: object[], hardViolations: object[] }}
|
|
4162
|
+
*/
|
|
4163
|
+
function validateTasks(tasks) {
|
|
4164
|
+
const results = [];
|
|
4165
|
+
const hardViolations = [];
|
|
4166
|
+
for (const t of tasks || []) {
|
|
4167
|
+
const split = t.split === 'hard' ? 'hard' : 'easy';
|
|
4168
|
+
const { leaked, clean } = queryLeakage(t.query, t.expected_files);
|
|
4169
|
+
const row = { id: t.id || '?', split, leaked, clean };
|
|
4170
|
+
results.push(row);
|
|
4171
|
+
if (split === 'hard' && !clean) hardViolations.push(row);
|
|
4172
|
+
}
|
|
4173
|
+
return { results, hardViolations };
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
/**
|
|
4177
|
+
* Size bucket for a repo by indexed file count.
|
|
4178
|
+
* @param {number} fileCount
|
|
4179
|
+
* @returns {'small'|'medium'|'large'}
|
|
4180
|
+
*/
|
|
4181
|
+
function sizeBucket(fileCount) {
|
|
4182
|
+
if (fileCount < BUCKET_LIMITS.small) return 'small';
|
|
4183
|
+
if (fileCount <= BUCKET_LIMITS.medium) return 'medium';
|
|
4184
|
+
return 'large';
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
module.exports = { basenameTokens, queryLeakage, validateTasks, sizeBucket, BUCKET_LIMITS };
|
|
4188
|
+
|
|
4189
|
+
};
|
|
4190
|
+
|
|
4102
4191
|
// ── ./src/eval/llm-ablation ──
|
|
4103
4192
|
__factories["./src/eval/llm-ablation"] = function(module, exports) {
|
|
4104
4193
|
|
|
@@ -4398,7 +4487,7 @@ __factories["./src/eval/runner"] = function(module, exports) {
|
|
|
4398
4487
|
|
|
4399
4488
|
/**
|
|
4400
4489
|
* Load tasks from a JSONL file.
|
|
4401
|
-
* Each line: { id, query, expected_files, repo }
|
|
4490
|
+
* Each line: { id, query, expected_files, repo, split? ('easy'|'hard') }
|
|
4402
4491
|
* Invalid or blank lines are silently skipped.
|
|
4403
4492
|
* @param {string} tasksFile - absolute or relative path
|
|
4404
4493
|
* @returns {Array<{id:string, query:string, expected:string[], repo:string}>}
|
|
@@ -4418,6 +4507,7 @@ __factories["./src/eval/runner"] = function(module, exports) {
|
|
|
4418
4507
|
query: obj.query,
|
|
4419
4508
|
expected: obj.expected_files,
|
|
4420
4509
|
repo: obj.repo || '.',
|
|
4510
|
+
split: obj.split === 'hard' ? 'hard' : 'easy',
|
|
4421
4511
|
});
|
|
4422
4512
|
}
|
|
4423
4513
|
} catch {
|
|
@@ -5928,6 +6018,10 @@ __factories["./src/extractors/go"] = function(module, exports) {
|
|
|
5928
6018
|
function extract(src) {
|
|
5929
6019
|
if (!src || typeof src !== 'string') return [];
|
|
5930
6020
|
const sigs = [];
|
|
6021
|
+
const docHints = buildDocHints(src);
|
|
6022
|
+
// Append the godoc hint after the anchor as ` # <hint>` — same convention
|
|
6023
|
+
// as the Python/JS extractors' doc hints.
|
|
6024
|
+
const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
|
|
5931
6025
|
|
|
5932
6026
|
const stripped = src
|
|
5933
6027
|
.replace(/\/\/.*$/gm, '')
|
|
@@ -5939,14 +6033,14 @@ __factories["./src/extractors/go"] = function(module, exports) {
|
|
|
5939
6033
|
// Structs
|
|
5940
6034
|
for (const m of stripped.matchAll(/^type\s+(\w+)\s+struct\s*\{/gm)) {
|
|
5941
6035
|
const end = blockEndIdx(m.index + m[0].length);
|
|
5942
|
-
sigs.push(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)));
|
|
6036
|
+
sigs.push(hinted(withAnchor(`type ${m[1]} struct`, lineAt(stripped, m.index), lineAt(stripped, end)), m[1]));
|
|
5943
6037
|
}
|
|
5944
6038
|
|
|
5945
6039
|
// Interfaces
|
|
5946
6040
|
for (const m of stripped.matchAll(/^type\s+(\w+)\s+interface\s*\{/gm)) {
|
|
5947
6041
|
const bodyStart = m.index + m[0].length;
|
|
5948
6042
|
const block = extractBlock(stripped, bodyStart);
|
|
5949
|
-
sigs.push(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
|
|
6043
|
+
sigs.push(hinted(withAnchor(`type ${m[1]} interface`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[1]));
|
|
5950
6044
|
for (const meth of extractInterfaceMethods(block)) {
|
|
5951
6045
|
sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
|
|
5952
6046
|
}
|
|
@@ -5958,7 +6052,7 @@ __factories["./src/extractors/go"] = function(module, exports) {
|
|
|
5958
6052
|
const retType = m[4] ? m[4].trim().replace(/\s+/g, ' ') : '';
|
|
5959
6053
|
const retStr = retType ? ` → ${retType.slice(0, 30)}` : '';
|
|
5960
6054
|
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)));
|
|
6055
|
+
sigs.push(hinted(withAnchor(`func ${receiver}${m[2]}(${normalizeParams(m[3])})${retStr}`, lineAt(stripped, m.index), lineAt(stripped, end)), m[2]));
|
|
5962
6056
|
}
|
|
5963
6057
|
|
|
5964
6058
|
return sigs.slice(0, 25);
|
|
@@ -5994,6 +6088,30 @@ __factories["./src/extractors/go"] = function(module, exports) {
|
|
|
5994
6088
|
return params.trim().replace(/\s+/g, ' ');
|
|
5995
6089
|
}
|
|
5996
6090
|
|
|
6091
|
+
// Godoc: the `//` comment block directly above a top-level func/type/method
|
|
6092
|
+
// declaration → first prose sentence, 60-char cap. Runs on the ORIGINAL src
|
|
6093
|
+
// (extract strips comments before matching). Compiler directives (`//go:...`)
|
|
6094
|
+
// carry no prose and are skipped.
|
|
6095
|
+
function buildDocHints(src) {
|
|
6096
|
+
const hints = new Map();
|
|
6097
|
+
const re = /((?:^\/\/[^\n]*\n)+)(?:func\s+(?:\(\w+\s+[\w*]+\)\s+)?(\w+)\s*\(|type\s+(\w+)\s+(?:struct|interface)\b)/gm;
|
|
6098
|
+
for (const m of src.matchAll(re)) {
|
|
6099
|
+
const name = m[2] || m[3];
|
|
6100
|
+
const hint = firstDocSentence(m[1]);
|
|
6101
|
+
if (hint && !hints.has(name)) hints.set(name, hint);
|
|
6102
|
+
}
|
|
6103
|
+
return hints;
|
|
6104
|
+
}
|
|
6105
|
+
|
|
6106
|
+
// First non-directive prose line of a `//` block → first sentence, 60-char cap.
|
|
6107
|
+
function firstDocSentence(block) {
|
|
6108
|
+
const line = String(block).split('\n')
|
|
6109
|
+
.map((l) => l.replace(/^\/\/\s?/, '').trim())
|
|
6110
|
+
.find((l) => l && !l.startsWith('go:') && !l.startsWith('nolint'));
|
|
6111
|
+
if (!line) return '';
|
|
6112
|
+
return line.split(/[.!?]/)[0].trim().slice(0, 60);
|
|
6113
|
+
}
|
|
6114
|
+
|
|
5997
6115
|
module.exports = { extract };
|
|
5998
6116
|
|
|
5999
6117
|
};
|
|
@@ -6126,6 +6244,10 @@ __factories["./src/extractors/java"] = function(module, exports) {
|
|
|
6126
6244
|
function extract(src) {
|
|
6127
6245
|
if (!src || typeof src !== 'string') return [];
|
|
6128
6246
|
const sigs = [];
|
|
6247
|
+
const docHints = buildDocHints(src);
|
|
6248
|
+
// Append the Javadoc hint after the anchor as ` # <hint>` — same convention
|
|
6249
|
+
// as the Python/JS extractors' doc hints.
|
|
6250
|
+
const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
|
|
6129
6251
|
|
|
6130
6252
|
const stripped = src
|
|
6131
6253
|
.replace(/\/\/.*$/gm, '')
|
|
@@ -6136,9 +6258,9 @@ __factories["./src/extractors/java"] = function(module, exports) {
|
|
|
6136
6258
|
for (const m of stripped.matchAll(typeRegex)) {
|
|
6137
6259
|
const bodyStart = m.index + m[0].length;
|
|
6138
6260
|
const block = extractBlock(stripped, bodyStart);
|
|
6139
|
-
sigs.push(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
|
|
6261
|
+
sigs.push(hinted(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[2]));
|
|
6140
6262
|
for (const meth of extractMembers(block)) {
|
|
6141
|
-
sigs.push(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)));
|
|
6263
|
+
sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)), meth.name));
|
|
6142
6264
|
}
|
|
6143
6265
|
}
|
|
6144
6266
|
|
|
@@ -6165,6 +6287,7 @@ __factories["./src/extractors/java"] = function(module, exports) {
|
|
|
6165
6287
|
const retStr = ret ? ` → ${ret}` : '';
|
|
6166
6288
|
members.push({
|
|
6167
6289
|
text: `${m[2]}(${normalizeParams(m[3])})${retStr}`,
|
|
6290
|
+
name: m[2],
|
|
6168
6291
|
declIdx: m.index + (m[0].length - m[0].trimStart().length),
|
|
6169
6292
|
endIdx: m.index + m[0].length,
|
|
6170
6293
|
});
|
|
@@ -6182,6 +6305,36 @@ __factories["./src/extractors/java"] = function(module, exports) {
|
|
|
6182
6305
|
return type.trim().replace(/\s+/g, ' ').slice(0, 30);
|
|
6183
6306
|
}
|
|
6184
6307
|
|
|
6308
|
+
// Javadoc: the `/** ... */` block directly above a type or public/protected
|
|
6309
|
+
// member declaration → first prose sentence, 60-char cap. Runs on the
|
|
6310
|
+
// ORIGINAL src (extract strips comments before matching). Annotation lines
|
|
6311
|
+
// (`@Override` etc.) between the doc block and the declaration are tolerated.
|
|
6312
|
+
// Body may not contain `*/` so a failed adjacency check can't expand across
|
|
6313
|
+
// code to the next comment block and misattribute the hint.
|
|
6314
|
+
function buildDocHints(src) {
|
|
6315
|
+
const hints = new Map();
|
|
6316
|
+
const patterns = [
|
|
6317
|
+
/\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public\s+|protected\s+)?(?:abstract\s+|final\s+)?(?:class|interface|enum)\s+(\w+)/g,
|
|
6318
|
+
/\/\*\*((?:[^*]|\*(?!\/))*)\*\/\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public|protected)\s+(?:static\s+)?(?:final\s+)?(?:synchronized\s+)?(?:<[^>]+>\s+)?[\w<>\[\], ?.]+\s+(\w+)\s*\(/g,
|
|
6319
|
+
];
|
|
6320
|
+
for (const re of patterns) {
|
|
6321
|
+
for (const m of src.matchAll(re)) {
|
|
6322
|
+
const hint = firstDocSentence(m[1]);
|
|
6323
|
+
if (hint && !hints.has(m[2])) hints.set(m[2], hint);
|
|
6324
|
+
}
|
|
6325
|
+
}
|
|
6326
|
+
return hints;
|
|
6327
|
+
}
|
|
6328
|
+
|
|
6329
|
+
// First non-tag prose line of a Javadoc body → first sentence, 60-char cap.
|
|
6330
|
+
function firstDocSentence(body) {
|
|
6331
|
+
const line = String(body).split('\n')
|
|
6332
|
+
.map((l) => l.replace(/^\s*\*\s?/, '').trim())
|
|
6333
|
+
.find((l) => l && !l.startsWith('@'));
|
|
6334
|
+
if (!line) return '';
|
|
6335
|
+
return line.split(/[.!?]/)[0].trim().slice(0, 60);
|
|
6336
|
+
}
|
|
6337
|
+
|
|
6185
6338
|
module.exports = { extract };
|
|
6186
6339
|
|
|
6187
6340
|
};
|
|
@@ -7680,6 +7833,10 @@ __factories["./src/extractors/rust"] = function(module, exports) {
|
|
|
7680
7833
|
function extract(src) {
|
|
7681
7834
|
if (!src || typeof src !== 'string') return [];
|
|
7682
7835
|
const sigs = [];
|
|
7836
|
+
const docHints = buildDocHints(src);
|
|
7837
|
+
// Append the doc-comment hint after the anchor as ` # <hint>` — same
|
|
7838
|
+
// convention as the Python/JS extractors' doc hints.
|
|
7839
|
+
const hinted = (sig, name) => (docHints.has(name) ? `${sig} # ${docHints.get(name)}` : sig);
|
|
7683
7840
|
|
|
7684
7841
|
const stripped = src
|
|
7685
7842
|
.replace(/\/\/.*$/gm, '')
|
|
@@ -7701,19 +7858,19 @@ __factories["./src/extractors/rust"] = function(module, exports) {
|
|
|
7701
7858
|
// Structs
|
|
7702
7859
|
for (const m of stripped.matchAll(/^pub\s+struct\s+(\w+)(?:<[^{]*>)?/gm)) {
|
|
7703
7860
|
const [s, e] = rangeFor(m.index, m.index + m[0].length);
|
|
7704
|
-
sigs.push(withAnchor(`pub struct ${m[1]}`, s, e));
|
|
7861
|
+
sigs.push(hinted(withAnchor(`pub struct ${m[1]}`, s, e), m[1]));
|
|
7705
7862
|
}
|
|
7706
7863
|
|
|
7707
7864
|
// Enums
|
|
7708
7865
|
for (const m of stripped.matchAll(/^pub\s+enum\s+(\w+)(?:<[^{]*>)?/gm)) {
|
|
7709
7866
|
const [s, e] = rangeFor(m.index, m.index + m[0].length);
|
|
7710
|
-
sigs.push(withAnchor(`pub enum ${m[1]}`, s, e));
|
|
7867
|
+
sigs.push(hinted(withAnchor(`pub enum ${m[1]}`, s, e), m[1]));
|
|
7711
7868
|
}
|
|
7712
7869
|
|
|
7713
7870
|
// Traits
|
|
7714
7871
|
for (const m of stripped.matchAll(/^pub\s+trait\s+(\w+)(?:<[^{]*>)?/gm)) {
|
|
7715
7872
|
const [s, e] = rangeFor(m.index, m.index + m[0].length);
|
|
7716
|
-
sigs.push(withAnchor(`pub trait ${m[1]}`, s, e));
|
|
7873
|
+
sigs.push(hinted(withAnchor(`pub trait ${m[1]}`, s, e), m[1]));
|
|
7717
7874
|
}
|
|
7718
7875
|
|
|
7719
7876
|
// impl blocks
|
|
@@ -7722,7 +7879,7 @@ __factories["./src/extractors/rust"] = function(module, exports) {
|
|
|
7722
7879
|
const block = extractBlock(stripped, bodyStart);
|
|
7723
7880
|
sigs.push(withAnchor(`impl ${m[1]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)));
|
|
7724
7881
|
for (const fn of extractMethods(block)) {
|
|
7725
|
-
sigs.push(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)));
|
|
7882
|
+
sigs.push(hinted(withAnchor(` ${fn.text}`, lineAt(stripped, bodyStart + fn.declIdx), lineAt(stripped, bodyStart + fn.endIdx)), fn.name));
|
|
7726
7883
|
}
|
|
7727
7884
|
}
|
|
7728
7885
|
|
|
@@ -7731,7 +7888,7 @@ __factories["./src/extractors/rust"] = function(module, exports) {
|
|
|
7731
7888
|
const asyncKw = m[0].includes('async') ? 'async ' : '';
|
|
7732
7889
|
const retStr = extractReturnType(m[3]);
|
|
7733
7890
|
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));
|
|
7891
|
+
sigs.push(hinted(withAnchor(`pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`, s, e), m[1]));
|
|
7735
7892
|
}
|
|
7736
7893
|
|
|
7737
7894
|
return sigs.slice(0, 25);
|
|
@@ -7755,6 +7912,7 @@ __factories["./src/extractors/rust"] = function(module, exports) {
|
|
|
7755
7912
|
const retStr = extractReturnType(m[3]);
|
|
7756
7913
|
methods.push({
|
|
7757
7914
|
text: `pub ${asyncKw}fn ${m[1]}(${normalizeParams(m[2])})${retStr}`,
|
|
7915
|
+
name: m[1],
|
|
7758
7916
|
declIdx: m.index + (m[0].length - m[0].trimStart().length),
|
|
7759
7917
|
endIdx: m.index + m[0].length,
|
|
7760
7918
|
});
|
|
@@ -7775,6 +7933,29 @@ __factories["./src/extractors/rust"] = function(module, exports) {
|
|
|
7775
7933
|
return ` → ${rt.length > 30 ? rt.slice(0, 27) + '...' : rt}`;
|
|
7776
7934
|
}
|
|
7777
7935
|
|
|
7936
|
+
// Rustdoc: the `///` block directly above a declaration → first prose
|
|
7937
|
+
// sentence, 60-char cap. Runs on the ORIGINAL src (extract strips comments
|
|
7938
|
+
// before matching). Attribute lines (`#[...]`) between the doc block and the
|
|
7939
|
+
// declaration are tolerated.
|
|
7940
|
+
function buildDocHints(src) {
|
|
7941
|
+
const hints = new Map();
|
|
7942
|
+
const re = /((?:^[ \t]*\/\/\/[^\n]*\n)+)(?:[ \t]*#\[[^\n]*\n)*[ \t]*pub(?:\s+async)?\s+(?:fn|struct|enum|trait)\s+(\w+)/gm;
|
|
7943
|
+
for (const m of src.matchAll(re)) {
|
|
7944
|
+
const hint = firstDocSentence(m[1]);
|
|
7945
|
+
if (hint && !hints.has(m[2])) hints.set(m[2], hint);
|
|
7946
|
+
}
|
|
7947
|
+
return hints;
|
|
7948
|
+
}
|
|
7949
|
+
|
|
7950
|
+
// First prose line of a `///` block → first sentence, 60-char cap.
|
|
7951
|
+
function firstDocSentence(block) {
|
|
7952
|
+
const line = String(block).split('\n')
|
|
7953
|
+
.map((l) => l.replace(/^[ \t]*\/\/\/\s?/, '').trim())
|
|
7954
|
+
.find((l) => l);
|
|
7955
|
+
if (!line) return '';
|
|
7956
|
+
return line.split(/[.!?]/)[0].trim().slice(0, 60);
|
|
7957
|
+
}
|
|
7958
|
+
|
|
7778
7959
|
module.exports = { extract };
|
|
7779
7960
|
|
|
7780
7961
|
};
|
|
@@ -11806,6 +11987,71 @@ __factories["./src/graph/call-graph"] = function(module, exports) {
|
|
|
11806
11987
|
|
|
11807
11988
|
};
|
|
11808
11989
|
|
|
11990
|
+
// ── ./src/graph/centrality ──
|
|
11991
|
+
__factories["./src/graph/centrality"] = function(module, exports) {
|
|
11992
|
+
|
|
11993
|
+
/**
|
|
11994
|
+
* Zero-dependency import-graph centrality (Semantic Bridge II, B3).
|
|
11995
|
+
*
|
|
11996
|
+
* Power iteration over the forward dependency graph: rank flows from each
|
|
11997
|
+
* importer to the files it imports, so heavily-referenced files accumulate
|
|
11998
|
+
* centrality and one-off helpers do not. Deterministic — fixed damping,
|
|
11999
|
+
* fixed iteration count, nodes processed in sorted order.
|
|
12000
|
+
*
|
|
12001
|
+
* The result feeds the opt-in `retrieval.centralityBlend` ranking prior
|
|
12002
|
+
* (see src/retrieval/ranker.js) — a principled deepening of the existing
|
|
12003
|
+
* graph-boost idea, not a replacement for query relevance.
|
|
12004
|
+
*/
|
|
12005
|
+
|
|
12006
|
+
const DAMPING = 0.85;
|
|
12007
|
+
const ITERATIONS = 20;
|
|
12008
|
+
|
|
12009
|
+
/**
|
|
12010
|
+
* Compute a normalized centrality score for every file in a dependency graph.
|
|
12011
|
+
*
|
|
12012
|
+
* @param {{ forward: Map<string, string[]> }} graph - forward dependency graph
|
|
12013
|
+
* (file → files it imports), as built by src/graph/builder.js
|
|
12014
|
+
* @returns {Map<string, number>} file → centrality in (0, 1], max-normalized;
|
|
12015
|
+
* empty Map when the graph is missing or empty
|
|
12016
|
+
*/
|
|
12017
|
+
function computeCentrality(graph) {
|
|
12018
|
+
if (!graph || !(graph.forward instanceof Map) || graph.forward.size === 0) return new Map();
|
|
12019
|
+
|
|
12020
|
+
const nodes = new Set(graph.forward.keys());
|
|
12021
|
+
for (const deps of graph.forward.values()) {
|
|
12022
|
+
for (const dep of deps || []) nodes.add(dep);
|
|
12023
|
+
}
|
|
12024
|
+
const nodeList = [...nodes].sort();
|
|
12025
|
+
const n = nodeList.length;
|
|
12026
|
+
const indexOf = new Map(nodeList.map((file, i) => [file, i]));
|
|
12027
|
+
const outLinks = nodeList.map((file) =>
|
|
12028
|
+
(graph.forward.get(file) || []).map((dep) => indexOf.get(dep)).filter((i) => i !== undefined));
|
|
12029
|
+
|
|
12030
|
+
let ranks = new Array(n).fill(1 / n);
|
|
12031
|
+
for (let iter = 0; iter < ITERATIONS; iter++) {
|
|
12032
|
+
const next = new Array(n).fill((1 - DAMPING) / n);
|
|
12033
|
+
let dangling = 0;
|
|
12034
|
+
for (let i = 0; i < n; i++) {
|
|
12035
|
+
if (outLinks[i].length === 0) { dangling += ranks[i]; continue; }
|
|
12036
|
+
const share = (DAMPING * ranks[i]) / outLinks[i].length;
|
|
12037
|
+
for (const j of outLinks[i]) next[j] += share;
|
|
12038
|
+
}
|
|
12039
|
+
// Dangling mass (files that import nothing) is redistributed uniformly.
|
|
12040
|
+
const danglingShare = (DAMPING * dangling) / n;
|
|
12041
|
+
for (let i = 0; i < n; i++) next[i] += danglingShare;
|
|
12042
|
+
ranks = next;
|
|
12043
|
+
}
|
|
12044
|
+
|
|
12045
|
+
const max = Math.max(...ranks) || 1;
|
|
12046
|
+
const result = new Map();
|
|
12047
|
+
for (let i = 0; i < n; i++) result.set(nodeList[i], ranks[i] / max);
|
|
12048
|
+
return result;
|
|
12049
|
+
}
|
|
12050
|
+
|
|
12051
|
+
module.exports = { computeCentrality, DAMPING, ITERATIONS };
|
|
12052
|
+
|
|
12053
|
+
};
|
|
12054
|
+
|
|
11809
12055
|
// ── ./src/graph/impact ──
|
|
11810
12056
|
__factories["./src/graph/impact"] = function(module, exports) {
|
|
11811
12057
|
|
|
@@ -13963,8 +14209,9 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
|
|
|
13963
14209
|
// Build dependency graph for neighbor boost — non-fatal if it fails
|
|
13964
14210
|
let graph = null;
|
|
13965
14211
|
try { graph = buildFromCwd(cwd); } catch (_) {}
|
|
13966
|
-
// Opt-in call-graph neighbor boost + surface enrichment — non-fatal
|
|
14212
|
+
// Opt-in call-graph neighbor boost + surface enrichment + centrality blend — non-fatal
|
|
13967
14213
|
let callGraph = null;
|
|
14214
|
+
let centrality = null;
|
|
13968
14215
|
try {
|
|
13969
14216
|
const { loadConfig } = __require('./src/config/loader');
|
|
13970
14217
|
const retrieval = loadConfig(cwd).retrieval;
|
|
@@ -13974,8 +14221,11 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
|
|
|
13974
14221
|
if (retrieval && retrieval.surfaceEnrichment) {
|
|
13975
14222
|
__require('./src/retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd);
|
|
13976
14223
|
}
|
|
14224
|
+
if (retrieval && retrieval.centralityBlend && graph) {
|
|
14225
|
+
centrality = __require('./src/graph/centrality').computeCentrality(graph);
|
|
14226
|
+
}
|
|
13977
14227
|
} catch (_) {}
|
|
13978
|
-
const results = rank(args.query, index, { topK, cwd, graph, callGraph });
|
|
14228
|
+
const results = rank(args.query, index, { topK, cwd, graph, callGraph, centrality });
|
|
13979
14229
|
return formatRankTable(results, args.query);
|
|
13980
14230
|
} catch (err) {
|
|
13981
14231
|
return `_query_context failed: ${err.message}_`;
|
|
@@ -14688,7 +14938,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
|
|
|
14688
14938
|
|
|
14689
14939
|
const SERVER_INFO = {
|
|
14690
14940
|
name: 'sigmap',
|
|
14691
|
-
version: '8.
|
|
14941
|
+
version: '8.22.0',
|
|
14692
14942
|
description: 'SigMap MCP server — code signatures on demand',
|
|
14693
14943
|
};
|
|
14694
14944
|
|
|
@@ -15818,6 +16068,9 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
15818
16068
|
callHop: 0.30, // call-graph file neighbor (opt-in retrieval.callGraphBoost)
|
|
15819
16069
|
};
|
|
15820
16070
|
|
|
16071
|
+
// Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
|
|
16072
|
+
const CENTRALITY_BLEND_WEIGHT = 0.3;
|
|
16073
|
+
|
|
15821
16074
|
// Intent-specific weight adjustments
|
|
15822
16075
|
const INTENT_WEIGHTS = {
|
|
15823
16076
|
search: DEFAULT_WEIGHTS,
|
|
@@ -15950,6 +16203,8 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
15950
16203
|
* @param {{ forward: Map<string,string[]> }} [opts.graph] - dependency graph for neighbor boost
|
|
15951
16204
|
* @param {{ forward: Map<string,string[]> }} [opts.callGraph] - file-level call-graph edges
|
|
15952
16205
|
* (from buildCallFileGraph) for the opt-in call-neighbor boost
|
|
16206
|
+
* @param {Map<string,number>} [opts.centrality] - absolute file → normalized
|
|
16207
|
+
* centrality (from computeCentrality) for the opt-in centrality blend
|
|
15953
16208
|
* @returns {{ file: string, score: number, sigs: string[], tokens: number, intent: string, signals: object }[]}
|
|
15954
16209
|
*/
|
|
15955
16210
|
function rank(query, sigIndex, opts) {
|
|
@@ -16098,6 +16353,26 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16098
16353
|
}
|
|
16099
16354
|
}
|
|
16100
16355
|
|
|
16356
|
+
// Centrality blend (opt-in via retrieval.centralityBlend): a small additive
|
|
16357
|
+
// prior from import-graph centrality so heavily-referenced files rank above
|
|
16358
|
+
// one-off helpers on ambiguous queries. Applied only to positively-scored
|
|
16359
|
+
// files — a tie-breaker among matches, never a way to surface non-matches.
|
|
16360
|
+
const centrality = (opts && opts.centrality instanceof Map && opts.centrality.size > 0) ? opts.centrality : null;
|
|
16361
|
+
if (centrality && cwd) {
|
|
16362
|
+
const path = require('path');
|
|
16363
|
+
for (const entry of scored) {
|
|
16364
|
+
if (entry.score <= 0) continue;
|
|
16365
|
+
const abs = path.resolve(cwd, entry.file);
|
|
16366
|
+
// The graph builder lowercases paths (normalizePath) — probe both forms.
|
|
16367
|
+
const c = centrality.get(abs) || centrality.get(abs.toLowerCase());
|
|
16368
|
+
if (c) {
|
|
16369
|
+
const bonus = CENTRALITY_BLEND_WEIGHT * c;
|
|
16370
|
+
entry.score += bonus;
|
|
16371
|
+
entry.signals.centrality = bonus;
|
|
16372
|
+
}
|
|
16373
|
+
}
|
|
16374
|
+
}
|
|
16375
|
+
|
|
16101
16376
|
// Compute confidence levels based on score distribution
|
|
16102
16377
|
if (scored.length > 0) {
|
|
16103
16378
|
const scores = scored.map(s => s.score);
|
|
@@ -16373,7 +16648,7 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16373
16648
|
return 'search';
|
|
16374
16649
|
}
|
|
16375
16650
|
|
|
16376
|
-
module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, detectIntent };
|
|
16651
|
+
module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
|
|
16377
16652
|
|
|
16378
16653
|
};
|
|
16379
16654
|
|
|
@@ -18357,6 +18632,8 @@ __factories["./src/tracking/pricing"] = function(module, exports) {
|
|
|
18357
18632
|
'gpt-4o-mini': 0.15,
|
|
18358
18633
|
'gemini-1.5-pro': 1.25,
|
|
18359
18634
|
'gemini-1.5-flash': 0.075,
|
|
18635
|
+
'minimax-m3': 0.6,
|
|
18636
|
+
'minimax-m2.7': 0.3,
|
|
18360
18637
|
};
|
|
18361
18638
|
|
|
18362
18639
|
const DEFAULT_MODEL = 'claude-sonnet';
|
|
@@ -19881,7 +20158,7 @@ function __tryGit(args, opts = {}) {
|
|
|
19881
20158
|
catch (_) { return ''; }
|
|
19882
20159
|
}
|
|
19883
20160
|
|
|
19884
|
-
const VERSION = '8.
|
|
20161
|
+
const VERSION = '8.22.0';
|
|
19885
20162
|
const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
|
|
19886
20163
|
|
|
19887
20164
|
function requireSourceOrBundled(key) {
|
|
@@ -22215,8 +22492,16 @@ function main() {
|
|
|
22215
22492
|
if (config && config.retrieval && config.retrieval.surfaceEnrichment) {
|
|
22216
22493
|
try { requireSourceOrBundled('./src/retrieval/enrich-from-maps').enrichWithSurfaces(sigIndex, cwd); } catch (_) {}
|
|
22217
22494
|
}
|
|
22495
|
+
// Opt-in import-graph centrality blend (retrieval.centralityBlend) — non-fatal
|
|
22496
|
+
let askCentrality = null;
|
|
22497
|
+
if (config && config.retrieval && config.retrieval.centralityBlend) {
|
|
22498
|
+
try {
|
|
22499
|
+
const askCentralityGraph = requireSourceOrBundled('./src/graph/builder').buildFromCwd(cwd);
|
|
22500
|
+
askCentrality = requireSourceOrBundled('./src/graph/centrality').computeCentrality(askCentralityGraph);
|
|
22501
|
+
} catch (_) {}
|
|
22502
|
+
}
|
|
22218
22503
|
|
|
22219
|
-
let ranked = rank(query, sigIndex, { topK: 5, weights: intentWeights, cwd, callGraph: askCallGraph });
|
|
22504
|
+
let ranked = rank(query, sigIndex, { topK: 5, weights: intentWeights, cwd, callGraph: askCallGraph, centrality: askCentrality });
|
|
22220
22505
|
|
|
22221
22506
|
// v6.10: Workspace scoping — infer package from query and apply boost
|
|
22222
22507
|
const workspaces = detectWorkspaces(cwd);
|
|
@@ -24447,7 +24732,15 @@ function main() {
|
|
|
24447
24732
|
if (config && config.retrieval && config.retrieval.surfaceEnrichment) {
|
|
24448
24733
|
try { requireSourceOrBundled('./src/retrieval/enrich-from-maps').enrichWithSurfaces(index, cwd); } catch (_) {}
|
|
24449
24734
|
}
|
|
24450
|
-
|
|
24735
|
+
// Opt-in import-graph centrality blend (retrieval.centralityBlend) — non-fatal
|
|
24736
|
+
let queryCentrality = null;
|
|
24737
|
+
if (config && config.retrieval && config.retrieval.centralityBlend) {
|
|
24738
|
+
try {
|
|
24739
|
+
const centralityGraph = requireSourceOrBundled('./src/graph/builder').buildFromCwd(cwd);
|
|
24740
|
+
queryCentrality = requireSourceOrBundled('./src/graph/centrality').computeCentrality(centralityGraph);
|
|
24741
|
+
} catch (_) {}
|
|
24742
|
+
}
|
|
24743
|
+
const results = rank(query, index, { topK, recencyBoost, cwd, callGraph: queryCallGraph, centrality: queryCentrality });
|
|
24451
24744
|
if (args.includes('--context')) {
|
|
24452
24745
|
const miniCtx = buildMiniContext(results, cwd);
|
|
24453
24746
|
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.
|
|
14
|
+
# Version: 8.22.0 | Benchmark: sigmap-v8.22-main (2026-07-27)
|
|
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
|
+
## Core metrics (benchmark: sigmap-v8.22-main, 2026-07-27)
|
|
21
21
|
|
|
22
22
|
| Metric | Without SigMap | With SigMap |
|
|
23
23
|
|--------|----------------|-------------|
|
|
24
|
-
| Retrieval hit@5 |
|
|
24
|
+
| Retrieval hit@5 | 44.8% (single-shot grep) | 82.2% (1.59× lift) |
|
|
25
25
|
| Token reduction | — | 96.8% average |
|
|
26
|
-
| Task-success proxy (modeled) | — |
|
|
27
|
-
| Prompts per task | 2.84 | 1.
|
|
26
|
+
| Task-success proxy (modeled) | — | 64.8% |
|
|
27
|
+
| Prompts per task | 2.84 | 1.53 (46.1% 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.
|
|
14
|
+
# Version: 8.22.0 | Benchmark: sigmap-v8.22-main (2026-07-27)
|
|
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.
|
|
26
|
+
## Core metrics (benchmark: sigmap-v8.22-main, 2026-07-27)
|
|
27
27
|
|
|
28
|
-
- hit@5 retrieval:
|
|
28
|
+
- hit@5 retrieval: 82.2% vs 44.8% single-shot grep baseline (1.59× lift)
|
|
29
29
|
- Token reduction: 96.8% average across benchmark repos
|
|
30
|
-
- Task-success proxy:
|
|
31
|
-
- Prompts per task: 1.
|
|
30
|
+
- Task-success proxy: 64.8% (modeled from retrieval tiers, not measured LLM sessions)
|
|
31
|
+
- Prompts per task: 1.53 vs 2.84 baseline (46.1% 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.
|
|
3
|
+
"version": "8.22.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",
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
},
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Task-corpus hygiene (A3, v8.22 "Hard Corpus").
|
|
5
|
+
*
|
|
6
|
+
* A benchmark query "leaks" when it shares a token with the basenames of its
|
|
7
|
+
* expected files — hit@5 then partly measures filename matching, not
|
|
8
|
+
* retrieval. The criterion is deterministic and reuses the production
|
|
9
|
+
* tokenizer (identifier splitting + stemming from src/retrieval/bm25.js), so
|
|
10
|
+
* "payments" leaks against payment.js and "InterceptorManager" leaks against
|
|
11
|
+
* "interceptor manager" the same way the ranker would see them.
|
|
12
|
+
*
|
|
13
|
+
* Tasks carry an optional `split` field: 'hard' tasks MUST be leak-free
|
|
14
|
+
* (validateTasks reports them as violations); 'easy' tasks (the default) may
|
|
15
|
+
* leak — that is what makes them easy.
|
|
16
|
+
*
|
|
17
|
+
* Size buckets group repos by indexed file count so large repos stop being
|
|
18
|
+
* averaged away by tiny ones. Thresholds are the rough tertiles of the
|
|
19
|
+
* current benchmarks/repos corpus (43 repos, 27–3450 source files).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { tokenize } = require('../retrieval/bm25');
|
|
23
|
+
|
|
24
|
+
const BUCKET_LIMITS = { small: 200, medium: 1000 }; // files; large = above medium
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Stemmed tokens of a file path's basename (extension stripped).
|
|
28
|
+
* @param {string} filePath
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
function basenameTokens(filePath) {
|
|
32
|
+
const base = String(filePath).split('/').pop() || '';
|
|
33
|
+
return tokenize(base.replace(/\.[^.]*$/, ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Leaked tokens between a query and its expected files' basenames.
|
|
38
|
+
* @param {string} query
|
|
39
|
+
* @param {string[]} expectedFiles
|
|
40
|
+
* @returns {{ leaked: string[], clean: boolean }}
|
|
41
|
+
*/
|
|
42
|
+
function queryLeakage(query, expectedFiles) {
|
|
43
|
+
const qToks = new Set(tokenize(query));
|
|
44
|
+
const leaked = new Set();
|
|
45
|
+
for (const f of expectedFiles || []) {
|
|
46
|
+
for (const t of basenameTokens(f)) {
|
|
47
|
+
if (qToks.has(t)) leaked.add(t);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { leaked: [...leaked].sort(), clean: leaked.size === 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate a task list: every task gets a leakage result; hard-split tasks
|
|
55
|
+
* that leak are violations.
|
|
56
|
+
* @param {Array<{id?:string, query:string, expected_files?:string[], split?:string}>} tasks
|
|
57
|
+
* @returns {{ results: object[], hardViolations: object[] }}
|
|
58
|
+
*/
|
|
59
|
+
function validateTasks(tasks) {
|
|
60
|
+
const results = [];
|
|
61
|
+
const hardViolations = [];
|
|
62
|
+
for (const t of tasks || []) {
|
|
63
|
+
const split = t.split === 'hard' ? 'hard' : 'easy';
|
|
64
|
+
const { leaked, clean } = queryLeakage(t.query, t.expected_files);
|
|
65
|
+
const row = { id: t.id || '?', split, leaked, clean };
|
|
66
|
+
results.push(row);
|
|
67
|
+
if (split === 'hard' && !clean) hardViolations.push(row);
|
|
68
|
+
}
|
|
69
|
+
return { results, hardViolations };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Size bucket for a repo by indexed file count.
|
|
74
|
+
* @param {number} fileCount
|
|
75
|
+
* @returns {'small'|'medium'|'large'}
|
|
76
|
+
*/
|
|
77
|
+
function sizeBucket(fileCount) {
|
|
78
|
+
if (fileCount < BUCKET_LIMITS.small) return 'small';
|
|
79
|
+
if (fileCount <= BUCKET_LIMITS.medium) return 'medium';
|
|
80
|
+
return 'large';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { basenameTokens, queryLeakage, validateTasks, sizeBucket, BUCKET_LIMITS };
|
package/src/eval/runner.js
CHANGED
|
@@ -124,7 +124,7 @@ function estimateTokens(sigs) {
|
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
126
|
* Load tasks from a JSONL file.
|
|
127
|
-
* Each line: { id, query, expected_files, repo }
|
|
127
|
+
* Each line: { id, query, expected_files, repo, split? ('easy'|'hard') }
|
|
128
128
|
* Invalid or blank lines are silently skipped.
|
|
129
129
|
* @param {string} tasksFile - absolute or relative path
|
|
130
130
|
* @returns {Array<{id:string, query:string, expected:string[], repo:string}>}
|
|
@@ -144,6 +144,7 @@ function loadTasks(tasksFile) {
|
|
|
144
144
|
query: obj.query,
|
|
145
145
|
expected: obj.expected_files,
|
|
146
146
|
repo: obj.repo || '.',
|
|
147
|
+
split: obj.split === 'hard' ? 'hard' : 'easy',
|
|
147
148
|
});
|
|
148
149
|
}
|
|
149
150
|
} catch {
|
package/src/extractors/go.js
CHANGED
|
@@ -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 };
|
package/src/extractors/java.js
CHANGED
|
@@ -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 };
|
package/src/extractors/rust.js
CHANGED
|
@@ -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 };
|
package/src/mcp/handlers.js
CHANGED
|
@@ -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
package/src/retrieval/ranker.js
CHANGED
|
@@ -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 };
|