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/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
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
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
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
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
|
|
4780
|
-
const
|
|
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
|
-
//
|
|
11349
|
-
//
|
|
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
|
|
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
|
|
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
|
|
11581
|
-
|
|
11582
|
-
|
|
11583
|
-
|
|
11584
|
-
path.
|
|
11585
|
-
|
|
11586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12305
|
-
|
|
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.
|
|
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
|
-
|
|
16407
|
-
|
|
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) +
|
|
16410
|
-
|
|
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
|
-
//
|
|
16549
|
-
|
|
16550
|
-
|
|
16551
|
-
|
|
16552
|
-
|
|
16553
|
-
|
|
16554
|
-
|
|
16555
|
-
|
|
16556
|
-
|
|
16557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
//
|
|
16614
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
16697
|
-
|
|
16698
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
16759
|
-
|
|
16760
|
-
|
|
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
|
-
|
|
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(); //
|
|
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
|
|
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
|
-
|
|
16777
|
-
|
|
16778
|
-
const idx =
|
|
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(
|
|
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
|
|
16789
|
-
if (
|
|
16790
|
-
const neighbors = graph.forward
|
|
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
|
-
|
|
16793
|
-
if (
|
|
16794
|
-
|
|
16795
|
-
const idx =
|
|
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
|
-
|
|
16813
|
-
|
|
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
|
|
16818
|
-
|
|
16819
|
-
if (_isHub(
|
|
16820
|
-
const
|
|
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
|
-
|
|
16980
|
-
|
|
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(
|
|
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|
|
|
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(
|
|
17113
|
-
integrate:/\b(
|
|
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
|
-
|
|
17118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 : ${
|
|
24208
|
+
` Intent : ${detectIntents(query).join(', ')}`,
|
|
23675
24209
|
` Context : ${ctxTok.toLocaleString()} tokens → ${path.relative(cwd, outPath)}`,
|
|
23676
24210
|
` Coverage : ${coveragePct}%`,
|
|
23677
24211
|
` Risk : ${riskLevel}`,
|