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.
@@ -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 = {
@@ -61,14 +72,69 @@ const PENALTY_SIGNALS = {
61
72
  generatedCode: 0.3, // dist/build/.next in path
62
73
  docsFile: 0.2, // docs/doc/README in path
63
74
  nodeModules: 0.0, // node_modules (zero score)
75
+ dataHolder: 0.3, // generated POJO/entity: almost entirely accessors
64
76
  };
65
77
 
66
- function _computePenalty(filePath) {
78
+ // A file whose members are overwhelmingly trivial accessors is a data holder,
79
+ // not logic. Path-based detection cannot see these: generated JPA/MyBatis
80
+ // entities live in ordinary source trees. They match a query on any column
81
+ // name they happen to carry (`getNote`/`setNote` matches "note" as strongly as
82
+ // the service that actually implements order notes), so on an entity-heavy
83
+ // repo they crowd real code out of the top results.
84
+ const ACCESSOR_RE = /^\s*(get|set|is)[A-Z]\w*\s*\(/;
85
+ const DATA_HOLDER_RATIO = 0.8;
86
+ const DATA_HOLDER_MIN_MEMBERS = 6;
87
+
88
+ // Query terms that mean the penalised category IS the target. Read from the
89
+ // query tokens directly, NOT via detectIntent: that classifier is first-match-
90
+ // wins over its pattern object, and `debug` precedes `test`, so "fix the failing
91
+ // test" classifies as debug and never reaches the test branch.
92
+ const WANTS_TESTS = new Set(['test', 'tests', 'spec', 'specs', 'unit', 'integration', 'e2e', 'assertion', 'assert', 'mock', 'fixture', 'coverage', 'testing']);
93
+ const WANTS_DOCS = new Set(['doc', 'docs', 'documentation', 'readme', 'changelog', 'guide', 'tutorial']);
94
+ const WANTS_MODELS = new Set(['entity', 'entities', 'model', 'models', 'pojo', 'dto', 'bean', 'getter', 'getters', 'setter', 'setters', 'accessor', 'accessors', 'field', 'fields', 'column', 'columns', 'schema']);
95
+
96
+ /** Which penalised categories the query is explicitly asking for. */
97
+ function _queryWants(queryTokens) {
98
+ const wants = { tests: false, docs: false, models: false };
99
+ for (const t of queryTokens || []) {
100
+ if (WANTS_TESTS.has(t)) wants.tests = true;
101
+ if (WANTS_DOCS.has(t)) wants.docs = true;
102
+ if (WANTS_MODELS.has(t)) wants.models = true;
103
+ }
104
+ return wants;
105
+ }
106
+
107
+ /**
108
+ * True when a file's members are overwhelmingly trivial accessors — a generated
109
+ * entity or POJO rather than logic. Type declarations are excluded from the
110
+ * ratio so a small class is not misjudged by its own `class X` line.
111
+ */
112
+ function _isDataHolder(sigs) {
113
+ if (!Array.isArray(sigs)) return false;
114
+ const members = sigs.filter((line) => /^\s/.test(line) || !/^(class|interface|enum|struct|function|module\.exports)\b/.test(line));
115
+ if (members.length < DATA_HOLDER_MIN_MEMBERS) return false;
116
+ const accessors = members.filter((line) => ACCESSOR_RE.test(line)).length;
117
+ return accessors / members.length >= DATA_HOLDER_RATIO;
118
+ }
119
+
120
+ function _computePenalty(filePath, wants, sigs) {
67
121
  const pathLower = filePath.toLowerCase();
68
122
  if (pathLower.includes('node_modules')) return PENALTY_SIGNALS.nodeModules;
69
- if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.testFile;
123
+ // A penalty must never fire on the very thing the user asked for. Before
124
+ // this, "write tests for the ranker" multiplied every test file by 0.4 —
125
+ // the query and the penalty were pulling in opposite directions.
126
+ if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower) || /\.(test|spec)\./.test(pathLower)) {
127
+ return (wants && wants.tests) ? 1.0 : PENALTY_SIGNALS.testFile;
128
+ }
70
129
  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;
130
+ if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower)) {
131
+ return (wants && wants.docs) ? 1.0 : PENALTY_SIGNALS.docsFile;
132
+ }
133
+ // Content-based, and last: a data holder is still a real source file, so it
134
+ // is only demoted once the path-based categories have had their say.
135
+ if (_isDataHolder(sigs)) {
136
+ return (wants && wants.models) ? 1.0 : PENALTY_SIGNALS.dataHolder;
137
+ }
72
138
  return 1.0;
