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.
@@ -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
+ }
@@ -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 };