sigmap 6.10.12 → 6.11.1
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/CHANGELOG.md +21 -0
- package/README.md +9 -9
- package/gen-context.js +125 -85
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/extractors/line-anchor.js +52 -0
- package/src/extractors/python.js +36 -8
- package/src/extractors/python_ast.py +13 -6
- package/src/extractors/typescript.js +46 -15
- package/src/mcp/server.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,27 @@ Format: [Semantic Versioning](https://semver.org/)
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## [6.11.1] — 2026-06-04
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **MCP hot-cold cold signatures in bundled server** — the bundled MCP server now includes the hot-cold "cold" signatures, so context lookups return complete results under the hot-cold strategy (closes #201, PR #216). Thanks @rudi193-cmd.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## [6.11.0] — 2026-06-03
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Line anchors on signatures (Surgical Context Phase 1)** — top-level TypeScript and Python signatures now carry a `:start-end` line anchor (e.g. `export class UserRepository :18-36`), so agents can read the exact lines instead of re-opening whole files. Rendered automatically by `ask`, `CLAUDE.md`, and every adapter — no consumer changes (closes #212).
|
|
26
|
+
- New shared `src/extractors/line-anchor.js` helper (`lineAt`, `anchor`, `withAnchor`).
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Block-comment / docstring line-shift bug** — comment stripping that blanked `/* */` and `"""…"""` to `''` destroyed newlines and corrupted line numbers. Replaced with a newline-preserving strip so char-offset → line-number stays exact. The Python AST and regex fallback paths now produce identical anchors.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
13
34
|
## [6.10.12] — 2026-05-27
|
|
14
35
|
|
|
15
36
|
### Added
|
package/README.md
CHANGED
|
@@ -47,10 +47,10 @@ SigMap extracts function and class signatures from your codebase and feeds the r
|
|
|
47
47
|
|
|
48
48
|
## Why SigMap?
|
|
49
49
|
|
|
50
|
-
- **
|
|
51
|
-
- **
|
|
52
|
-
- **
|
|
53
|
-
- **1.66 prompts per task** — down from 2.84 (
|
|
50
|
+
- **81.1% hit@5** — right file found in top 5 results (vs 13.6% baseline)
|
|
51
|
+
- **96.5% token reduction** — average across 21 real repos
|
|
52
|
+
- **53.3% task success rate** — up from 10% without context
|
|
53
|
+
- **1.66 prompts per task** — down from 2.84 (41.8% fewer retries)
|
|
54
54
|
- **31 languages supported** — TypeScript, Python, Go, Rust, Java, R, and 25 others
|
|
55
55
|
- **No vendor lock-in** — works with any AI assistant or local LLM
|
|
56
56
|
- **No API costs** — use local models (Ollama, llama.cpp, vLLM) with zero token fees
|
|
@@ -63,7 +63,7 @@ SigMap extracts function and class signatures from your codebase and feeds the r
|
|
|
63
63
|
|
|
64
64
|
| Without SigMap | With SigMap |
|
|
65
65
|
|---|---|
|
|
66
|
-
| ❌ Guessing which files are relevant | ✅ Right file in context —
|
|
66
|
+
| ❌ Guessing which files are relevant | ✅ Right file in context — 81% of the time |
|
|
67
67
|
| ❌ Sending the full repo to your AI | ✅ Minimal context — only what matters |
|
|
68
68
|
| ❌ Embeddings / vector DB required | ✅ Grounded answers, no infra needed |
|
|
69
69
|
|
|
@@ -87,12 +87,12 @@ Ask → Rank → Context → Validate → Judge → Learn
|
|
|
87
87
|
## Benchmark
|
|
88
88
|
|
|
89
89
|
```
|
|
90
|
-
Benchmark : sigmap-v6.
|
|
91
|
-
Date : 2026-
|
|
90
|
+
Benchmark : sigmap-v6.11-main (21 repositories, including R language)
|
|
91
|
+
Date : 2026-06-04
|
|
92
92
|
|
|
93
|
-
Hit@5 :
|
|
93
|
+
Hit@5 : 81.1% (baseline 13.6% — 6.0× lift)
|
|
94
94
|
Token reduction: 96.5% (across 21 repos)
|
|
95
|
-
Prompt reduction : 41.
|
|
95
|
+
Prompt reduction : 41.8% (2.84 → 1.66 prompts per task)
|
|
96
96
|
Task success : 53.3% (baseline 10%)
|
|
97
97
|
Repos tested : 21 (JavaScript, Python, Go, Rust, Java, R, C++, C#, Dart, Swift, Ruby, PHP, Scala, Kotlin, and more)
|
|
98
98
|
```
|
package/gen-context.js
CHANGED
|
@@ -1174,6 +1174,30 @@ __factories["./src/extractors/php"] = function(module, exports) {
|
|
|
1174
1174
|
|
|
1175
1175
|
};
|
|
1176
1176
|
|
|
1177
|
+
// ── ./src/extractors/line-anchor ──
|
|
1178
|
+
__factories["./src/extractors/line-anchor"] = function(module, exports) {
|
|
1179
|
+
|
|
1180
|
+
function lineAt(src, idx) {
|
|
1181
|
+
let line = 1;
|
|
1182
|
+
const end = Math.min(idx, src.length);
|
|
1183
|
+
for (let i = 0; i < end; i++) {
|
|
1184
|
+
if (src.charCodeAt(i) === 10) line++;
|
|
1185
|
+
}
|
|
1186
|
+
return line;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function anchor(start, end) {
|
|
1190
|
+
return ` :${start}-${end}`;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function withAnchor(sig, start, end) {
|
|
1194
|
+
return `${sig}${anchor(start, end)}`;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
module.exports = { lineAt, anchor, withAnchor };
|
|
1198
|
+
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1177
1201
|
// ── ./src/extractors/python ──
|
|
1178
1202
|
__factories["./src/extractors/python"] = function(module, exports) {
|
|
1179
1203
|
|
|
@@ -5339,42 +5363,29 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
|
|
|
5339
5363
|
*/
|
|
5340
5364
|
function searchSignatures(args, cwd) {
|
|
5341
5365
|
if (!args || !args.query) return 'Missing required argument: query';
|
|
5342
|
-
|
|
5343
|
-
const contextPath = path.join(cwd, CONTEXT_FILE);
|
|
5344
|
-
if (!fs.existsSync(contextPath)) {
|
|
5345
|
-
return 'No context file found. Run: node gen-context.js';
|
|
5346
|
-
}
|
|
5347
|
-
|
|
5348
|
-
const content = fs.readFileSync(contextPath, 'utf8');
|
|
5349
5366
|
const query = args.query.toLowerCase();
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
for (const line of lines) {
|
|
5357
|
-
if (line.startsWith('### ')) {
|
|
5358
|
-
currentFile = line.slice(4).trim();
|
|
5359
|
-
fileHeaderAdded = false;
|
|
5360
|
-
continue;
|
|
5361
|
-
}
|
|
5362
|
-
// Skip markdown fences and top-level headers
|
|
5363
|
-
if (line.startsWith('```') || line.startsWith('## ') || line.startsWith('# ') || line.startsWith('<!--')) {
|
|
5364
|
-
continue;
|
|
5367
|
+
|
|
5368
|
+
try {
|
|
5369
|
+
const { buildSigIndex } = __require('./src/retrieval/ranker');
|
|
5370
|
+
const index = buildSigIndex(cwd);
|
|
5371
|
+
if (index.size === 0) {
|
|
5372
|
+
return 'No context file found. Run: node gen-context.js';
|
|
5365
5373
|
}
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
result.push(
|
|
5374
|
+
|
|
5375
|
+
const result = [];
|
|
5376
|
+
for (const [file, sigs] of index.entries()) {
|
|
5377
|
+
const hits = sigs.filter((s) => s.toLowerCase().includes(query));
|
|
5378
|
+
if (hits.length === 0) continue;
|
|
5379
|
+
if (result.length > 0) result.push('');
|
|
5380
|
+
result.push(`### ${file}`);
|
|
5381
|
+
result.push(...hits);
|
|
5373
5382
|
}
|
|
5383
|
+
|
|
5384
|
+
if (result.length === 0) return `No signatures found matching: ${args.query}`;
|
|
5385
|
+
return result.join('\n');
|
|
5386
|
+
} catch (err) {
|
|
5387
|
+
return `_search_signatures failed: ${err.message}_`;
|
|
5374
5388
|
}
|
|
5375
|
-
|
|
5376
|
-
if (result.length === 0) return `No signatures found matching: ${args.query}`;
|
|
5377
|
-
return result.join('\n');
|
|
5378
5389
|
}
|
|
5379
5390
|
|
|
5380
5391
|
/**
|
|
@@ -5640,56 +5651,44 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
|
|
|
5640
5651
|
}
|
|
5641
5652
|
|
|
5642
5653
|
function listModules(args, cwd) {
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
let blockBuf = [];
|
|
5653
|
-
|
|
5654
|
-
function flushBlock() {
|
|
5655
|
-
if (currentGroup === null || blockBuf.length === 0) return;
|
|
5656
|
-
if (!groups[currentGroup]) groups[currentGroup] = { fileCount: 0, tokenCount: 0 };
|
|
5657
|
-
groups[currentGroup].fileCount++;
|
|
5658
|
-
groups[currentGroup].tokenCount += Math.ceil(blockBuf.join('\n').length / 4);
|
|
5659
|
-
blockBuf = [];
|
|
5660
|
-
}
|
|
5661
|
-
|
|
5662
|
-
for (const line of ctxLines) {
|
|
5663
|
-
if (line.startsWith('### ')) {
|
|
5664
|
-
flushBlock();
|
|
5665
|
-
const rel = line.slice(4).trim().replace(/\\/g, '/');
|
|
5654
|
+
try {
|
|
5655
|
+
const { buildSigIndex } = __require('./src/retrieval/ranker');
|
|
5656
|
+
const index = buildSigIndex(cwd);
|
|
5657
|
+
if (index.size === 0) {
|
|
5658
|
+
return 'No context file found. Run: node gen-context.js';
|
|
5659
|
+
}
|
|
5660
|
+
|
|
5661
|
+
const groups = {};
|
|
5662
|
+
for (const [rel, sigs] of index.entries()) {
|
|
5666
5663
|
const parts = rel.split('/');
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5664
|
+
const mod = parts.length > 1 ? parts[0] : '.';
|
|
5665
|
+
if (!groups[mod]) groups[mod] = { fileCount: 0, tokenCount: 0 };
|
|
5666
|
+
groups[mod].fileCount++;
|
|
5667
|
+
groups[mod].tokenCount += Math.ceil(sigs.join('\n').length / 4);
|
|
5670
5668
|
}
|
|
5669
|
+
|
|
5670
|
+
const sorted = Object.entries(groups)
|
|
5671
|
+
.map(([mod, data]) => ({ module: mod, fileCount: data.fileCount, tokenCount: data.tokenCount }))
|
|
5672
|
+
.sort((a, b) => b.tokenCount - a.tokenCount);
|
|
5673
|
+
|
|
5674
|
+
if (sorted.length === 0) return 'No modules found in context file.';
|
|
5675
|
+
|
|
5676
|
+
const total = sorted.reduce((s, m) => s + m.tokenCount, 0);
|
|
5677
|
+
|
|
5678
|
+
return [
|
|
5679
|
+
'# Modules',
|
|
5680
|
+
'',
|
|
5681
|
+
'| Module | Files | Tokens |',
|
|
5682
|
+
'|--------|-------|--------|',
|
|
5683
|
+
...sorted.map((m) => `| ${m.module} | ${m.fileCount} | ~${m.tokenCount} |`),
|
|
5684
|
+
'',
|
|
5685
|
+
`**Total context tokens: ~${total}**`,
|
|
5686
|
+
'',
|
|
5687
|
+
'_Use `read_context({ module: "name" })` to get signatures for a specific module._',
|
|
5688
|
+
].join('\n');
|
|
5689
|
+
} catch (err) {
|
|
5690
|
+
return `_list_modules failed: ${err.message}_`;
|
|
5671
5691
|
}
|
|
5672
|
-
flushBlock();
|
|
5673
|
-
|
|
5674
|
-
const sorted = Object.entries(groups)
|
|
5675
|
-
.map(([mod, data]) => ({ module: mod, fileCount: data.fileCount, tokenCount: data.tokenCount }))
|
|
5676
|
-
.sort((a, b) => b.tokenCount - a.tokenCount);
|
|
5677
|
-
|
|
5678
|
-
if (sorted.length === 0) return 'No modules found in context file.';
|
|
5679
|
-
|
|
5680
|
-
const total = sorted.reduce((s, m) => s + m.tokenCount, 0);
|
|
5681
|
-
|
|
5682
|
-
return [
|
|
5683
|
-
'# Modules',
|
|
5684
|
-
'',
|
|
5685
|
-
'| Module | Files | Tokens |',
|
|
5686
|
-
'|--------|-------|--------|',
|
|
5687
|
-
...sorted.map((m) => `| ${m.module} | ${m.fileCount} | ~${m.tokenCount} |`),
|
|
5688
|
-
'',
|
|
5689
|
-
`**Total context tokens: ~${total}**`,
|
|
5690
|
-
'',
|
|
5691
|
-
'_Use `read_context({ module: "name" })` to get signatures for a specific module._',
|
|
5692
|
-
].join('\n');
|
|
5693
5692
|
}
|
|
5694
5693
|
|
|
5695
5694
|
function queryContext(args, cwd) {
|
|
@@ -5901,7 +5900,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
|
|
|
5901
5900
|
|
|
5902
5901
|
const SERVER_INFO = {
|
|
5903
5902
|
name: 'sigmap',
|
|
5904
|
-
version: '6.
|
|
5903
|
+
version: '6.11.1',
|
|
5905
5904
|
description: 'SigMap MCP server — code signatures on demand',
|
|
5906
5905
|
};
|
|
5907
5906
|
|
|
@@ -6968,9 +6967,50 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
6968
6967
|
if (currentFile !== null) index.set(currentFile, sigs);
|
|
6969
6968
|
return index;
|
|
6970
6969
|
}
|
|
6970
|
+
|
|
6971
|
+
function _mergeSigIndex(target, source) {
|
|
6972
|
+
for (const [file, sigs] of source.entries()) {
|
|
6973
|
+
if (!sigs || sigs.length === 0) continue;
|
|
6974
|
+
if (!target.has(file) || target.get(file).length < sigs.length) {
|
|
6975
|
+
target.set(file, sigs);
|
|
6976
|
+
}
|
|
6977
|
+
}
|
|
6978
|
+
return target;
|
|
6979
|
+
}
|
|
6980
|
+
|
|
6981
|
+
function _buildSigIndexFromCache(cwd) {
|
|
6982
|
+
const fs = require('fs');
|
|
6983
|
+
const path = require('path');
|
|
6984
|
+
const index = new Map();
|
|
6985
|
+
try {
|
|
6986
|
+
const { loadCache } = require('../cache/sig-cache');
|
|
6987
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
6988
|
+
let version = '0.0.0';
|
|
6989
|
+
if (fs.existsSync(pkgPath)) {
|
|
6990
|
+
version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || version;
|
|
6991
|
+
}
|
|
6992
|
+
const cache = loadCache(cwd, version);
|
|
6993
|
+
for (const [absPath, entry] of cache.entries()) {
|
|
6994
|
+
if (!entry || !entry.sigs || entry.sigs.length === 0) continue;
|
|
6995
|
+
const rel = path.relative(cwd, absPath).replace(/\\/g, '/');
|
|
6996
|
+
if (!rel || rel.startsWith('..')) continue;
|
|
6997
|
+
index.set(rel, entry.sigs);
|
|
6998
|
+
}
|
|
6999
|
+
} catch (_) {}
|
|
7000
|
+
return index;
|
|
7001
|
+
}
|
|
7002
|
+
|
|
7003
|
+
function _enrichSigIndexFromStrategy(cwd, index) {
|
|
7004
|
+
const path = require('path');
|
|
7005
|
+
const coldPath = path.join(cwd, '.github', 'context-cold.md');
|
|
7006
|
+
_mergeSigIndex(index, _parseContextFile(coldPath));
|
|
7007
|
+
_mergeSigIndex(index, _buildSigIndexFromCache(cwd));
|
|
7008
|
+
return index;
|
|
7009
|
+
}
|
|
7010
|
+
|
|
6971
7011
|
function buildSigIndex(cwd, opts) {
|
|
6972
7012
|
const fs = require('fs'); const path = require('path');
|
|
6973
|
-
if (opts && opts.contextPath) return _parseContextFile(opts.contextPath);
|
|
7013
|
+
if (opts && opts.contextPath) return _enrichSigIndexFromStrategy(cwd, _parseContextFile(opts.contextPath));
|
|
6974
7014
|
// Check gen-context.config.json for a persisted customOutput path.
|
|
6975
7015
|
try {
|
|
6976
7016
|
const cfgPath = path.join(cwd, 'gen-context.config.json');
|
|
@@ -6978,16 +7018,16 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
|
|
|
6978
7018
|
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
6979
7019
|
if (cfg.customOutput) {
|
|
6980
7020
|
const idx = _parseContextFile(path.resolve(cwd, cfg.customOutput));
|
|
6981
|
-
if (idx.size > 0) return idx;
|
|
7021
|
+
if (idx.size > 0) return _enrichSigIndexFromStrategy(cwd, idx);
|
|
6982
7022
|
}
|
|
6983
7023
|
}
|
|
6984
7024
|
} catch (_) {}
|
|
6985
7025
|
for (const parts of ADAPTER_OUTPUT_PATHS) {
|
|
6986
7026
|
const contextPath = path.join(cwd, ...parts);
|
|
6987
7027
|
const index = _parseContextFile(contextPath);
|
|
6988
|
-
if (index.size > 0) return index;
|
|
7028
|
+
if (index.size > 0) return _enrichSigIndexFromStrategy(cwd, index);
|
|
6989
7029
|
}
|
|
6990
|
-
return new Map();
|
|
7030
|
+
return _enrichSigIndexFromStrategy(cwd, new Map());
|
|
6991
7031
|
}
|
|
6992
7032
|
function formatRankTable(results, query) {
|
|
6993
7033
|
if (!results || results.length === 0) return `No matching files found for query: "${query}"\n`;
|
|
@@ -8655,7 +8695,7 @@ const path = require('path');
|
|
|
8655
8695
|
const os = require('os');
|
|
8656
8696
|
const { execSync } = require('child_process');
|
|
8657
8697
|
|
|
8658
|
-
const VERSION = '6.
|
|
8698
|
+
const VERSION = '6.11.1';
|
|
8659
8699
|
const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
|
|
8660
8700
|
|
|
8661
8701
|
function requireSourceOrBundled(key) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Line-anchor helpers for Surgical Context (v6.11.0).
|
|
5
|
+
*
|
|
6
|
+
* Signatures carry their source location as a `:start-end` suffix so an agent
|
|
7
|
+
* can read the exact lines instead of re-opening the whole file. The anchor is
|
|
8
|
+
* a plain string suffix, which keeps the existing `string[]` signature contract
|
|
9
|
+
* intact — ranker, adapters, and CLAUDE.md render it for free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 1-based line number of character index `idx` within `src`.
|
|
14
|
+
* Counts newlines in the prefix, so it stays correct as long as the source
|
|
15
|
+
* being indexed preserves every newline (see the newline-preserving comment
|
|
16
|
+
* strips in the extractors).
|
|
17
|
+
*
|
|
18
|
+
* @param {string} src
|
|
19
|
+
* @param {number} idx
|
|
20
|
+
* @returns {number}
|
|
21
|
+
*/
|
|
22
|
+
function lineAt(src, idx) {
|
|
23
|
+
let line = 1;
|
|
24
|
+
const end = Math.min(idx, src.length);
|
|
25
|
+
for (let i = 0; i < end; i++) {
|
|
26
|
+
if (src.charCodeAt(i) === 10) line++;
|
|
27
|
+
}
|
|
28
|
+
return line;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render an anchor suffix: ` :start-end`.
|
|
33
|
+
* @param {number} start
|
|
34
|
+
* @param {number} end
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function anchor(start, end) {
|
|
38
|
+
return ` :${start}-${end}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Append a line anchor to a signature string.
|
|
43
|
+
* @param {string} sig
|
|
44
|
+
* @param {number} start
|
|
45
|
+
* @param {number} end
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function withAnchor(sig, start, end) {
|
|
49
|
+
return `${sig}${anchor(start, end)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { lineAt, anchor, withAnchor };
|
package/src/extractors/python.js
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const { lineAt } = require('./line-anchor');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 1-based line of the last source line belonging to a top-level (indent 0)
|
|
8
|
+
* def/class body that starts at `startLine` (1-based). Trailing blank lines
|
|
9
|
+
* are excluded.
|
|
10
|
+
* @param {string[]} srcLines
|
|
11
|
+
* @param {number} startLine
|
|
12
|
+
* @returns {number}
|
|
13
|
+
*/
|
|
14
|
+
function pyBlockEnd(srcLines, startLine) {
|
|
15
|
+
let end = startLine;
|
|
16
|
+
for (let i = startLine; i < srcLines.length; i++) {
|
|
17
|
+
const line = srcLines[i];
|
|
18
|
+
if (line.trim() === '') continue;
|
|
19
|
+
const indent = line.match(/^(\s*)/)[1].length;
|
|
20
|
+
if (indent === 0) break;
|
|
21
|
+
end = i + 1;
|
|
22
|
+
}
|
|
23
|
+
return end;
|
|
24
|
+
}
|
|
4
25
|
|
|
5
26
|
/**
|
|
6
27
|
* Try to extract signatures using the native Python AST extractor.
|
|
@@ -42,21 +63,26 @@ function extract(src, filePath) {
|
|
|
42
63
|
|
|
43
64
|
// noComments: strip only # comments, keep docstrings (needed for @decorator detection)
|
|
44
65
|
const noComments = src.replace(/#.*$/gm, '');
|
|
45
|
-
// stripped: also strip docstrings (safe for regex matching)
|
|
66
|
+
// stripped: also strip docstrings (safe for regex matching). Docstrings are
|
|
67
|
+
// blanked newline-by-newline (non-newline chars → spaces) so character
|
|
68
|
+
// offsets and line numbers stay exact for line anchors.
|
|
46
69
|
const stripped = noComments
|
|
47
|
-
.replace(/"""[\s\S]*?"""/g, '')
|
|
48
|
-
.replace(/'''[\s\S]*?'''/g, '');
|
|
70
|
+
.replace(/"""[\s\S]*?"""/g, (m) => m.replace(/[^\n]/g, ' '))
|
|
71
|
+
.replace(/'''[\s\S]*?'''/g, (m) => m.replace(/[^\n]/g, ' '));
|
|
72
|
+
const srcLines = src.split('\n');
|
|
49
73
|
|
|
50
74
|
// Classes
|
|
51
75
|
for (const m of stripped.matchAll(/^class\s+(\w+)(?:\s*\(([^)]*)\))?\s*:/gm)) {
|
|
52
76
|
const className = m[1];
|
|
53
77
|
const baseName = m[2] ? m[2].trim() : '';
|
|
54
78
|
const bodyStart = m.index + m[0].length;
|
|
79
|
+
const clsStart = lineAt(stripped, m.index);
|
|
80
|
+
const clsAnchor = ` :${clsStart}-${pyBlockEnd(srcLines, clsStart)}`;
|
|
55
81
|
|
|
56
82
|
// Try @dataclass collapse
|
|
57
83
|
const dcFields = tryExtractDataclassFields(stripped, m.index);
|
|
58
84
|
if (dcFields !== null) {
|
|
59
|
-
sigs.push(`@dataclass ${className}(${dcFields})`);
|
|
85
|
+
sigs.push(`@dataclass ${className}(${dcFields})${clsAnchor}`);
|
|
60
86
|
continue;
|
|
61
87
|
}
|
|
62
88
|
|
|
@@ -64,13 +90,13 @@ function extract(src, filePath) {
|
|
|
64
90
|
if (/(BaseModel|BaseSettings)/.test(baseName)) {
|
|
65
91
|
const bmFields = tryExtractBaseModelFields(stripped, bodyStart);
|
|
66
92
|
if (bmFields) {
|
|
67
|
-
sigs.push(`class ${className}(${baseName}) ${bmFields}`);
|
|
93
|
+
sigs.push(`class ${className}(${baseName}) ${bmFields}${clsAnchor}`);
|
|
68
94
|
continue;
|
|
69
95
|
}
|
|
70
96
|
}
|
|
71
97
|
|
|
72
98
|
const baseStr = baseName ? `(${baseName})` : '';
|
|
73
|
-
sigs.push(`class ${className}${baseStr}`);
|
|
99
|
+
sigs.push(`class ${className}${baseStr}${clsAnchor}`);
|
|
74
100
|
|
|
75
101
|
// Class-level ALL_CAPS constants
|
|
76
102
|
for (const c of extractClassConstants(stripped, bodyStart)) {
|
|
@@ -91,7 +117,9 @@ function extract(src, filePath) {
|
|
|
91
117
|
const retStr = retType ? ` → ${retType}` : '';
|
|
92
118
|
const hint = extractDocHint(src, m[2], m[0]);
|
|
93
119
|
const hintStr = hint ? ` # ${hint}` : '';
|
|
94
|
-
|
|
120
|
+
const fnStart = lineAt(stripped, m.index);
|
|
121
|
+
const fnAnchor = ` :${fnStart}-${pyBlockEnd(srcLines, fnStart)}`;
|
|
122
|
+
sigs.push(`${asyncKw}def ${m[2]}(${params})${retStr}${fnAnchor}${hintStr}`);
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
// FastAPI router endpoints: @router.METHOD("path") + async def name(...)
|
|
@@ -103,7 +131,7 @@ function extract(src, filePath) {
|
|
|
103
131
|
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
|
104
132
|
const fl = lines[j].trim();
|
|
105
133
|
const fm = fl.match(/^(?:async\s+)?def\s+(\w+)/);
|
|
106
|
-
if (fm) { sigs.push(`${rm[1].toUpperCase()} ${rm[2]} → ${fm[1]}()`); break; }
|
|
134
|
+
if (fm) { const rs = j + 1; sigs.push(`${rm[1].toUpperCase()} ${rm[2]} → ${fm[1]}() :${rs}-${pyBlockEnd(srcLines, rs)}`); break; }
|
|
107
135
|
if (fl && !fl.startsWith('@') && !fl.startsWith('#')) break;
|
|
108
136
|
}
|
|
109
137
|
}
|
|
@@ -213,6 +213,12 @@ def extract_class_constants(class_node):
|
|
|
213
213
|
yield f"{name}={val}"
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
def _anchor(node):
|
|
217
|
+
"""Return a ` :start-end` line anchor for a top-level node (1-based)."""
|
|
218
|
+
end = getattr(node, "end_lineno", None) or node.lineno
|
|
219
|
+
return f" :{node.lineno}-{end}"
|
|
220
|
+
|
|
221
|
+
|
|
216
222
|
def extract_method_sig(func_node):
|
|
217
223
|
"""Format a method signature string (already indented by caller)."""
|
|
218
224
|
is_async = isinstance(func_node, ast.AsyncFunctionDef)
|
|
@@ -232,7 +238,7 @@ def extract_function_sig(func_node, src_lines=None):
|
|
|
232
238
|
ret_str = f" → {ret}" if ret else ""
|
|
233
239
|
hint = get_docstring_hint(func_node)
|
|
234
240
|
hint_str = f" # {hint}" if hint else ""
|
|
235
|
-
return f"{prefix}def {func_node.name}({params}){ret_str}{hint_str}"
|
|
241
|
+
return f"{prefix}def {func_node.name}({params}){ret_str}{_anchor(func_node)}{hint_str}"
|
|
236
242
|
|
|
237
243
|
|
|
238
244
|
def extract_fastapi_routes(tree, src_lines):
|
|
@@ -255,7 +261,7 @@ def extract_fastapi_routes(tree, src_lines):
|
|
|
255
261
|
path_node = dec.args[0]
|
|
256
262
|
if isinstance(path_node, ast.Constant):
|
|
257
263
|
path = path_node.value
|
|
258
|
-
routes.append(f"{method.upper()} {path} → {node.name}()")
|
|
264
|
+
routes.append(f"{method.upper()} {path} → {node.name}(){_anchor(node)}")
|
|
259
265
|
return routes
|
|
260
266
|
|
|
261
267
|
|
|
@@ -276,10 +282,11 @@ def extract(filepath):
|
|
|
276
282
|
if isinstance(node, ast.ClassDef):
|
|
277
283
|
bases_str = ", ".join(annotation_to_str(b) for b in node.bases if b)
|
|
278
284
|
dec_names = get_decorator_names(node)
|
|
285
|
+
cls_anchor = _anchor(node)
|
|
279
286
|
|
|
280
287
|
if is_dataclass(node):
|
|
281
288
|
fields = extract_dataclass_fields(node)
|
|
282
|
-
sigs.append(f"@dataclass {node.name}({fields})")
|
|
289
|
+
sigs.append(f"@dataclass {node.name}({fields}){cls_anchor}")
|
|
283
290
|
elif is_basemodel(node.bases):
|
|
284
291
|
bm_fields = extract_basemodel_fields(node)
|
|
285
292
|
base_label = next(
|
|
@@ -288,12 +295,12 @@ def extract(filepath):
|
|
|
288
295
|
"BaseModel"
|
|
289
296
|
)
|
|
290
297
|
if bm_fields:
|
|
291
|
-
sigs.append(f"class {node.name}({base_label}) {bm_fields}")
|
|
298
|
+
sigs.append(f"class {node.name}({base_label}) {bm_fields}{cls_anchor}")
|
|
292
299
|
else:
|
|
293
|
-
sigs.append(f"class {node.name}({base_label})")
|
|
300
|
+
sigs.append(f"class {node.name}({base_label}){cls_anchor}")
|
|
294
301
|
else:
|
|
295
302
|
base_part = f"({bases_str})" if bases_str else ""
|
|
296
|
-
sigs.append(f"class {node.name}{base_part}")
|
|
303
|
+
sigs.append(f"class {node.name}{base_part}{cls_anchor}")
|
|
297
304
|
|
|
298
305
|
# Class constants
|
|
299
306
|
for const in extract_class_constants(node):
|
|
@@ -1,37 +1,54 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { lineAt, withAnchor } = require('./line-anchor');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Extract signatures from TypeScript source code.
|
|
7
|
+
* Top-level declarations carry a `:start-end` line anchor (see line-anchor.js);
|
|
8
|
+
* indented members do not.
|
|
5
9
|
* @param {string} src - Raw file content
|
|
6
10
|
* @returns {string[]} Array of signature strings
|
|
7
11
|
*/
|
|
8
12
|
function extract(src) {
|
|
9
13
|
if (!src || typeof src !== 'string') return [];
|
|
10
14
|
const sigs = [];
|
|
11
|
-
|
|
12
|
-
//
|
|
15
|
+
// anchors[i] is [start, end] for a top-level sig, or null for an indented member.
|
|
16
|
+
// Kept parallel to `sigs` so existing push/mutation logic stays untouched;
|
|
17
|
+
// anchors are applied once at return.
|
|
18
|
+
const anchors = [];
|
|
19
|
+
|
|
20
|
+
// Strip comments to simplify matching. Block comments are blanked
|
|
21
|
+
// newline-by-newline (non-newline chars → spaces) so character offsets AND
|
|
22
|
+
// line numbers stay exact. Line comments preserve their trailing newline.
|
|
13
23
|
const stripped = src
|
|
14
24
|
.replace(/\/\/.*$/gm, '')
|
|
15
|
-
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
25
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
26
|
+
|
|
27
|
+
// Index of the closing brace for a block whose body starts at bodyStart.
|
|
28
|
+
const blockEndIdx = (bodyStart) => bodyStart + extractBlock(stripped, bodyStart).length;
|
|
16
29
|
|
|
17
30
|
// Exported interfaces
|
|
18
31
|
for (const m of stripped.matchAll(/^export\s+interface\s+(\w+)(?:<[^{]*>)?\s*(?:extends\s+[^{]+)?\{/gm)) {
|
|
32
|
+
const bodyStart = m.index + m[0].length;
|
|
19
33
|
sigs.push(`export interface ${m[1]}`);
|
|
34
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
20
35
|
// Collect members
|
|
21
|
-
const
|
|
22
|
-
const block = extractBlock(stripped, start);
|
|
36
|
+
const block = extractBlock(stripped, bodyStart);
|
|
23
37
|
const members = extractInterfaceMembers(block);
|
|
24
|
-
for (const mem of members) sigs.push(` ${mem}`);
|
|
38
|
+
for (const mem of members) { sigs.push(` ${mem}`); anchors.push(null); }
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
// Exported type aliases
|
|
28
42
|
for (const m of stripped.matchAll(/^export\s+type\s+(\w+)(?:<[^=]*>)?\s*=/gm)) {
|
|
29
43
|
sigs.push(`export type ${m[1]}`);
|
|
44
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, m.index + m[0].length)]);
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
// Exported enums
|
|
33
48
|
for (const m of stripped.matchAll(/^export\s+(?:const\s+)?enum\s+(\w+)\s*\{/gm)) {
|
|
49
|
+
const bodyStart = m.index + m[0].length;
|
|
34
50
|
sigs.push(`export enum ${m[1]}`);
|
|
51
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
// Classes (exported and internal)
|
|
@@ -39,11 +56,12 @@ function extract(src) {
|
|
|
39
56
|
for (const m of stripped.matchAll(classRegex)) {
|
|
40
57
|
const prefix = m[1] ? 'export ' : '';
|
|
41
58
|
const abs = m[2] ? 'abstract ' : '';
|
|
59
|
+
const bodyStart = m.index + m[0].length;
|
|
42
60
|
sigs.push(`${prefix}${abs}class ${m[3]}`);
|
|
43
|
-
|
|
44
|
-
const block = extractBlock(stripped,
|
|
61
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
62
|
+
const block = extractBlock(stripped, bodyStart);
|
|
45
63
|
const methods = extractClassMembers(block);
|
|
46
|
-
for (const meth of methods) sigs.push(` ${meth}`);
|
|
64
|
+
for (const meth of methods) { sigs.push(` ${meth}`); anchors.push(null); }
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
// Exported top-level functions (not methods)
|
|
@@ -53,11 +71,12 @@ function extract(src) {
|
|
|
53
71
|
const retMatch = m[0].match(/\)\s*:\s*([^{]+)\s*\{/);
|
|
54
72
|
const retType = retMatch ? retMatch[1].trim().replace(/\s+/g, ' ').slice(0, 30) : '';
|
|
55
73
|
const retStr = retType ? ` → ${retType}` : '';
|
|
74
|
+
const bodyStart = m.index + m[0].length;
|
|
56
75
|
sigs.push(`export ${asyncKw}function ${m[1]}(${params})${retStr}`);
|
|
76
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
57
77
|
|
|
58
78
|
// Hooks: capture compact return object shape for use* functions.
|
|
59
79
|
if (m[1].startsWith('use')) {
|
|
60
|
-
const bodyStart = m.index + m[0].length;
|
|
61
80
|
const body = stripped.slice(bodyStart, bodyStart + 800);
|
|
62
81
|
const ret = body.match(/return\s*\{([^}]{1,260})\}/);
|
|
63
82
|
if (ret) {
|
|
@@ -78,10 +97,14 @@ function extract(src) {
|
|
|
78
97
|
const asyncKw = /=\s*async\s+/.test(m[0]) ? 'async ' : '';
|
|
79
98
|
const params = normalizeParams(m[2]);
|
|
80
99
|
sigs.push(`export const ${m[1]} = ${asyncKw}(${params}) =>`);
|
|
100
|
+
const bodyStart = stripped.indexOf('{', m.index + m[0].length);
|
|
101
|
+
const endLn = bodyStart !== -1
|
|
102
|
+
? lineAt(stripped, blockEndIdx(bodyStart + 1))
|
|
103
|
+
: lineAt(stripped, m.index + m[0].length);
|
|
104
|
+
anchors.push([lineAt(stripped, m.index), endLn]);
|
|
81
105
|
|
|
82
106
|
// Hooks: capture compact return object shape for use* functions.
|
|
83
107
|
if (m[1].startsWith('use')) {
|
|
84
|
-
const bodyStart = stripped.indexOf('{', m.index + m[0].length);
|
|
85
108
|
if (bodyStart !== -1) {
|
|
86
109
|
const body = stripped.slice(bodyStart, bodyStart + 800);
|
|
87
110
|
const ret = body.match(/return\s*\{([^}]{1,260})\}/);
|
|
@@ -102,22 +125,30 @@ function extract(src) {
|
|
|
102
125
|
// Zustand stores: export const useXxxStore = create<State>()(...)
|
|
103
126
|
for (const m of stripped.matchAll(/^export\s+const\s+(use\w+Store)\s*=\s*create(?:<[^>]*>)?\s*\(/gm)) {
|
|
104
127
|
const stateType = m[0].match(/create<([\w]+)>/)?.[1] || '';
|
|
128
|
+
const startLn = lineAt(stripped, m.index);
|
|
105
129
|
sigs.push(`export const ${m[1]} = create<${stateType}>(...)`);
|
|
130
|
+
anchors.push([startLn, startLn]);
|
|
106
131
|
const ifaceRe = new RegExp(`interface\\s+${stateType}\\s*\\{([\\s\\S]*?)\\}`);
|
|
107
132
|
const ifm = stripped.match(ifaceRe);
|
|
108
133
|
if (ifm) {
|
|
109
|
-
for (const fm of ifm[1].matchAll(/^\s+(\w+)\s*(?:\([^)]*\))?\s*:/gm)) sigs.push(` ${fm[1]}`);
|
|
134
|
+
for (const fm of ifm[1].matchAll(/^\s+(\w+)\s*(?:\([^)]*\))?\s*:/gm)) { sigs.push(` ${fm[1]}`); anchors.push(null); }
|
|
110
135
|
}
|
|
111
136
|
}
|
|
112
137
|
|
|
113
138
|
// API client objects: const xxxApi = { method: async () => {} }
|
|
114
139
|
for (const m of stripped.matchAll(/^(?:export\s+default\s+|const\s+)(\w*[Aa]pi\w*)\s*=\s*\{/gm)) {
|
|
115
|
-
const
|
|
140
|
+
const bodyStart = m.index + m[0].length;
|
|
141
|
+
const block = extractBlock(stripped, bodyStart);
|
|
116
142
|
const methods = [...block.matchAll(/^\s+(\w+)\s*:\s*(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>/gm)].map(mm => mm[1]);
|
|
117
|
-
if (methods.length)
|
|
143
|
+
if (methods.length) {
|
|
144
|
+
sigs.push(`${m[1]}: { ${methods.join(', ')} }`);
|
|
145
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)]);
|
|
146
|
+
}
|
|
118
147
|
}
|
|
119
148
|
|
|
120
|
-
return sigs
|
|
149
|
+
return sigs
|
|
150
|
+
.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s))
|
|
151
|
+
.slice(0, 35);
|
|
121
152
|
}
|
|
122
153
|
|
|
123
154
|
function extractBlock(src, startIndex) {
|
package/src/mcp/server.js
CHANGED