73
139
  }
74
140
 
@@ -87,6 +153,26 @@ function _computeHubs(graph) {
87
153
  }
88
154
 
89
155
  // Common utility paths that should be treated as hubs regardless of fanout
156
+ // The graph builders disagree on key case: src/graph/builder.js lowercases every
157
+ // node (normalizePath), while src/graph/call-graph.js keys by a case-preserving
158
+ // path.resolve. Assuming either one breaks the other, so every graph lookup in
159
+ // this file probes both forms — the same thing the centrality blend already does.
160
+ function _graphKeys(p) {
161
+ const norm = require('path').normalize(p);
162
+ const lower = norm.toLowerCase();
163
+ return lower === norm ? [norm] : [norm, lower];
164
+ }
165
+ function _graphGet(map, absPath) {
166
+ for (const k of _graphKeys(absPath)) {
167
+ const hit = map.get(k);
168
+ if (hit !== undefined) return hit;
169
+ }
170
+ return undefined;
171
+ }
172
+ function _registerKeys(map, absPath, value) {
173
+ for (const k of _graphKeys(absPath)) if (!map.has(k)) map.set(k, value);
174
+ }
175
+
90
176
  function _isHub(filePath) {
91
177
  return /\/(utils|helpers|shared|common|constants|types|interfaces|index|zzz|globals)\.(ts|tsx|js|jsx|r|R)$/.test(filePath)
92
178
  || filePath.endsWith('/index.ts') || filePath.endsWith('/index.js')
@@ -102,14 +188,18 @@ function _isHub(filePath) {
102
188
  * @param {object} weights
103
189
  * @returns {{ score: number, signals: { exactToken: number, symbolMatch: number, prefixMatch: number, pathMatch: number, penalty: number } }}
104
190
  */
105
- function scoreFile(filePath, sigs, queryTokens, weights) {
191
+ function scoreFile(filePath, sigs, queryTokens, weights, wants) {
106
192
  if (!sigs || sigs.length === 0) return { score: 0, signals: { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: 1.0 } };
107
193
 
108
194
  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(' ');
195
+ const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath, wants, sigs) };
196
+
197
+ // Module-doc prose is excluded here on purpose. This signal measures overlap
198
+ // with DECLARED IDENTIFIERS; prose relevance is BM25's job, where it is scored
199
+ // as its own weighted field. Letting descriptive text inflate the identifier
200
+ // signal double-counts it and measurably degraded MRR.
201
+ const codeSigs = sigs.filter((line) => !MODULE_DOC_RE.test(line));
202
+ const sigText = codeSigs.join(' ');
113
203
  const sigTokenSet = new Set(tokenize(sigText));
114
204
 
115
205
  // Build token set from the file path
@@ -127,7 +217,7 @@ function scoreFile(filePath, sigs, queryTokens, weights) {
127
217
  signals.exactToken += bonus;
128
218
 
129
219
  // Bonus: appears directly in a function/class/method name line
130
- const nameLineMatch = sigs.some((sig) => {
220
+ const nameLineMatch = codeSigs.some((sig) => {
131
221
  const nt = tokenize(sig.replace(/[^a-zA-Z0-9_\s]/g, ' '));
132
222
  return nt.includes(qt);
133
223
  });
@@ -189,13 +279,18 @@ function rank(query, sigIndex, opts) {
189
279
  const graph = (opts && opts.graph && opts.graph.forward instanceof Map) ? opts.graph : null;
190
280
  const cwd = (opts && opts.cwd) || null;
191
281
 
192
- // Detect query intent and get appropriate weights
282
+ // Intent is reported to the user and shapes output depth; it no longer
283
+ // selects scoring weights (see SIGNAL_BLEND).
193
284
  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;
285
+ const weights = (opts && opts.weights) ? Object.assign({}, DEFAULT_WEIGHTS, opts.weights) : DEFAULT_WEIGHTS;
286
+ // Learned per-file multipliers are a LOCAL, evolving signal (.context/weights.json).
287
+ // Benchmarks and CI gates must opt out via { learned: false }, or a developer's
288
+ // local learned state silently changes the score and CI stops being reproducible.
289
+ const useLearned = !(opts && opts.learned === false);
290
+ const learnedWeights = opts && opts.cwd && useLearned ? loadWeights(opts.cwd) : null;
197
291
 
198
292
  const queryTokens = tokenize(query);
293
+ const queryWants = _queryWants(queryTokens);
199
294
  if (queryTokens.length === 0) {
200
295
  // Empty query: return top-K by file count (most signatures = most useful)
201
296
  const all = [];
@@ -212,18 +307,32 @@ function rank(query, sigIndex, opts) {
212
307
  // are matched. The existing negative-signal penalty and recency/graph/learned
213
308
  // boosts are layered on top; the per-token signals stay for the explain table.
214
309
  const bm25Scores = new Map();
215
- for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })))) {
310
+ for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })), opts)) {
216
311
  bm25Scores.set(c.file, c.score);
217
312
  }
