sigmap 8.28.0 → 8.29.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.
@@ -19,7 +19,7 @@
19
19
 
20
20
  const { loadWeights } = require('../learning/weights');
21
21
  const { tokenize, STOP_WORDS } = require('./tokenizer');
22
- const { bm25rank } = require('./bm25');
22
+ const { bm25rank, MODULE_DOC_RE } = require('./bm25');
23
23
 
24
24
  // ---------------------------------------------------------------------------
25
25
  // Default weights
@@ -43,17 +43,28 @@ const GRAPH_BOOST_AMOUNTS = {
43
43
  // Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
44
44
  const CENTRALITY_BLEND_WEIGHT = 0.3;
45
45
 
46
- // Intent-specific weight adjustments
47
- const INTENT_WEIGHTS = {
48
- search: DEFAULT_WEIGHTS,
49
- debug: { ...DEFAULT_WEIGHTS, exactToken: 1.2, pathMatch: 0.6 },
50
- explain: { ...DEFAULT_WEIGHTS, symbolMatch: 0.8, pathMatch: 0.9 },
51
- refactor: { ...DEFAULT_WEIGHTS, symbolMatch: 0.9, exactToken: 0.8 },
52
- review: { ...DEFAULT_WEIGHTS, pathMatch: 1.0, exactToken: 0.9 },
53
- test: { ...DEFAULT_WEIGHTS, exactToken: 0.7, symbolMatch: 0.4 },
54
- integrate: { ...DEFAULT_WEIGHTS, graphBoost: 0.7, pathMatch: 1.1 },
55
- navigate: { ...DEFAULT_WEIGHTS, pathMatch: 1.2, exactToken: 0.9 },
56
- };
46
+ // Per-intent weight profiles were removed in favour of a single weight set.
47
+ // They were provably inert: scoreFile's score was discarded by rank(), so the
48
+ // profiles only ever reached the explain table. Once the signal WAS wired into
49
+ // the score (see SIGNAL_BLEND below), a sweep over the leak-free hard corpus
50
+ // showed intent-specific profiles produced byte-identical metrics to the flat
51
+ // DEFAULT_WEIGHTS at every blend value — so they earn nothing and are gone.
52
+ // `detectIntent` is retained: it is still reported to the user and is the right
53
+ // hook for shaping OUTPUT depth later.
54
+
55
+ // How much the weighted keyword/symbol/path signal modulates the BM25 base.
56
+ // Multiplicative and bounded, so it can only reorder files that already match —
57
+ // it can never lift a zero-BM25 file into the results. Tuned on the leak-free
58
+ // corpus: 0.5 gave hit@5 50.0% -> 56.7% and MRR 0.419 -> 0.447; higher values
59
+ // held hit@5 but degraded MRR.
60
+ const SIGNAL_BLEND = 0.5;
61
+
62
+ // TRIED AND REJECTED: a same-line co-occurrence bonus, on the theory that a file
63
+ // declaring `parseAuthToken` should outrank one mentioning `parseAuth` and
64
+ // `token` on separate lines. Swept 0.15-1.0: hit@5 did not move on either the
65
+ // 90-task authored corpus or the 32-task mined one, and MRR degraded
66
+ // monotonically as the weight rose. Signatures are short and dense enough that
67
+ // BM25's bag already captures this. Not reinstated without new evidence.
57
68
 
58
69
  // Penalty multipliers for negative signals
59
70
  const PENALTY_SIGNALS = {
@@ -63,12 +74,36 @@ const PENALTY_SIGNALS = {
63
74
  nodeModules: 0.0, // node_modules (zero score)
64
75
  };
65
76
 
66
- function _computePenalty(filePath) {
77
+ // Query terms that mean the penalised category IS the target. Read from the
78
+ // query tokens directly, NOT via detectIntent: that classifier is first-match-
79
+ // wins over its pattern object, and `debug` precedes `test`, so "fix the failing
80
+ // test" classifies as debug and never reaches the test branch.
81
+ const WANTS_TESTS = new Set(['test', 'tests', 'spec', 'specs', 'unit', 'integration', 'e2e', 'assertion', 'assert', 'mock', 'fixture', 'coverage', 'testing']);
82
+ const WANTS_DOCS = new Set(['doc', 'docs', 'documentation', 'readme', 'changelog', 'guide', 'tutorial']);
83
+
84
+ /** Which penalised categories the query is explicitly asking for. */
85
+ function _queryWants(queryTokens) {
86
+ const wants = { tests: false, docs: false };
87
+ for (const t of queryTokens || []) {
88
+ if (WANTS_TESTS.has(t)) wants.tests = true;
89
+ if (WANTS_DOCS.has(t)) wants.docs = true;
90
+ }
91
+ return wants;
92
+ }
93
+
94
+ function _computePenalty(filePath, wants) {
67
95
  const pathLower = filePath.toLowerCase();
68
96
  if (pathLower.includes('node_modules')) return PENALTY_SIGNALS.nodeModules;
69
- if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.testFile;
97
+ // A penalty must never fire on the very thing the user asked for. Before
98
+ // this, "write tests for the ranker" multiplied every test file by 0.4 —
99
+ // the query and the penalty were pulling in opposite directions.
100
+ if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower) || /\.(test|spec)\./.test(pathLower)) {
101
+ return (wants && wants.tests) ? 1.0 : PENALTY_SIGNALS.testFile;
102
+ }
70
103
  if (/(^|\/)(dist|build|\.next|\.nuxt|out|\.venv|venv)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.generatedCode;
71
- if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.docsFile;
104
+ if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower)) {
105
+ return (wants && wants.docs) ? 1.0 : PENALTY_SIGNALS.docsFile;
106
+ }
72
107
  return 1.0;
73
108
  }
