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.
package/gen-context.js CHANGED
@@ -4623,47 +4623,12 @@ __factories["./src/eval/runner"] = function(module, exports) {
4623
4623
  * @returns {Map<string, string[]>}
4624
4624
  */
4625
4625
  function buildSigIndex(cwd) {
4626
- const contextPath = path.join(cwd, '.github', 'copilot-instructions.md');
4627
- const index = new Map();
4628
-
4629
- if (!fs.existsSync(contextPath)) return index;
4630
-
4631
- const content = fs.readFileSync(contextPath, 'utf8');
4632
- const lines = content.split('\n');
4633
-
4634
- let currentFile = null;
4635
- let inBlock = false;
4636
- let sigs = [];
4637
-
4638
- for (const line of lines) {
4639
- // Section header: ### path/to/file.js
4640
- const headerMatch = line.match(/^###\s+(\S+\.\w+)\s*$/);
4641
- if (headerMatch) {
4642
- if (currentFile !== null) {
4643
- index.set(currentFile, sigs);
4644
- }
4645
- currentFile = headerMatch[1];
4646
- sigs = [];
4647
- inBlock = false;
4648
- continue;
4649
- }
4650
-
4651
- if (line.startsWith('```')) {
4652
- inBlock = !inBlock;
4653
- continue;
4654
- }
4655
-
4656
- if (inBlock && currentFile && line.trim()) {
4657
- sigs.push(line.trim());
4658
- }
4659
- }
4660
-
4661
- // Flush last file
4662
- if (currentFile !== null) {
4663
- index.set(currentFile, sigs);
4664
- }
4665
-
4666
- return index;
4626
+ // Delegate to the production index builder. This used to parse
4627
+ // .github/copilot-instructions.md directly a second parallel implementation
4628
+ // that saw only the token-BUDGETED view, ignored the other adapters, the
4629
+ // hot-cold/per-module strategy splits, and the complete retrieval index. The
4630
+ // corpus therefore scored a smaller index than `sigmap ask` actually uses.
4631
+ return __require('./src/retrieval/ranker').buildSigIndex(cwd);
4667
4632
  }
4668
4633
 
4669
4634
  // ---------------------------------------------------------------------------
@@ -4681,12 +4646,13 @@ __factories["./src/eval/runner"] = function(module, exports) {
4681
4646
  * @param {number} topK
4682
4647
  * @returns {{ file: string, score: number, sigs: string[] }[]}
4683
4648
  */
4684
- function rank(query, index, topK = 10) {
4685
- const candidates = [];
4686
- for (const [file, sigs] of index.entries()) {
4687
- candidates.push({ file, sigs });
4688
- }
4689
- return bm25rank(query, candidates).slice(0, topK);
4649
+ function rank(query, index, topK = 10, opts = {}) {
4650
+ // Measure the ranker users actually hit. This used to call bm25rank directly,
4651
+ // which meant the corpus scored a parallel implementation with no penalties,
4652
+ // graph boost, recency or learned weights — so no ranking regression in
4653
+ // src/retrieval/ranker.js could ever show up in the benchmark numbers.
4654
+ const { rank: prodRank } = __require('./src/retrieval/ranker');
4655
+ return prodRank(query, index, Object.assign({ topK }, opts)).slice(0, topK);
4690
4656
  }
4691
4657
 
4692
4658
  // ---------------------------------------------------------------------------
@@ -4773,11 +4739,14 @@ __factories["./src/eval/runner"] = function(module, exports) {
4773
4739
 
4774
4740
  // Build index once (re-used across all tasks in the same repo)
4775
4741
  const index = buildSigIndex(cwd);
4742
+ // Import graph built once too — the hop-1/hop-2 boost is part of what ships.
4743
+ let graph = null;
4744
+ try { graph = __require('./src/graph/builder').buildFromCwd(cwd); } catch (_) {}
4776
4745
 
4777
4746
  const taskResults = [];
4778
4747
  for (const task of tasks) {
4779
- const ranked = rank(task.query, index, topK).map((r) => r.file);
4780
- const topResult = rank(task.query, index, topK);
4748
+ const topResult = rank(task.query, index, topK, { cwd, graph, learned: opts.learned });
4749
+ const ranked = topResult.map((r) => r.file);
4781
4750
  const tokens = topResult.reduce((sum, r) => sum + estimateTokens(r.sigs), 0);
4782
4751
 
4783
4752
  const { hitAtK, reciprocalRank, precisionAtK } = __require('./src/eval/scorer');
@@ -11345,10 +11314,11 @@ __factories["./src/graph/builder"] = function(module, exports) {
11345
11314
  const fs = require('fs');
11346
11315
  const path = require('path');
11347
11316
 
11348
- // Normalize paths for cross-platform consistency (Windows uses backslashes, Unix uses forward slashes)
11349
- // Use lowercase to enable case-insensitive lookups on case-sensitive Windows filesystems
11317
+ // Cross-platform node key. Delegates to the ONE shared definition so this graph
11318
+ // and the call-graph cannot drift apart again (see src/graph/path-key.js).
11319
+ const { graphKey } = __require('./src/graph/path-key');
11350
11320
  function normalizePath(p) {
11351
- return path.normalize(p).toLowerCase();
11321
+ return graphKey(p);
11352
11322
  }
11353
11323
 
11354
11324
  // ---------------------------------------------------------------------------
@@ -11573,23 +11543,31 @@ __factories["./src/graph/builder"] = function(module, exports) {
11573
11543
  }
11574
11544
  }
11575
11545
 
11576
- // Absolute imports: from package.module import ... (infer from project structure)
11546
+ // Absolute imports: from package.module import ... (infer from project
11547
+ // structure). The module is resolved against EVERY ancestor of the
11548
+ // importing file up to the project root, nearest first — any of them can
11549
+ // be the source root on sys.path (src/ layouts, pytest rootdir). The old
11550
+ // dir + one-parent probe silently dropped edges for files nested two or
11551
+ // more directories below the source root (#532), and a false "zero
11552
+ // importers" from get_impact is exactly the signal that says a change is
11553
+ // safe to make.
11577
11554
  const reAbs = /^[ \t]*from\s+([\w.]+)\s+import/gm;
11578
11555
  while ((m = reAbs.exec(content)) !== null) {
11579
11556
  const modulePath = m[1].replace(/\./g, '/');
11580
- const candidates = [
11581
- path.join(dir, modulePath + '.py'),
11582
- path.join(dir, modulePath, '__init__.py'),
11583
- path.resolve(dir, '..', modulePath + '.py'),
11584
- path.resolve(dir, '..', modulePath, '__init__.py'),
11585
- ];
11586
- for (const c of candidates) {
11587
- const normC = normalizePath(c);
11588
- if (fileSet.has(normC)) {
11589
- found.push(normC);
11590
- break;
11557
+ const normCwd = normalizePath(path.resolve(cwd));
11558
+ let base = dir;
11559
+ let hit = null;
11560
+ for (let depth = 0; depth < 16 && !hit; depth++) {
11561
+ for (const c of [path.join(base, modulePath + '.py'), path.join(base, modulePath, '__init__.py')]) {
11562
+ const normC = normalizePath(c);
11563
+ if (fileSet.has(normC)) { hit = normC; break; }
11591
11564
  }
11565
+ if (normalizePath(base) === normCwd) break;
11566
+ const parent = path.dirname(base);
11567
+ if (parent === base) break;
11568
+ base = parent;
11592
11569
  }
11570
+ if (hit) found.push(hit);
11593
11571
  }
11594
11572
  }
11595
11573
 
@@ -11869,7 +11847,8 @@ __factories["./src/graph/call-graph"] = function(module, exports) {
11869
11847
  'synchronized',
11870
11848
  ]);
11871
11849
 
11872
- function normalizePath(p) { return path.normalize(p).toLowerCase(); }
11850
+ const { graphKey } = __require('./src/graph/path-key');
11851
+ function normalizePath(p) { return graphKey(p); }
11873
11852
  function toRel(cwd, f) { return path.relative(cwd, f).replace(/\\/g, '/'); }
11874
11853
  function symId(cwd, absFile, name) { return `${toRel(cwd, absFile)}#${name}`; }
11875
11854
 
@@ -12301,8 +12280,11 @@ __factories["./src/graph/call-graph"] = function(module, exports) {
12301
12280
  for (const calleeId of calleeIds) {
12302
12281
  const calleeDef = graph.defs.get(calleeId);
12303
12282
  if (!calleeDef || calleeDef.file === callerDef.file) continue;
12304
- const a = path.resolve(cwd, callerDef.file);
12305
- const b = path.resolve(cwd, calleeDef.file);
12283
+ // Keyed through graphKey so the file-level call graph shares ONE key space
12284
+ // with the import graph. Previously this kept case while builder.js
12285
+ // lowercased, so a lookup correct for one silently missed on the other.
12286
+ const a = graphKey(path.resolve(cwd, callerDef.file));
12287
+ const b = graphKey(path.resolve(cwd, calleeDef.file));
12306
12288
  add(a, b);
12307
12289
  add(b, a);
12308
12290
  }
@@ -12708,6 +12690,36 @@ __factories["./src/graph/impact"] = function(module, exports) {
12708
12690
 
12709
12691
  };
12710
12692
 
12693
+ // ── ./src/graph/path-key ──
12694
+ __factories["./src/graph/path-key"] = function(module, exports) {
12695
+
12696
+ /**
12697
+ * The single definition of a graph node key.
12698
+ *
12699
+ * src/graph/builder.js and src/graph/call-graph.js each used to normalise paths
12700
+ * their own way — builder lowercased, call-graph did not — so a lookup written
12701
+ * for one silently missed on the other. That divergence disabled the import
12702
+ * boost on every repo whose path contains an uppercase letter, and later caused
12703
+ * a "fix" for one graph to break the other. Both now key through this function,
12704
+ * so there is one convention rather than two conventions and a convention.
12705
+ *
12706
+ * Lowercasing keeps lookups stable across case-insensitive filesystems (macOS,
12707
+ * Windows), where the same file legitimately arrives spelled two ways.
12708
+ *
12709
+ * Zero-dependency, pure, bundle-safe.
12710
+ */
12711
+
12712
+ const path = require('path');
12713
+
12714
+ /** Canonical key for a filesystem path used as a graph node. */
12715
+ function graphKey(p) {
12716
+ return path.normalize(String(p)).toLowerCase();
12717
+ }
12718
+
12719
+ module.exports = { graphKey };
12720
+
12721
+ };
12722
+
12711
12723
  // ── ./src/health/scorer ──
12712
12724
  __factories["./src/health/scorer"] = function(module, exports) {
12713
12725
 
@@ -15385,7 +15397,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
15385
15397
 
15386
15398
  const SERVER_INFO = {
15387
15399
  name: 'sigmap',
15388
- version: '8.28.0',
15400
+ version: '8.29.0',
15389
15401
  description: 'SigMap MCP server — code signatures on demand',
15390
15402
  };
15391
15403
 
@@ -16349,6 +16361,33 @@ __factories["./src/retrieval/bm25"] = function(module, exports) {
16349
16361
  // the literal query token always outranks a synonym-only match.
16350
16362
  const EXPANSION_WEIGHT = 0.15;
16351
16363
 
16364
+ // Module-doc prose is indexed as a `# module: ...` pseudo-signature (index-only,
16365
+ // see src/retrieval/module-doc.js). Per token it is a weaker relevance signal
16366
+ // than a real signature — descriptive rather than definitional — so it is scored
16367
+ // as its own BM25F field rather than pooled with the code terms.
16368
+ const MODULE_DOC_RE = /^#\s*(module|docs):/;
16369
+
16370
+ // Line anchors are metadata, not content, and this ranker is documented as
16371
+ // anchor-invariant. The previous strip was end-anchored, so it only fired when
16372
+ // the anchor was the last thing on the line — but extractors append a doc hint
16373
+ // AFTER it ("... :27-59 # Compute a normalized centrality score"). 27% of
16374
+ // signatures therefore leaked their line numbers into the term space as tokens
16375
+ // like "27" and "59": 840 junk terms, inflating document length for exactly the
16376
+ // well-documented files, which BM25 then penalised via length normalisation.
16377
+ const ANCHOR_RE = /\s*:\d+(?:-\d+)?(?=\s|$)/g;
16378
+
16379
+ function stripAnchor(line) {
16380
+ return String(line).replace(ANCHOR_RE, '');
16381
+ }
16382
+ // Tuned on the 30-task leak-free corpus. The 0.5-0.8 band is flat
16383
+ // (hit@5 63.3-66.7%, easy MRR steady at 0.825); adjacent values swing by up to
16384
+ // 6.7pp, which at 30 tasks is literally two tasks — noise, not signal. 0.6 is
16385
+ // chosen from the middle of that band rather than at its peak, because a
16386
+ // per-token weight below 1 is the principled position (prose is descriptive,
16387
+ // a signature is definitional) and picking the argmax of a 30-task sweep is
16388
+ // how you overfit a benchmark.
16389
+ const DOC_WEIGHT = 0.6;
16390
+
16352
16391
  // Build a stemmed lookup: stem(member) → Set of the group's other stemmed members.
16353
16392
  const EXPANSIONS = (() => {
16354
16393
  const map = new Map();
@@ -16392,22 +16431,45 @@ __factories["./src/retrieval/bm25"] = function(module, exports) {
16392
16431
  * @param {{ file: string, sigs: string[] }[]} candidates
16393
16432
  * @returns {Array<object & { score: number }>}
16394
16433
  */
16395
- function bm25rank(query, candidates) {
16434
+ function bm25rank(query, candidates, opts) {
16396
16435
  if (!Array.isArray(candidates) || candidates.length === 0) return [];
16397
16436
 
16398
16437
  const k1 = 1.5;
16399
16438
  const b = 0.75;
16400
16439
 
16440
+ const docWeight = (opts && typeof opts.docWeight === 'number') ? opts.docWeight : DOC_WEIGHT;
16441
+
16401
16442
  const docs = candidates.map((c) => {
16402
16443
  const pathToks = tokenize(c.file || '');
16403
16444
  // Ranking is anchor-invariant: `:start-end` line anchors are metadata,
16404
16445
  // not content — strip them before tokenizing so adding anchors to an
16405
16446
  // extractor never shifts BM25 length normalization or token counts.
16406
- const toks = tokenize((c.sigs || []).map((s) => String(s).replace(/\s*:\d+(?:-\d+)?\s*$/, '')).join(' '));
16407
- for (let i = 0; i < PATH_BOOST; i++) toks.push(...pathToks);
16447
+ // BM25F-style fields. Module-doc prose and code signatures are different
16448
+ // kinds of evidence and must not share one term-frequency pool: prose is
16449
+ // ~30% of all indexed tokens, and a short file with a long header (few
16450
+ // signatures, lots of description) otherwise wins unrelated queries purely
16451
+ // through length normalisation.
16452
+ const docLines = [];
16453
+ const codeLines = [];
16454
+ for (const line of (c.sigs || [])) (MODULE_DOC_RE.test(line) ? docLines : codeLines).push(line);
16455
+ // TRIED AND REJECTED: splitting the declared symbol NAME into its own
16456
+ // weighted BM25F field, on the IR prior that a name is a "title" and params
16457
+ // are "body". Swept 1.0-4.0. hit@5 on the mined corpus rose 60.9% -> 65.2%,
16458
+ // which is a single task crossing the rank-5 line — over 113 combined tasks
16459
+ // hit@1 fell 52.2% -> 51.3%, hit@3 fell 66.4% -> 65.5%, hit@10 was identical
16460
+ // and MRR dropped. It moves correct answers DOWN and happens to nudge one
16461
+ // past a cutoff. A hit@5-only view would have shipped this.
16462
+ const codeToks = tokenize(codeLines.map((x) => stripAnchor(x)).join(' '));
16463
+ const docToks = tokenize(docLines.join(' '));
16408
16464
  const tf = new Map();
16409
- for (const t of toks) tf.set(t, (tf.get(t) || 0) + 1);
16410
- return { cand: c, tf, len: toks.length };
16465
+ const addField = (toks, weight) => { for (const t of toks) tf.set(t, (tf.get(t) || 0) + weight); };
16466
+ addField(codeToks, 1);
16467
+ addField(pathToks, PATH_BOOST);
16468
+ addField(docToks, docWeight);
16469
+ // Length accumulates with the SAME weights, or a field's influence leaks
16470
+ // back in through the normalisation term.
16471
+ const len = codeToks.length + (PATH_BOOST * pathToks.length) + (docWeight * docToks.length);
16472
+ return { cand: c, tf, len };
16411
16473
  });
16412
16474
 
16413
16475
  const N = docs.length || 1;
@@ -16436,7 +16498,7 @@ __factories["./src/retrieval/bm25"] = function(module, exports) {
16436
16498
  .sort((a, c) => c.score - a.score || String(a.file).localeCompare(String(c.file)));
16437
16499
  }
16438
16500
 
16439
- module.exports = { tokenize, stem, bm25rank, PATH_BOOST, STOP, expandQuery, EXPANSIONS, EXPANSION_WEIGHT };
16501
+ module.exports = { tokenize, stem, bm25rank, PATH_BOOST, STOP, expandQuery, EXPANSIONS, EXPANSION_WEIGHT, DOC_WEIGHT, MODULE_DOC_RE, stripAnchor };
16440
16502
 
16441
16503
  };
16442
16504
 
@@ -16499,6 +16561,130 @@ __factories["./src/retrieval/enrich-from-maps"] = function(module, exports) {
16499
16561
 
16500
16562
  };
16501
16563
 
16564
+ // ── ./src/retrieval/module-doc ──
16565
+ __factories["./src/retrieval/module-doc"] = function(module, exports) {
16566
+
16567
+ /**
16568
+ * Module-level documentation extractor (retrieval only).
16569
+ *
16570
+ * Signatures describe a file's SHAPE — names, params, return types. A module's
16571
+ * leading comment describes its PURPOSE, in prose, using the words a person
16572
+ * would actually search with. That prose is the bridge between a behavioural
16573
+ * query ("what fraction of the repo made it into the output") and the code
16574
+ * that implements it (`coverageScore(cwd, fileEntries, config)`), which shares
16575
+ * not one token with the query.
16576
+ *
16577
+ * This text is added to the RETRIEVAL INDEX ONLY — never to the generated
16578
+ * context file. The prompt artifact is token-budgeted and prose is expensive
16579
+ * there; the index is not injected into any prompt, so it can afford the words
16580
+ * that make a file findable.
16581
+ *
16582
+ * Zero-dependency, pure, bundle-safe.
16583
+ */
16584
+
16585
+ // Enough to characterise a module without letting one verbose header dominate
16586
+ // BM25 length normalisation for the whole corpus.
16587
+ const MAX_CHARS = 400;
16588
+ const MAX_SCAN_LINES = 60;
16589
+
16590
+ // Legal boilerplate is high-frequency noise: it appears in many files, shares no
16591
+ // vocabulary with real queries, and would flatten idf across the corpus.
16592
+ const BOILERPLATE = /\b(copyright|licensed under|SPDX-License|all rights reserved|permission is hereby granted)\b/i;
16593
+
16594
+ const BLOCK_LANGS = new Set(['js', 'jsx', 'ts', 'tsx', 'java', 'go', 'rs', 'kt', 'swift', 'scala', 'cs', 'php', 'dart', 'c', 'cpp', 'h']);
16595
+ const HASH_LANGS = new Set(['py', 'rb', 'r', 'sh', 'yml', 'yaml', 'toml']);
16596
+
16597
+ function _extOf(filePath) {
16598
+ const m = String(filePath).match(/\.([A-Za-z0-9]+)$/);
16599
+ return m ? m[1].toLowerCase() : '';
16600
+ }
16601
+
16602
+ /** Strip comment furniture, JSDoc tags, and markup from one raw comment line. */
16603
+ function _cleanLine(line) {
16604
+ return String(line)
16605
+ .replace(/^\s*[/*#-]+\s?/, '') // leading // /* * # ---
16606
+ .replace(/\*+\/\s*$/, '') // trailing */
16607
+ .replace(/^\s*@\w+.*$/, '') // @param / @returns tag lines
16608
+ .replace(/[*_`]/g, '') // markdown emphasis / code ticks
16609
+ .trim();
16610
+ }
16611
+
16612
+ /**
16613
+ * Extract a module's leading documentation prose.
16614
+ *
16615
+ * @param {string} src file contents
16616
+ * @param {string} filePath used only to pick a comment syntax
16617
+ * @returns {string} collapsed prose, capped, or '' when there is none
16618
+ */
16619
+ function extractModuleDoc(src, filePath) {
16620
+ if (!src || typeof src !== 'string') return '';
16621
+ const ext = _extOf(filePath);
16622
+ const lines = src.split('\n', MAX_SCAN_LINES);
16623
+
16624
+ const collected = [];
16625
+ let inBlock = false;
16626
+ let started = false;
16627
+
16628
+ for (const raw of lines) {
16629
+ const line = raw.trim();
16630
+ if (!started) {
16631
+ // Skip preamble that precedes the real header comment.
16632
+ if (!line) continue;
16633
+ if (line.startsWith('#!')) continue; // shebang
16634
+ if (/^['"]use strict['"];?$/.test(line)) continue;
16635
+ if (/^(package|import|from|using|#include)\b/.test(line)) continue;
16636
+ }
16637
+
16638
+ if (BLOCK_LANGS.has(ext) || ext === '') {
16639
+ if (!inBlock && line.startsWith('/*')) { inBlock = true; started = true; }
16640
+ if (inBlock) {
16641
+ const cleaned = _cleanLine(line);
16642
+ if (cleaned) collected.push(cleaned);
16643
+ if (line.includes('*/')) break;
16644
+ continue;
16645
+ }
16646
+ if (line.startsWith('//')) { // run of // lines
16647
+ started = true;
16648
+ const cleaned = _cleanLine(line);
16649
+ if (cleaned) collected.push(cleaned);
16650
+ continue;
16651
+ }
16652
+ if (started || collected.length) break;
16653
+ break; // first real code — no header
16654
+ }
16655
+
16656
+ if (HASH_LANGS.has(ext)) {
16657
+ if (/^("""|''')/.test(line)) { // python docstring
16658
+ started = true; inBlock = !inBlock;
16659
+ const cleaned = _cleanLine(line.replace(/^("""|''')/, '').replace(/("""|''')$/, ''));
16660
+ if (cleaned) collected.push(cleaned);
16661
+ if (!inBlock) break;
16662
+ continue;
16663
+ }
16664
+ if (inBlock) { const c = _cleanLine(line); if (c) collected.push(c); continue; }
16665
+ if (line.startsWith('#')) { started = true; const c = _cleanLine(line); if (c) collected.push(c); continue; }
16666
+ if (collected.length) break;
16667
+ break;
16668
+ }
16669
+ break;
16670
+ }
16671
+
16672
+ const text = collected.join(' ').replace(/\s+/g, ' ').trim();
16673
+ if (!text || BOILERPLATE.test(text)) return '';
16674
+ if (text.length <= MAX_CHARS) return text;
16675
+ return text.slice(0, MAX_CHARS).replace(/\s+\S*$/, ''); // never cut mid-word
16676
+ }
16677
+
16678
+ /** Render as an index-only pseudo-signature, or '' when there is no doc. */
16679
+ function moduleDocSig(src, filePath) {
16680
+ const doc = extractModuleDoc(src, filePath);
16681
+ return doc ? `# module: ${doc}` : '';
16682
+ }
16683
+
16684
+ module.exports = { extractModuleDoc, moduleDocSig, MAX_CHARS };
16685
+
16686
+ };
16687
+
16502
16688
  // ── ./src/retrieval/ranker ──
16503
16689
  __factories["./src/retrieval/ranker"] = function(module, exports) {
16504
16690
 
@@ -16521,7 +16707,7 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16521
16707
 
16522
16708
  const { loadWeights } = __require('./src/learning/weights');
16523
16709
  const { tokenize, STOP_WORDS } = __require('./src/retrieval/tokenizer');
16524
- const { bm25rank } = __require('./src/retrieval/bm25');
16710
+ const { bm25rank, MODULE_DOC_RE } = __require('./src/retrieval/bm25');
16525
16711
 
16526
16712
  // ---------------------------------------------------------------------------
16527
16713
  // Default weights
@@ -16545,17 +16731,28 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16545
16731
  // Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
16546
16732
  const CENTRALITY_BLEND_WEIGHT = 0.3;
16547
16733
 
16548
- // Intent-specific weight adjustments
16549
- const INTENT_WEIGHTS = {
16550
- search: DEFAULT_WEIGHTS,
16551
- debug: { ...DEFAULT_WEIGHTS, exactToken: 1.2, pathMatch: 0.6 },
16552
- explain: { ...DEFAULT_WEIGHTS, symbolMatch: 0.8, pathMatch: 0.9 },
16553
- refactor: { ...DEFAULT_WEIGHTS, symbolMatch: 0.9, exactToken: 0.8 },
16554
- review: { ...DEFAULT_WEIGHTS, pathMatch: 1.0, exactToken: 0.9 },
16555
- test: { ...DEFAULT_WEIGHTS, exactToken: 0.7, symbolMatch: 0.4 },
16556
- integrate: { ...DEFAULT_WEIGHTS, graphBoost: 0.7, pathMatch: 1.1 },
16557
- navigate: { ...DEFAULT_WEIGHTS, pathMatch: 1.2, exactToken: 0.9 },
16558
- };
16734
+ // Per-intent weight profiles were removed in favour of a single weight set.
16735
+ // They were provably inert: scoreFile's score was discarded by rank(), so the
16736
+ // profiles only ever reached the explain table. Once the signal WAS wired into
16737
+ // the score (see SIGNAL_BLEND below), a sweep over the leak-free hard corpus
16738
+ // showed intent-specific profiles produced byte-identical metrics to the flat
16739
+ // DEFAULT_WEIGHTS at every blend value — so they earn nothing and are gone.
16740
+ // `detectIntent` is retained: it is still reported to the user and is the right
16741
+ // hook for shaping OUTPUT depth later.
16742
+
16743
+ // How much the weighted keyword/symbol/path signal modulates the BM25 base.
16744
+ // Multiplicative and bounded, so it can only reorder files that already match —
16745
+ // it can never lift a zero-BM25 file into the results. Tuned on the leak-free
16746
+ // corpus: 0.5 gave hit@5 50.0% -> 56.7% and MRR 0.419 -> 0.447; higher values
16747
+ // held hit@5 but degraded MRR.
16748
+ const SIGNAL_BLEND = 0.5;
16749
+
16750
+ // TRIED AND REJECTED: a same-line co-occurrence bonus, on the theory that a file
16751
+ // declaring `parseAuthToken` should outrank one mentioning `parseAuth` and
16752
+ // `token` on separate lines. Swept 0.15-1.0: hit@5 did not move on either the
16753
+ // 90-task authored corpus or the 32-task mined one, and MRR degraded
16754
+ // monotonically as the weight rose. Signatures are short and dense enough that
16755
+ // BM25's bag already captures this. Not reinstated without new evidence.
16559
16756
 
16560
16757
  // Penalty multipliers for negative signals
16561
16758
  const PENALTY_SIGNALS = {
@@ -16565,12 +16762,36 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16565
16762
  nodeModules: 0.0, // node_modules (zero score)
16566
16763
  };
16567
16764
 
16568
- function _computePenalty(filePath) {
16765
+ // Query terms that mean the penalised category IS the target. Read from the
16766
+ // query tokens directly, NOT via detectIntent: that classifier is first-match-
16767
+ // wins over its pattern object, and `debug` precedes `test`, so "fix the failing
16768
+ // test" classifies as debug and never reaches the test branch.
16769
+ const WANTS_TESTS = new Set(['test', 'tests', 'spec', 'specs', 'unit', 'integration', 'e2e', 'assertion', 'assert', 'mock', 'fixture', 'coverage', 'testing']);
16770
+ const WANTS_DOCS = new Set(['doc', 'docs', 'documentation', 'readme', 'changelog', 'guide', 'tutorial']);
16771
+
16772
+ /** Which penalised categories the query is explicitly asking for. */
16773
+ function _queryWants(queryTokens) {
16774
+ const wants = { tests: false, docs: false };
16775
+ for (const t of queryTokens || []) {
16776
+ if (WANTS_TESTS.has(t)) wants.tests = true;
16777
+ if (WANTS_DOCS.has(t)) wants.docs = true;
16778
+ }
16779
+ return wants;
16780
+ }
16781
+
16782
+ function _computePenalty(filePath, wants) {
16569
16783
  const pathLower = filePath.toLowerCase();
16570
16784
  if (pathLower.includes('node_modules')) return PENALTY_SIGNALS.nodeModules;
16571
- if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.testFile;
16785
+ // A penalty must never fire on the very thing the user asked for. Before
16786
+ // this, "write tests for the ranker" multiplied every test file by 0.4 —
16787
+ // the query and the penalty were pulling in opposite directions.
16788
+ if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower) || /\.(test|spec)\./.test(pathLower)) {
16789
+ return (wants && wants.tests) ? 1.0 : PENALTY_SIGNALS.testFile;
16790
+ }
16572
16791
  if (/(^|\/)(dist|build|\.next|\.nuxt|out|\.venv|venv)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.generatedCode;
16573
- if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.docsFile;
16792
+ if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower)) {
16793
+ return (wants && wants.docs) ? 1.0 : PENALTY_SIGNALS.docsFile;
16794
+ }
16574
16795
  return 1.0;
16575
16796
  }
16576
16797
 
@@ -16589,6 +16810,26 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16589
16810
  }
16590
16811
 
16591
16812
  // Common utility paths that should be treated as hubs regardless of fanout
16813
+ // The graph builders disagree on key case: src/graph/builder.js lowercases every
16814
+ // node (normalizePath), while src/graph/call-graph.js keys by a case-preserving
16815
+ // path.resolve. Assuming either one breaks the other, so every graph lookup in
16816
+ // this file probes both forms — the same thing the centrality blend already does.
16817
+ function _graphKeys(p) {
16818
+ const norm = require('path').normalize(p);
16819
+ const lower = norm.toLowerCase();
16820
+ return lower === norm ? [norm] : [norm, lower];
16821
+ }
16822
+ function _graphGet(map, absPath) {
16823
+ for (const k of _graphKeys(absPath)) {
16824
+ const hit = map.get(k);
16825
+ if (hit !== undefined) return hit;
16826
+ }
16827
+ return undefined;
16828
+ }
16829
+ function _registerKeys(map, absPath, value) {
16830
+ for (const k of _graphKeys(absPath)) if (!map.has(k)) map.set(k, value);
16831
+ }
16832
+
16592
16833
  function _isHub(filePath) {
16593
16834
  return /\/(utils|helpers|shared|common|constants|types|interfaces|index|zzz|globals)\.(ts|tsx|js|jsx|r|R)$/.test(filePath)
16594
16835
  || filePath.endsWith('/index.ts') || filePath.endsWith('/index.js')
@@ -16604,14 +16845,18 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16604
16845
  * @param {object} weights
16605
16846
  * @returns {{ score: number, signals: { exactToken: number, symbolMatch: number, prefixMatch: number, pathMatch: number, penalty: number } }}
16606
16847
  */
16607
- function scoreFile(filePath, sigs, queryTokens, weights) {
16848
+ function scoreFile(filePath, sigs, queryTokens, weights, wants) {
16608
16849
  if (!sigs || sigs.length === 0) return { score: 0, signals: { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: 1.0 } };
16609
16850
 
16610
16851
  const w = weights || DEFAULT_WEIGHTS;
16611
- const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath) };
16612
-
16613
- // Build token set from all signatures
16614
- const sigText = sigs.join(' ');
16852
+ const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath, wants) };
16853
+
16854
+ // Module-doc prose is excluded here on purpose. This signal measures overlap
16855
+ // with DECLARED IDENTIFIERS; prose relevance is BM25's job, where it is scored
16856
+ // as its own weighted field. Letting descriptive text inflate the identifier
16857
+ // signal double-counts it and measurably degraded MRR.
16858
+ const codeSigs = sigs.filter((line) => !MODULE_DOC_RE.test(line));
16859
+ const sigText = codeSigs.join(' ');
16615
16860
  const sigTokenSet = new Set(tokenize(sigText));
16616
16861
 
16617
16862
  // Build token set from the file path
@@ -16629,7 +16874,7 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16629
16874
  signals.exactToken += bonus;
16630
16875
 
16631
16876
  // Bonus: appears directly in a function/class/method name line
16632
- const nameLineMatch = sigs.some((sig) => {
16877
+ const nameLineMatch = codeSigs.some((sig) => {
16633
16878
  const nt = tokenize(sig.replace(/[^a-zA-Z0-9_\s]/g, ' '));
16634
16879
  return nt.includes(qt);
16635
16880
  });
@@ -16691,13 +16936,18 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16691
16936
  const graph = (opts && opts.graph && opts.graph.forward instanceof Map) ? opts.graph : null;
16692
16937
  const cwd = (opts && opts.cwd) || null;
16693
16938
 
16694
- // Detect query intent and get appropriate weights
16939
+ // Intent is reported to the user and shapes output depth; it no longer
16940
+ // selects scoring weights (see SIGNAL_BLEND).
16695
16941
  const intent = detectIntent(query);
16696
- const intentWeights = INTENT_WEIGHTS[intent] || DEFAULT_WEIGHTS;
16697
- const weights = (opts && opts.weights) ? Object.assign({}, intentWeights, opts.weights) : intentWeights;
16698
- const learnedWeights = opts && opts.cwd ? loadWeights(opts.cwd) : null;
16942
+ const weights = (opts && opts.weights) ? Object.assign({}, DEFAULT_WEIGHTS, opts.weights) : DEFAULT_WEIGHTS;
16943
+ // Learned per-file multipliers are a LOCAL, evolving signal (.context/weights.json).
16944
+ // Benchmarks and CI gates must opt out via { learned: false }, or a developer's
16945
+ // local learned state silently changes the score and CI stops being reproducible.
16946
+ const useLearned = !(opts && opts.learned === false);
16947
+ const learnedWeights = opts && opts.cwd && useLearned ? loadWeights(opts.cwd) : null;
16699
16948
 
16700
16949
  const queryTokens = tokenize(query);
16950
+ const queryWants = _queryWants(queryTokens);
16701
16951
  if (queryTokens.length === 0) {
16702
16952
  // Empty query: return top-K by file count (most signatures = most useful)
16703
16953
  const all = [];
@@ -16714,18 +16964,32 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16714
16964
  // are matched. The existing negative-signal penalty and recency/graph/learned
16715
16965
  // boosts are layered on top; the per-token signals stay for the explain table.
16716
16966
  const bm25Scores = new Map();
16717
- for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })))) {
16967
+ for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })), opts)) {
16718
16968
  bm25Scores.set(c.file, c.score);
16719
16969
  }
16720
16970
 
16721
- const scored = [];
16971
+ // Two passes: scoreFile's weighted signal needs the max across the corpus to
16972
+ // normalise against, so collect first, then combine.
16973
+ const prescored = [];
16974
+ let maxSignal = 0;
16722
16975
  for (const [file, sigs] of sigIndex.entries()) {
16723
- const result = scoreFile(file, sigs, queryTokens, weights);
16976
+ const result = scoreFile(file, sigs, queryTokens, weights, queryWants);
16977
+ if (result.score > maxSignal) maxSignal = result.score;
16978
+ prescored.push({ file, sigs, result });
16979
+ }
16980
+
16981
+ const scored = [];
16982
+ for (const { file, sigs, result } of prescored) {
16724
16983
  const penalty = result.signals.penalty;
16725
16984
  const base = bm25Scores.get(file) || 0;
16726
- let score = base * penalty;
16985
+ // Blend the weighted keyword/symbol/path signal into the BM25 base. This
16986
+ // was previously computed and thrown away — `result.score` was never read,
16987
+ // which silently made DEFAULT_WEIGHTS and every intent profile dead config.
16988
+ const signalNorm = maxSignal > 0 ? result.score / maxSignal : 0;
16989
+ let score = base * penalty * (1 + SIGNAL_BLEND * signalNorm);
16727
16990
  const signals = result.signals;
16728
16991
  signals.bm25 = base;
16992
+ signals.signalBlend = signalNorm;
16729
16993
 
16730
16994
  // Recency boost
16731
16995
  if (recencySet && recencySet.has(file) && score > 0) {
@@ -16755,44 +17019,46 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16755
17019
  // Hub suppression: files with high fanout (>20%) are not boosted
16756
17020
  if (graph && cwd) {
16757
17021
  const path = require('path');
16758
- // Build maps for relative absolute path conversion and index lookup
16759
- const relToIdx = new Map();
16760
- const absToRel = new Map();
17022
+ // Every graph node is keyed by `path.normalize(p).toLowerCase()` (see
17023
+ // normalizePath in src/graph/builder.js and src/graph/call-graph.js).
17024
+ // Lookups MUST use the same key space: a bare path.resolve() preserves
17025
+ // case, so on any repo whose absolute path contains an uppercase letter
17026
+ // every .get() missed and this entire block was silently inert.
17027
+ const keyToIdx = new Map();
16761
17028
  for (let i = 0; i < scored.length; i++) {
16762
- relToIdx.set(scored[i].file, i);
16763
- const abs = path.resolve(cwd, scored[i].file);
16764
- absToRel.set(abs, scored[i].file);
17029
+ _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
16765
17030
  }
16766
17031
 
16767
17032
  const hubs = _computeHubs(graph);
16768
- const hop1Files = new Set(); // track which files received hop1 boost
17033
+ const hop1Files = new Set(); // normalised keys that received a hop1 boost
17034
+ const hop1Seeds = []; // original (un-normalised) paths, for hop-2 lookup
16769
17035
 
16770
17036
  // Hop 1: direct neighbors of scored files
16771
17037
  for (const entry of scored) {
16772
17038
  if (entry.score <= 0) continue;
16773
- const abs = path.resolve(cwd, entry.file);
16774
- const neighbors = graph.forward.get(abs) || [];
17039
+ const neighbors = _graphGet(graph.forward, path.resolve(cwd, entry.file)) || [];
16775
17040
  for (const neighborAbs of neighbors) {
16776
- if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
16777
- const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
16778
- const idx = relToIdx.get(neighborRel);
17041
+ const nk = path.normalize(neighborAbs);
17042
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
17043
+ const idx = _graphGet(keyToIdx, nk);
16779
17044
  if (idx !== undefined) {
16780
17045
  scored[idx].score += GRAPH_BOOST_AMOUNTS.hop1;
16781
17046
  scored[idx].signals.graphBoost = (scored[idx].signals.graphBoost || 0) + GRAPH_BOOST_AMOUNTS.hop1;
16782
- hop1Files.add(neighborAbs);
17047
+ hop1Files.add(nk);
17048
+ hop1Seeds.push(neighborAbs);
16783
17049
  }
16784
17050
  }
16785
17051
  }
16786
17052
 
16787
17053
  // Hop 2: neighbors of hop1 files (only if they didn't get a direct score)
16788
- for (const hop1File of hop1Files) {
16789
- if (!absToRel.has(hop1File)) continue; // skip files not in index
16790
- const neighbors = graph.forward.get(hop1File) || [];
17054
+ for (const hop1Key of hop1Seeds) {
17055
+ if (_graphGet(keyToIdx, hop1Key) === undefined) continue; // skip files not in index
17056
+ const neighbors = _graphGet(graph.forward, hop1Key) || [];
16791
17057
  for (const neighborAbs of neighbors) {
16792
- if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
16793
- if (hop1Files.has(neighborAbs)) continue; // skip already hop1-boosted
16794
- const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
16795
- const idx = relToIdx.get(neighborRel);
17058
+ const nk = path.normalize(neighborAbs);
17059
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
17060
+ if (hop1Files.has(nk)) continue; // skip already hop1-boosted
17061
+ const idx = _graphGet(keyToIdx, nk);
16796
17062
  if (idx !== undefined && scored[idx].score > 0) {
16797
17063
  // Only boost files that have some baseline score (not noise)
16798
17064
  scored[idx].score += GRAPH_BOOST_AMOUNTS.hop2;
@@ -16809,16 +17075,17 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16809
17075
  const callGraph = (opts && opts.callGraph && opts.callGraph.forward instanceof Map) ? opts.callGraph : null;
16810
17076
  if (callGraph && cwd) {
16811
17077
  const path = require('path');
16812
- const relToIdx = new Map();
16813
- for (let i = 0; i < scored.length; i++) relToIdx.set(scored[i].file, i);
17078
+ // buildCallFileGraph keys by a CASE-PRESERVING path.resolve, unlike the
17079
+ // import graph builder which lowercases hence the dual-form probe.
17080
+ const keyToIdx = new Map();
17081
+ for (let i = 0; i < scored.length; i++) _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
16814
17082
  const hubs = _computeHubs(callGraph);
16815
17083
  const seeds = scored.filter((e) => e.score > 0).map((e) => e.file);
16816
17084
  for (const file of seeds) {
16817
- const abs = path.resolve(cwd, file);
16818
- for (const neighborAbs of (callGraph.forward.get(abs) || [])) {
16819
- if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
16820
- const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
16821
- const idx = relToIdx.get(neighborRel);
17085
+ for (const neighborAbs of (_graphGet(callGraph.forward, path.resolve(cwd, file)) || [])) {
17086
+ const nk = path.normalize(neighborAbs);
17087
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
17088
+ const idx = _graphGet(keyToIdx, nk);
16822
17089
  if (idx !== undefined && scored[idx].file !== file) {
16823
17090
  scored[idx].score += GRAPH_BOOST_AMOUNTS.callHop;
16824
17091
  scored[idx].signals.callGraphBoost = (scored[idx].signals.callGraphBoost || 0) + GRAPH_BOOST_AMOUNTS.callHop;
@@ -16975,10 +17242,35 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16975
17242
  * @returns {Map<string, string[]>}
16976
17243
  */
16977
17244
  function _enrichSigIndexFromStrategy(cwd, index) {
17245
+ const fs = require('fs');
16978
17246
  const path = require('path');
16979
- const coldPath = path.join(cwd, '.github', 'context-cold.md');
16980
- _mergeSigIndex(index, _parseContextFile(coldPath));
17247
+ // Merge every strategy split file: context-cold.md (hot-cold) AND each
17248
+ // per-module context-<module>.md — the per-module strategy stores ALL
17249
+ // signatures in these, leaving the primary file as a thin overview, so
17250
+ // skipping them made ask/query_context see an empty index (#534).
17251
+ // Sorted for deterministic merge order.
17252
+ try {
17253
+ const ghDir = path.join(cwd, '.github');
17254
+ const splits = fs.readdirSync(ghDir)
17255
+ .filter((f) => /^context-[\w.-]+\.md$/.test(f))
17256
+ .sort();
17257
+ for (const f of splits) {
17258
+ _mergeSigIndex(index, _parseContextFile(path.join(ghDir, f)));
17259
+ }
17260
+ } catch (_) {}
16981
17261
  _mergeSigIndex(index, _buildSigIndexFromCache(cwd));
17262
+
17263
+ // The complete retrieval index (written by generate before applyTokenBudget)
17264
+ // takes precedence: it is the only source containing files the budget dropped,
17265
+ // and full signatures for files the budget collapsed to line anchors. It is
17266
+ // merged as the BASE rather than on top because _mergeSigIndex only replaces
17267
+ // when the source has MORE signatures — a collapsed entry has the same count
17268
+ // as its full form, so merging the other way would keep the anchors.
17269
+ try {
17270
+ const full = __require('./src/retrieval/sig-index-store').readFullIndex(cwd);
17271
+ if (full.size > 0) return _mergeSigIndex(full, index);
17272
+ } catch (_) { /* absent → budgeted view is still served */ }
17273
+
16982
17274
  return index;
16983
17275
  }
16984
17276
 
@@ -17104,25 +17396,155 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
17104
17396
  // ---------------------------------------------------------------------------
17105
17397
  // Intent detection — 7 intents
17106
17398
  // ---------------------------------------------------------------------------
17399
+ // Nouns carry an optional plural: `\btest\b` does not match "tests", so
17400
+ // "write unit tests for the ranker" matched NO intent at all and fell through
17401
+ // to the 'search' default.
17107
17402
  const INTENT_PATTERNS = {
17108
- debug: /\b(bug|fix|error|crash|exception|broken|failing|issue|problem|regression)\b/i,
17403
+ debug: /\b(bugs?|fix(es|ed)?|errors?|crash(es)?|exceptions?|broken|failing|failures?|issues?|problems?|regressions?)\b/i,
17109
17404
  explain: /\b(explain|how does|what is|understand|overview|architecture|describe|walk me|teach)\b/i,
17110
- refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimize)\b/i,
17405
+ refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimi[sz]e)\b/i,
17111
17406
  review: /\b(review|check|audit|security|pr|pull request|assess|validate)\b/i,
17112
- test: /\b(test|unit test|integration test|testing|spec|assert|mock)\b/i,
17113
- integrate:/\b(import|integrate|connect|wire|bind|require|export|depend|graph)\b|require[ds]\b/i,
17407
+ test: /\b(tests?|unit tests?|integration tests?|testing|specs?|assert(ion)?s?|mocks?|fixtures?)\b/i,
17408
+ integrate:/\b(imports?|integrate|connect|wire|bind|requires?|exports?|depends?|dependenc(y|ies)|graph)\b/i,
17114
17409
  navigate: /\b(find|locate|where|search|look for|show me|navigate|browse|list)\b/i,
17115
17410
  };
17116
17411
 
17117
- function detectIntent(query) {
17118
- if (!query || typeof query !== 'string') return 'search';
17412
+ /**
17413
+ * Every intent whose pattern matches, strongest first.
17414
+ *
17415
+ * A real request is routinely multi-intent — "fix the failing test" is both a
17416
+ * debug task and a test task — and reporting one label discards that. Worse,
17417
+ * the single-label version returned the FIRST key in INTENT_PATTERNS order, so
17418
+ * `debug` permanently shadowed `test`: no query containing "fix" or "failing"
17419
+ * could ever be labelled a test, no matter how test-shaped it was.
17420
+ *
17421
+ * Ranked by how many distinct terms each pattern matched, so the dominant
17422
+ * intent leads; ties fall back to declaration order for determinism.
17423
+ *
17424
+ * @param {string} query
17425
+ * @returns {string[]} matched intents, never empty (defaults to ['search'])
17426
+ */
17427
+ function detectIntents(query) {
17428
+ if (!query || typeof query !== 'string') return ['search'];
17429
+ const scored = [];
17430
+ let order = 0;
17119
17431
  for (const [intent, re] of Object.entries(INTENT_PATTERNS)) {
17120
- if (re.test(query)) return intent;
17432
+ const hits = query.match(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'));
17433
+ if (hits && hits.length) {
17434
+ scored.push({ intent, hits: new Set(hits.map((h) => h.toLowerCase())).size, order: order });
17435
+ }
17436
+ order++;
17437
+ }
17438
+ if (scored.length === 0) return ['search'];
17439
+ scored.sort((a, b) => (b.hits - a.hits) || (a.order - b.order));
17440
+ return scored.map((s) => s.intent);
17441
+ }
17442
+
17443
+ /** Primary intent. Kept for callers that want a single label. */
17444
+ function detectIntent(query) {
17445
+ return detectIntents(query)[0];
17446
+ }
17447
+
17448
+ module.exports = { rank, buildSigIndex, scoreFile, _queryWants, detectIntents, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
17449
+
17450
+ };
17451
+
17452
+ // ── ./src/retrieval/sig-index-store ──
17453
+ __factories["./src/retrieval/sig-index-store"] = function(module, exports) {
17454
+
17455
+ /**
17456
+ * Complete, unbudgeted signature index for retrieval.
17457
+ *
17458
+ * WHY THIS EXISTS
17459
+ * ---------------
17460
+ * The generated context file (CLAUDE.md / AGENTS.md / copilot-instructions.md)
17461
+ * is a BUDGETED VIEW: `applyTokenBudget` drops and collapses files so the
17462
+ * artifact stays under `maxTokens`, because it is injected into every prompt.
17463
+ *
17464
+ * `buildSigIndex` used to parse that same artifact to build the ranker's index,
17465
+ * so retrieval inherited the prompt budget. Every file the budget dropped became
17466
+ * permanently unreachable by `sigmap ask` — no ranking change can surface a file
17467
+ * that is not in the index. On this repo that was 53 of 155 source files (34%),
17468
+ * and restoring them moved hit@5 from 50% to 90% on the retrieval corpus.
17469
+ *
17470
+ * The two artifacts have opposite requirements — the prompt file wants to be
17471
+ * small, the index wants to be complete — so they are now separate. This store
17472
+ * is written by `generate` BEFORE the budget is applied, and lives under
17473
+ * `.context/` (gitignored, never injected into a prompt).
17474
+ *
17475
+ * Zero-dependency, bundle-safe (fs + path only).
17476
+ */
17477
+
17478
+ const fs = require('fs');
17479
+ const path = require('path');
17480
+
17481
+ const INDEX_DIR = '.context';
17482
+ const INDEX_FILE = 'sig-index.json';
17483
+ const SCHEMA = 1;
17484
+
17485
+ /** Absolute path to the retrieval index artifact. */
17486
+ function indexPath(cwd) {
17487
+ return path.join(cwd, INDEX_DIR, INDEX_FILE);
17488
+ }
17489
+
17490
+ /**
17491
+ * Persist the complete signature index.
17492
+ *
17493
+ * @param {string} cwd
17494
+ * @param {Array<{filePath: string, sigs: string[]}>} fileEntries - every
17495
+ * extracted entry, BEFORE applyTokenBudget has dropped or collapsed any.
17496
+ * @param {{ version?: string }} [opts]
17497
+ * @returns {{ path: string, files: number }}
17498
+ */
17499
+ function writeFullIndex(cwd, fileEntries, opts = {}) {
17500
+ const files = {};
17501
+ let count = 0;
17502
+ for (const e of fileEntries || []) {
17503
+ if (!e || !e.filePath || !Array.isArray(e.sigs) || e.sigs.length === 0) continue;
17504
+ const rel = path.relative(cwd, e.filePath).replace(/\\/g, '/');
17505
+ if (!rel || rel.startsWith('..')) continue;
17506
+ files[rel] = e.sigs;
17507
+ count++;
17121
17508
  }
17122
- return 'search';
17509
+
17510
+ const out = indexPath(cwd);
17511
+ fs.mkdirSync(path.dirname(out), { recursive: true });
17512
+ // Write-then-rename so a concurrent `ask` never observes a half-written index.
17513
+ const tmp = `${out}.tmp`;
17514
+ fs.writeFileSync(tmp, JSON.stringify({
17515
+ schema: SCHEMA,
17516
+ sigmapVersion: opts.version || null,
17517
+ generated: new Date().toISOString(),
17518
+ files,
17519
+ }), 'utf8');
17520
+ fs.renameSync(tmp, out);
17521
+ return { path: out, files: count };
17123
17522
  }
17124
17523
 
17125
- module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
17524
+ /**
17525
+ * Load the complete signature index, or an empty Map when absent/unreadable.
17526
+ *
17527
+ * Deliberately NOT version-busted (unlike .sigmap-cache.json): a stale but
17528
+ * complete index still retrieves the right files, whereas discarding it drops
17529
+ * retrieval back to the budgeted view — the exact failure this store exists to
17530
+ * prevent. Staleness is handled by re-running generate or by cache/freshen.
17531
+ *
17532
+ * @param {string} cwd
17533
+ * @returns {Map<string, string[]>}
17534
+ */
17535
+ function readFullIndex(cwd) {
17536
+ const index = new Map();
17537
+ try {
17538
+ const data = JSON.parse(fs.readFileSync(indexPath(cwd), 'utf8'));
17539
+ if (!data || data.schema !== SCHEMA || !data.files) return index;
17540
+ for (const [rel, sigs] of Object.entries(data.files)) {
17541
+ if (Array.isArray(sigs) && sigs.length > 0) index.set(rel, sigs);
17542
+ }
17543
+ } catch (_) { /* absent or corrupt → caller falls back to the context file */ }
17544
+ return index;
17545
+ }
17546
+
17547
+ module.exports = { writeFullIndex, readFullIndex, indexPath, SCHEMA, INDEX_FILE };
17126
17548
 
17127
17549
  };
17128
17550
 
@@ -21214,7 +21636,7 @@ function __tryGit(args, opts = {}) {
21214
21636
  catch (_) { return ''; }
21215
21637
  }
21216
21638
 
21217
- const VERSION = '8.28.0';
21639
+ const VERSION = '8.29.0';
21218
21640
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
21219
21641
 
21220
21642
  function requireSourceOrBundled(key) {
@@ -21351,10 +21773,70 @@ function buildFileList(cwd, config) {
21351
21773
  const found = walkDir(abs, config.exclude, config.maxDepth);
21352
21774
  files.push(...found);
21353
21775
  }
21776
+ files.push(...declaredEntrypoints(cwd, config, files));
21354
21777
  // Deduplicate
21355
21778
  return [...new Set(files)];
21356
21779
  }
21357
21780
 
21781
+ /**
21782
+ * Source files a project declares as its own entrypoints in package.json
21783
+ * (`main` and every `bin` target), when they live outside srcDirs.
21784
+ *
21785
+ * A CLI's entrypoint is usually at the repo root, so a srcDirs of [src] left
21786
+ * it unindexed and therefore unreachable by `sigmap ask` — on this repo that
21787
+ * was gen-context.js, which holds the whole generator pipeline. Declared
21788
+ * entrypoints are load-bearing by definition, so they are always scanned.
21789
+ */
21790
+ function collectTestEntries(cwd, config, existing) {
21791
+ const TEST_ROOTS = ['test', 'tests', '__tests__', 'spec', 'e2e'];
21792
+ const have = new Set((existing || []).map((e) => e.filePath));
21793
+ const out = [];
21794
+ let moduleDocSig = null;
21795
+ try { ({ moduleDocSig } = requireSourceOrBundled('./src/retrieval/module-doc')); } catch (_) {}
21796
+ for (const root of TEST_ROOTS) {
21797
+ const abs = path.join(cwd, root);
21798
+ if (!fs.existsSync(abs)) continue;
21799
+ let files = [];
21800
+ try { files = walkDir(abs, config.exclude, config.maxDepth); } catch (_) { continue; }
21801
+ for (const fp of files) {
21802
+ if (have.has(fp)) continue;
21803
+ let src = '';
21804
+ try { src = fs.readFileSync(fp, 'utf8'); } catch (_) { continue; }
21805
+ let sigs = [];
21806
+ try {
21807
+ const { extractFile } = requireSourceOrBundled('./src/extractors/dispatch');
21808
+ sigs = extractFile(fp, src) || [];
21809
+ } catch (_) { continue; }
21810
+ if (!sigs.length) continue;
21811
+ const doc = moduleDocSig ? moduleDocSig(src, fp) : '';
21812
+ out.push({ filePath: fp, sigs: doc ? [doc, ...sigs] : sigs });
21813
+ }
21814
+ }
21815
+ return out;
21816
+ }
21817
+
21818
+ function declaredEntrypoints(cwd, config, existing) {
21819
+ const out = [];
21820
+ try {
21821
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
21822
+ const have = new Set(existing);
21823
+ const refs = [];
21824
+ if (typeof pkg.main === 'string') refs.push(pkg.main);
21825
+ if (typeof pkg.bin === 'string') refs.push(pkg.bin);
21826
+ else if (pkg.bin && typeof pkg.bin === 'object') refs.push(...Object.values(pkg.bin).filter((v) => typeof v === 'string'));
21827
+ for (const ref of refs) {
21828
+ const abs = path.resolve(cwd, ref);
21829
+ if (have.has(abs) || out.includes(abs)) continue;
21830
+ const rel = path.relative(cwd, abs);
21831
+ if (!rel || rel.startsWith('..')) continue; // outside the repo
21832
+ if ((config.exclude || []).some((x) => rel.split(path.sep).includes(x))) continue;
21833
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
21834
+ out.push(abs);
21835
+ }
21836
+ } catch (_) { /* no package.json, or unreadable → nothing to add */ }
21837
+ return out;
21838
+ }
21839
+
21358
21840
  // ---------------------------------------------------------------------------
21359
21841
  // Extractor loader (lazy, cached)
21360
21842
  // ---------------------------------------------------------------------------
@@ -22626,6 +23108,60 @@ function runGenerate(cwd, config, reportMode, reportJson = false) {
22626
23108
 
22627
23109
  let result;
22628
23110
  if (!reportMode) {
23111
+ // Retrieval index (complete, unbudgeted) — written BEFORE applyTokenBudget
23112
+ // and before the strategy split, so it holds every extracted file for every
23113
+ // strategy. The generated context file is a budgeted VIEW for prompt
23114
+ // injection; the ranker must not inherit that budget or files dropped to fit
23115
+ // maxTokens become permanently unreachable by `sigmap ask`.
23116
+ try {
23117
+ const __store = requireSourceOrBundled('./src/retrieval/sig-index-store');
23118
+ // Honour --terse: `sigmap ask` renders .context/query-context.md straight
23119
+ // from these signatures, and that IS prompt-bound, so the user's format
23120
+ // choice has to survive into the index. Terse encoding preserves line
23121
+ // anchors byte-exactly, so get_lines still resolves.
23122
+ // Index-only enrichment: a module's leading comment describes its PURPOSE
23123
+ // in prose, which is the vocabulary a behavioural query actually uses.
23124
+ // Signatures carry shape, not intent — `coverageScore(cwd, fileEntries, config)`
23125
+ // shares no token with "what fraction of the repo made it into the output",
23126
+ // but that file's header says exactly that. Added to the retrieval index
23127
+ // ONLY: the prompt artifact is token-budgeted, the index is not.
23128
+ let __entries = fileEntries;
23129
+ try {
23130
+ // TRIED AND REJECTED: also indexing every per-symbol doc sentence
23131
+ // untruncated (src/retrieval/doc-text.js). 39% of extractor doc hints are
23132
+ // cut at 60 chars, so recovering them looked like free vocabulary. It is
23133
+ // not: train hit@5 fell 75.6% -> 73.3% at every docWeight from 0.2 to 1.0,
23134
+ // and the mined corpus never moved off 62.5%. The MODULE HEADER is the
23135
+ // high-signal prose — it states the file's purpose. Per-symbol sentences
23136
+ // describe internal helpers, so they broaden what each file matches
23137
+ // without making any file a better answer.
23138
+ const { moduleDocSig } = requireSourceOrBundled('./src/retrieval/module-doc');
23139
+ __entries = __entries.map((e) => {
23140
+ let src = e.content;
23141
+ if (typeof src !== 'string') { try { src = fs.readFileSync(e.filePath, 'utf8'); } catch (_) { src = ''; } }
23142
+ const doc = moduleDocSig(src, e.filePath);
23143
+ return doc ? Object.assign({}, e, { sigs: [doc, ...e.sigs] }) : e;
23144
+ });
23145
+ } catch (_) { /* enrichment is best-effort */ }
23146
+ if (config && config.terse) {
23147
+ try {
23148
+ const { encodeTerseSigs } = requireSourceOrBundled('./src/format/terse');
23149
+ __entries = fileEntries.map((e) => Object.assign({}, e, { sigs: encodeTerseSigs(e.sigs) }));
23150
+ } catch (_) { /* terse unavailable → index full signatures */ }
23151
+ }
23152
+ // Test files: indexed, never rendered into the prompt. They were the one
23153
+ // whole category `sigmap ask` could not reach at all — srcDirs excludes
23154
+ // them, so "where are the tests for X" had no answer at any rank. The
23155
+ // prompt artifact stays clean because this list never reaches formatOutput.
23156
+ try {
23157
+ __entries = __entries.concat(collectTestEntries(cwd, config, __entries));
23158
+ } catch (_) { /* best-effort */ }
23159
+ const __w = __store.writeFullIndex(cwd, __entries, { version: VERSION });
23160
+ if (process.argv.includes('--verbose')) {
23161
+ console.warn(`[sigmap] retrieval index: ${__w.files} file(s) → ${path.relative(cwd, __w.path)}`);
23162
+ }
23163
+ } catch (_) { /* non-fatal: ranker falls back to parsing the context file */ }
23164
+
22629
23165
  if (strategy === 'per-module') {
22630
23166
  result = runPerModuleStrategy(cwd, configWithBudget, fileEntries, inputTokenTotal);
22631
23167
  } else if (strategy === 'hot-cold') {
@@ -23312,14 +23848,12 @@ function getRawTokenCount(cwd, config) {
23312
23848
  return total;
23313
23849
  }
23314
23850
 
23315
- function getIntentWeights(intent) {
23851
+ // Intent no longer selects scoring weights — the per-intent multipliers were
23852
+ // measured to have zero effect on ranking (see src/retrieval/ranker.js).
23853
+ // Kept as a single call site so the ask handler keeps one weights source.
23854
+ function getIntentWeights(_intent) {
23316
23855
  const { DEFAULT_WEIGHTS } = requireSourceOrBundled('./src/retrieval/ranker');
23317
- const base = Object.assign({}, DEFAULT_WEIGHTS);
23318
- if (intent === 'debug') return Object.assign({}, base, { recencyBoost: base.recencyBoost * 1.5 });
23319
- if (intent === 'explain') return Object.assign({}, base, { symbolMatch: base.symbolMatch * 1.5 });
23320
- if (intent === 'refactor') return Object.assign({}, base, { pathMatch: base.pathMatch * 1.5 });
23321
- if (intent === 'review') return Object.assign({}, base, { exactToken: base.exactToken * 1.3 });
23322
- return base;
23856
+ return Object.assign({}, DEFAULT_WEIGHTS);
23323
23857
  }
23324
23858
 
23325
23859
  function extractQuerySymbols(query) {
@@ -23494,7 +24028,7 @@ function main() {
23494
24028
  process.exit(1);
23495
24029
  }
23496
24030
 
23497
- const { detectIntent, buildSigIndex, rank } = requireSourceOrBundled('./src/retrieval/ranker');
24031
+ const { detectIntent, detectIntents, buildSigIndex, rank } = requireSourceOrBundled('./src/retrieval/ranker');
23498
24032
  const { coverageScore } = requireSourceOrBundled('./src/analysis/coverage-score');
23499
24033
  const { loadSession, saveSession, mergeSessionContext } = requireSourceOrBundled('./src/session/memory');
23500
24034
  const { detectWorkspaces, inferPackage, scopeToPackage } = requireSourceOrBundled('./src/workspace/detector');
@@ -23671,7 +24205,7 @@ function main() {
23671
24205
  console.log([
23672
24206
  bar,
23673
24207
  ` sigmap ask "${query}"`,
23674
- ` Intent : ${intent}`,
24208
+ ` Intent : ${detectIntents(query).join(', ')}`,
23675
24209
  ` Context : ${ctxTok.toLocaleString()} tokens → ${path.relative(cwd, outPath)}`,
23676
24210
  ` Coverage : ${coveragePct}%`,
23677
24211
  ` Risk : ${riskLevel}`,