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/AGENTS.md +539 -824
- package/CHANGELOG.md +41 -0
- package/README.md +2 -2
- package/gen-context.js +685 -151
- package/llms-full.txt +2 -2
- package/llms.txt +2 -2
- package/package.json +5 -3
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/eval/runner.js +18 -49
- package/src/graph/builder.js +24 -15
- package/src/graph/call-graph.js +7 -3
- package/src/graph/path-key.js +26 -0
- package/src/mcp/server.js +1 -1
- package/src/retrieval/bm25.js +56 -6
- package/src/retrieval/module-doc.js +120 -0
- package/src/retrieval/ranker.js +204 -67
- package/src/retrieval/sig-index-store.js +95 -0
package/src/retrieval/ranker.js
CHANGED
|
@@ -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
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
//
|
|
112
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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(); //
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
const idx =
|
|
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(
|
|
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
|
|
287
|
-
if (
|
|
288
|
-
const neighbors = graph.forward
|
|
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
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
const idx =
|
|
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
|
-
|
|
311
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
if (_isHub(
|
|
318
|
-
const
|
|
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
|
-
|
|
478
|
-
|
|
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(
|
|
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|
|
|
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(
|
|
611
|
-
integrate:/\b(
|
|
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
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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 };
|