218
313
 
219
- const scored = [];
314
+ // Two passes: scoreFile's weighted signal needs the max across the corpus to
315
+ // normalise against, so collect first, then combine.
316
+ const prescored = [];
317
+ let maxSignal = 0;
220
318
  for (const [file, sigs] of sigIndex.entries()) {
221
- const result = scoreFile(file, sigs, queryTokens, weights);
319
+ const result = scoreFile(file, sigs, queryTokens, weights, queryWants);
320
+ if (result.score > maxSignal) maxSignal = result.score;
321
+ prescored.push({ file, sigs, result });
322
+ }
323
+
324
+ const scored = [];
325
+ for (const { file, sigs, result } of prescored) {
222
326
  const penalty = result.signals.penalty;
223
327
  const base = bm25Scores.get(file) || 0;
224
- let score = base * penalty;
328
+ // Blend the weighted keyword/symbol/path signal into the BM25 base. This
329
+ // was previously computed and thrown away — `result.score` was never read,
330
+ // which silently made DEFAULT_WEIGHTS and every intent profile dead config.
331
+ const signalNorm = maxSignal > 0 ? result.score / maxSignal : 0;
332
+ let score = base * penalty * (1 + SIGNAL_BLEND * signalNorm);
225
333
  const signals = result.signals;
226
334
  signals.bm25 = base;
335
+ signals.signalBlend = signalNorm;
227
336
 
228
337
  // Recency boost
229
338
  if (recencySet && recencySet.has(file) && score > 0) {
@@ -253,44 +362,46 @@ function rank(query, sigIndex, opts) {
253
362
  // Hub suppression: files with high fanout (>20%) are not boosted
254
363
  if (graph && cwd) {
255
364
  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();
365
+ // Every graph node is keyed by `path.normalize(p).toLowerCase()` (see
366
+ // normalizePath in src/graph/builder.js and src/graph/call-graph.js).
367
+ // Lookups MUST use the same key space: a bare path.resolve() preserves
368
+ // case, so on any repo whose absolute path contains an uppercase letter
369
+ // every .get() missed and this entire block was silently inert.
370
+ const keyToIdx = new Map();
259
371
  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);
372
+ _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
263
373
  }
264
374
 
265
375
  const hubs = _computeHubs(graph);
266
- const hop1Files = new Set(); // track which files received hop1 boost
376
+ const hop1Files = new Set(); // normalised keys that received a hop1 boost
377
+ const hop1Seeds = []; // original (un-normalised) paths, for hop-2 lookup
267
378
 
268
379
  // Hop 1: direct neighbors of scored files
269
380
  for (const entry of scored) {
270
381
  if (entry.score <= 0) continue;
271
- const abs = path.resolve(cwd, entry.file);
272
- const neighbors = graph.forward.get(abs) || [];
382
+ const neighbors = _graphGet(graph.forward, path.resolve(cwd, entry.file)) || [];
273
383
  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);
384
+ const nk = path.normalize(neighborAbs);
385
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
386
+ const idx = _graphGet(keyToIdx, nk);
277
387
  if (idx !== undefined) {
278
388
  scored[idx].score += GRAPH_BOOST_AMOUNTS.hop1;
279
389
  scored[idx].signals.graphBoost = (scored[idx].signals.graphBoost || 0) + GRAPH_BOOST_AMOUNTS.hop1;
280
- hop1Files.add(neighborAbs);
390
+ hop1Files.add(nk);
391
+ hop1Seeds.push(neighborAbs);
281
392
  }
282
393
  }
283
394
  }