74
109
 
@@ -87,6 +122,26 @@ function _computeHubs(graph) {
87
122
  }
88
123
 
89
124
  // Common utility paths that should be treated as hubs regardless of fanout
125
+ // The graph builders disagree on key case: src/graph/builder.js lowercases every
126
+ // node (normalizePath), while src/graph/call-graph.js keys by a case-preserving
127
+ // path.resolve. Assuming either one breaks the other, so every graph lookup in
128
+ // this file probes both forms — the same thing the centrality blend already does.
129
+ function _graphKeys(p) {
130
+ const norm = require('path').normalize(p);
131
+ const lower = norm.toLowerCase();
132
+ return lower === norm ? [norm] : [norm, lower];
133
+ }
134
+ function _graphGet(map, absPath) {
135
+ for (const k of _graphKeys(absPath)) {
136
+ const hit = map.get(k);
137
+ if (hit !== undefined) return hit;
138
+ }
139
+ return undefined;
140
+ }
141
+ function _registerKeys(map, absPath, value) {
142
+ for (const k of _graphKeys(absPath)) if (!map.has(k)) map.set(k, value);
143
+ }
144
+
90
145
  function _isHub(filePath) {
91
146
  return /\/(utils|helpers|shared|common|constants|types|interfaces|index|zzz|globals)\.(ts|tsx|js|jsx|r|R)$/.test(filePath)
92
147
  || filePath.endsWith('/index.ts') || filePath.endsWith('/index.js')
@@ -102,14 +157,18 @@ function _isHub(filePath) {
102
157
  * @param {object} weights
103
158
  * @returns {{ score: number, signals: { exactToken: number, symbolMatch: number, prefixMatch: number, pathMatch: number, penalty: number } }}
104
159
  */
105
- function scoreFile(filePath, sigs, queryTokens, weights) {
160
+ function scoreFile(filePath, sigs, queryTokens, weights, wants) {
106
161
  if (!sigs || sigs.length === 0) return { score: 0, signals: { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: 1.0 } };
107
162
 
108
163
  const w = weights || DEFAULT_WEIGHTS;
109
- const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath) };
110
-
111
- // Build token set from all signatures
112
- const sigText = sigs.join(' ');
164
+ const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath, wants) };
165
+
166
+ // Module-doc prose is excluded here on purpose. This signal measures overlap
167
+ // with DECLARED IDENTIFIERS; prose relevance is BM25's job, where it is scored
168
+ // as its own weighted field. Letting descriptive text inflate the identifier
169
+ // signal double-counts it and measurably degraded MRR.
170
+ const codeSigs = sigs.filter((line) => !MODULE_DOC_RE.test(line));
171
+ const sigText = codeSigs.join(' ');
113
172
  const sigTokenSet = new Set(tokenize(sigText));
