sigmap 8.28.1 → 8.30.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/AGENTS.md +539 -824
- package/CHANGELOG.md +38 -0
- package/README.md +2 -2
- package/gen-context.js +755 -144
- package/llms-full.txt +2 -2
- package/llms.txt +2 -2
- package/package.json +5 -3
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/eval/runner.js +18 -49
- package/src/extractors/java.js +20 -4
- package/src/graph/builder.js +4 -3
- package/src/graph/call-graph.js +7 -3
- package/src/graph/path-key.js +26 -0
- package/src/mcp/install.js +29 -2
- package/src/mcp/server.js +1 -1
- package/src/retrieval/bm25.js +56 -6
- package/src/retrieval/module-doc.js +120 -0
- package/src/retrieval/ranker.js +220 -65
- package/src/retrieval/sig-index-store.js +95 -0
- package/src/skills/skills.js +25 -1
package/llms-full.txt
CHANGED
|
@@ -11,13 +11,13 @@ 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.30.0 | Benchmark: sigmap-v8.30-main (2026-09-07)
|
|
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.30-main, 2026-09-07)
|
|
21
21
|
|
|
22
22
|
| Metric | Without SigMap | With SigMap |
|
|
23
23
|
|--------|----------------|-------------|
|
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.30.0 | Benchmark: sigmap-v8.30-main (2026-09-07)
|
|
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,7 +23,7 @@ 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.30-main, 2026-09-07)
|
|
27
27
|
|
|
28
28
|
- hit@5 retrieval: 81.1% vs 44.0% single-shot grep baseline (1.73× lift)
|
|
29
29
|
- Token reduction: 96.8% average across benchmark repos
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sigmap",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.30.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": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"test": "node test/run.js && node test/r-language.test.js",
|
|
18
|
-
"test:integration": "node test/integration/
|
|
18
|
+
"test:integration": "node test/integration/all.js",
|
|
19
19
|
"test:integration:all": "node test/integration/all.js",
|
|
20
20
|
"test:python": "python3 test/test_python_ast_extractor.py",
|
|
21
21
|
"test:all": "node test/run.js && node test/r-language.test.js && node test/integration/strategy.test.js && node test/integration/secret-scan.test.js",
|
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
"prepublishOnly": "node scripts/check-bundle.mjs && node scripts/build-bundle.mjs --check && node scripts/gen-benchmark-latest.mjs --check && node scripts/check-version-meta.mjs && node scripts/sync-metrics.mjs --check && node scripts/generate-llms.mjs",
|
|
54
54
|
"benchmark:grounding": "node scripts/run-hallucination-benchmark.mjs",
|
|
55
55
|
"benchmark:honest": "node scripts/run-honest-benchmark.mjs --save",
|
|
56
|
-
"benchmark:llm-ablation": "node scripts/run-llm-ablation.mjs"
|
|
56
|
+
"benchmark:llm-ablation": "node scripts/run-llm-ablation.mjs",
|
|
57
|
+
"benchmark:retrieval": "node scripts/run-retrieval-gate.mjs",
|
|
58
|
+
"validate:retrieval": "node scripts/check-corpus.mjs && node scripts/check-corpus.mjs benchmarks/tasks/retrieval-mined.jsonl && node scripts/run-retrieval-gate.mjs --gate --no-regress"
|
|
57
59
|
},
|
|
58
60
|
"files": [
|
|
59
61
|
"gen-context.js",
|
package/src/eval/runner.js
CHANGED
|
@@ -38,47 +38,12 @@ const { bm25rank } = require('../retrieval/bm25');
|
|
|
38
38
|
* @returns {Map<string, string[]>}
|
|
39
39
|
*/
|
|
40
40
|
function buildSigIndex(cwd) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const lines = content.split('\n');
|
|
48
|
-
|
|
49
|
-
let currentFile = null;
|
|
50
|
-
let inBlock = false;
|
|
51
|
-
let sigs = [];
|
|
52
|
-
|
|
53
|
-
for (const line of lines) {
|
|
54
|
-
// Section header: ### path/to/file.js
|
|
55
|
-
const headerMatch = line.match(/^###\s+(\S+\.\w+)\s*$/);
|
|
56
|
-
if (headerMatch) {
|
|
57
|
-
if (currentFile !== null) {
|
|
58
|
-
index.set(currentFile, sigs);
|
|
59
|
-
}
|
|
60
|
-
currentFile = headerMatch[1];
|
|
61
|
-
sigs = [];
|
|
62
|
-
inBlock = false;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (line.startsWith('```')) {
|
|
67
|
-
inBlock = !inBlock;
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (inBlock && currentFile && line.trim()) {
|
|
72
|
-
sigs.push(line.trim());
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Flush last file
|
|
77
|
-
if (currentFile !== null) {
|
|
78
|
-
index.set(currentFile, sigs);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return index;
|
|
41
|
+
// Delegate to the production index builder. This used to parse
|
|
42
|
+
// .github/copilot-instructions.md directly — a second parallel implementation
|
|
43
|
+
// that saw only the token-BUDGETED view, ignored the other adapters, the
|
|
44
|
+
// hot-cold/per-module strategy splits, and the complete retrieval index. The
|
|
45
|
+
// corpus therefore scored a smaller index than `sigmap ask` actually uses.
|
|
46
|
+
return require('../retrieval/ranker').buildSigIndex(cwd);
|
|
82
47
|
}
|
|
83
48
|
|
|
84
49
|
// ---------------------------------------------------------------------------
|
|
@@ -96,12 +61,13 @@ const { tokenize } = require('../retrieval/bm25');
|
|
|
96
61
|
* @param {number} topK
|
|
97
62
|
* @returns {{ file: string, score: number, sigs: string[] }[]}
|
|
98
63
|
*/
|
|
99
|
-
function rank(query, index, topK = 10) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
64
|
+
function rank(query, index, topK = 10, opts = {}) {
|
|
65
|
+
// Measure the ranker users actually hit. This used to call bm25rank directly,
|
|
66
|
+
// which meant the corpus scored a parallel implementation with no penalties,
|
|
67
|
+
// graph boost, recency or learned weights — so no ranking regression in
|
|
68
|
+
// src/retrieval/ranker.js could ever show up in the benchmark numbers.
|
|
69
|
+
const { rank: prodRank } = require('../retrieval/ranker');
|
|
70
|
+
return prodRank(query, index, Object.assign({ topK }, opts)).slice(0, topK);
|
|
105
71
|
}
|
|
106
72
|
|
|
107
73
|
// ---------------------------------------------------------------------------
|
|
@@ -188,11 +154,14 @@ function run(tasksFile, cwd, opts = {}) {
|
|
|
188
154
|
|
|
189
155
|
// Build index once (re-used across all tasks in the same repo)
|
|
190
156
|
const index = buildSigIndex(cwd);
|
|
157
|
+
// Import graph built once too — the hop-1/hop-2 boost is part of what ships.
|
|
158
|
+
let graph = null;
|
|
159
|
+
try { graph = require('../graph/builder').buildFromCwd(cwd); } catch (_) {}
|
|
191
160
|
|
|
192
161
|
const taskResults = [];
|
|
193
162
|
for (const task of tasks) {
|
|
194
|
-
const
|
|
195
|
-
const
|
|
163
|
+
const topResult = rank(task.query, index, topK, { cwd, graph, learned: opts.learned });
|
|
164
|
+
const ranked = topResult.map((r) => r.file);
|
|
196
165
|
const tokens = topResult.reduce((sum, r) => sum + estimateTokens(r.sigs), 0);
|
|
197
166
|
|
|
198
167
|
const { hitAtK, reciprocalRank, precisionAtK } = require('./scorer');
|
package/src/extractors/java.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { lineAt, withAnchor } = require('./line-anchor');
|
|
4
|
+
const { capWithNotice, capMembersWithNotice } = require('../util/truncate');
|
|
5
|
+
|
|
6
|
+
// Class bodies are scanned to this many characters. Generated JVM sources
|
|
7
|
+
// (MyBatis/JPA entities) routinely run past 10KB, so the ceiling only guards
|
|
8
|
+
// against pathological input rather than trimming ordinary classes.
|
|
9
|
+
const MAX_CLASS_BODY_CHARS = 200000;
|
|
10
|
+
|
|
11
|
+
// Per-class member ceiling. Sits above the default `maxSigsPerFile` so the
|
|
12
|
+
// caller's configured budget governs the output rather than this file.
|
|
13
|
+
const MAX_MEMBERS_PER_CLASS = 120;
|
|
14
|
+
|
|
15
|
+
// Per-file signature ceiling, likewise above the configured default.
|
|
16
|
+
const MAX_SIGS_PER_FILE = 200;
|
|
4
17
|
|
|
5
18
|
/**
|
|
6
19
|
* Extract signatures from Java source code.
|
|
@@ -28,17 +41,20 @@ function extract(src) {
|
|
|
28
41
|
const block = extractBlock(stripped, bodyStart);
|
|
29
42
|
sigs.push(hinted(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[2]));
|
|
30
43
|
for (const meth of extractMembers(block)) {
|
|
31
|
-
|
|
44
|
+
// The disclosure marker carries no offsets; anchor it at the class body.
|
|
45
|
+
const declIdx = meth.declIdx || 0;
|
|
46
|
+
const endIdx = meth.endIdx || 0;
|
|
47
|
+
sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + declIdx), lineAt(stripped, bodyStart + endIdx)), meth.name));
|
|
32
48
|
}
|
|
33
49
|
}
|
|
34
50
|
|
|
35
|
-
return sigs
|
|
51
|
+
return capWithNotice(sigs, MAX_SIGS_PER_FILE, 'signatures');
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
function extractBlock(src, startIndex) {
|
|
39
55
|
let depth = 1;
|
|
40
56
|
let i = startIndex;
|
|
41
|
-
const end = Math.min(src.length, startIndex +
|
|
57
|
+
const end = Math.min(src.length, startIndex + MAX_CLASS_BODY_CHARS);
|
|
42
58
|
while (i < end && depth > 0) {
|
|
43
59
|
if (src[i] === '{') depth++;
|
|
44
60
|
else if (src[i] === '}') depth--;
|
|
@@ -60,7 +76,7 @@ function extractMembers(block) {
|
|
|
60
76
|
endIdx: m.index + m[0].length,
|
|
61
77
|
});
|
|
62
78
|
}
|
|
63
|
-
return members
|
|
79
|
+
return capMembersWithNotice(members, MAX_MEMBERS_PER_CLASS);
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
function normalizeParams(params) {
|
package/src/graph/builder.js
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
15
|
+
// Cross-platform node key. Delegates to the ONE shared definition so this graph
|
|
16
|
+
// and the call-graph cannot drift apart again (see src/graph/path-key.js).
|
|
17
|
+
const { graphKey } = require('./path-key');
|
|
17
18
|
function normalizePath(p) {
|
|
18
|
-
return
|
|
19
|
+
return graphKey(p);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
package/src/graph/call-graph.js
CHANGED
|
@@ -38,7 +38,8 @@ const NON_CALL = new Set([
|
|
|
38
38
|
'synchronized',
|
|
39
39
|
]);
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
const { graphKey } = require('./path-key');
|
|
42
|
+
function normalizePath(p) { return graphKey(p); }
|
|
42
43
|
function toRel(cwd, f) { return path.relative(cwd, f).replace(/\\/g, '/'); }
|
|
43
44
|
function symId(cwd, absFile, name) { return `${toRel(cwd, absFile)}#${name}`; }
|
|
44
45
|
|
|
@@ -470,8 +471,11 @@ function buildCallFileGraph(cwd, opts = {}) {
|
|
|
470
471
|
for (const calleeId of calleeIds) {
|
|
471
472
|
const calleeDef = graph.defs.get(calleeId);
|
|
472
473
|
if (!calleeDef || calleeDef.file === callerDef.file) continue;
|
|
473
|
-
|
|
474
|
-
|
|
474
|
+
// Keyed through graphKey so the file-level call graph shares ONE key space
|
|
475
|
+
// with the import graph. Previously this kept case while builder.js
|
|
476
|
+
// lowercased, so a lookup correct for one silently missed on the other.
|
|
477
|
+
const a = graphKey(path.resolve(cwd, callerDef.file));
|
|
478
|
+
const b = graphKey(path.resolve(cwd, calleeDef.file));
|
|
475
479
|
add(a, b);
|
|
476
480
|
add(b, a);
|
|
477
481
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The single definition of a graph node key.
|
|
5
|
+
*
|
|
6
|
+
* src/graph/builder.js and src/graph/call-graph.js each used to normalise paths
|
|
7
|
+
* their own way — builder lowercased, call-graph did not — so a lookup written
|
|
8
|
+
* for one silently missed on the other. That divergence disabled the import
|
|
9
|
+
* boost on every repo whose path contains an uppercase letter, and later caused
|
|
10
|
+
* a "fix" for one graph to break the other. Both now key through this function,
|
|
11
|
+
* so there is one convention rather than two conventions and a convention.
|
|
12
|
+
*
|
|
13
|
+
* Lowercasing keeps lookups stable across case-insensitive filesystems (macOS,
|
|
14
|
+
* Windows), where the same file legitimately arrives spelled two ways.
|
|
15
|
+
*
|
|
16
|
+
* Zero-dependency, pure, bundle-safe.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
/** Canonical key for a filesystem path used as a graph node. */
|
|
22
|
+
function graphKey(p) {
|
|
23
|
+
return path.normalize(String(p)).toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { graphKey };
|
package/src/mcp/install.js
CHANGED
|
@@ -17,6 +17,7 @@ const os = require('os');
|
|
|
17
17
|
|
|
18
18
|
// Config shapes the supported clients use.
|
|
19
19
|
// - 'json' → { mcpServers: { sigmap: { command, args } } }
|
|
20
|
+
// - 'vscode'→ { servers: { sigmap: { type: 'stdio', command, args } } }
|
|
20
21
|
// - 'zed' → { context_servers: { sigmap: { command: { path, args } } } }
|
|
21
22
|
// - 'yaml' → Codex CLI ~/.codex/config.yaml (mcpServers block, appended)
|
|
22
23
|
const CLIENTS = {
|
|
@@ -25,7 +26,7 @@ const CLIENTS = {
|
|
|
25
26
|
windsurf: { label: 'Windsurf', format: 'json', scope: 'both',
|
|
26
27
|
project: ['.windsurf', 'mcp.json'],
|
|
27
28
|
global: ['.codeium', 'windsurf', 'mcp_config.json'] },
|
|
28
|
-
vscode: { label: 'VS Code', format: '
|
|
29
|
+
vscode: { label: 'VS Code', format: 'vscode', scope: 'project', project: ['.vscode', 'mcp.json'] },
|
|
29
30
|
opencode: { label: 'OpenCode', format: 'json', scope: 'both',
|
|
30
31
|
project: ['opencode.json'],
|
|
31
32
|
global: ['.config', 'opencode', 'config.json'] },
|
|
@@ -80,6 +81,31 @@ function _installJson(filePath, scriptPath) {
|
|
|
80
81
|
return 'installed';
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Install into VS Code's `.vscode/mcp.json`, which keys servers under `servers`
|
|
86
|
+
* (not `mcpServers`) and expects an explicit transport `type`. A config written
|
|
87
|
+
* by an older SigMap under `mcpServers` is migrated rather than left in place,
|
|
88
|
+
* so re-running repairs it instead of leaving two entries VS Code cannot read.
|
|
89
|
+
*/
|
|
90
|
+
function _installVscode(filePath, scriptPath) {
|
|
91
|
+
let settings = {};
|
|
92
|
+
if (fs.existsSync(filePath)) {
|
|
93
|
+
try { settings = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}; }
|
|
94
|
+
catch (_) { settings = {}; }
|
|
95
|
+
}
|
|
96
|
+
const stale = settings.mcpServers && settings.mcpServers.sigmap;
|
|
97
|
+
if (stale) {
|
|
98
|
+
delete settings.mcpServers.sigmap;
|
|
99
|
+
if (Object.keys(settings.mcpServers).length === 0) delete settings.mcpServers;
|
|
100
|
+
}
|
|
101
|
+
if (!settings.servers) settings.servers = {};
|
|
102
|
+
if (settings.servers.sigmap && !stale) return 'already';
|
|
103
|
+
settings.servers.sigmap = { type: 'stdio', command: 'node', args: serverArgs(scriptPath) };
|
|
104
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
105
|
+
fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + '\n');
|
|
106
|
+
return stale ? 'updated' : 'installed';
|
|
107
|
+
}
|
|
108
|
+
|
|
83
109
|
/** Install into Zed's `context_servers` config (create file/dir if absent). */
|
|
84
110
|
function _installZed(filePath, scriptPath) {
|
|
85
111
|
let settings = {};
|
|
@@ -132,7 +158,8 @@ function installClient(client, opts = {}) {
|
|
|
132
158
|
const filePath = resolveTarget(spec, cwd, home, opts.global);
|
|
133
159
|
|
|
134
160
|
let status;
|
|
135
|
-
if (spec.format === '
|
|
161
|
+
if (spec.format === 'vscode') status = _installVscode(filePath, scriptPath);
|
|
162
|
+
else if (spec.format === 'zed') status = _installZed(filePath, scriptPath);
|
|
136
163
|
else if (spec.format === 'yaml') status = _installYaml(filePath, scriptPath);
|
|
137
164
|
else status = _installJson(filePath, scriptPath);
|
|
138
165
|
|
package/src/mcp/server.js
CHANGED
package/src/retrieval/bm25.js
CHANGED
|
@@ -108,6 +108,33 @@ const EXPANSION_GROUPS = [
|
|
|
108
108
|
// the literal query token always outranks a synonym-only match.
|
|
109
109
|
const EXPANSION_WEIGHT = 0.15;
|
|
110
110
|
|
|
111
|
+
// Module-doc prose is indexed as a `# module: ...` pseudo-signature (index-only,
|
|
112
|
+
// see src/retrieval/module-doc.js). Per token it is a weaker relevance signal
|
|
113
|
+
// than a real signature — descriptive rather than definitional — so it is scored
|
|
114
|
+
// as its own BM25F field rather than pooled with the code terms.
|
|
115
|
+
const MODULE_DOC_RE = /^#\s*(module|docs):/;
|
|
116
|
+
|
|
117
|
+
// Line anchors are metadata, not content, and this ranker is documented as
|
|
118
|
+
// anchor-invariant. The previous strip was end-anchored, so it only fired when
|
|
119
|
+
// the anchor was the last thing on the line — but extractors append a doc hint
|
|
120
|
+
// AFTER it ("... :27-59 # Compute a normalized centrality score"). 27% of
|
|
121
|
+
// signatures therefore leaked their line numbers into the term space as tokens
|
|
122
|
+
// like "27" and "59": 840 junk terms, inflating document length for exactly the
|
|
123
|
+
// well-documented files, which BM25 then penalised via length normalisation.
|
|
124
|
+
const ANCHOR_RE = /\s*:\d+(?:-\d+)?(?=\s|$)/g;
|
|
125
|
+
|
|
126
|
+
function stripAnchor(line) {
|
|
127
|
+
return String(line).replace(ANCHOR_RE, '');
|
|
128
|
+
}
|
|
129
|
+
// Tuned on the 30-task leak-free corpus. The 0.5-0.8 band is flat
|
|
130
|
+
// (hit@5 63.3-66.7%, easy MRR steady at 0.825); adjacent values swing by up to
|
|
131
|
+
// 6.7pp, which at 30 tasks is literally two tasks — noise, not signal. 0.6 is
|
|
132
|
+
// chosen from the middle of that band rather than at its peak, because a
|
|
133
|
+
// per-token weight below 1 is the principled position (prose is descriptive,
|
|
134
|
+
// a signature is definitional) and picking the argmax of a 30-task sweep is
|
|
135
|
+
// how you overfit a benchmark.
|
|
136
|
+
const DOC_WEIGHT = 0.6;
|
|
137
|
+
|
|
111
138
|
// Build a stemmed lookup: stem(member) → Set of the group's other stemmed members.
|
|
112
139
|
const EXPANSIONS = (() => {
|
|
113
140
|
const map = new Map();
|
|
@@ -151,22 +178,45 @@ function expandQuery(qToks) {
|
|
|
151
178
|
* @param {{ file: string, sigs: string[] }[]} candidates
|
|
152
179
|
* @returns {Array<object & { score: number }>}
|
|
153
180
|
*/
|
|
154
|
-
function bm25rank(query, candidates) {
|
|
181
|
+
function bm25rank(query, candidates, opts) {
|
|
155
182
|
if (!Array.isArray(candidates) || candidates.length === 0) return [];
|
|
156
183
|
|
|
157
184
|
const k1 = 1.5;
|
|
158
185
|
const b = 0.75;
|
|
159
186
|
|
|
187
|
+
const docWeight = (opts && typeof opts.docWeight === 'number') ? opts.docWeight : DOC_WEIGHT;
|
|
188
|
+
|
|
160
189
|
const docs = candidates.map((c) => {
|
|
161
190
|
const pathToks = tokenize(c.file || '');
|
|
162
191
|
// Ranking is anchor-invariant: `:start-end` line anchors are metadata,
|
|
163
192
|
// not content — strip them before tokenizing so adding anchors to an
|
|
164
193
|
// extractor never shifts BM25 length normalization or token counts.
|
|
165
|
-
|
|
166
|
-
|
|
194
|
+
// BM25F-style fields. Module-doc prose and code signatures are different
|
|
195
|
+
// kinds of evidence and must not share one term-frequency pool: prose is
|
|
196
|
+
// ~30% of all indexed tokens, and a short file with a long header (few
|
|
197
|
+
// signatures, lots of description) otherwise wins unrelated queries purely
|
|
198
|
+
// through length normalisation.
|
|
199
|
+
const docLines = [];
|
|
200
|
+
const codeLines = [];
|
|
201
|
+
for (const line of (c.sigs || [])) (MODULE_DOC_RE.test(line) ? docLines : codeLines).push(line);
|
|
202
|
+
// TRIED AND REJECTED: splitting the declared symbol NAME into its own
|
|
203
|
+
// weighted BM25F field, on the IR prior that a name is a "title" and params
|
|
204
|
+
// are "body". Swept 1.0-4.0. hit@5 on the mined corpus rose 60.9% -> 65.2%,
|
|
205
|
+
// which is a single task crossing the rank-5 line — over 113 combined tasks
|
|
206
|
+
// hit@1 fell 52.2% -> 51.3%, hit@3 fell 66.4% -> 65.5%, hit@10 was identical
|
|
207
|
+
// and MRR dropped. It moves correct answers DOWN and happens to nudge one
|
|
208
|
+
// past a cutoff. A hit@5-only view would have shipped this.
|
|
209
|
+
const codeToks = tokenize(codeLines.map((x) => stripAnchor(x)).join(' '));
|
|
210
|
+
const docToks = tokenize(docLines.join(' '));
|
|
167
211
|
const tf = new Map();
|
|
168
|
-
for (const t of toks) tf.set(t, (tf.get(t) || 0) +
|
|
169
|
-
|
|
212
|
+
const addField = (toks, weight) => { for (const t of toks) tf.set(t, (tf.get(t) || 0) + weight); };
|
|
213
|
+
addField(codeToks, 1);
|
|
214
|
+
addField(pathToks, PATH_BOOST);
|
|
215
|
+
addField(docToks, docWeight);
|
|
216
|
+
// Length accumulates with the SAME weights, or a field's influence leaks
|
|
217
|
+
// back in through the normalisation term.
|
|
218
|
+
const len = codeToks.length + (PATH_BOOST * pathToks.length) + (docWeight * docToks.length);
|
|
219
|
+
return { cand: c, tf, len };
|
|
170
220
|
});
|
|
171
221
|
|
|
172
222
|
const N = docs.length || 1;
|
|
@@ -195,4 +245,4 @@ function bm25rank(query, candidates) {
|
|
|
195
245
|
.sort((a, c) => c.score - a.score || String(a.file).localeCompare(String(c.file)));
|
|
196
246
|
}
|
|
197
247
|
|
|
198
|
-
module.exports = { tokenize, stem, bm25rank, PATH_BOOST, STOP, expandQuery, EXPANSIONS, EXPANSION_WEIGHT };
|
|
248
|
+
module.exports = { tokenize, stem, bm25rank, PATH_BOOST, STOP, expandQuery, EXPANSIONS, EXPANSION_WEIGHT, DOC_WEIGHT, MODULE_DOC_RE, stripAnchor };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Module-level documentation extractor (retrieval only).
|
|
5
|
+
*
|
|
6
|
+
* Signatures describe a file's SHAPE — names, params, return types. A module's
|
|
7
|
+
* leading comment describes its PURPOSE, in prose, using the words a person
|
|
8
|
+
* would actually search with. That prose is the bridge between a behavioural
|
|
9
|
+
* query ("what fraction of the repo made it into the output") and the code
|
|
10
|
+
* that implements it (`coverageScore(cwd, fileEntries, config)`), which shares
|
|
11
|
+
* not one token with the query.
|
|
12
|
+
*
|
|
13
|
+
* This text is added to the RETRIEVAL INDEX ONLY — never to the generated
|
|
14
|
+
* context file. The prompt artifact is token-budgeted and prose is expensive
|
|
15
|
+
* there; the index is not injected into any prompt, so it can afford the words
|
|
16
|
+
* that make a file findable.
|
|
17
|
+
*
|
|
18
|
+
* Zero-dependency, pure, bundle-safe.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Enough to characterise a module without letting one verbose header dominate
|
|
22
|
+
// BM25 length normalisation for the whole corpus.
|
|
23
|
+
const MAX_CHARS = 400;
|
|
24
|
+
const MAX_SCAN_LINES = 60;
|
|
25
|
+
|
|
26
|
+
// Legal boilerplate is high-frequency noise: it appears in many files, shares no
|
|
27
|
+
// vocabulary with real queries, and would flatten idf across the corpus.
|
|
28
|
+
const BOILERPLATE = /\b(copyright|licensed under|SPDX-License|all rights reserved|permission is hereby granted)\b/i;
|
|
29
|
+
|
|
30
|
+
const BLOCK_LANGS = new Set(['js', 'jsx', 'ts', 'tsx', 'java', 'go', 'rs', 'kt', 'swift', 'scala', 'cs', 'php', 'dart', 'c', 'cpp', 'h']);
|
|
31
|
+
const HASH_LANGS = new Set(['py', 'rb', 'r', 'sh', 'yml', 'yaml', 'toml']);
|
|
32
|
+
|
|
33
|
+
function _extOf(filePath) {
|
|
34
|
+
const m = String(filePath).match(/\.([A-Za-z0-9]+)$/);
|
|
35
|
+
return m ? m[1].toLowerCase() : '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Strip comment furniture, JSDoc tags, and markup from one raw comment line. */
|
|
39
|
+
function _cleanLine(line) {
|
|
40
|
+
return String(line)
|
|
41
|
+
.replace(/^\s*[/*#-]+\s?/, '') // leading // /* * # ---
|
|
42
|
+
.replace(/\*+\/\s*$/, '') // trailing */
|
|
43
|
+
.replace(/^\s*@\w+.*$/, '') // @param / @returns tag lines
|
|
44
|
+
.replace(/[*_`]/g, '') // markdown emphasis / code ticks
|
|
45
|
+
.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract a module's leading documentation prose.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} src file contents
|
|
52
|
+
* @param {string} filePath used only to pick a comment syntax
|
|
53
|
+
* @returns {string} collapsed prose, capped, or '' when there is none
|
|
54
|
+
*/
|
|
55
|
+
function extractModuleDoc(src, filePath) {
|
|
56
|
+
if (!src || typeof src !== 'string') return '';
|
|
57
|
+
const ext = _extOf(filePath);
|
|
58
|
+
const lines = src.split('\n', MAX_SCAN_LINES);
|
|
59
|
+
|
|
60
|
+
const collected = [];
|
|
61
|
+
let inBlock = false;
|
|
62
|
+
let started = false;
|
|
63
|
+
|
|
64
|
+
for (const raw of lines) {
|
|
65
|
+
const line = raw.trim();
|
|
66
|
+
if (!started) {
|
|
67
|
+
// Skip preamble that precedes the real header comment.
|
|
68
|
+
if (!line) continue;
|
|
69
|
+
if (line.startsWith('#!')) continue; // shebang
|
|
70
|
+
if (/^['"]use strict['"];?$/.test(line)) continue;
|
|
71
|
+
if (/^(package|import|from|using|#include)\b/.test(line)) continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (BLOCK_LANGS.has(ext) || ext === '') {
|
|
75
|
+
if (!inBlock && line.startsWith('/*')) { inBlock = true; started = true; }
|
|
76
|
+
if (inBlock) {
|
|
77
|
+
const cleaned = _cleanLine(line);
|
|
78
|
+
if (cleaned) collected.push(cleaned);
|
|
79
|
+
if (line.includes('*/')) break;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (line.startsWith('//')) { // run of // lines
|
|
83
|
+
started = true;
|
|
84
|
+
const cleaned = _cleanLine(line);
|
|
85
|
+
if (cleaned) collected.push(cleaned);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (started || collected.length) break;
|
|
89
|
+
break; // first real code — no header
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (HASH_LANGS.has(ext)) {
|
|
93
|
+
if (/^("""|''')/.test(line)) { // python docstring
|
|
94
|
+
started = true; inBlock = !inBlock;
|
|
95
|
+
const cleaned = _cleanLine(line.replace(/^("""|''')/, '').replace(/("""|''')$/, ''));
|
|
96
|
+
if (cleaned) collected.push(cleaned);
|
|
97
|
+
if (!inBlock) break;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (inBlock) { const c = _cleanLine(line); if (c) collected.push(c); continue; }
|
|
101
|
+
if (line.startsWith('#')) { started = true; const c = _cleanLine(line); if (c) collected.push(c); continue; }
|
|
102
|
+
if (collected.length) break;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const text = collected.join(' ').replace(/\s+/g, ' ').trim();
|
|
109
|
+
if (!text || BOILERPLATE.test(text)) return '';
|
|
110
|
+
if (text.length <= MAX_CHARS) return text;
|
|
111
|
+
return text.slice(0, MAX_CHARS).replace(/\s+\S*$/, ''); // never cut mid-word
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Render as an index-only pseudo-signature, or '' when there is no doc. */
|
|
115
|
+
function moduleDocSig(src, filePath) {
|
|
116
|
+
const doc = extractModuleDoc(src, filePath);
|
|
117
|
+
return doc ? `# module: ${doc}` : '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { extractModuleDoc, moduleDocSig, MAX_CHARS };
|