284
395
 
285
396
  // 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) || [];
397
+ for (const hop1Key of hop1Seeds) {
398
+ if (_graphGet(keyToIdx, hop1Key) === undefined) continue; // skip files not in index
399
+ const neighbors = _graphGet(graph.forward, hop1Key) || [];
289
400
  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);
401
+ const nk = path.normalize(neighborAbs);
402
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
403
+ if (hop1Files.has(nk)) continue; // skip already hop1-boosted
404
+ const idx = _graphGet(keyToIdx, nk);
294
405
  if (idx !== undefined && scored[idx].score > 0) {
295
406
  // Only boost files that have some baseline score (not noise)
296
407
  scored[idx].score += GRAPH_BOOST_AMOUNTS.hop2;
@@ -307,16 +418,17 @@ function rank(query, sigIndex, opts) {
307
418
  const callGraph = (opts && opts.callGraph && opts.callGraph.forward instanceof Map) ? opts.callGraph : null;
308
419
  if (callGraph && cwd) {
309
420
  const path = require('path');
310
- const relToIdx = new Map();
311
- for (let i = 0; i < scored.length; i++) relToIdx.set(scored[i].file, i);
421
+ // buildCallFileGraph keys by a CASE-PRESERVING path.resolve, unlike the
422
+ // import graph builder which lowercases hence the dual-form probe.
423
+ const keyToIdx = new Map();
424
+ for (let i = 0; i < scored.length; i++) _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
312
425
  const hubs = _computeHubs(callGraph);
313
426
  const seeds = scored.filter((e) => e.score > 0).map((e) => e.file);
314
427
  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);
428
+ for (const neighborAbs of (_graphGet(callGraph.forward, path.resolve(cwd, file)) || [])) {
429
+ const nk = path.normalize(neighborAbs);
430
+ if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
431
+ const idx = _graphGet(keyToIdx, nk);
320
432
  if (idx !== undefined && scored[idx].file !== file) {
321
433
  scored[idx].score += GRAPH_BOOST_AMOUNTS.callHop;
322
434
  scored[idx].signals.callGraphBoost = (scored[idx].signals.callGraphBoost || 0) + GRAPH_BOOST_AMOUNTS.callHop;
@@ -490,6 +602,18 @@ function _enrichSigIndexFromStrategy(cwd, index) {
490
602
  }
491
603
  } catch (_) {}
492
604
  _mergeSigIndex(index, _buildSigIndexFromCache(cwd));
605
+
606
+ // The complete retrieval index (written by generate before applyTokenBudget)
607
+ // takes precedence: it is the only source containing files the budget dropped,
608
+ // and full signatures for files the budget collapsed to line anchors. It is
609
+ // merged as the BASE rather than on top because _mergeSigIndex only replaces
610
+ // when the source has MORE signatures — a collapsed entry has the same count
611
+ // as its full form, so merging the other way would keep the anchors.
612
+ try {
613
+ const full = require('./sig-index-store').readFullIndex(cwd);
614
+ if (full.size > 0) return _mergeSigIndex(full, index);
615
+ } catch (_) { /* absent → budgeted view is still served */ }
616
+
493
617
  return index;
494
618
  }
495
619
 
@@ -615,22 +739,53 @@ function formatRankJSON(results, query) {
615
739
  // ---------------------------------------------------------------------------
616
740
  // Intent detection — 7 intents
617
741
  // ---------------------------------------------------------------------------
742
+ // Nouns carry an optional plural: `\btest\b` does not match "tests", so
743
+ // "write unit tests for the ranker" matched NO intent at all and fell through
744
+ // to the 'search' default.
618
745
  const INTENT_PATTERNS = {
619
- debug: /\b(bug|fix|error|crash|exception|broken|failing|issue|problem|regression)\b/i,
746
+ debug: /\b(bugs?|fix(es|ed)?|errors?|crash(es)?|exceptions?|broken|failing|failures?|issues?|problems?|regressions?)\b/i,
620
747
  explain: /\b(explain|how does|what is|understand|overview|architecture|describe|walk me|teach)\b/i,
621
- refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimize)\b/i,
748
+ refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimi[sz]e)\b/i,
622
749
  review: /\b(review|check|audit|security|pr|pull request|assess|validate)\b/i,
623
- test: /\b(test|unit test|integration test|testing|spec|assert|mock)\b/i,
624
- integrate:/\b(import|integrate|connect|wire|bind|require|export|depend|graph)\b|require[ds]\b/i,
750
+ test: /\b(tests?|unit tests?|integration tests?|testing|specs?|assert(ion)?s?|mocks?|fixtures?)\b/i,
751
+ integrate:/\b(imports?|integrate|connect|wire|bind|requires?|exports?|depends?|dependenc(y|ies)|graph)\b/i,
625
752
  navigate: /\b(find|locate|where|search|look for|show me|navigate|browse|list)\b/i,
626
753
  };
627
754
 
628
- function detectIntent(query) {
629
- if (!query || typeof query !== 'string') return 'search';
755
+ /**
756
+ * Every intent whose pattern matches, strongest first.
757
+ *
758
+ * A real request is routinely multi-intent — "fix the failing test" is both a
759
+ * debug task and a test task — and reporting one label discards that. Worse,
760
+ * the single-label version returned the FIRST key in INTENT_PATTERNS order, so
761
+ * `debug` permanently shadowed `test`: no query containing "fix" or "failing"
762
+ * could ever be labelled a test, no matter how test-shaped it was.
763
+ *
764
+ * Ranked by how many distinct terms each pattern matched, so the dominant
765
+ * intent leads; ties fall back to declaration order for determinism.
766
+ *
767
+ * @param {string} query
768
+ * @returns {string[]} matched intents, never empty (defaults to ['search'])
769
+ */
770
+ function detectIntents(query) {
771
+ if (!query || typeof query !== 'string') return ['search'];
772
+ const scored = [];
773
+ let order = 0;
630
774
  for (const [intent, re] of Object.entries(INTENT_PATTERNS)) {
631
- if (re.test(query)) return intent;
775
+ const hits = query.match(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'));
776
+ if (hits && hits.length) {
777
+ scored.push({ intent, hits: new Set(hits.map((h) => h.toLowerCase())).size, order: order });
778
+ }
779
+ order++;
632
780
  }
633
- return 'search';
781
+ if (scored.length === 0) return ['search'];
782
+ scored.sort((a, b) => (b.hits - a.hits) || (a.order - b.order));
783
+ return scored.map((s) => s.intent);
784
+ }
785
+
786
+ /** Primary intent. Kept for callers that want a single label. */
787
+ function detectIntent(query) {
788
+ return detectIntents(query)[0];
634
789
  }
635
790
 
636
- module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
791
+ module.exports = { rank, buildSigIndex, scoreFile, _queryWants, _isDataHolder, 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 };
@@ -34,6 +34,24 @@ const SKILLS = {
34
34
  '6. **Watch the budget.** Check the `get_budget` MCP tool or `sigmap budget` (estimates from SigMap\'s local ledger — no LLM calls). Near the budget: summarize-then-drop older context instead of accumulating, and prefer terse output.',
35
35
  ].join('\n'),
36
36
  },
37
+ 'sigmap-task': {
38
+ title: 'SigMap task loop',
39
+ kind: 'prompt',
40
+ description: 'Do a coding task grounded in SigMap: look up before reading, edit by line anchor, verify before reporting.',
41
+ argumentHint: 'the change you want, in plain words',
42
+ body: [
43
+ 'Work through these steps **in order**. Do not open any file before step 2.',
44
+ 'Every command runs from the integrated terminal — do not ask the user to run them for you.',
45
+ '',
46
+ '1. **Look up, do not search.** `npx sigmap ask "<the task>"` — this writes `.context/query-context.md`.',
47
+ '2. **Read the map.** `cat .context/query-context.md`. It ranks the relevant files and lists their signatures with `:start-end` line anchors — a few hundred tokens where the same files read whole are tens of thousands. Say which files it surfaced before continuing. If nothing relevant appears, re-run step 1 with different wording; fall back to search only after two attempts, and say so.',
48
+ '3. **Open only the anchored ranges.** A signature ending `:425-425` means read line 425, not the whole file. Never read a file in full when you hold an anchor for it.',
49
+ '4. **Make the change.** Follow the conventions visible in the signatures — same layering, same response wrapper, same annotation style. Add no dependencies.',
50
+ '5. **Verify before reporting.** Write what you changed to `.sigmap-notes.md`, naming every file by its **full repository-relative path** (a bare filename is reported as fake), then run `npx sigmap verify-ai-output .sigmap-notes.md`. It checks every name against the real index, offline, with no model call. Fix anything it flags and re-run before you reply.',
51
+ '6. **Refresh the map.** `npx sigmap` — your edits made it stale.',
52
+ '7. **Report.** The files you changed, the ranges you actually read, the step-1 token count, and the step-5 verify result. Say so if you fell back to searching or if verify flagged something.',
53
+ ].join('\n'),
54
+ },
37
55
  'sigmap-config-optimizer': {
38
56
  title: 'SigMap config optimizer',
39
57
  description: 'Playbook for getting a correct SigMap config on any repo: detect with sigmap tune, review the per-change reasons, apply, validate.',
@@ -58,7 +76,9 @@ const SKILL_CLIENTS = {
58
76
  windsurf: { label: 'Windsurf', parent: ['.windsurf'],
59
77
  target: (cwd, skill) => path.join(cwd, '.windsurf', 'rules', `${skill}.md`) },
60
78
  copilot: { label: 'GitHub Copilot', parent: ['.github'],
61
- target: (cwd, skill) => path.join(cwd, '.github', 'instructions', `${skill}.instructions.md`) },
79
+ target: (cwd, skill) => (SKILLS[skill] && SKILLS[skill].kind === 'prompt'
80
+ ? path.join(cwd, '.github', 'prompts', `${skill}.prompt.md`)
81
+ : path.join(cwd, '.github', 'instructions', `${skill}.instructions.md`)) },
62
82
  codex: { label: 'Codex CLI (AGENTS.md)', parent: ['AGENTS.md'],
63
83
  target: (cwd) => path.join(cwd, 'AGENTS.md'), inject: true },
64
84
  };
@@ -79,6 +99,10 @@ function renderSkill(client, skillName, version) {
79
99
  return `---\ndescription: ${skill.description}\nalwaysApply: false\n---\n\n${body}`;
80
100
  }
81
101
  if (client === 'copilot') {
102
+ if (skill.kind === 'prompt') {
103
+ return `---\nname: ${skillName}\nagent: 'agent'\ndescription: ${skill.description}\n`
104
+ + `argument-hint: ${skill.argumentHint}\n---\n\n${body}`;
105
+ }
82
106
  return `---\napplyTo: "**"\n---\n\n${body}`;
83
107
  }
84
108
  return body; // windsurf: plain markdown