114
173
 
115
174
  // Build token set from the file path
@@ -127,7 +186,7 @@ function scoreFile(filePath, sigs, queryTokens, weights) {
127
186
  signals.exactToken += bonus;
128
187
 
129
188
  // Bonus: appears directly in a function/class/method name line
130
- const nameLineMatch = sigs.some((sig) => {
189
+ const nameLineMatch = codeSigs.some((sig) => {
131
190
  const nt = tokenize(sig.replace(/[^a-zA-Z0-9_\s]/g, ' '));
132
191
  return nt.includes(qt);
133
192
  });
@@ -189,13 +248,18 @@ function rank(query, sigIndex, opts) {
189
248
  const graph = (opts && opts.graph && opts.graph.forward instanceof Map) ? opts.graph : null;
190
249
  const cwd = (opts && opts.cwd) || null;
191
250
 
192
- // Detect query intent and get appropriate weights
251
+ // Intent is reported to the user and shapes output depth; it no longer
252
+ // selects scoring weights (see SIGNAL_BLEND).
193
253
  const intent = detectIntent(query);
194
- const intentWeights = INTENT_WEIGHTS[intent] || DEFAULT_WEIGHTS;
195
- const weights = (opts && opts.weights) ? Object.assign({}, intentWeights, opts.weights) : intentWeights;
196
- const learnedWeights = opts && opts.cwd ? loadWeights(opts.cwd) : null;
254
+ const weights = (opts && opts.weights) ? Object.assign({}, DEFAULT_WEIGHTS, opts.weights) : DEFAULT_WEIGHTS;
255
+ // Learned per-file multipliers are a LOCAL, evolving signal (.context/weights.json).
256
+ // Benchmarks and CI gates must opt out via { learned: false }, or a developer's
257
+ // local learned state silently changes the score and CI stops being reproducible.
258
+ const useLearned = !(opts && opts.learned === false);
259
+ const learnedWeights = opts && opts.cwd && useLearned ? loadWeights(opts.cwd) : null;
197
260
 
198
261
  const queryTokens = tokenize(query);
262
+ const queryWants = _queryWants(queryTokens);
199
263
  if (queryTokens.length === 0) {
200
264
  // Empty query: return top-K by file count (most signatures = most useful)
201
265
  const all = [];
@@ -212,18 +276,32 @@ function rank(query, sigIndex, opts) {
212
276
  // are matched. The existing negative-signal penalty and recency/graph/learned
213
277
  // boosts are layered on top; the per-token signals stay for the explain table.
214
278
  const bm25Scores = new Map();
215
- for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })))) {
279
+ for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })), opts)) {
216
280
  bm25Scores.set(c.file, c.score);
217
281
  }
218
282
 
