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/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.28.1 | Benchmark: sigmap-v8.28-main (2026-08-22)
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.28-main, 2026-08-22)
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.28.1 | Benchmark: sigmap-v8.28-main (2026-08-22)
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.28-main, 2026-08-22)
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.28.1",
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/strategy.test.js && node test/integration/secret-scan.test.js && node test/integration/token-budget.test.js && node test/integration/auto-budget.test.js && node test/integration/mcp/server.test.js && node test/integration/verify-ai-output.test.js && node test/integration/memory-tools.test.js && node test/integration/squeeze.test.js && node test/integration/context-consistency.test.js && node test/integration/features/llms-current.test.js",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "8.28.1",
3
+ "version": "8.30.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "8.28.1",
3
+ "version": "8.30.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -38,47 +38,12 @@ const { bm25rank } = require('../retrieval/bm25');
38
38
  * @returns {Map<string, string[]>}
39
39
  */
40
40
  function buildSigIndex(cwd) {
41
- const contextPath = path.join(cwd, '.github', 'copilot-instructions.md');
42
- const index = new Map();
43
-
44
- if (!fs.existsSync(contextPath)) return index;
45
-
46
- const content = fs.readFileSync(contextPath, 'utf8');
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
- const candidates = [];
101
- for (const [file, sigs] of index.entries()) {
102
- candidates.push({ file, sigs });
103
- }
104
- return bm25rank(query, candidates).slice(0, topK);
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 ranked = rank(task.query, index, topK).map((r) => r.file);
195
- const topResult = rank(task.query, index, topK);
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');
@@ -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
- sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + meth.declIdx), lineAt(stripped, bodyStart + meth.endIdx)), meth.name));
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.slice(0, 25);
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 + 5000);
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.slice(0, 8);
79
+ return capMembersWithNotice(members, MAX_MEMBERS_PER_CLASS);
64
80
  }
65
81
 
66
82
  function normalizeParams(params) {
@@ -12,10 +12,11 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
 
15
- // Normalize paths for cross-platform consistency (Windows uses backslashes, Unix uses forward slashes)
16
- // Use lowercase to enable case-insensitive lookups on case-sensitive Windows filesystems
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 path.normalize(p).toLowerCase();
19
+ return graphKey(p);
19
20
  }
20
21
 
21
22
  // ---------------------------------------------------------------------------
@@ -38,7 +38,8 @@ const NON_CALL = new Set([
38
38
  'synchronized',
39
39
  ]);
40
40
 
41
- function normalizePath(p) { return path.normalize(p).toLowerCase(); }
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
- const a = path.resolve(cwd, callerDef.file);
474
- const b = path.resolve(cwd, calleeDef.file);
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 };
@@ -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: 'json', scope: 'project', project: ['.vscode', 'mcp.json'] },
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 === 'zed') status = _installZed(filePath, scriptPath);
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
@@ -18,7 +18,7 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '8.28.1',
21
+ version: '8.30.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -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
- const toks = tokenize((c.sigs || []).map((s) => String(s).replace(/\s*:\d+(?:-\d+)?\s*$/, '')).join(' '));
166
- for (let i = 0; i < PATH_BOOST; i++) toks.push(...pathToks);
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) + 1);
169
- return { cand: c, tf, len: toks.length };
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 };