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.
- package/AGENTS.md +539 -824
- package/CHANGELOG.md +38 -0
- package/README.md +2 -2
- package/gen-context.js +755 -144
- 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/extractors/java.js +20 -4
- package/src/graph/builder.js +4 -3
- package/src/graph/call-graph.js +7 -3
- package/src/graph/path-key.js +26 -0
- package/src/mcp/install.js +29 -2
- 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 +220 -65
- package/src/retrieval/sig-index-store.js +95 -0
- package/src/skills/skills.js +25 -1
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');
|
|
@@ -6455,6 +6424,19 @@ __factories["./src/extractors/html"] = function(module, exports) {
|
|
|
6455
6424
|
__factories["./src/extractors/java"] = function(module, exports) {
|
|
6456
6425
|
|
|
6457
6426
|
const { lineAt, withAnchor } = __require('./src/extractors/line-anchor');
|
|
6427
|
+
const { capWithNotice, capMembersWithNotice } = __require('./src/util/truncate');
|
|
6428
|
+
|
|
6429
|
+
// Class bodies are scanned to this many characters. Generated JVM sources
|
|
6430
|
+
// (MyBatis/JPA entities) routinely run past 10KB, so the ceiling only guards
|
|
6431
|
+
// against pathological input rather than trimming ordinary classes.
|
|
6432
|
+
const MAX_CLASS_BODY_CHARS = 200000;
|
|
6433
|
+
|
|
6434
|
+
// Per-class member ceiling. Sits above the default `maxSigsPerFile` so the
|
|
6435
|
+
// caller's configured budget governs the output rather than this file.
|
|
6436
|
+
const MAX_MEMBERS_PER_CLASS = 120;
|
|
6437
|
+
|
|
6438
|
+
// Per-file signature ceiling, likewise above the configured default.
|
|
6439
|
+
const MAX_SIGS_PER_FILE = 200;
|
|
6458
6440
|
|
|
6459
6441
|
/**
|
|
6460
6442
|
* Extract signatures from Java source code.
|
|
@@ -6482,17 +6464,20 @@ __factories["./src/extractors/java"] = function(module, exports) {
|
|
|
6482
6464
|
const block = extractBlock(stripped, bodyStart);
|
|
6483
6465
|
sigs.push(hinted(withAnchor(`${m[1]} ${m[2]}`, lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)), m[2]));
|
|
6484
6466
|
for (const meth of extractMembers(block)) {
|
|
6485
|
-
|
|
6467
|
+
// The disclosure marker carries no offsets; anchor it at the class body.
|
|
6468
|
+
const declIdx = meth.declIdx || 0;
|
|
6469
|
+
const endIdx = meth.endIdx || 0;
|
|
6470
|
+
sigs.push(hinted(withAnchor(` ${meth.text}`, lineAt(stripped, bodyStart + declIdx), lineAt(stripped, bodyStart + endIdx)), meth.name));
|
|
6486
6471
|
}
|
|
6487
6472
|
}
|
|
6488
6473
|
|
|
6489
|
-
return sigs
|
|
6474
|
+
return capWithNotice(sigs, MAX_SIGS_PER_FILE, 'signatures');
|
|
6490
6475
|
}
|
|
6491
6476
|
|
|
6492
6477
|
function extractBlock(src, startIndex) {
|
|
6493
6478
|
let depth = 1;
|
|
6494
6479
|
let i = startIndex;
|
|
6495
|
-
const end = Math.min(src.length, startIndex +
|
|
6480
|
+
const end = Math.min(src.length, startIndex + MAX_CLASS_BODY_CHARS);
|
|
6496
6481
|
while (i < end && depth > 0) {
|
|
6497
6482
|
if (src[i] === '{') depth++;
|
|
6498
6483
|
else if (src[i] === '}') depth--;
|
|
@@ -6514,7 +6499,7 @@ __factories["./src/extractors/java"] = function(module, exports) {
|
|
|
6514
6499
|
endIdx: m.index + m[0].length,
|
|
6515
6500
|
});
|
|
6516
6501
|
}
|
|
6517
|
-
return members
|
|
6502
|
+
return capMembersWithNotice(members, MAX_MEMBERS_PER_CLASS);
|
|
6518
6503
|
}
|
|
6519
6504
|
|
|
6520
6505
|
function normalizeParams(params) {
|
|
@@ -11345,10 +11330,11 @@ __factories["./src/graph/builder"] = function(module, exports) {
|
|
|
11345
11330
|
const fs = require('fs');
|
|
11346
11331
|
const path = require('path');
|
|
11347
11332
|
|
|
11348
|
-
//
|
|
11349
|
-
//
|
|
11333
|
+
// Cross-platform node key. Delegates to the ONE shared definition so this graph
|
|
11334
|
+
// and the call-graph cannot drift apart again (see src/graph/path-key.js).
|
|
11335
|
+
const { graphKey } = __require('./src/graph/path-key');
|
|
11350
11336
|
function normalizePath(p) {
|
|
11351
|
-
return
|
|
11337
|
+
return graphKey(p);
|
|
11352
11338
|
}
|
|
11353
11339
|
|
|
11354
11340
|
// ---------------------------------------------------------------------------
|
|
@@ -11877,7 +11863,8 @@ __factories["./src/graph/call-graph"] = function(module, exports) {
|
|
|
11877
11863
|
'synchronized',
|
|
11878
11864
|
]);
|
|
11879
11865
|
|
|
11880
|
-
|
|
11866
|
+
const { graphKey } = __require('./src/graph/path-key');
|
|
11867
|
+
function normalizePath(p) { return graphKey(p); }
|
|
11881
11868
|
function toRel(cwd, f) { return path.relative(cwd, f).replace(/\\/g, '/'); }
|
|
11882
11869
|
function symId(cwd, absFile, name) { return `${toRel(cwd, absFile)}#${name}`; }
|
|
11883
11870
|
|
|
@@ -12309,8 +12296,11 @@ __factories["./src/graph/call-graph"] = function(module, exports) {
|
|
|
12309
12296
|
for (const calleeId of calleeIds) {
|
|
12310
12297
|
const calleeDef = graph.defs.get(calleeId);
|
|
12311
12298
|
if (!calleeDef || calleeDef.file === callerDef.file) continue;
|
|
12312
|
-
|
|
12313
|
-
|
|
12299
|
+
// Keyed through graphKey so the file-level call graph shares ONE key space
|
|
12300
|
+
// with the import graph. Previously this kept case while builder.js
|
|
12301
|
+
// lowercased, so a lookup correct for one silently missed on the other.
|
|
12302
|
+
const a = graphKey(path.resolve(cwd, callerDef.file));
|
|
12303
|
+
const b = graphKey(path.resolve(cwd, calleeDef.file));
|
|
12314
12304
|
add(a, b);
|
|
12315
12305
|
add(b, a);
|
|
12316
12306
|
}
|
|
@@ -12716,6 +12706,36 @@ __factories["./src/graph/impact"] = function(module, exports) {
|
|
|
12716
12706
|
|
|
12717
12707
|
};
|
|
12718
12708
|
|
|
12709
|
+
// ── ./src/graph/path-key ──
|
|
12710
|
+
__factories["./src/graph/path-key"] = function(module, exports) {
|
|
12711
|
+
|
|
12712
|
+
/**
|
|
12713
|
+
* The single definition of a graph node key.
|
|
12714
|
+
*
|
|
12715
|
+
* src/graph/builder.js and src/graph/call-graph.js each used to normalise paths
|
|
12716
|
+
* their own way — builder lowercased, call-graph did not — so a lookup written
|
|
12717
|
+
* for one silently missed on the other. That divergence disabled the import
|
|
12718
|
+
* boost on every repo whose path contains an uppercase letter, and later caused
|
|
12719
|
+
* a "fix" for one graph to break the other. Both now key through this function,
|
|
12720
|
+
* so there is one convention rather than two conventions and a convention.
|
|
12721
|
+
*
|
|
12722
|
+
* Lowercasing keeps lookups stable across case-insensitive filesystems (macOS,
|
|
12723
|
+
* Windows), where the same file legitimately arrives spelled two ways.
|
|
12724
|
+
*
|
|
12725
|
+
* Zero-dependency, pure, bundle-safe.
|
|
12726
|
+
*/
|
|
12727
|
+
|
|
12728
|
+
const path = require('path');
|
|
12729
|
+
|
|
12730
|
+
/** Canonical key for a filesystem path used as a graph node. */
|
|
12731
|
+
function graphKey(p) {
|
|
12732
|
+
return path.normalize(String(p)).toLowerCase();
|
|
12733
|
+
}
|
|
12734
|
+
|
|
12735
|
+
module.exports = { graphKey };
|
|
12736
|
+
|
|
12737
|
+
};
|
|
12738
|
+
|
|
12719
12739
|
// ── ./src/health/scorer ──
|
|
12720
12740
|
__factories["./src/health/scorer"] = function(module, exports) {
|
|
12721
12741
|
|
|
@@ -15246,6 +15266,7 @@ __factories["./src/mcp/install"] = function(module, exports) {
|
|
|
15246
15266
|
|
|
15247
15267
|
// Config shapes the supported clients use.
|
|
15248
15268
|
// - 'json' → { mcpServers: { sigmap: { command, args } } }
|
|
15269
|
+
// - 'vscode'→ { servers: { sigmap: { type: 'stdio', command, args } } }
|
|
15249
15270
|
// - 'zed' → { context_servers: { sigmap: { command: { path, args } } } }
|
|
15250
15271
|
// - 'yaml' → Codex CLI ~/.codex/config.yaml (mcpServers block, appended)
|
|
15251
15272
|
const CLIENTS = {
|
|
@@ -15254,7 +15275,7 @@ __factories["./src/mcp/install"] = function(module, exports) {
|
|
|
15254
15275
|
windsurf: { label: 'Windsurf', format: 'json', scope: 'both',
|
|
15255
15276
|
project: ['.windsurf', 'mcp.json'],
|
|
15256
15277
|
global: ['.codeium', 'windsurf', 'mcp_config.json'] },
|
|
15257
|
-
vscode: { label: 'VS Code', format: '
|
|
15278
|
+
vscode: { label: 'VS Code', format: 'vscode', scope: 'project', project: ['.vscode', 'mcp.json'] },
|
|
15258
15279
|
opencode: { label: 'OpenCode', format: 'json', scope: 'both',
|
|
15259
15280
|
project: ['opencode.json'],
|
|
15260
15281
|
global: ['.config', 'opencode', 'config.json'] },
|
|
@@ -15309,6 +15330,31 @@ __factories["./src/mcp/install"] = function(module, exports) {
|
|
|
15309
15330
|
return 'installed';
|
|
15310
15331
|
}
|
|
15311
15332
|
|
|
15333
|
+
/**
|
|
15334
|
+
* Install into VS Code's `.vscode/mcp.json`, which keys servers under `servers`
|
|
15335
|
+
* (not `mcpServers`) and expects an explicit transport `type`. A config written
|
|
15336
|
+
* by an older SigMap under `mcpServers` is migrated rather than left in place,
|
|
15337
|
+
* so re-running repairs it instead of leaving two entries VS Code cannot read.
|
|
15338
|
+
*/
|
|
15339
|
+
function _installVscode(filePath, scriptPath) {
|
|
15340
|
+
let settings = {};
|
|
15341
|
+
if (fs.existsSync(filePath)) {
|
|
15342
|
+
try { settings = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}; }
|
|
15343
|
+
catch (_) { settings = {}; }
|
|
15344
|
+
}
|
|
15345
|
+
const stale = settings.mcpServers && settings.mcpServers.sigmap;
|
|
15346
|
+
if (stale) {
|
|
15347
|
+
delete settings.mcpServers.sigmap;
|
|
15348
|
+
if (Object.keys(settings.mcpServers).length === 0) delete settings.mcpServers;
|
|
15349
|
+
}
|
|
15350
|
+
if (!settings.servers) settings.servers = {};
|
|
15351
|
+
if (settings.servers.sigmap && !stale) return 'already';
|
|
15352
|
+
settings.servers.sigmap = { type: 'stdio', command: 'node', args: serverArgs(scriptPath) };
|
|
15353
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
15354
|
+
fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + '\n');
|
|
15355
|
+
return stale ? 'updated' : 'installed';
|
|
15356
|
+
}
|
|
15357
|
+
|
|
15312
15358
|
/** Install into Zed's `context_servers` config (create file/dir if absent). */
|
|
15313
15359
|
function _installZed(filePath, scriptPath) {
|
|
15314
15360
|
let settings = {};
|
|
@@ -15361,7 +15407,8 @@ __factories["./src/mcp/install"] = function(module, exports) {
|
|
|
15361
15407
|
const filePath = resolveTarget(spec, cwd, home, opts.global);
|
|
15362
15408
|
|
|
15363
15409
|
let status;
|
|
15364
|
-
if (spec.format === '
|
|
15410
|
+
if (spec.format === 'vscode') status = _installVscode(filePath, scriptPath);
|
|
15411
|
+
else if (spec.format === 'zed') status = _installZed(filePath, scriptPath);
|
|
15365
15412
|
else if (spec.format === 'yaml') status = _installYaml(filePath, scriptPath);
|
|
15366
15413
|
else status = _installJson(filePath, scriptPath);
|
|
15367
15414
|
|
|
@@ -15393,7 +15440,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
|
|
|
15393
15440
|
|
|
15394
15441
|
const SERVER_INFO = {
|
|
15395
15442
|
name: 'sigmap',
|
|
15396
|
-
version: '8.
|
|
15443
|
+
version: '8.30.0',
|
|
15397
15444
|
description: 'SigMap MCP server — code signatures on demand',
|
|
15398
15445
|
};
|
|
15399
15446
|
|
|
@@ -16357,6 +16404,33 @@ __factories["./src/retrieval/bm25"] = function(module, exports) {
|
|
|
16357
16404
|
// the literal query token always outranks a synonym-only match.
|
|
16358
16405
|
const EXPANSION_WEIGHT = 0.15;
|
|
16359
16406
|
|
|
16407
|
+
// Module-doc prose is indexed as a `# module: ...` pseudo-signature (index-only,
|
|
16408
|
+
// see src/retrieval/module-doc.js). Per token it is a weaker relevance signal
|
|
16409
|
+
// than a real signature — descriptive rather than definitional — so it is scored
|
|
16410
|
+
// as its own BM25F field rather than pooled with the code terms.
|
|
16411
|
+
const MODULE_DOC_RE = /^#\s*(module|docs):/;
|
|
16412
|
+
|
|
16413
|
+
// Line anchors are metadata, not content, and this ranker is documented as
|
|
16414
|
+
// anchor-invariant. The previous strip was end-anchored, so it only fired when
|
|
16415
|
+
// the anchor was the last thing on the line — but extractors append a doc hint
|
|
16416
|
+
// AFTER it ("... :27-59 # Compute a normalized centrality score"). 27% of
|
|
16417
|
+
// signatures therefore leaked their line numbers into the term space as tokens
|
|
16418
|
+
// like "27" and "59": 840 junk terms, inflating document length for exactly the
|
|
16419
|
+
// well-documented files, which BM25 then penalised via length normalisation.
|
|
16420
|
+
const ANCHOR_RE = /\s*:\d+(?:-\d+)?(?=\s|$)/g;
|
|
16421
|
+
|
|
16422
|
+
function stripAnchor(line) {
|
|
16423
|
+
return String(line).replace(ANCHOR_RE, '');
|
|
16424
|
+
}
|
|
16425
|
+
// Tuned on the 30-task leak-free corpus. The 0.5-0.8 band is flat
|
|
16426
|
+
// (hit@5 63.3-66.7%, easy MRR steady at 0.825); adjacent values swing by up to
|
|
16427
|
+
// 6.7pp, which at 30 tasks is literally two tasks — noise, not signal. 0.6 is
|
|
16428
|
+
// chosen from the middle of that band rather than at its peak, because a
|
|
16429
|
+
// per-token weight below 1 is the principled position (prose is descriptive,
|
|
16430
|
+
// a signature is definitional) and picking the argmax of a 30-task sweep is
|
|
16431
|
+
// how you overfit a benchmark.
|
|
16432
|
+
const DOC_WEIGHT = 0.6;
|
|
16433
|
+
|
|
16360
16434
|
// Build a stemmed lookup: stem(member) → Set of the group's other stemmed members.
|
|
16361
16435
|
const EXPANSIONS = (() => {
|
|
16362
16436
|
const map = new Map();
|
|
@@ -16400,22 +16474,45 @@ __factories["./src/retrieval/bm25"] = function(module, exports) {
|
|
|
16400
16474
|
* @param {{ file: string, sigs: string[] }[]} candidates
|
|
16401
16475
|
* @returns {Array<object & { score: number }>}
|
|
16402
16476
|
*/
|
|
16403
|
-
function bm25rank(query, candidates) {
|
|
16477
|
+
function bm25rank(query, candidates, opts) {
|
|
16404
16478
|
if (!Array.isArray(candidates) || candidates.length === 0) return [];
|
|
16405
16479
|
|
|
16406
16480
|
const k1 = 1.5;
|
|
16407
16481
|
const b = 0.75;
|
|
16408
16482
|
|
|
16483
|
+
const docWeight = (opts && typeof opts.docWeight === 'number') ? opts.docWeight : DOC_WEIGHT;
|
|
16484
|
+
|
|
16409
16485
|
const docs = candidates.map((c) => {
|
|
16410
16486
|
const pathToks = tokenize(c.file || '');
|
|
16411
16487
|
// Ranking is anchor-invariant: `:start-end` line anchors are metadata,
|
|
16412
16488
|
// not content — strip them before tokenizing so adding anchors to an
|
|
16413
16489
|
// extractor never shifts BM25 length normalization or token counts.
|
|
16414
|
-
|
|
16415
|
-
|
|
16490
|
+
// BM25F-style fields. Module-doc prose and code signatures are different
|
|
16491
|
+
// kinds of evidence and must not share one term-frequency pool: prose is
|
|
16492
|
+
// ~30% of all indexed tokens, and a short file with a long header (few
|
|
16493
|
+
// signatures, lots of description) otherwise wins unrelated queries purely
|
|
16494
|
+
// through length normalisation.
|
|
16495
|
+
const docLines = [];
|
|
16496
|
+
const codeLines = [];
|
|
16497
|
+
for (const line of (c.sigs || [])) (MODULE_DOC_RE.test(line) ? docLines : codeLines).push(line);
|
|
16498
|
+
// TRIED AND REJECTED: splitting the declared symbol NAME into its own
|
|
16499
|
+
// weighted BM25F field, on the IR prior that a name is a "title" and params
|
|
16500
|
+
// are "body". Swept 1.0-4.0. hit@5 on the mined corpus rose 60.9% -> 65.2%,
|
|
16501
|
+
// which is a single task crossing the rank-5 line — over 113 combined tasks
|
|
16502
|
+
// hit@1 fell 52.2% -> 51.3%, hit@3 fell 66.4% -> 65.5%, hit@10 was identical
|
|
16503
|
+
// and MRR dropped. It moves correct answers DOWN and happens to nudge one
|
|
16504
|
+
// past a cutoff. A hit@5-only view would have shipped this.
|
|
16505
|
+
const codeToks = tokenize(codeLines.map((x) => stripAnchor(x)).join(' '));
|
|
16506
|
+
const docToks = tokenize(docLines.join(' '));
|
|
16416
16507
|
const tf = new Map();
|
|
16417
|
-
for (const t of toks) tf.set(t, (tf.get(t) || 0) +
|
|
16418
|
-
|
|
16508
|
+
const addField = (toks, weight) => { for (const t of toks) tf.set(t, (tf.get(t) || 0) + weight); };
|
|
16509
|
+
addField(codeToks, 1);
|
|
16510
|
+
addField(pathToks, PATH_BOOST);
|
|
16511
|
+
addField(docToks, docWeight);
|
|
16512
|
+
// Length accumulates with the SAME weights, or a field's influence leaks
|
|
16513
|
+
// back in through the normalisation term.
|
|
16514
|
+
const len = codeToks.length + (PATH_BOOST * pathToks.length) + (docWeight * docToks.length);
|
|
16515
|
+
return { cand: c, tf, len };
|
|
16419
16516
|
});
|
|
16420
16517
|
|
|
16421
16518
|
const N = docs.length || 1;
|
|
@@ -16444,7 +16541,7 @@ __factories["./src/retrieval/bm25"] = function(module, exports) {
|
|
|
16444
16541
|
.sort((a, c) => c.score - a.score || String(a.file).localeCompare(String(c.file)));
|
|
16445
16542
|
}
|
|
16446
16543
|
|
|
16447
|
-
module.exports = { tokenize, stem, bm25rank, PATH_BOOST, STOP, expandQuery, EXPANSIONS, EXPANSION_WEIGHT };
|
|
16544
|
+
module.exports = { tokenize, stem, bm25rank, PATH_BOOST, STOP, expandQuery, EXPANSIONS, EXPANSION_WEIGHT, DOC_WEIGHT, MODULE_DOC_RE, stripAnchor };
|
|
16448
16545
|
|
|
16449
16546
|
};
|
|
16450
16547
|
|
|
@@ -16507,6 +16604,130 @@ __factories["./src/retrieval/enrich-from-maps"] = function(module, exports) {
|
|
|
16507
16604
|
|
|
16508
16605
|
};
|
|
16509
16606
|
|
|
16607
|
+
// ── ./src/retrieval/module-doc ──
|
|
16608
|
+
__factories["./src/retrieval/module-doc"] = function(module, exports) {
|
|
16609
|
+
|
|
16610
|
+
/**
|
|
16611
|
+
* Module-level documentation extractor (retrieval only).
|
|
16612
|
+
*
|
|
16613
|
+
* Signatures describe a file's SHAPE — names, params, return types. A module's
|
|
16614
|
+
* leading comment describes its PURPOSE, in prose, using the words a person
|
|
16615
|
+
* would actually search with. That prose is the bridge between a behavioural
|
|
16616
|
+
* query ("what fraction of the repo made it into the output") and the code
|
|
16617
|
+
* that implements it (`coverageScore(cwd, fileEntries, config)`), which shares
|
|
16618
|
+
* not one token with the query.
|
|
16619
|
+
*
|
|
16620
|
+
* This text is added to the RETRIEVAL INDEX ONLY — never to the generated
|
|
16621
|
+
* context file. The prompt artifact is token-budgeted and prose is expensive
|
|
16622
|
+
* there; the index is not injected into any prompt, so it can afford the words
|
|
16623
|
+
* that make a file findable.
|
|
16624
|
+
*
|
|
16625
|
+
* Zero-dependency, pure, bundle-safe.
|
|
16626
|
+
*/
|
|
16627
|
+
|
|
16628
|
+
// Enough to characterise a module without letting one verbose header dominate
|
|
16629
|
+
// BM25 length normalisation for the whole corpus.
|
|
16630
|
+
const MAX_CHARS = 400;
|
|
16631
|
+
const MAX_SCAN_LINES = 60;
|
|
16632
|
+
|
|
16633
|
+
// Legal boilerplate is high-frequency noise: it appears in many files, shares no
|
|
16634
|
+
// vocabulary with real queries, and would flatten idf across the corpus.
|
|
16635
|
+
const BOILERPLATE = /\b(copyright|licensed under|SPDX-License|all rights reserved|permission is hereby granted)\b/i;
|
|
16636
|
+
|
|
16637
|
+
const BLOCK_LANGS = new Set(['js', 'jsx', 'ts', 'tsx', 'java', 'go', 'rs', 'kt', 'swift', 'scala', 'cs', 'php', 'dart', 'c', 'cpp', 'h']);
|
|
16638
|
+
const HASH_LANGS = new Set(['py', 'rb', 'r', 'sh', 'yml', 'yaml', 'toml']);
|
|
16639
|
+
|
|
16640
|
+
function _extOf(filePath) {
|
|
16641
|
+
const m = String(filePath).match(/\.([A-Za-z0-9]+)$/);
|
|
16642
|
+
return m ? m[1].toLowerCase() : '';
|
|
16643
|
+
}
|
|
16644
|
+
|
|
16645
|
+
/** Strip comment furniture, JSDoc tags, and markup from one raw comment line. */
|
|
16646
|
+
function _cleanLine(line) {
|
|
16647
|
+
return String(line)
|
|
16648
|
+
.replace(/^\s*[/*#-]+\s?/, '') // leading // /* * # ---
|
|
16649
|
+
.replace(/\*+\/\s*$/, '') // trailing */
|
|
16650
|
+
.replace(/^\s*@\w+.*$/, '') // @param / @returns tag lines
|
|
16651
|
+
.replace(/[*_`]/g, '') // markdown emphasis / code ticks
|
|
16652
|
+
.trim();
|
|
16653
|
+
}
|
|
16654
|
+
|
|
16655
|
+
/**
|
|
16656
|
+
* Extract a module's leading documentation prose.
|
|
16657
|
+
*
|
|
16658
|
+
* @param {string} src file contents
|
|
16659
|
+
* @param {string} filePath used only to pick a comment syntax
|
|
16660
|
+
* @returns {string} collapsed prose, capped, or '' when there is none
|
|
16661
|
+
*/
|
|
16662
|
+
function extractModuleDoc(src, filePath) {
|
|
16663
|
+
if (!src || typeof src !== 'string') return '';
|
|
16664
|
+
const ext = _extOf(filePath);
|
|
16665
|
+
const lines = src.split('\n', MAX_SCAN_LINES);
|
|
16666
|
+
|
|
16667
|
+
const collected = [];
|
|
16668
|
+
let inBlock = false;
|
|
16669
|
+
let started = false;
|
|
16670
|
+
|
|
16671
|
+
for (const raw of lines) {
|
|
16672
|
+
const line = raw.trim();
|
|
16673
|
+
if (!started) {
|
|
16674
|
+
// Skip preamble that precedes the real header comment.
|
|
16675
|
+
if (!line) continue;
|
|
16676
|
+
if (line.startsWith('#!')) continue; // shebang
|
|
16677
|
+
if (/^['"]use strict['"];?$/.test(line)) continue;
|
|
16678
|
+
if (/^(package|import|from|using|#include)\b/.test(line)) continue;
|
|
16679
|
+
}
|
|
16680
|
+
|
|
16681
|
+
if (BLOCK_LANGS.has(ext) || ext === '') {
|
|
16682
|
+
if (!inBlock && line.startsWith('/*')) { inBlock = true; started = true; }
|
|
16683
|
+
if (inBlock) {
|
|
16684
|
+
const cleaned = _cleanLine(line);
|
|
16685
|
+
if (cleaned) collected.push(cleaned);
|
|
16686
|
+
if (line.includes('*/')) break;
|
|
16687
|
+
continue;
|
|
16688
|
+
}
|
|
16689
|
+
if (line.startsWith('//')) { // run of // lines
|
|
16690
|
+
started = true;
|
|
16691
|
+
const cleaned = _cleanLine(line);
|
|
16692
|
+
if (cleaned) collected.push(cleaned);
|
|
16693
|
+
continue;
|
|
16694
|
+
}
|
|
16695
|
+
if (started || collected.length) break;
|
|
16696
|
+
break; // first real code — no header
|
|
16697
|
+
}
|
|
16698
|
+
|
|
16699
|
+
if (HASH_LANGS.has(ext)) {
|
|
16700
|
+
if (/^("""|''')/.test(line)) { // python docstring
|
|
16701
|
+
started = true; inBlock = !inBlock;
|
|
16702
|
+
const cleaned = _cleanLine(line.replace(/^("""|''')/, '').replace(/("""|''')$/, ''));
|
|
16703
|
+
if (cleaned) collected.push(cleaned);
|
|
16704
|
+
if (!inBlock) break;
|
|
16705
|
+
continue;
|
|
16706
|
+
}
|
|
16707
|
+
if (inBlock) { const c = _cleanLine(line); if (c) collected.push(c); continue; }
|
|
16708
|
+
if (line.startsWith('#')) { started = true; const c = _cleanLine(line); if (c) collected.push(c); continue; }
|
|
16709
|
+
if (collected.length) break;
|
|
16710
|
+
break;
|
|
16711
|
+
}
|
|
16712
|
+
break;
|
|
16713
|
+
}
|
|
16714
|
+
|
|
16715
|
+
const text = collected.join(' ').replace(/\s+/g, ' ').trim();
|
|
16716
|
+
if (!text || BOILERPLATE.test(text)) return '';
|
|
16717
|
+
if (text.length <= MAX_CHARS) return text;
|
|
16718
|
+
return text.slice(0, MAX_CHARS).replace(/\s+\S*$/, ''); // never cut mid-word
|
|
16719
|
+
}
|
|
16720
|
+
|
|
16721
|
+
/** Render as an index-only pseudo-signature, or '' when there is no doc. */
|
|
16722
|
+
function moduleDocSig(src, filePath) {
|
|
16723
|
+
const doc = extractModuleDoc(src, filePath);
|
|
16724
|
+
return doc ? `# module: ${doc}` : '';
|
|
16725
|
+
}
|
|
16726
|
+
|
|
16727
|
+
module.exports = { extractModuleDoc, moduleDocSig, MAX_CHARS };
|
|
16728
|
+
|
|
16729
|
+
};
|
|
16730
|
+
|
|
16510
16731
|
// ── ./src/retrieval/ranker ──
|
|
16511
16732
|
__factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
16512
16733
|
|
|
@@ -16529,7 +16750,7 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16529
16750
|
|
|
16530
16751
|
const { loadWeights } = __require('./src/learning/weights');
|
|
16531
16752
|
const { tokenize, STOP_WORDS } = __require('./src/retrieval/tokenizer');
|
|
16532
|
-
const { bm25rank } = __require('./src/retrieval/bm25');
|
|
16753
|
+
const { bm25rank, MODULE_DOC_RE } = __require('./src/retrieval/bm25');
|
|
16533
16754
|
|
|
16534
16755
|
// ---------------------------------------------------------------------------
|
|
16535
16756
|
// Default weights
|
|
@@ -16553,17 +16774,28 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16553
16774
|
// Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
|
|
16554
16775
|
const CENTRALITY_BLEND_WEIGHT = 0.3;
|
|
16555
16776
|
|
|
16556
|
-
//
|
|
16557
|
-
|
|
16558
|
-
|
|
16559
|
-
|
|
16560
|
-
|
|
16561
|
-
|
|
16562
|
-
|
|
16563
|
-
|
|
16564
|
-
|
|
16565
|
-
|
|
16566
|
-
|
|
16777
|
+
// Per-intent weight profiles were removed in favour of a single weight set.
|
|
16778
|
+
// They were provably inert: scoreFile's score was discarded by rank(), so the
|
|
16779
|
+
// profiles only ever reached the explain table. Once the signal WAS wired into
|
|
16780
|
+
// the score (see SIGNAL_BLEND below), a sweep over the leak-free hard corpus
|
|
16781
|
+
// showed intent-specific profiles produced byte-identical metrics to the flat
|
|
16782
|
+
// DEFAULT_WEIGHTS at every blend value — so they earn nothing and are gone.
|
|
16783
|
+
// `detectIntent` is retained: it is still reported to the user and is the right
|
|
16784
|
+
// hook for shaping OUTPUT depth later.
|
|
16785
|
+
|
|
16786
|
+
// How much the weighted keyword/symbol/path signal modulates the BM25 base.
|
|
16787
|
+
// Multiplicative and bounded, so it can only reorder files that already match —
|
|
16788
|
+
// it can never lift a zero-BM25 file into the results. Tuned on the leak-free
|
|
16789
|
+
// corpus: 0.5 gave hit@5 50.0% -> 56.7% and MRR 0.419 -> 0.447; higher values
|
|
16790
|
+
// held hit@5 but degraded MRR.
|
|
16791
|
+
const SIGNAL_BLEND = 0.5;
|
|
16792
|
+
|
|
16793
|
+
// TRIED AND REJECTED: a same-line co-occurrence bonus, on the theory that a file
|
|
16794
|
+
// declaring `parseAuthToken` should outrank one mentioning `parseAuth` and
|
|
16795
|
+
// `token` on separate lines. Swept 0.15-1.0: hit@5 did not move on either the
|
|
16796
|
+
// 90-task authored corpus or the 32-task mined one, and MRR degraded
|
|
16797
|
+
// monotonically as the weight rose. Signatures are short and dense enough that
|
|
16798
|
+
// BM25's bag already captures this. Not reinstated without new evidence.
|
|
16567
16799
|
|
|
16568
16800
|
// Penalty multipliers for negative signals
|
|
16569
16801
|
const PENALTY_SIGNALS = {
|
|
@@ -16571,14 +16803,69 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16571
16803
|
generatedCode: 0.3, // dist/build/.next in path
|
|
16572
16804
|
docsFile: 0.2, // docs/doc/README in path
|
|
16573
16805
|
nodeModules: 0.0, // node_modules (zero score)
|
|
16806
|
+
dataHolder: 0.3, // generated POJO/entity: almost entirely accessors
|
|
16574
16807
|
};
|
|
16575
16808
|
|
|
16576
|
-
|
|
16809
|
+
// A file whose members are overwhelmingly trivial accessors is a data holder,
|
|
16810
|
+
// not logic. Path-based detection cannot see these: generated JPA/MyBatis
|
|
16811
|
+
// entities live in ordinary source trees. They match a query on any column
|
|
16812
|
+
// name they happen to carry (`getNote`/`setNote` matches "note" as strongly as
|
|
16813
|
+
// the service that actually implements order notes), so on an entity-heavy
|
|
16814
|
+
// repo they crowd real code out of the top results.
|
|
16815
|
+
const ACCESSOR_RE = /^\s*(get|set|is)[A-Z]\w*\s*\(/;
|
|
16816
|
+
const DATA_HOLDER_RATIO = 0.8;
|
|
16817
|
+
const DATA_HOLDER_MIN_MEMBERS = 6;
|
|
16818
|
+
|
|
16819
|
+
// Query terms that mean the penalised category IS the target. Read from the
|
|
16820
|
+
// query tokens directly, NOT via detectIntent: that classifier is first-match-
|
|
16821
|
+
// wins over its pattern object, and `debug` precedes `test`, so "fix the failing
|
|
16822
|
+
// test" classifies as debug and never reaches the test branch.
|
|
16823
|
+
const WANTS_TESTS = new Set(['test', 'tests', 'spec', 'specs', 'unit', 'integration', 'e2e', 'assertion', 'assert', 'mock', 'fixture', 'coverage', 'testing']);
|
|
16824
|
+
const WANTS_DOCS = new Set(['doc', 'docs', 'documentation', 'readme', 'changelog', 'guide', 'tutorial']);
|
|
16825
|
+
const WANTS_MODELS = new Set(['entity', 'entities', 'model', 'models', 'pojo', 'dto', 'bean', 'getter', 'getters', 'setter', 'setters', 'accessor', 'accessors', 'field', 'fields', 'column', 'columns', 'schema']);
|
|
16826
|
+
|
|
16827
|
+
/** Which penalised categories the query is explicitly asking for. */
|
|
16828
|
+
function _queryWants(queryTokens) {
|
|
16829
|
+
const wants = { tests: false, docs: false, models: false };
|
|
16830
|
+
for (const t of queryTokens || []) {
|
|
16831
|
+
if (WANTS_TESTS.has(t)) wants.tests = true;
|
|
16832
|
+
if (WANTS_DOCS.has(t)) wants.docs = true;
|
|
16833
|
+
if (WANTS_MODELS.has(t)) wants.models = true;
|
|
16834
|
+
}
|
|
16835
|
+
return wants;
|
|
16836
|
+
}
|
|
16837
|
+
|
|
16838
|
+
/**
|
|
16839
|
+
* True when a file's members are overwhelmingly trivial accessors — a generated
|
|
16840
|
+
* entity or POJO rather than logic. Type declarations are excluded from the
|
|
16841
|
+
* ratio so a small class is not misjudged by its own `class X` line.
|
|
16842
|
+
*/
|
|
16843
|
+
function _isDataHolder(sigs) {
|
|
16844
|
+
if (!Array.isArray(sigs)) return false;
|
|
16845
|
+
const members = sigs.filter((line) => /^\s/.test(line) || !/^(class|interface|enum|struct|function|module\.exports)\b/.test(line));
|
|
16846
|
+
if (members.length < DATA_HOLDER_MIN_MEMBERS) return false;
|
|
16847
|
+
const accessors = members.filter((line) => ACCESSOR_RE.test(line)).length;
|
|
16848
|
+
return accessors / members.length >= DATA_HOLDER_RATIO;
|
|
16849
|
+
}
|
|
16850
|
+
|
|
16851
|
+
function _computePenalty(filePath, wants, sigs) {
|
|
16577
16852
|
const pathLower = filePath.toLowerCase();
|
|
16578
16853
|
if (pathLower.includes('node_modules')) return PENALTY_SIGNALS.nodeModules;
|
|
16579
|
-
|
|
16854
|
+
// A penalty must never fire on the very thing the user asked for. Before
|
|
16855
|
+
// this, "write tests for the ranker" multiplied every test file by 0.4 —
|
|
16856
|
+
// the query and the penalty were pulling in opposite directions.
|
|
16857
|
+
if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower) || /\.(test|spec)\./.test(pathLower)) {
|
|
16858
|
+
return (wants && wants.tests) ? 1.0 : PENALTY_SIGNALS.testFile;
|
|
16859
|
+
}
|
|
16580
16860
|
if (/(^|\/)(dist|build|\.next|\.nuxt|out|\.venv|venv)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.generatedCode;
|
|
16581
|
-
if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower))
|
|
16861
|
+
if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower)) {
|
|
16862
|
+
return (wants && wants.docs) ? 1.0 : PENALTY_SIGNALS.docsFile;
|
|
16863
|
+
}
|
|
16864
|
+
// Content-based, and last: a data holder is still a real source file, so it
|
|
16865
|
+
// is only demoted once the path-based categories have had their say.
|
|
16866
|
+
if (_isDataHolder(sigs)) {
|
|
16867
|
+
return (wants && wants.models) ? 1.0 : PENALTY_SIGNALS.dataHolder;
|
|
16868
|
+
}
|
|
16582
16869
|
return 1.0;
|
|
16583
16870
|
}
|
|
16584
16871
|
|
|
@@ -16597,6 +16884,26 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16597
16884
|
}
|
|
16598
16885
|
|
|
16599
16886
|
// Common utility paths that should be treated as hubs regardless of fanout
|
|
16887
|
+
// The graph builders disagree on key case: src/graph/builder.js lowercases every
|
|
16888
|
+
// node (normalizePath), while src/graph/call-graph.js keys by a case-preserving
|
|
16889
|
+
// path.resolve. Assuming either one breaks the other, so every graph lookup in
|
|
16890
|
+
// this file probes both forms — the same thing the centrality blend already does.
|
|
16891
|
+
function _graphKeys(p) {
|
|
16892
|
+
const norm = require('path').normalize(p);
|
|
16893
|
+
const lower = norm.toLowerCase();
|
|
16894
|
+
return lower === norm ? [norm] : [norm, lower];
|
|
16895
|
+
}
|
|
16896
|
+
function _graphGet(map, absPath) {
|
|
16897
|
+
for (const k of _graphKeys(absPath)) {
|
|
16898
|
+
const hit = map.get(k);
|
|
16899
|
+
if (hit !== undefined) return hit;
|
|
16900
|
+
}
|
|
16901
|
+
return undefined;
|
|
16902
|
+
}
|
|
16903
|
+
function _registerKeys(map, absPath, value) {
|
|
16904
|
+
for (const k of _graphKeys(absPath)) if (!map.has(k)) map.set(k, value);
|
|
16905
|
+
}
|
|
16906
|
+
|
|
16600
16907
|
function _isHub(filePath) {
|
|
16601
16908
|
return /\/(utils|helpers|shared|common|constants|types|interfaces|index|zzz|globals)\.(ts|tsx|js|jsx|r|R)$/.test(filePath)
|
|
16602
16909
|
|| filePath.endsWith('/index.ts') || filePath.endsWith('/index.js')
|
|
@@ -16612,14 +16919,18 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16612
16919
|
* @param {object} weights
|
|
16613
16920
|
* @returns {{ score: number, signals: { exactToken: number, symbolMatch: number, prefixMatch: number, pathMatch: number, penalty: number } }}
|
|
16614
16921
|
*/
|
|
16615
|
-
function scoreFile(filePath, sigs, queryTokens, weights) {
|
|
16922
|
+
function scoreFile(filePath, sigs, queryTokens, weights, wants) {
|
|
16616
16923
|
if (!sigs || sigs.length === 0) return { score: 0, signals: { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: 1.0 } };
|
|
16617
16924
|
|
|
16618
16925
|
const w = weights || DEFAULT_WEIGHTS;
|
|
16619
|
-
const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath) };
|
|
16620
|
-
|
|
16621
|
-
//
|
|
16622
|
-
|
|
16926
|
+
const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath, wants, sigs) };
|
|
16927
|
+
|
|
16928
|
+
// Module-doc prose is excluded here on purpose. This signal measures overlap
|
|
16929
|
+
// with DECLARED IDENTIFIERS; prose relevance is BM25's job, where it is scored
|
|
16930
|
+
// as its own weighted field. Letting descriptive text inflate the identifier
|
|
16931
|
+
// signal double-counts it and measurably degraded MRR.
|
|
16932
|
+
const codeSigs = sigs.filter((line) => !MODULE_DOC_RE.test(line));
|
|
16933
|
+
const sigText = codeSigs.join(' ');
|
|
16623
16934
|
const sigTokenSet = new Set(tokenize(sigText));
|
|
16624
16935
|
|
|
16625
16936
|
// Build token set from the file path
|
|
@@ -16637,7 +16948,7 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16637
16948
|
signals.exactToken += bonus;
|
|
16638
16949
|
|
|
16639
16950
|
// Bonus: appears directly in a function/class/method name line
|
|
16640
|
-
const nameLineMatch =
|
|
16951
|
+
const nameLineMatch = codeSigs.some((sig) => {
|
|
16641
16952
|
const nt = tokenize(sig.replace(/[^a-zA-Z0-9_\s]/g, ' '));
|
|
16642
16953
|
return nt.includes(qt);
|
|
16643
16954
|
});
|
|
@@ -16699,13 +17010,18 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16699
17010
|
const graph = (opts && opts.graph && opts.graph.forward instanceof Map) ? opts.graph : null;
|
|
16700
17011
|
const cwd = (opts && opts.cwd) || null;
|
|
16701
17012
|
|
|
16702
|
-
//
|
|
17013
|
+
// Intent is reported to the user and shapes output depth; it no longer
|
|
17014
|
+
// selects scoring weights (see SIGNAL_BLEND).
|
|
16703
17015
|
const intent = detectIntent(query);
|
|
16704
|
-
const
|
|
16705
|
-
|
|
16706
|
-
|
|
17016
|
+
const weights = (opts && opts.weights) ? Object.assign({}, DEFAULT_WEIGHTS, opts.weights) : DEFAULT_WEIGHTS;
|
|
17017
|
+
// Learned per-file multipliers are a LOCAL, evolving signal (.context/weights.json).
|
|
17018
|
+
// Benchmarks and CI gates must opt out via { learned: false }, or a developer's
|
|
17019
|
+
// local learned state silently changes the score and CI stops being reproducible.
|
|
17020
|
+
const useLearned = !(opts && opts.learned === false);
|
|
17021
|
+
const learnedWeights = opts && opts.cwd && useLearned ? loadWeights(opts.cwd) : null;
|
|
16707
17022
|
|
|
16708
17023
|
const queryTokens = tokenize(query);
|
|
17024
|
+
const queryWants = _queryWants(queryTokens);
|
|
16709
17025
|
if (queryTokens.length === 0) {
|
|
16710
17026
|
// Empty query: return top-K by file count (most signatures = most useful)
|
|
16711
17027
|
const all = [];
|
|
@@ -16722,18 +17038,32 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16722
17038
|
// are matched. The existing negative-signal penalty and recency/graph/learned
|
|
16723
17039
|
// boosts are layered on top; the per-token signals stay for the explain table.
|
|
16724
17040
|
const bm25Scores = new Map();
|
|
16725
|
-
for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })))) {
|
|
17041
|
+
for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })), opts)) {
|
|
16726
17042
|
bm25Scores.set(c.file, c.score);
|
|
16727
17043
|
}
|
|
16728
17044
|
|
|
16729
|
-
|
|
17045
|
+
// Two passes: scoreFile's weighted signal needs the max across the corpus to
|
|
17046
|
+
// normalise against, so collect first, then combine.
|
|
17047
|
+
const prescored = [];
|
|
17048
|
+
let maxSignal = 0;
|
|
16730
17049
|
for (const [file, sigs] of sigIndex.entries()) {
|
|
16731
|
-
const result = scoreFile(file, sigs, queryTokens, weights);
|
|
17050
|
+
const result = scoreFile(file, sigs, queryTokens, weights, queryWants);
|
|
17051
|
+
if (result.score > maxSignal) maxSignal = result.score;
|
|
17052
|
+
prescored.push({ file, sigs, result });
|
|
17053
|
+
}
|
|
17054
|
+
|
|
17055
|
+
const scored = [];
|
|
17056
|
+
for (const { file, sigs, result } of prescored) {
|
|
16732
17057
|
const penalty = result.signals.penalty;
|
|
16733
17058
|
const base = bm25Scores.get(file) || 0;
|
|
16734
|
-
|
|
17059
|
+
// Blend the weighted keyword/symbol/path signal into the BM25 base. This
|
|
17060
|
+
// was previously computed and thrown away — `result.score` was never read,
|
|
17061
|
+
// which silently made DEFAULT_WEIGHTS and every intent profile dead config.
|
|
17062
|
+
const signalNorm = maxSignal > 0 ? result.score / maxSignal : 0;
|
|
17063
|
+
let score = base * penalty * (1 + SIGNAL_BLEND * signalNorm);
|
|
16735
17064
|
const signals = result.signals;
|
|
16736
17065
|
signals.bm25 = base;
|
|
17066
|
+
signals.signalBlend = signalNorm;
|
|
16737
17067
|
|
|
16738
17068
|
// Recency boost
|
|
16739
17069
|
if (recencySet && recencySet.has(file) && score > 0) {
|
|
@@ -16763,44 +17093,46 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16763
17093
|
// Hub suppression: files with high fanout (>20%) are not boosted
|
|
16764
17094
|
if (graph && cwd) {
|
|
16765
17095
|
const path = require('path');
|
|
16766
|
-
//
|
|
16767
|
-
|
|
16768
|
-
|
|
17096
|
+
// Every graph node is keyed by `path.normalize(p).toLowerCase()` (see
|
|
17097
|
+
// normalizePath in src/graph/builder.js and src/graph/call-graph.js).
|
|
17098
|
+
// Lookups MUST use the same key space: a bare path.resolve() preserves
|
|
17099
|
+
// case, so on any repo whose absolute path contains an uppercase letter
|
|
17100
|
+
// every .get() missed and this entire block was silently inert.
|
|
17101
|
+
const keyToIdx = new Map();
|
|
16769
17102
|
for (let i = 0; i < scored.length; i++) {
|
|
16770
|
-
|
|
16771
|
-
const abs = path.resolve(cwd, scored[i].file);
|
|
16772
|
-
absToRel.set(abs, scored[i].file);
|
|
17103
|
+
_registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
|
|
16773
17104
|
}
|
|
16774
17105
|
|
|
16775
17106
|
const hubs = _computeHubs(graph);
|
|
16776
|
-
const hop1Files = new Set(); //
|
|
17107
|
+
const hop1Files = new Set(); // normalised keys that received a hop1 boost
|
|
17108
|
+
const hop1Seeds = []; // original (un-normalised) paths, for hop-2 lookup
|
|
16777
17109
|
|
|
16778
17110
|
// Hop 1: direct neighbors of scored files
|
|
16779
17111
|
for (const entry of scored) {
|
|
16780
17112
|
if (entry.score <= 0) continue;
|
|
16781
|
-
const
|
|
16782
|
-
const neighbors = graph.forward.get(abs) || [];
|
|
17113
|
+
const neighbors = _graphGet(graph.forward, path.resolve(cwd, entry.file)) || [];
|
|
16783
17114
|
for (const neighborAbs of neighbors) {
|
|
16784
|
-
|
|
16785
|
-
|
|
16786
|
-
const idx =
|
|
17115
|
+
const nk = path.normalize(neighborAbs);
|
|
17116
|
+
if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
|
|
17117
|
+
const idx = _graphGet(keyToIdx, nk);
|
|
16787
17118
|
if (idx !== undefined) {
|
|
16788
17119
|
scored[idx].score += GRAPH_BOOST_AMOUNTS.hop1;
|
|
16789
17120
|
scored[idx].signals.graphBoost = (scored[idx].signals.graphBoost || 0) + GRAPH_BOOST_AMOUNTS.hop1;
|
|
16790
|
-
hop1Files.add(
|
|
17121
|
+
hop1Files.add(nk);
|
|
17122
|
+
hop1Seeds.push(neighborAbs);
|
|
16791
17123
|
}
|
|
16792
17124
|
}
|
|
16793
17125
|
}
|
|
16794
17126
|
|
|
16795
17127
|
// Hop 2: neighbors of hop1 files (only if they didn't get a direct score)
|
|
16796
|
-
for (const
|
|
16797
|
-
if (
|
|
16798
|
-
const neighbors = graph.forward
|
|
17128
|
+
for (const hop1Key of hop1Seeds) {
|
|
17129
|
+
if (_graphGet(keyToIdx, hop1Key) === undefined) continue; // skip files not in index
|
|
17130
|
+
const neighbors = _graphGet(graph.forward, hop1Key) || [];
|
|
16799
17131
|
for (const neighborAbs of neighbors) {
|
|
16800
|
-
|
|
16801
|
-
if (
|
|
16802
|
-
|
|
16803
|
-
const idx =
|
|
17132
|
+
const nk = path.normalize(neighborAbs);
|
|
17133
|
+
if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
|
|
17134
|
+
if (hop1Files.has(nk)) continue; // skip already hop1-boosted
|
|
17135
|
+
const idx = _graphGet(keyToIdx, nk);
|
|
16804
17136
|
if (idx !== undefined && scored[idx].score > 0) {
|
|
16805
17137
|
// Only boost files that have some baseline score (not noise)
|
|
16806
17138
|
scored[idx].score += GRAPH_BOOST_AMOUNTS.hop2;
|
|
@@ -16817,16 +17149,17 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
16817
17149
|
const callGraph = (opts && opts.callGraph && opts.callGraph.forward instanceof Map) ? opts.callGraph : null;
|
|
16818
17150
|
if (callGraph && cwd) {
|
|
16819
17151
|
const path = require('path');
|
|
16820
|
-
|
|
16821
|
-
|
|
17152
|
+
// buildCallFileGraph keys by a CASE-PRESERVING path.resolve, unlike the
|
|
17153
|
+
// import graph builder which lowercases — hence the dual-form probe.
|
|
17154
|
+
const keyToIdx = new Map();
|
|
17155
|
+
for (let i = 0; i < scored.length; i++) _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
|
|
16822
17156
|
const hubs = _computeHubs(callGraph);
|
|
16823
17157
|
const seeds = scored.filter((e) => e.score > 0).map((e) => e.file);
|
|
16824
17158
|
for (const file of seeds) {
|
|
16825
|
-
const
|
|
16826
|
-
|
|
16827
|
-
if (_isHub(
|
|
16828
|
-
const
|
|
16829
|
-
const idx = relToIdx.get(neighborRel);
|
|
17159
|
+
for (const neighborAbs of (_graphGet(callGraph.forward, path.resolve(cwd, file)) || [])) {
|
|
17160
|
+
const nk = path.normalize(neighborAbs);
|
|
17161
|
+
if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
|
|
17162
|
+
const idx = _graphGet(keyToIdx, nk);
|
|
16830
17163
|
if (idx !== undefined && scored[idx].file !== file) {
|
|
16831
17164
|
scored[idx].score += GRAPH_BOOST_AMOUNTS.callHop;
|
|
16832
17165
|
scored[idx].signals.callGraphBoost = (scored[idx].signals.callGraphBoost || 0) + GRAPH_BOOST_AMOUNTS.callHop;
|
|
@@ -17000,6 +17333,18 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
17000
17333
|
}
|
|
17001
17334
|
} catch (_) {}
|
|
17002
17335
|
_mergeSigIndex(index, _buildSigIndexFromCache(cwd));
|
|
17336
|
+
|
|
17337
|
+
// The complete retrieval index (written by generate before applyTokenBudget)
|
|
17338
|
+
// takes precedence: it is the only source containing files the budget dropped,
|
|
17339
|
+
// and full signatures for files the budget collapsed to line anchors. It is
|
|
17340
|
+
// merged as the BASE rather than on top because _mergeSigIndex only replaces
|
|
17341
|
+
// when the source has MORE signatures — a collapsed entry has the same count
|
|
17342
|
+
// as its full form, so merging the other way would keep the anchors.
|
|
17343
|
+
try {
|
|
17344
|
+
const full = __require('./src/retrieval/sig-index-store').readFullIndex(cwd);
|
|
17345
|
+
if (full.size > 0) return _mergeSigIndex(full, index);
|
|
17346
|
+
} catch (_) { /* absent → budgeted view is still served */ }
|
|
17347
|
+
|
|
17003
17348
|
return index;
|
|
17004
17349
|
}
|
|
17005
17350
|
|
|
@@ -17125,25 +17470,155 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
17125
17470
|
// ---------------------------------------------------------------------------
|
|
17126
17471
|
// Intent detection — 7 intents
|
|
17127
17472
|
// ---------------------------------------------------------------------------
|
|
17473
|
+
// Nouns carry an optional plural: `\btest\b` does not match "tests", so
|
|
17474
|
+
// "write unit tests for the ranker" matched NO intent at all and fell through
|
|
17475
|
+
// to the 'search' default.
|
|
17128
17476
|
const INTENT_PATTERNS = {
|
|
17129
|
-
debug: /\b(
|
|
17477
|
+
debug: /\b(bugs?|fix(es|ed)?|errors?|crash(es)?|exceptions?|broken|failing|failures?|issues?|problems?|regressions?)\b/i,
|
|
17130
17478
|
explain: /\b(explain|how does|what is|understand|overview|architecture|describe|walk me|teach)\b/i,
|
|
17131
|
-
refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|
|
|
17479
|
+
refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimi[sz]e)\b/i,
|
|
17132
17480
|
review: /\b(review|check|audit|security|pr|pull request|assess|validate)\b/i,
|
|
17133
|
-
test: /\b(
|
|
17134
|
-
integrate:/\b(
|
|
17481
|
+
test: /\b(tests?|unit tests?|integration tests?|testing|specs?|assert(ion)?s?|mocks?|fixtures?)\b/i,
|
|
17482
|
+
integrate:/\b(imports?|integrate|connect|wire|bind|requires?|exports?|depends?|dependenc(y|ies)|graph)\b/i,
|
|
17135
17483
|
navigate: /\b(find|locate|where|search|look for|show me|navigate|browse|list)\b/i,
|
|
17136
17484
|
};
|
|
17137
17485
|
|
|
17138
|
-
|
|
17139
|
-
|
|
17486
|
+
/**
|
|
17487
|
+
* Every intent whose pattern matches, strongest first.
|
|
17488
|
+
*
|
|
17489
|
+
* A real request is routinely multi-intent — "fix the failing test" is both a
|
|
17490
|
+
* debug task and a test task — and reporting one label discards that. Worse,
|
|
17491
|
+
* the single-label version returned the FIRST key in INTENT_PATTERNS order, so
|
|
17492
|
+
* `debug` permanently shadowed `test`: no query containing "fix" or "failing"
|
|
17493
|
+
* could ever be labelled a test, no matter how test-shaped it was.
|
|
17494
|
+
*
|
|
17495
|
+
* Ranked by how many distinct terms each pattern matched, so the dominant
|
|
17496
|
+
* intent leads; ties fall back to declaration order for determinism.
|
|
17497
|
+
*
|
|
17498
|
+
* @param {string} query
|
|
17499
|
+
* @returns {string[]} matched intents, never empty (defaults to ['search'])
|
|
17500
|
+
*/
|
|
17501
|
+
function detectIntents(query) {
|
|
17502
|
+
if (!query || typeof query !== 'string') return ['search'];
|
|
17503
|
+
const scored = [];
|
|
17504
|
+
let order = 0;
|
|
17140
17505
|
for (const [intent, re] of Object.entries(INTENT_PATTERNS)) {
|
|
17141
|
-
|
|
17506
|
+
const hits = query.match(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'));
|
|
17507
|
+
if (hits && hits.length) {
|
|
17508
|
+
scored.push({ intent, hits: new Set(hits.map((h) => h.toLowerCase())).size, order: order });
|
|
17509
|
+
}
|
|
17510
|
+
order++;
|
|
17511
|
+
}
|
|
17512
|
+
if (scored.length === 0) return ['search'];
|
|
17513
|
+
scored.sort((a, b) => (b.hits - a.hits) || (a.order - b.order));
|
|
17514
|
+
return scored.map((s) => s.intent);
|
|
17515
|
+
}
|
|
17516
|
+
|
|
17517
|
+
/** Primary intent. Kept for callers that want a single label. */
|
|
17518
|
+
function detectIntent(query) {
|
|
17519
|
+
return detectIntents(query)[0];
|
|
17520
|
+
}
|
|
17521
|
+
|
|
17522
|
+
module.exports = { rank, buildSigIndex, scoreFile, _queryWants, _isDataHolder, detectIntents, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
|
|
17523
|
+
|
|
17524
|
+
};
|
|
17525
|
+
|
|
17526
|
+
// ── ./src/retrieval/sig-index-store ──
|
|
17527
|
+
__factories["./src/retrieval/sig-index-store"] = function(module, exports) {
|
|
17528
|
+
|
|
17529
|
+
/**
|
|
17530
|
+
* Complete, unbudgeted signature index for retrieval.
|
|
17531
|
+
*
|
|
17532
|
+
* WHY THIS EXISTS
|
|
17533
|
+
* ---------------
|
|
17534
|
+
* The generated context file (CLAUDE.md / AGENTS.md / copilot-instructions.md)
|
|
17535
|
+
* is a BUDGETED VIEW: `applyTokenBudget` drops and collapses files so the
|
|
17536
|
+
* artifact stays under `maxTokens`, because it is injected into every prompt.
|
|
17537
|
+
*
|
|
17538
|
+
* `buildSigIndex` used to parse that same artifact to build the ranker's index,
|
|
17539
|
+
* so retrieval inherited the prompt budget. Every file the budget dropped became
|
|
17540
|
+
* permanently unreachable by `sigmap ask` — no ranking change can surface a file
|
|
17541
|
+
* that is not in the index. On this repo that was 53 of 155 source files (34%),
|
|
17542
|
+
* and restoring them moved hit@5 from 50% to 90% on the retrieval corpus.
|
|
17543
|
+
*
|
|
17544
|
+
* The two artifacts have opposite requirements — the prompt file wants to be
|
|
17545
|
+
* small, the index wants to be complete — so they are now separate. This store
|
|
17546
|
+
* is written by `generate` BEFORE the budget is applied, and lives under
|
|
17547
|
+
* `.context/` (gitignored, never injected into a prompt).
|
|
17548
|
+
*
|
|
17549
|
+
* Zero-dependency, bundle-safe (fs + path only).
|
|
17550
|
+
*/
|
|
17551
|
+
|
|
17552
|
+
const fs = require('fs');
|
|
17553
|
+
const path = require('path');
|
|
17554
|
+
|
|
17555
|
+
const INDEX_DIR = '.context';
|
|
17556
|
+
const INDEX_FILE = 'sig-index.json';
|
|
17557
|
+
const SCHEMA = 1;
|
|
17558
|
+
|
|
17559
|
+
/** Absolute path to the retrieval index artifact. */
|
|
17560
|
+
function indexPath(cwd) {
|
|
17561
|
+
return path.join(cwd, INDEX_DIR, INDEX_FILE);
|
|
17562
|
+
}
|
|
17563
|
+
|
|
17564
|
+
/**
|
|
17565
|
+
* Persist the complete signature index.
|
|
17566
|
+
*
|
|
17567
|
+
* @param {string} cwd
|
|
17568
|
+
* @param {Array<{filePath: string, sigs: string[]}>} fileEntries - every
|
|
17569
|
+
* extracted entry, BEFORE applyTokenBudget has dropped or collapsed any.
|
|
17570
|
+
* @param {{ version?: string }} [opts]
|
|
17571
|
+
* @returns {{ path: string, files: number }}
|
|
17572
|
+
*/
|
|
17573
|
+
function writeFullIndex(cwd, fileEntries, opts = {}) {
|
|
17574
|
+
const files = {};
|
|
17575
|
+
let count = 0;
|
|
17576
|
+
for (const e of fileEntries || []) {
|
|
17577
|
+
if (!e || !e.filePath || !Array.isArray(e.sigs) || e.sigs.length === 0) continue;
|
|
17578
|
+
const rel = path.relative(cwd, e.filePath).replace(/\\/g, '/');
|
|
17579
|
+
if (!rel || rel.startsWith('..')) continue;
|
|
17580
|
+
files[rel] = e.sigs;
|
|
17581
|
+
count++;
|
|
17142
17582
|
}
|
|
17143
|
-
|
|
17583
|
+
|
|
17584
|
+
const out = indexPath(cwd);
|
|
17585
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
17586
|
+
// Write-then-rename so a concurrent `ask` never observes a half-written index.
|
|
17587
|
+
const tmp = `${out}.tmp`;
|
|
17588
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
17589
|
+
schema: SCHEMA,
|
|
17590
|
+
sigmapVersion: opts.version || null,
|
|
17591
|
+
generated: new Date().toISOString(),
|
|
17592
|
+
files,
|
|
17593
|
+
}), 'utf8');
|
|
17594
|
+
fs.renameSync(tmp, out);
|
|
17595
|
+
return { path: out, files: count };
|
|
17596
|
+
}
|
|
17597
|
+
|
|
17598
|
+
/**
|
|
17599
|
+
* Load the complete signature index, or an empty Map when absent/unreadable.
|
|
17600
|
+
*
|
|
17601
|
+
* Deliberately NOT version-busted (unlike .sigmap-cache.json): a stale but
|
|
17602
|
+
* complete index still retrieves the right files, whereas discarding it drops
|
|
17603
|
+
* retrieval back to the budgeted view — the exact failure this store exists to
|
|
17604
|
+
* prevent. Staleness is handled by re-running generate or by cache/freshen.
|
|
17605
|
+
*
|
|
17606
|
+
* @param {string} cwd
|
|
17607
|
+
* @returns {Map<string, string[]>}
|
|
17608
|
+
*/
|
|
17609
|
+
function readFullIndex(cwd) {
|
|
17610
|
+
const index = new Map();
|
|
17611
|
+
try {
|
|
17612
|
+
const data = JSON.parse(fs.readFileSync(indexPath(cwd), 'utf8'));
|
|
17613
|
+
if (!data || data.schema !== SCHEMA || !data.files) return index;
|
|
17614
|
+
for (const [rel, sigs] of Object.entries(data.files)) {
|
|
17615
|
+
if (Array.isArray(sigs) && sigs.length > 0) index.set(rel, sigs);
|
|
17616
|
+
}
|
|
17617
|
+
} catch (_) { /* absent or corrupt → caller falls back to the context file */ }
|
|
17618
|
+
return index;
|
|
17144
17619
|
}
|
|
17145
17620
|
|
|
17146
|
-
module.exports = {
|
|
17621
|
+
module.exports = { writeFullIndex, readFullIndex, indexPath, SCHEMA, INDEX_FILE };
|
|
17147
17622
|
|
|
17148
17623
|
};
|
|
17149
17624
|
|
|
@@ -18337,6 +18812,24 @@ __factories["./src/skills/skills"] = function(module, exports) {
|
|
|
18337
18812
|
'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.',
|
|
18338
18813
|
].join('\n'),
|
|
18339
18814
|
},
|
|
18815
|
+
'sigmap-task': {
|
|
18816
|
+
title: 'SigMap task loop',
|
|
18817
|
+
kind: 'prompt',
|
|
18818
|
+
description: 'Do a coding task grounded in SigMap: look up before reading, edit by line anchor, verify before reporting.',
|
|
18819
|
+
argumentHint: 'the change you want, in plain words',
|
|
18820
|
+
body: [
|
|
18821
|
+
'Work through these steps **in order**. Do not open any file before step 2.',
|
|
18822
|
+
'Every command runs from the integrated terminal — do not ask the user to run them for you.',
|
|
18823
|
+
'',
|
|
18824
|
+
'1. **Look up, do not search.** `npx sigmap ask "<the task>"` — this writes `.context/query-context.md`.',
|
|
18825
|
+
'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.',
|
|
18826
|
+
'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.',
|
|
18827
|
+
'4. **Make the change.** Follow the conventions visible in the signatures — same layering, same response wrapper, same annotation style. Add no dependencies.',
|
|
18828
|
+
'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.',
|
|
18829
|
+
'6. **Refresh the map.** `npx sigmap` — your edits made it stale.',
|
|
18830
|
+
'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.',
|
|
18831
|
+
].join('\n'),
|
|
18832
|
+
},
|
|
18340
18833
|
'sigmap-config-optimizer': {
|
|
18341
18834
|
title: 'SigMap config optimizer',
|
|
18342
18835
|
description: 'Playbook for getting a correct SigMap config on any repo: detect with sigmap tune, review the per-change reasons, apply, validate.',
|
|
@@ -18361,7 +18854,9 @@ __factories["./src/skills/skills"] = function(module, exports) {
|
|
|
18361
18854
|
windsurf: { label: 'Windsurf', parent: ['.windsurf'],
|
|
18362
18855
|
target: (cwd, skill) => path.join(cwd, '.windsurf', 'rules', `${skill}.md`) },
|
|
18363
18856
|
copilot: { label: 'GitHub Copilot', parent: ['.github'],
|
|
18364
|
-
target: (cwd, skill) =>
|
|
18857
|
+
target: (cwd, skill) => (SKILLS[skill] && SKILLS[skill].kind === 'prompt'
|
|
18858
|
+
? path.join(cwd, '.github', 'prompts', `${skill}.prompt.md`)
|
|
18859
|
+
: path.join(cwd, '.github', 'instructions', `${skill}.instructions.md`)) },
|
|
18365
18860
|
codex: { label: 'Codex CLI (AGENTS.md)', parent: ['AGENTS.md'],
|
|
18366
18861
|
target: (cwd) => path.join(cwd, 'AGENTS.md'), inject: true },
|
|
18367
18862
|
};
|
|
@@ -18382,6 +18877,10 @@ __factories["./src/skills/skills"] = function(module, exports) {
|
|
|
18382
18877
|
return `---\ndescription: ${skill.description}\nalwaysApply: false\n---\n\n${body}`;
|
|
18383
18878
|
}
|
|
18384
18879
|
if (client === 'copilot') {
|
|
18880
|
+
if (skill.kind === 'prompt') {
|
|
18881
|
+
return `---\nname: ${skillName}\nagent: 'agent'\ndescription: ${skill.description}\n`
|
|
18882
|
+
+ `argument-hint: ${skill.argumentHint}\n---\n\n${body}`;
|
|
18883
|
+
}
|
|
18385
18884
|
return `---\napplyTo: "**"\n---\n\n${body}`;
|
|
18386
18885
|
}
|
|
18387
18886
|
return body; // windsurf: plain markdown
|
|
@@ -21235,7 +21734,7 @@ function __tryGit(args, opts = {}) {
|
|
|
21235
21734
|
catch (_) { return ''; }
|
|
21236
21735
|
}
|
|
21237
21736
|
|
|
21238
|
-
const VERSION = '8.
|
|
21737
|
+
const VERSION = '8.30.0';
|
|
21239
21738
|
const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
|
|
21240
21739
|
|
|
21241
21740
|
function requireSourceOrBundled(key) {
|
|
@@ -21372,10 +21871,70 @@ function buildFileList(cwd, config) {
|
|
|
21372
21871
|
const found = walkDir(abs, config.exclude, config.maxDepth);
|
|
21373
21872
|
files.push(...found);
|
|
21374
21873
|
}
|
|
21874
|
+
files.push(...declaredEntrypoints(cwd, config, files));
|
|
21375
21875
|
// Deduplicate
|
|
21376
21876
|
return [...new Set(files)];
|
|
21377
21877
|
}
|
|
21378
21878
|
|
|
21879
|
+
/**
|
|
21880
|
+
* Source files a project declares as its own entrypoints in package.json
|
|
21881
|
+
* (`main` and every `bin` target), when they live outside srcDirs.
|
|
21882
|
+
*
|
|
21883
|
+
* A CLI's entrypoint is usually at the repo root, so a srcDirs of [src] left
|
|
21884
|
+
* it unindexed and therefore unreachable by `sigmap ask` — on this repo that
|
|
21885
|
+
* was gen-context.js, which holds the whole generator pipeline. Declared
|
|
21886
|
+
* entrypoints are load-bearing by definition, so they are always scanned.
|
|
21887
|
+
*/
|
|
21888
|
+
function collectTestEntries(cwd, config, existing) {
|
|
21889
|
+
const TEST_ROOTS = ['test', 'tests', '__tests__', 'spec', 'e2e'];
|
|
21890
|
+
const have = new Set((existing || []).map((e) => e.filePath));
|
|
21891
|
+
const out = [];
|
|
21892
|
+
let moduleDocSig = null;
|
|
21893
|
+
try { ({ moduleDocSig } = requireSourceOrBundled('./src/retrieval/module-doc')); } catch (_) {}
|
|
21894
|
+
for (const root of TEST_ROOTS) {
|
|
21895
|
+
const abs = path.join(cwd, root);
|
|
21896
|
+
if (!fs.existsSync(abs)) continue;
|
|
21897
|
+
let files = [];
|
|
21898
|
+
try { files = walkDir(abs, config.exclude, config.maxDepth); } catch (_) { continue; }
|
|
21899
|
+
for (const fp of files) {
|
|
21900
|
+
if (have.has(fp)) continue;
|
|
21901
|
+
let src = '';
|
|
21902
|
+
try { src = fs.readFileSync(fp, 'utf8'); } catch (_) { continue; }
|
|
21903
|
+
let sigs = [];
|
|
21904
|
+
try {
|
|
21905
|
+
const { extractFile } = requireSourceOrBundled('./src/extractors/dispatch');
|
|
21906
|
+
sigs = extractFile(fp, src) || [];
|
|
21907
|
+
} catch (_) { continue; }
|
|
21908
|
+
if (!sigs.length) continue;
|
|
21909
|
+
const doc = moduleDocSig ? moduleDocSig(src, fp) : '';
|
|
21910
|
+
out.push({ filePath: fp, sigs: doc ? [doc, ...sigs] : sigs });
|
|
21911
|
+
}
|
|
21912
|
+
}
|
|
21913
|
+
return out;
|
|
21914
|
+
}
|
|
21915
|
+
|
|
21916
|
+
function declaredEntrypoints(cwd, config, existing) {
|
|
21917
|
+
const out = [];
|
|
21918
|
+
try {
|
|
21919
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
21920
|
+
const have = new Set(existing);
|
|
21921
|
+
const refs = [];
|
|
21922
|
+
if (typeof pkg.main === 'string') refs.push(pkg.main);
|
|
21923
|
+
if (typeof pkg.bin === 'string') refs.push(pkg.bin);
|
|
21924
|
+
else if (pkg.bin && typeof pkg.bin === 'object') refs.push(...Object.values(pkg.bin).filter((v) => typeof v === 'string'));
|
|
21925
|
+
for (const ref of refs) {
|
|
21926
|
+
const abs = path.resolve(cwd, ref);
|
|
21927
|
+
if (have.has(abs) || out.includes(abs)) continue;
|
|
21928
|
+
const rel = path.relative(cwd, abs);
|
|
21929
|
+
if (!rel || rel.startsWith('..')) continue; // outside the repo
|
|
21930
|
+
if ((config.exclude || []).some((x) => rel.split(path.sep).includes(x))) continue;
|
|
21931
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
|
|
21932
|
+
out.push(abs);
|
|
21933
|
+
}
|
|
21934
|
+
} catch (_) { /* no package.json, or unreadable → nothing to add */ }
|
|
21935
|
+
return out;
|
|
21936
|
+
}
|
|
21937
|
+
|
|
21379
21938
|
// ---------------------------------------------------------------------------
|
|
21380
21939
|
// Extractor loader (lazy, cached)
|
|
21381
21940
|
// ---------------------------------------------------------------------------
|
|
@@ -22647,6 +23206,60 @@ function runGenerate(cwd, config, reportMode, reportJson = false) {
|
|
|
22647
23206
|
|
|
22648
23207
|
let result;
|
|
22649
23208
|
if (!reportMode) {
|
|
23209
|
+
// Retrieval index (complete, unbudgeted) — written BEFORE applyTokenBudget
|
|
23210
|
+
// and before the strategy split, so it holds every extracted file for every
|
|
23211
|
+
// strategy. The generated context file is a budgeted VIEW for prompt
|
|
23212
|
+
// injection; the ranker must not inherit that budget or files dropped to fit
|
|
23213
|
+
// maxTokens become permanently unreachable by `sigmap ask`.
|
|
23214
|
+
try {
|
|
23215
|
+
const __store = requireSourceOrBundled('./src/retrieval/sig-index-store');
|
|
23216
|
+
// Honour --terse: `sigmap ask` renders .context/query-context.md straight
|
|
23217
|
+
// from these signatures, and that IS prompt-bound, so the user's format
|
|
23218
|
+
// choice has to survive into the index. Terse encoding preserves line
|
|
23219
|
+
// anchors byte-exactly, so get_lines still resolves.
|
|
23220
|
+
// Index-only enrichment: a module's leading comment describes its PURPOSE
|
|
23221
|
+
// in prose, which is the vocabulary a behavioural query actually uses.
|
|
23222
|
+
// Signatures carry shape, not intent — `coverageScore(cwd, fileEntries, config)`
|
|
23223
|
+
// shares no token with "what fraction of the repo made it into the output",
|
|
23224
|
+
// but that file's header says exactly that. Added to the retrieval index
|
|
23225
|
+
// ONLY: the prompt artifact is token-budgeted, the index is not.
|
|
23226
|
+
let __entries = fileEntries;
|
|
23227
|
+
try {
|
|
23228
|
+
// TRIED AND REJECTED: also indexing every per-symbol doc sentence
|
|
23229
|
+
// untruncated (src/retrieval/doc-text.js). 39% of extractor doc hints are
|
|
23230
|
+
// cut at 60 chars, so recovering them looked like free vocabulary. It is
|
|
23231
|
+
// not: train hit@5 fell 75.6% -> 73.3% at every docWeight from 0.2 to 1.0,
|
|
23232
|
+
// and the mined corpus never moved off 62.5%. The MODULE HEADER is the
|
|
23233
|
+
// high-signal prose — it states the file's purpose. Per-symbol sentences
|
|
23234
|
+
// describe internal helpers, so they broaden what each file matches
|
|
23235
|
+
// without making any file a better answer.
|
|
23236
|
+
const { moduleDocSig } = requireSourceOrBundled('./src/retrieval/module-doc');
|
|
23237
|
+
__entries = __entries.map((e) => {
|
|
23238
|
+
let src = e.content;
|
|
23239
|
+
if (typeof src !== 'string') { try { src = fs.readFileSync(e.filePath, 'utf8'); } catch (_) { src = ''; } }
|
|
23240
|
+
const doc = moduleDocSig(src, e.filePath);
|
|
23241
|
+
return doc ? Object.assign({}, e, { sigs: [doc, ...e.sigs] }) : e;
|
|
23242
|
+
});
|
|
23243
|
+
} catch (_) { /* enrichment is best-effort */ }
|
|
23244
|
+
if (config && config.terse) {
|
|
23245
|
+
try {
|
|
23246
|
+
const { encodeTerseSigs } = requireSourceOrBundled('./src/format/terse');
|
|
23247
|
+
__entries = fileEntries.map((e) => Object.assign({}, e, { sigs: encodeTerseSigs(e.sigs) }));
|
|
23248
|
+
} catch (_) { /* terse unavailable → index full signatures */ }
|
|
23249
|
+
}
|
|
23250
|
+
// Test files: indexed, never rendered into the prompt. They were the one
|
|
23251
|
+
// whole category `sigmap ask` could not reach at all — srcDirs excludes
|
|
23252
|
+
// them, so "where are the tests for X" had no answer at any rank. The
|
|
23253
|
+
// prompt artifact stays clean because this list never reaches formatOutput.
|
|
23254
|
+
try {
|
|
23255
|
+
__entries = __entries.concat(collectTestEntries(cwd, config, __entries));
|
|
23256
|
+
} catch (_) { /* best-effort */ }
|
|
23257
|
+
const __w = __store.writeFullIndex(cwd, __entries, { version: VERSION });
|
|
23258
|
+
if (process.argv.includes('--verbose')) {
|
|
23259
|
+
console.warn(`[sigmap] retrieval index: ${__w.files} file(s) → ${path.relative(cwd, __w.path)}`);
|
|
23260
|
+
}
|
|
23261
|
+
} catch (_) { /* non-fatal: ranker falls back to parsing the context file */ }
|
|
23262
|
+
|
|
22650
23263
|
if (strategy === 'per-module') {
|
|
22651
23264
|
result = runPerModuleStrategy(cwd, configWithBudget, fileEntries, inputTokenTotal);
|
|
22652
23265
|
} else if (strategy === 'hot-cold') {
|
|
@@ -23333,14 +23946,12 @@ function getRawTokenCount(cwd, config) {
|
|
|
23333
23946
|
return total;
|
|
23334
23947
|
}
|
|
23335
23948
|
|
|
23336
|
-
|
|
23949
|
+
// Intent no longer selects scoring weights — the per-intent multipliers were
|
|
23950
|
+
// measured to have zero effect on ranking (see src/retrieval/ranker.js).
|
|
23951
|
+
// Kept as a single call site so the ask handler keeps one weights source.
|
|
23952
|
+
function getIntentWeights(_intent) {
|
|
23337
23953
|
const { DEFAULT_WEIGHTS } = requireSourceOrBundled('./src/retrieval/ranker');
|
|
23338
|
-
|
|
23339
|
-
if (intent === 'debug') return Object.assign({}, base, { recencyBoost: base.recencyBoost * 1.5 });
|
|
23340
|
-
if (intent === 'explain') return Object.assign({}, base, { symbolMatch: base.symbolMatch * 1.5 });
|
|
23341
|
-
if (intent === 'refactor') return Object.assign({}, base, { pathMatch: base.pathMatch * 1.5 });
|
|
23342
|
-
if (intent === 'review') return Object.assign({}, base, { exactToken: base.exactToken * 1.3 });
|
|
23343
|
-
return base;
|
|
23954
|
+
return Object.assign({}, DEFAULT_WEIGHTS);
|
|
23344
23955
|
}
|
|
23345
23956
|
|
|
23346
23957
|
function extractQuerySymbols(query) {
|
|
@@ -23515,7 +24126,7 @@ function main() {
|
|
|
23515
24126
|
process.exit(1);
|
|
23516
24127
|
}
|
|
23517
24128
|
|
|
23518
|
-
const { detectIntent, buildSigIndex, rank } = requireSourceOrBundled('./src/retrieval/ranker');
|
|
24129
|
+
const { detectIntent, detectIntents, buildSigIndex, rank } = requireSourceOrBundled('./src/retrieval/ranker');
|
|
23519
24130
|
const { coverageScore } = requireSourceOrBundled('./src/analysis/coverage-score');
|
|
23520
24131
|
const { loadSession, saveSession, mergeSessionContext } = requireSourceOrBundled('./src/session/memory');
|
|
23521
24132
|
const { detectWorkspaces, inferPackage, scopeToPackage } = requireSourceOrBundled('./src/workspace/detector');
|
|
@@ -23692,7 +24303,7 @@ function main() {
|
|
|
23692
24303
|
console.log([
|
|
23693
24304
|
bar,
|
|
23694
24305
|
` sigmap ask "${query}"`,
|
|
23695
|
-
` Intent : ${
|
|
24306
|
+
` Intent : ${detectIntents(query).join(', ')}`,
|
|
23696
24307
|
` Context : ${ctxTok.toLocaleString()} tokens → ${path.relative(cwd, outPath)}`,
|
|
23697
24308
|
` Coverage : ${coveragePct}%`,
|
|
23698
24309
|
` Risk : ${riskLevel}`,
|