219
- const scored = [];
283
+ // Two passes: scoreFile's weighted signal needs the max across the corpus to
284
+ // normalise against, so collect first, then combine.
285
+ const prescored = [];
286
+ let maxSignal = 0;
220
287
  for (const [file, sigs] of sigIndex.entries()) {
221
- const result = scoreFile(file, sigs, queryTokens, weights);
288
+ const result = scoreFile(file, sigs, queryTokens, weights, queryWants);
289
+ if (result.score > maxSignal) maxSignal = result.score;
290
+ prescored.push({ file, sigs, result });
291
+ }
292
+
293
+ const scored = [];
294
+ for (const { file, sigs, result } of prescored) {
222
295
  const penalty = result.signals.penalty;
223
296
  const base = bm25Scores.get(file) || 0;
224
- let score = base * penalty;
297
+ // Blend the weighted keyword/symbol/path signal into the BM25 base. This
298
+ // was previously computed and thrown away — `result.score` was never read,
299
+ // which silently made DEFAULT_WEIGHTS and every intent profile dead config.
300
+ const signalNorm = maxSignal > 0 ? result.score / maxSignal : 0;
301
+ let score = base * penalty * (1 + SIGNAL_BLEND * signalNorm);
225
302
  const signals = result.signals;
226
303
  signals.bm25 = base;
304
+ signals.signalBlend = signalNorm;
227
305
 
228
306
  // Recency boost
229
307
  if (recencySet && recencySet.has(file) && score > 0) {
@@ -253,44 +331,46 @@ function rank(query, sigIndex, opts) {
253
331
  // Hub suppression: files with high fanout (>20%) are not boosted
254
332
  if (graph && cwd) {
255
333
  const path = require('path');
256
- // Build maps for relative absolute path conversion and index lookup
257
- const relToIdx = new Map();
258
- const absToRel = new Map();
334
+ // Every graph node is keyed by `path.normalize(p).toLowerCase()` (see
335
+ // normalizePath in src/graph/builder.js and src/graph/call-graph.js).
336
+ // Lookups MUST use the same key space: a bare path.resolve() preserves
337
+ // case, so on any repo whose absolute path contains an uppercase letter
338
+ // every .get() missed and this entire block was silently inert.
339
+ const keyToIdx = new Map();
259
340
  for (let i = 0; i < scored.length; i++) {
260
- relToIdx.set(scored[i].file, i);
261
- const abs = path.resolve(cwd, scored[i].file);
262
- absToRel.set(abs, scored[i].file);
341
+ _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
263
342
  }
264
343
 
265
344
  const hubs = _computeHubs(graph);
266
- const hop1Files = new Set(); // track which files received hop1 boost
345
+ const hop1Files = new Set(); // normalised keys that received a hop1 boost
346
+ const hop1Seeds = []; // original (un-normalised) paths, for hop-2 lookup
267
347
 
268
348
  // Hop 1: direct neighbors of scored files
269
349
  for (const entry of scored) {
270
350
  if (entry.score <= 0) continue;
271
- const abs = path.resolve(cwd, entry.file);
272
- const neighbors = graph.forward.get(abs) || [];
351
+ const neighbors = _graphGet(graph.forward, path.resolve(cwd, entry.file)) || [];
273
352
  for (const neighborAbs of neighbors) {
274
- if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
275
- const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
276
- const idx = relToIdx.get(neighborRel);
353
+ const nk = path.normalize(neighborAbs);
354
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
355
+ const idx = _graphGet(keyToIdx, nk);
277
356
  if (idx !== undefined) {
278
357
  scored[idx].score += GRAPH_BOOST_AMOUNTS.hop1;
279
358
  scored[idx].signals.graphBoost = (scored[idx].signals.graphBoost || 0) + GRAPH_BOOST_AMOUNTS.hop1;
280
- hop1Files.add(neighborAbs);
359
+ hop1Files.add(nk);
360
+ hop1Seeds.push(neighborAbs);
281
361
  }
282
362
  }
283
363
  }
284
364
 
285
365
  // Hop 2: neighbors of hop1 files (only if they didn't get a direct score)
286
- for (const hop1File of hop1Files) {
287
- if (!absToRel.has(hop1File)) continue; // skip files not in index
288
- const neighbors = graph.forward.get(hop1File) || [];
366
+ for (const hop1Key of hop1Seeds) {
367
+ if (_graphGet(keyToIdx, hop1Key) === undefined) continue; // skip files not in index
368
+ const neighbors = _graphGet(graph.forward, hop1Key) || [];
289
369
  for (const neighborAbs of neighbors) {
290
- if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
291
- if (hop1Files.has(neighborAbs)) continue; // skip already hop1-boosted
292
- const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
293
- const idx = relToIdx.get(neighborRel);
370
+ const nk = path.normalize(neighborAbs);
371
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
372
+ if (hop1Files.has(nk)) continue; // skip already hop1-boosted
373
+ const idx = _graphGet(keyToIdx, nk);
294
374
  if (idx !== undefined && scored[idx].score > 0) {
295
375
  // Only boost files that have some baseline score (not noise)
296
376
  scored[idx].score += GRAPH_BOOST_AMOUNTS.hop2;
@@ -307,16 +387,17 @@ function rank(query, sigIndex, opts) {
307
387
  const callGraph = (opts && opts.callGraph && opts.callGraph.forward instanceof Map) ? opts.callGraph : null;
308
388
  if (callGraph && cwd) {
309
389
  const path = require('path');
310
- const relToIdx = new Map();
311
- for (let i = 0; i < scored.length; i++) relToIdx.set(scored[i].file, i);
390
+ // buildCallFileGraph keys by a CASE-PRESERVING path.resolve, unlike the
391
+ // import graph builder which lowercases hence the dual-form probe.
392
+ const keyToIdx = new Map();
393
+ for (let i = 0; i < scored.length; i++) _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
312
394
  const hubs = _computeHubs(callGraph);
313
395
  const seeds = scored.filter((e) => e.score > 0).map((e) => e.file);
314
396
  for (const file of seeds) {
315
- const abs = path.resolve(cwd, file);
316
- for (const neighborAbs of (callGraph.forward.get(abs) || [])) {
317
- if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
318
- const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
319
- const idx = relToIdx.get(neighborRel);
397
+ for (const neighborAbs of (_graphGet(callGraph.forward, path.resolve(cwd, file)) || [])) {
398
+ const nk = path.normalize(neighborAbs);
399
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
400
+ const idx = _graphGet(keyToIdx, nk);
320
401
  if (idx !== undefined && scored[idx].file !== file) {
321
402
  scored[idx].score += GRAPH_BOOST_AMOUNTS.callHop;
322
403
  scored[idx].signals.callGraphBoost = (scored[idx].signals.callGraphBoost || 0) + GRAPH_BOOST_AMOUNTS.callHop;
@@ -473,10 +554,35 @@ function _buildSigIndexFromCache(cwd) {
473
554
  * @returns {Map<string, string[]>}
474
555
  */
475
556
  function _enrichSigIndexFromStrategy(cwd, index) {
557
+ const fs = require('fs');
476
558
  const path = require('path');
477
- const coldPath = path.join(cwd, '.github', 'context-cold.md');
478
- _mergeSigIndex(index, _parseContextFile(coldPath));
559
+ // Merge every strategy split file: context-cold.md (hot-cold) AND each
560
+ // per-module context-<module>.md — the per-module strategy stores ALL
561
+ // signatures in these, leaving the primary file as a thin overview, so
562
+ // skipping them made ask/query_context see an empty index (#534).
563
+ // Sorted for deterministic merge order.
564
+ try {
565
+ const ghDir = path.join(cwd, '.github');
566
+ const splits = fs.readdirSync(ghDir)
567
+ .filter((f) => /^context-[\w.-]+\.md$/.test(f))
568
+ .sort();
569
+ for (const f of splits) {
570
+ _mergeSigIndex(index, _parseContextFile(path.join(ghDir, f)));
571
+ }
572
+ } catch (_) {}
479
573
  _mergeSigIndex(index, _buildSigIndexFromCache(cwd));
574
+
575
+ // The complete retrieval index (written by generate before applyTokenBudget)
576
+ // takes precedence: it is the only source containing files the budget dropped,
577
+ // and full signatures for files the budget collapsed to line anchors. It is
578
+ // merged as the BASE rather than on top because _mergeSigIndex only replaces
579
+ // when the source has MORE signatures — a collapsed entry has the same count
580
+ // as its full form, so merging the other way would keep the anchors.
581
+ try {
582
+ const full = require('./sig-index-store').readFullIndex(cwd);
583
+ if (full.size > 0) return _mergeSigIndex(full, index);
584
+ } catch (_) { /* absent → budgeted view is still served */ }
585
+
480
586
  return index;
481
587
  }
482
588
 
@@ -602,22 +708,53 @@ function formatRankJSON(results, query) {
602
708
  // ---------------------------------------------------------------------------
603
709
  // Intent detection — 7 intents
604
710
  // ---------------------------------------------------------------------------
711
+ // Nouns carry an optional plural: `\btest\b` does not match "tests", so
712
+ // "write unit tests for the ranker" matched NO intent at all and fell through
713
+ // to the 'search' default.
605
714
  const INTENT_PATTERNS = {
606
- debug: /\b(bug|fix|error|crash|exception|broken|failing|issue|problem|regression)\b/i,
715
+ debug: /\b(bugs?|fix(es|ed)?|errors?|crash(es)?|exceptions?|broken|failing|failures?|issues?|problems?|regressions?)\b/i,
607
716
  explain: /\b(explain|how does|what is|understand|overview|architecture|describe|walk me|teach)\b/i,
608
- refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimize)\b/i,
717
+ refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimi[sz]e)\b/i,
609
718
  review: /\b(review|check|audit|security|pr|pull request|assess|validate)\b/i,
610
- test: /\b(test|unit test|integration test|testing|spec|assert|mock)\b/i,
611
- integrate:/\b(import|integrate|connect|wire|bind|require|export|depend|graph)\b|require[ds]\b/i,
719
+ test: /\b(tests?|unit tests?|integration tests?|testing|specs?|assert(ion)?s?|mocks?|fixtures?)\b/i,
720
+ integrate:/\b(imports?|integrate|connect|wire|bind|requires?|exports?|depends?|dependenc(y|ies)|graph)\b/i,
612
721
  navigate: /\b(find|locate|where|search|look for|show me|navigate|browse|list)\b/i,
613
722
  };
614
723
 
615
- function detectIntent(query) {
616
- if (!query || typeof query !== 'string') return 'search';
724
+ /**
725
+ * Every intent whose pattern matches, strongest first.
726
+ *
727
+ * A real request is routinely multi-intent — "fix the failing test" is both a
728
+ * debug task and a test task — and reporting one label discards that. Worse,
729
+ * the single-label version returned the FIRST key in INTENT_PATTERNS order, so
730
+ * `debug` permanently shadowed `test`: no query containing "fix" or "failing"
731
+ * could ever be labelled a test, no matter how test-shaped it was.
732
+ *
733
+ * Ranked by how many distinct terms each pattern matched, so the dominant
734
+ * intent leads; ties fall back to declaration order for determinism.
735
+ *
736
+ * @param {string} query
737
+ * @returns {string[]} matched intents, never empty (defaults to ['search'])
738
+ */
739
+ function detectIntents(query) {
740
+ if (!query || typeof query !== 'string') return ['search'];
741
+ const scored = [];
742
+ let order = 0;
617
743
  for (const [intent, re] of Object.entries(INTENT_PATTERNS)) {
618
- if (re.test(query)) return intent;
744
+ const hits = query.match(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'));
745
+ if (hits && hits.length) {
746
+ scored.push({ intent, hits: new Set(hits.map((h) => h.toLowerCase())).size, order: order });
747
+ }
748
+ order++;
619
749
  }
620
- return 'search';
750
+ if (scored.length === 0) return ['search'];
751
+ scored.sort((a, b) => (b.hits - a.hits) || (a.order - b.order));
752
+ return scored.map((s) => s.intent);
753
+ }
754
+
755
+ /** Primary intent. Kept for callers that want a single label. */
756
+ function detectIntent(query) {
757
+ return detectIntents(query)[0];
621
758
  }
622
759
 
623
- module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
760
+ module.exports = { rank, buildSigIndex, scoreFile, _queryWants, detectIntents, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Complete, unbudgeted signature index for retrieval.
5
+ *
6
+ * WHY THIS EXISTS
7
+ * ---------------
8
+ * The generated context file (CLAUDE.md / AGENTS.md / copilot-instructions.md)
9
+ * is a BUDGETED VIEW: `applyTokenBudget` drops and collapses files so the
10
+ * artifact stays under `maxTokens`, because it is injected into every prompt.
11
+ *
12
+ * `buildSigIndex` used to parse that same artifact to build the ranker's index,
13
+ * so retrieval inherited the prompt budget. Every file the budget dropped became
14
+ * permanently unreachable by `sigmap ask` — no ranking change can surface a file
15
+ * that is not in the index. On this repo that was 53 of 155 source files (34%),
16
+ * and restoring them moved hit@5 from 50% to 90% on the retrieval corpus.
17
+ *
18
+ * The two artifacts have opposite requirements — the prompt file wants to be
19
+ * small, the index wants to be complete — so they are now separate. This store
20
+ * is written by `generate` BEFORE the budget is applied, and lives under
21
+ * `.context/` (gitignored, never injected into a prompt).
22
+ *
23
+ * Zero-dependency, bundle-safe (fs + path only).
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ const INDEX_DIR = '.context';
30
+ const INDEX_FILE = 'sig-index.json';
31
+ const SCHEMA = 1;
32
+
33
+ /** Absolute path to the retrieval index artifact. */
34
+ function indexPath(cwd) {
35
+ return path.join(cwd, INDEX_DIR, INDEX_FILE);
36
+ }
37
+
38
+ /**
39
+ * Persist the complete signature index.
40
+ *
41
+ * @param {string} cwd
42
+ * @param {Array<{filePath: string, sigs: string[]}>} fileEntries - every
43
+ * extracted entry, BEFORE applyTokenBudget has dropped or collapsed any.
44
+ * @param {{ version?: string }} [opts]
45
+ * @returns {{ path: string, files: number }}
46
+ */
47
+ function writeFullIndex(cwd, fileEntries, opts = {}) {
48
+ const files = {};
49
+ let count = 0;
50
+ for (const e of fileEntries || []) {
51
+ if (!e || !e.filePath || !Array.isArray(e.sigs) || e.sigs.length === 0) continue;
52
+ const rel = path.relative(cwd, e.filePath).replace(/\\/g, '/');
53
+ if (!rel || rel.startsWith('..')) continue;
54
+ files[rel] = e.sigs;
55
+ count++;
56
+ }
57
+
58
+ const out = indexPath(cwd);
59
+ fs.mkdirSync(path.dirname(out), { recursive: true });
60
+ // Write-then-rename so a concurrent `ask` never observes a half-written index.
61
+ const tmp = `${out}.tmp`;
62
+ fs.writeFileSync(tmp, JSON.stringify({
63
+ schema: SCHEMA,
64
+ sigmapVersion: opts.version || null,
65
+ generated: new Date().toISOString(),
66
+ files,
67
+ }), 'utf8');
68
+ fs.renameSync(tmp, out);
69
+ return { path: out, files: count };
70
+ }
71
+
72
+ /**
73
+ * Load the complete signature index, or an empty Map when absent/unreadable.
74
+ *
75
+ * Deliberately NOT version-busted (unlike .sigmap-cache.json): a stale but
76
+ * complete index still retrieves the right files, whereas discarding it drops
77
+ * retrieval back to the budgeted view — the exact failure this store exists to
78
+ * prevent. Staleness is handled by re-running generate or by cache/freshen.
79
+ *
80
+ * @param {string} cwd
81
+ * @returns {Map<string, string[]>}
82
+ */
83
+ function readFullIndex(cwd) {
84
+ const index = new Map();
85
+ try {
86
+ const data = JSON.parse(fs.readFileSync(indexPath(cwd), 'utf8'));
87
+ if (!data || data.schema !== SCHEMA || !data.files) return index;
88
+ for (const [rel, sigs] of Object.entries(data.files)) {
89
+ if (Array.isArray(sigs) && sigs.length > 0) index.set(rel, sigs);
90
+ }
91
+ } catch (_) { /* absent or corrupt → caller falls back to the context file */ }
92
+ return index;
93
+ }
94
+
95
+ module.exports = { writeFullIndex, readFullIndex, indexPath, SCHEMA, INDEX_FILE };