sigmap 2.3.0 → 2.5.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/CHANGELOG.md +55 -0
- package/README.md +91 -16
- package/gen-context.js +248 -3
- package/package.json +7 -1
- package/packages/cli/index.js +63 -0
- package/packages/cli/package.json +26 -0
- package/packages/core/README.md +141 -0
- package/packages/core/index.js +215 -0
- package/packages/core/package.json +28 -0
- package/src/config/defaults.js +8 -0
- package/src/graph/builder.js +259 -0
- package/src/graph/impact.js +235 -0
- package/src/mcp/handlers.js +21 -1
- package/src/mcp/server.js +3 -2
- package/src/mcp/tools.js +24 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# sigmap-core
|
|
2
|
+
|
|
3
|
+
Programmatic API for [SigMap](https://manojmallick.github.io/sigmap/) — zero-dependency code signature extraction, ranked retrieval, secret scanning, and project health scoring.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install sigmap # installs the full package (CLI + core)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`require('sigmap')` resolves to this library via the root `exports` field.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const { extract, rank, buildSigIndex, scan, score } = require('sigmap');
|
|
17
|
+
|
|
18
|
+
// 1. Extract signatures from any source file
|
|
19
|
+
const sigs = extract('function hello() { return "world"; }', 'javascript');
|
|
20
|
+
// → ['function hello()']
|
|
21
|
+
|
|
22
|
+
// 2. Scan for secrets before storing signatures
|
|
23
|
+
const { safe, redacted } = scan(sigs, 'src/utils.js');
|
|
24
|
+
|
|
25
|
+
// 3. Build an index from the generated context file
|
|
26
|
+
const index = buildSigIndex('/path/to/your/project');
|
|
27
|
+
|
|
28
|
+
// 4. Rank files against a query
|
|
29
|
+
const results = rank('add a new language extractor', index, { topK: 5 });
|
|
30
|
+
// → [{ file: 'src/extractors/python.js', score: 3.5, sigs: [...], tokens: 42 }, ...]
|
|
31
|
+
|
|
32
|
+
// 5. Check project health
|
|
33
|
+
const health = score('/path/to/your/project');
|
|
34
|
+
// → { score: 92, grade: 'A', strategy: 'full', ... }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## API reference
|
|
38
|
+
|
|
39
|
+
### `extract(src, language)` → `string[]`
|
|
40
|
+
|
|
41
|
+
Extract code signatures from source text.
|
|
42
|
+
|
|
43
|
+
| Param | Type | Description |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `src` | `string` | Raw file content |
|
|
46
|
+
| `language` | `string` | Language name (`'typescript'`, `'python'`, etc.) **or** a file path/name with a recognised extension |
|
|
47
|
+
|
|
48
|
+
Returns an array of signature strings. Never throws — returns `[]` on any error.
|
|
49
|
+
|
|
50
|
+
**Supported languages:** typescript, javascript, python, java, kotlin, go, rust, csharp, cpp, ruby, php, swift, dart, scala, vue, svelte, html, css, yaml, shell, dockerfile (21 total)
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
// By language name
|
|
54
|
+
extract(src, 'python');
|
|
55
|
+
|
|
56
|
+
// By file path (extension is used to detect language)
|
|
57
|
+
extract(src, 'src/server.ts');
|
|
58
|
+
extract(src, 'Dockerfile');
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
### `rank(query, sigIndex, opts?)` → `Result[]`
|
|
64
|
+
|
|
65
|
+
Rank all files in a signature index against a natural-language query.
|
|
66
|
+
|
|
67
|
+
| Param | Type | Description |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `query` | `string` | Natural language or keyword query |
|
|
70
|
+
| `sigIndex` | `Map<string, string[]>` | File → signatures map (from `buildSigIndex`) |
|
|
71
|
+
| `opts.topK` | `number` | Max files to return (default: `10`) |
|
|
72
|
+
| `opts.weights` | `object` | Override default scoring weights |
|
|
73
|
+
| `opts.recencySet` | `Set<string>` | Files to boost with `recencyBoost` multiplier |
|
|
74
|
+
|
|
75
|
+
Each result: `{ file: string, score: number, sigs: string[], tokens: number }`
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
### `buildSigIndex(cwd)` → `Map<string, string[]>`
|
|
80
|
+
|
|
81
|
+
Build a file→signatures map from the generated `.github/copilot-instructions.md`.
|
|
82
|
+
Requires `node gen-context.js` to have been run first.
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
const index = buildSigIndex('/path/to/project');
|
|
86
|
+
// → Map { 'src/extractors/python.js' => ['class Extractor', ' def extract(src)'], ... }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### `scan(sigs, filePath)` → `{ safe: string[], redacted: boolean }`
|
|
92
|
+
|
|
93
|
+
Scan signature strings for secrets (AWS keys, GitHub tokens, DB connection strings, etc.) and redact any matches.
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
const { safe, redacted } = scan(
|
|
97
|
+
['const SECRET = "ghp_abc123xyz..."'],
|
|
98
|
+
'src/config.ts'
|
|
99
|
+
);
|
|
100
|
+
// safe → ['[REDACTED — GitHub Token detected in src/config.ts]']
|
|
101
|
+
// redacted → true
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Detected patterns:** AWS Access Key, AWS Secret Key, GCP API Key, GitHub Token, JWT Token, DB Connection String, SSH Private Key, Stripe Key, Twilio Key, Generic Secret
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### `score(cwd)` → `HealthResult`
|
|
109
|
+
|
|
110
|
+
Compute a composite health score for the SigMap installation in a project.
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
const health = score('/path/to/project');
|
|
114
|
+
// {
|
|
115
|
+
// score: 92,
|
|
116
|
+
// grade: 'A', // A ≥90 | B ≥75 | C ≥60 | D <60
|
|
117
|
+
// strategy: 'full',
|
|
118
|
+
// tokenReductionPct: 97.2,
|
|
119
|
+
// daysSinceRegen: 0.1,
|
|
120
|
+
// totalRuns: 48,
|
|
121
|
+
// overBudgetRuns: 0,
|
|
122
|
+
// }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Migration from v2.3 and earlier
|
|
126
|
+
|
|
127
|
+
`require('sigmap')` was not available before v2.4. The programmatic API is new — no migration needed for CLI usage.
|
|
128
|
+
|
|
129
|
+
All existing CLI flags (`--generate`, `--watch`, `--mcp`, `--query`, `--analyze`, `--benchmark`, `--health`, …) are unchanged.
|
|
130
|
+
|
|
131
|
+
## What's next — v2.5-v2.6
|
|
132
|
+
|
|
133
|
+
v2.5 adds `analyzeImpact(changedFiles, cwd)` to `packages/core` — given a list of changed files, it returns every file that transitively imports them. See [issue #14](https://github.com/manojmallick/sigmap/issues/14).
|
|
134
|
+
|
|
135
|
+
v2.6 adds benchmark and paper reporting capabilities — run evaluations against external repos and export metrics in LaTeX format for academic papers. See [issue #16](https://github.com/manojmallick/sigmap/issues/16).
|
|
136
|
+
|
|
137
|
+
See the full [roadmap](https://manojmallick.github.io/sigmap/roadmap.html).
|
|
138
|
+
|
|
139
|
+
## Zero dependencies
|
|
140
|
+
|
|
141
|
+
This package has zero runtime npm dependencies. It uses only Node.js built-ins: `fs`, `path`.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sigmap-core — public programmatic API
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const { extract, rank, scan, score } = require('sigmap');
|
|
8
|
+
*
|
|
9
|
+
* All functions are zero-dependency and never throw.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Language extractor registry
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const EXT_MAP = {
|
|
18
|
+
'.ts': 'typescript', '.tsx': 'typescript',
|
|
19
|
+
'.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
|
|
20
|
+
'.py': 'python', '.pyw': 'python',
|
|
21
|
+
'.java': 'java',
|
|
22
|
+
'.kt': 'kotlin', '.kts': 'kotlin',
|
|
23
|
+
'.go': 'go',
|
|
24
|
+
'.rs': 'rust',
|
|
25
|
+
'.cs': 'csharp',
|
|
26
|
+
'.cpp': 'cpp', '.c': 'cpp', '.h': 'cpp', '.hpp': 'cpp', '.cc': 'cpp',
|
|
27
|
+
'.rb': 'ruby', '.rake': 'ruby',
|
|
28
|
+
'.php': 'php',
|
|
29
|
+
'.swift': 'swift',
|
|
30
|
+
'.dart': 'dart',
|
|
31
|
+
'.scala': 'scala', '.sc': 'scala',
|
|
32
|
+
'.vue': 'vue',
|
|
33
|
+
'.svelte': 'svelte',
|
|
34
|
+
'.html': 'html', '.htm': 'html',
|
|
35
|
+
'.css': 'css', '.scss': 'css', '.sass': 'css', '.less': 'css',
|
|
36
|
+
'.yml': 'yaml', '.yaml': 'yaml',
|
|
37
|
+
'.sh': 'shell', '.bash': 'shell', '.zsh': 'shell', '.fish': 'shell',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const SRC_ROOT = path.resolve(__dirname, '..', '..', 'src');
|
|
41
|
+
|
|
42
|
+
function _resolveExtractor(language) {
|
|
43
|
+
const extPath = path.join(SRC_ROOT, 'extractors', language + '.js');
|
|
44
|
+
try {
|
|
45
|
+
return require(extPath);
|
|
46
|
+
} catch (_) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// extract(src, language) → string[]
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* Extract code signatures from source text for the given language.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} src - Raw source file content
|
|
58
|
+
* @param {string} language - Language name (e.g. 'typescript', 'python')
|
|
59
|
+
* OR a file path/name with a recognised extension
|
|
60
|
+
* @returns {string[]} Array of signature strings (never throws)
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* const sigs = extract('function hello() {}', 'javascript');
|
|
64
|
+
* // → ['function hello()']
|
|
65
|
+
*
|
|
66
|
+
* const sigs2 = extract(src, 'src/server.ts');
|
|
67
|
+
* // → detected as typescript via extension
|
|
68
|
+
*/
|
|
69
|
+
function extract(src, language) {
|
|
70
|
+
if (!src || typeof src !== 'string') return [];
|
|
71
|
+
if (!language || typeof language !== 'string') return [];
|
|
72
|
+
|
|
73
|
+
// If language looks like a file path, derive language from extension
|
|
74
|
+
let lang = language;
|
|
75
|
+
if (language.includes('.') || language.includes('/') || language.includes('\\')) {
|
|
76
|
+
const ext = path.extname(language).toLowerCase();
|
|
77
|
+
const base = path.basename(language);
|
|
78
|
+
if (base === 'Dockerfile' || base.startsWith('Dockerfile.')) {
|
|
79
|
+
lang = 'dockerfile';
|
|
80
|
+
} else {
|
|
81
|
+
lang = EXT_MAP[ext] || null;
|
|
82
|
+
}
|
|
83
|
+
if (!lang) return [];
|
|
84
|
+
} else {
|
|
85
|
+
// Normalise e.g. 'JavaScript' → 'javascript'
|
|
86
|
+
lang = language.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const mod = _resolveExtractor(lang);
|
|
90
|
+
if (!mod || typeof mod.extract !== 'function') return [];
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const result = mod.extract(src);
|
|
94
|
+
return Array.isArray(result) ? result : [];
|
|
95
|
+
} catch (_) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// rank(query, sigIndex, opts?) → { file, score, sigs, tokens }[]
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
/**
|
|
104
|
+
* Rank files in a signature index against a natural-language query.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} query - Natural language or keyword query
|
|
107
|
+
* @param {Map<string, string[]>} sigIndex - File → signatures map
|
|
108
|
+
* @param {object} [opts]
|
|
109
|
+
* @param {number} [opts.topK=10] - Maximum results to return
|
|
110
|
+
* @param {number} [opts.recencyBoost] - Score multiplier for recent files
|
|
111
|
+
* @param {Set<string>} [opts.recencySet] - Set of file paths considered recent
|
|
112
|
+
* @param {object} [opts.weights] - Override default scoring weights
|
|
113
|
+
* @returns {{ file: string, score: number, sigs: string[], tokens: number }[]}
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* const { rank, buildSigIndex } = require('sigmap');
|
|
117
|
+
* const index = buildSigIndex('/path/to/project');
|
|
118
|
+
* const results = rank('add a new language extractor', index, { topK: 5 });
|
|
119
|
+
*/
|
|
120
|
+
function rank(query, sigIndex, opts) {
|
|
121
|
+
try {
|
|
122
|
+
const { rank: _rank } = require(path.join(SRC_ROOT, 'retrieval', 'ranker.js'));
|
|
123
|
+
return _rank(query, sigIndex, opts);
|
|
124
|
+
} catch (_) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// buildSigIndex(cwd) → Map<string, string[]>
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
/**
|
|
133
|
+
* Build a file→signatures index from the generated context file.
|
|
134
|
+
* Requires gen-context.js to have been run first.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} cwd - Project root directory
|
|
137
|
+
* @returns {Map<string, string[]>}
|
|
138
|
+
*/
|
|
139
|
+
function buildSigIndex(cwd) {
|
|
140
|
+
try {
|
|
141
|
+
const { buildSigIndex: _build } = require(path.join(SRC_ROOT, 'retrieval', 'ranker.js'));
|
|
142
|
+
return _build(cwd);
|
|
143
|
+
} catch (_) {
|
|
144
|
+
return new Map();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// scan(sigs, filePath) → { safe: string[], redacted: boolean }
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
/**
|
|
152
|
+
* Scan an array of signature strings for secrets and redact any matches.
|
|
153
|
+
*
|
|
154
|
+
* @param {string[]} sigs - Signature strings to scan
|
|
155
|
+
* @param {string} filePath - Source file path (used in redaction message)
|
|
156
|
+
* @returns {{ safe: string[], redacted: boolean }}
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* const { safe, redacted } = scan(['const KEY = "AKIAEXAMPLE123..."'], 'config.js');
|
|
160
|
+
* // redacted === true — key was replaced with [REDACTED — AWS Access Key ...]
|
|
161
|
+
*/
|
|
162
|
+
function scan(sigs, filePath) {
|
|
163
|
+
try {
|
|
164
|
+
const { scan: _scan } = require(path.join(SRC_ROOT, 'security', 'scanner.js'));
|
|
165
|
+
return _scan(sigs, filePath);
|
|
166
|
+
} catch (_) {
|
|
167
|
+
return { safe: Array.isArray(sigs) ? sigs : [], redacted: false };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// score(cwd) → { score, grade, ... }
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Compute a composite health score for the project at cwd.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} cwd - Project root directory
|
|
178
|
+
* @returns {{
|
|
179
|
+
* score: number,
|
|
180
|
+
* grade: 'A'|'B'|'C'|'D',
|
|
181
|
+
* strategy: string,
|
|
182
|
+
* tokenReductionPct: number|null,
|
|
183
|
+
* daysSinceRegen: number|null,
|
|
184
|
+
* totalRuns: number,
|
|
185
|
+
* overBudgetRuns: number,
|
|
186
|
+
* }}
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* const health = score('/path/to/project');
|
|
190
|
+
* console.log(health.grade); // 'A'
|
|
191
|
+
*/
|
|
192
|
+
function score(cwd) {
|
|
193
|
+
try {
|
|
194
|
+
const { score: _score } = require(path.join(SRC_ROOT, 'health', 'scorer.js'));
|
|
195
|
+
return _score(cwd);
|
|
196
|
+
} catch (_) {
|
|
197
|
+
return { score: 0, grade: 'D', strategy: 'full', tokenReductionPct: null, daysSinceRegen: null, totalRuns: 0, overBudgetRuns: 0 };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Exports
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
module.exports = {
|
|
205
|
+
/** Extract signatures from source text */
|
|
206
|
+
extract,
|
|
207
|
+
/** Rank project files against a query */
|
|
208
|
+
rank,
|
|
209
|
+
/** Build a signature index from the generated context file */
|
|
210
|
+
buildSigIndex,
|
|
211
|
+
/** Scan signatures for secrets (redacts matches) */
|
|
212
|
+
scan,
|
|
213
|
+
/** Compute project health score */
|
|
214
|
+
score,
|
|
215
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sigmap-core",
|
|
3
|
+
"version": "2.4.0",
|
|
4
|
+
"description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"sigmap",
|
|
8
|
+
"ai-context",
|
|
9
|
+
"code-signatures",
|
|
10
|
+
"extraction",
|
|
11
|
+
"retrieval",
|
|
12
|
+
"zero-dependency"
|
|
13
|
+
],
|
|
14
|
+
"author": {
|
|
15
|
+
"name": "Manoj Mallick",
|
|
16
|
+
"url": "https://github.com/manojmallick"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/manojmallick/sigmap.git",
|
|
21
|
+
"directory": "packages/core"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://manojmallick.github.io/sigmap/",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -100,6 +100,14 @@ const DEFAULTS = {
|
|
|
100
100
|
// Multiplier applied to recently-changed files (>1 boosts them up)
|
|
101
101
|
recencyBoost: 1.5,
|
|
102
102
|
},
|
|
103
|
+
|
|
104
|
+
// Impact layer settings (v2.5)
|
|
105
|
+
impact: {
|
|
106
|
+
// BFS traversal depth limit for --impact (0 = unlimited)
|
|
107
|
+
depth: 3,
|
|
108
|
+
// Include signatures of impacted files in --impact output
|
|
109
|
+
includeSigs: true,
|
|
110
|
+
},
|
|
103
111
|
};
|
|
104
112
|
|
|
105
113
|
module.exports = { DEFAULTS };
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dependency graph builder (v2.5).
|
|
5
|
+
*
|
|
6
|
+
* Builds a forward and reverse dependency graph by resolving import/require
|
|
7
|
+
* statements across JS/TS, Python, Go, Rust, Java, Kotlin, and Ruby files.
|
|
8
|
+
*
|
|
9
|
+
* @module src/graph/builder
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Language-specific import extractors
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
20
|
+
const PY_EXTS = new Set(['.py', '.pyw']);
|
|
21
|
+
const GO_EXTS = new Set(['.go']);
|
|
22
|
+
const RS_EXTS = new Set(['.rs']);
|
|
23
|
+
const JVM_EXTS = new Set(['.java', '.kt', '.kts', '.scala', '.sc']);
|
|
24
|
+
const RB_EXTS = new Set(['.rb', '.rake']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a JS/TS relative import string to an absolute path in fileSet.
|
|
28
|
+
* @param {string} dir - directory of the importing file
|
|
29
|
+
* @param {string} importStr - raw import string (e.g. './utils', '../store')
|
|
30
|
+
* @param {Set<string>} fileSet
|
|
31
|
+
* @returns {string|null}
|
|
32
|
+
*/
|
|
33
|
+
function resolveJsPath(dir, importStr, fileSet) {
|
|
34
|
+
const base = path.resolve(dir, importStr);
|
|
35
|
+
const candidates = [
|
|
36
|
+
base,
|
|
37
|
+
base + '.ts', base + '.tsx',
|
|
38
|
+
base + '.js', base + '.jsx', base + '.mjs', base + '.cjs',
|
|
39
|
+
path.join(base, 'index.ts'),
|
|
40
|
+
path.join(base, 'index.js'),
|
|
41
|
+
];
|
|
42
|
+
for (const c of candidates) {
|
|
43
|
+
if (fileSet.has(c)) return c;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract absolute dependency paths from a single file.
|
|
50
|
+
* @param {string} filePath - absolute path to the file
|
|
51
|
+
* @param {string} content - file source content
|
|
52
|
+
* @param {Set<string>} fileSet - set of all known absolute file paths
|
|
53
|
+
* @returns {string[]} resolved absolute paths this file imports
|
|
54
|
+
*/
|
|
55
|
+
function extractFileDeps(filePath, content, fileSet) {
|
|
56
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
57
|
+
const dir = path.dirname(filePath);
|
|
58
|
+
const found = [];
|
|
59
|
+
|
|
60
|
+
// ── JS / TS ───────────────────────────────────────────────────────────────
|
|
61
|
+
if (JS_EXTS.has(ext)) {
|
|
62
|
+
const stripped = content
|
|
63
|
+
.replace(/\/\/.*$/gm, '')
|
|
64
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
65
|
+
|
|
66
|
+
// ES imports: import ... from './foo' or import './side-effect'
|
|
67
|
+
const reEs = /(?:^|[\r\n])\s*import\s+(?:[^'";\r\n]*?\s+from\s+)?['"](\.[^'"]+)['"]/g;
|
|
68
|
+
let m;
|
|
69
|
+
while ((m = reEs.exec(stripped)) !== null) {
|
|
70
|
+
const r = resolveJsPath(dir, m[1], fileSet);
|
|
71
|
+
if (r) found.push(r);
|
|
72
|
+
}
|
|
73
|
+
// CommonJS: require('./foo')
|
|
74
|
+
const reCjs = /\brequire\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g;
|
|
75
|
+
while ((m = reCjs.exec(stripped)) !== null) {
|
|
76
|
+
const r = resolveJsPath(dir, m[1], fileSet);
|
|
77
|
+
if (r) found.push(r);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Python ────────────────────────────────────────────────────────────────
|
|
82
|
+
if (PY_EXTS.has(ext)) {
|
|
83
|
+
// from .module import ... / from ..pkg import ...
|
|
84
|
+
const re = /^[ \t]*from\s+(\.+[\w.]*)\s+import/gm;
|
|
85
|
+
let m;
|
|
86
|
+
while ((m = re.exec(content)) !== null) {
|
|
87
|
+
const dotCount = (m[1].match(/^\.+/) || [''])[0].length;
|
|
88
|
+
const modPart = m[1].slice(dotCount).replace(/\./g, '/');
|
|
89
|
+
let base = dir;
|
|
90
|
+
for (let i = 1; i < dotCount; i++) base = path.dirname(base);
|
|
91
|
+
const candidate = modPart
|
|
92
|
+
? path.join(base, modPart + '.py')
|
|
93
|
+
: null;
|
|
94
|
+
if (candidate && fileSet.has(candidate)) found.push(candidate);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Go ────────────────────────────────────────────────────────────────────
|
|
99
|
+
// Go uses module paths, not relative file paths — we match same-module paths
|
|
100
|
+
// by checking if any known file's relative path matches the imported suffix.
|
|
101
|
+
if (GO_EXTS.has(ext)) {
|
|
102
|
+
const re = /import\s*\(\s*([\s\S]*?)\s*\)/g;
|
|
103
|
+
const reInline = /import\s+"([^"]+)"/g;
|
|
104
|
+
const imports = [];
|
|
105
|
+
let m;
|
|
106
|
+
while ((m = re.exec(content)) !== null) {
|
|
107
|
+
for (const imp of m[1].matchAll(/"([^"]+)"/g)) imports.push(imp[1]);
|
|
108
|
+
}
|
|
109
|
+
while ((m = reInline.exec(content)) !== null) imports.push(m[1]);
|
|
110
|
+
|
|
111
|
+
for (const imp of imports) {
|
|
112
|
+
const suffix = imp.split('/').pop();
|
|
113
|
+
for (const f of fileSet) {
|
|
114
|
+
if (f.endsWith(path.sep + suffix + '.go') ||
|
|
115
|
+
f.includes(path.sep + suffix + path.sep)) {
|
|
116
|
+
found.push(f);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Rust ──────────────────────────────────────────────────────────────────
|
|
124
|
+
// Match `mod foo;` and `use crate::foo::bar` — resolve to sibling .rs files
|
|
125
|
+
if (RS_EXTS.has(ext)) {
|
|
126
|
+
const reMod = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gm;
|
|
127
|
+
let m;
|
|
128
|
+
while ((m = reMod.exec(content)) !== null) {
|
|
129
|
+
const candidate = path.join(dir, m[1] + '.rs');
|
|
130
|
+
if (fileSet.has(candidate)) found.push(candidate);
|
|
131
|
+
// Also try mod/mod.rs
|
|
132
|
+
const candidate2 = path.join(dir, m[1], 'mod.rs');
|
|
133
|
+
if (fileSet.has(candidate2)) found.push(candidate2);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Java / Kotlin / Scala ─────────────────────────────────────────────────
|
|
138
|
+
// Match same-project import statements by matching package-relative paths
|
|
139
|
+
if (JVM_EXTS.has(ext)) {
|
|
140
|
+
const re = /^\s*import\s+([\w.]+)\s*;?/gm;
|
|
141
|
+
let m;
|
|
142
|
+
while ((m = re.exec(content)) !== null) {
|
|
143
|
+
// Convert com.example.utils.StringHelper → com/example/utils/StringHelper.java
|
|
144
|
+
const asPath = m[1].replace(/\./g, path.sep);
|
|
145
|
+
for (const jvmExt of ['.java', '.kt', '.kts', '.scala', '.sc']) {
|
|
146
|
+
for (const f of fileSet) {
|
|
147
|
+
if (f.endsWith(asPath + jvmExt)) { found.push(f); break; }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Ruby ──────────────────────────────────────────────────────────────────
|
|
154
|
+
if (RB_EXTS.has(ext)) {
|
|
155
|
+
const re = /^\s*require_relative\s+['"]([^'"]+)['"]/gm;
|
|
156
|
+
let m;
|
|
157
|
+
while ((m = re.exec(content)) !== null) {
|
|
158
|
+
const base = path.resolve(dir, m[1]);
|
|
159
|
+
const candidate = base.endsWith('.rb') ? base : base + '.rb';
|
|
160
|
+
if (fileSet.has(candidate)) found.push(candidate);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return [...new Set(found)];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Public API
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build a forward and reverse dependency graph for all given files.
|
|
173
|
+
*
|
|
174
|
+
* @param {string[]} files - absolute file paths to analyze
|
|
175
|
+
* @param {string} cwd - project root (used only for error reporting)
|
|
176
|
+
* @returns {{ forward: Map<string,string[]>, reverse: Map<string,string[]> }}
|
|
177
|
+
*/
|
|
178
|
+
function build(files, cwd) {
|
|
179
|
+
const fileSet = new Set(files.map((f) => path.resolve(f)));
|
|
180
|
+
const forward = new Map();
|
|
181
|
+
const reverse = new Map();
|
|
182
|
+
|
|
183
|
+
// Initialise every known file in both maps (ensures isolated files appear)
|
|
184
|
+
for (const f of fileSet) {
|
|
185
|
+
if (!forward.has(f)) forward.set(f, []);
|
|
186
|
+
if (!reverse.has(f)) reverse.set(f, []);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const filePath of fileSet) {
|
|
190
|
+
let content;
|
|
191
|
+
try {
|
|
192
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
193
|
+
} catch (_) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const deps = extractFileDeps(filePath, content, fileSet);
|
|
198
|
+
if (deps.length > 0) {
|
|
199
|
+
forward.set(filePath, deps);
|
|
200
|
+
for (const dep of deps) {
|
|
201
|
+
if (!reverse.has(dep)) reverse.set(dep, []);
|
|
202
|
+
reverse.get(dep).push(filePath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { forward, reverse };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build a dependency graph scoped to a single cwd by walking all JS/TS/Py/Go
|
|
212
|
+
* files under srcDirs. Useful for the MCP tool handler.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} cwd
|
|
215
|
+
* @param {object} [opts]
|
|
216
|
+
* @param {string[]} [opts.srcDirs]
|
|
217
|
+
* @param {string[]} [opts.exclude]
|
|
218
|
+
* @returns {{ forward: Map<string,string[]>, reverse: Map<string,string[]> }}
|
|
219
|
+
*/
|
|
220
|
+
function buildFromCwd(cwd, opts) {
|
|
221
|
+
const { srcDirs = ['src', 'app', 'lib'], exclude = ['node_modules', '.git', 'dist', 'build'] } = opts || {};
|
|
222
|
+
const excludeSet = new Set(exclude);
|
|
223
|
+
|
|
224
|
+
function walkDir(dir, depth) {
|
|
225
|
+
if (depth > 8) return [];
|
|
226
|
+
let entries;
|
|
227
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return []; }
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const e of entries) {
|
|
230
|
+
if (excludeSet.has(e.name) || e.name.startsWith('.')) continue;
|
|
231
|
+
const full = path.join(dir, e.name);
|
|
232
|
+
if (e.isDirectory()) {
|
|
233
|
+
out.push(...walkDir(full, depth + 1));
|
|
234
|
+
} else if (e.isFile()) {
|
|
235
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
236
|
+
if (JS_EXTS.has(ext) || PY_EXTS.has(ext) || GO_EXTS.has(ext) ||
|
|
237
|
+
RS_EXTS.has(ext) || JVM_EXTS.has(ext) || RB_EXTS.has(ext)) {
|
|
238
|
+
out.push(full);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const files = [];
|
|
246
|
+
for (const sd of srcDirs) {
|
|
247
|
+
const absDir = path.resolve(cwd, sd);
|
|
248
|
+
if (fs.existsSync(absDir)) files.push(...walkDir(absDir, 0));
|
|
249
|
+
}
|
|
250
|
+
// Also include root-level entry files
|
|
251
|
+
for (const rootFile of ['gen-context.js', 'index.js', 'main.js', 'app.js']) {
|
|
252
|
+
const abs = path.resolve(cwd, rootFile);
|
|
253
|
+
if (fs.existsSync(abs)) files.push(abs);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return build(files, cwd);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = { build, buildFromCwd, extractFileDeps };
|