sigmap 6.13.0 → 6.14.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 +12 -0
- package/gen-context.js +43 -2
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/mcp/server.js +1 -1
- package/src/verify/hallucination-guard.js +219 -0
- package/src/verify/parsers.js +149 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,18 @@ Format: [Semantic Versioning](https://semver.org/)
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## [6.14.0] — 2026-06-07
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`verify-ai-output` — Hallucination Guard prototype (Phase 1 MVP, #227, PR #228):**
|
|
18
|
+
- New command `sigmap verify-ai-output <answer.md> [--json]` flags fabricated claims in an AI answer against the real repository. Deterministic core — runs fully offline, no LLM.
|
|
19
|
+
- Three detectors: **fake-file** (referenced path absent on disk), **fake-import** (relative import does not resolve; bare import absent from `package.json` deps, with Node/Python builtins allow-listed and scoped packages handled), and **fake-symbol** (called function/class absent from the SigMap symbol index via `buildSigIndex`).
|
|
20
|
+
- Markdown report by default, `--json` for CI (`{ file, issues, summary }`). Exits `1` when any issue is found, `0` when clean.
|
|
21
|
+
- New modules `src/verify/parsers.js` (file/import/symbol/code-block extraction) and `src/verify/hallucination-guard.js` (`verify(answerText, cwd, opts)`); all external lookups are injectable so the core is unit-testable.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
13
25
|
## [6.13.0] — 2026-06-05
|
|
14
26
|
|
|
15
27
|
### Added
|
package/gen-context.js
CHANGED
|
@@ -6154,7 +6154,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
|
|
|
6154
6154
|
|
|
6155
6155
|
const SERVER_INFO = {
|
|
6156
6156
|
name: 'sigmap',
|
|
6157
|
-
version: '6.
|
|
6157
|
+
version: '6.14.0',
|
|
6158
6158
|
description: 'SigMap MCP server — code signatures on demand',
|
|
6159
6159
|
};
|
|
6160
6160
|
|
|
@@ -8978,7 +8978,7 @@ const path = require('path');
|
|
|
8978
8978
|
const os = require('os');
|
|
8979
8979
|
const { execSync } = require('child_process');
|
|
8980
8980
|
|
|
8981
|
-
const VERSION = '6.
|
|
8981
|
+
const VERSION = '6.14.0';
|
|
8982
8982
|
const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
|
|
8983
8983
|
|
|
8984
8984
|
function requireSourceOrBundled(key) {
|
|
@@ -10737,6 +10737,8 @@ Usage:
|
|
|
10737
10737
|
${cmd} --impact <file> Show every file impacted by changing <file>
|
|
10738
10738
|
${cmd} --impact <file> --json Impact as JSON {changed, direct, transitive, tests, routes}
|
|
10739
10739
|
${cmd} --impact <file> --depth <n> BFS depth limit (default 3, 0=unlimited)
|
|
10740
|
+
${cmd} verify-ai-output <answer.md> Flag fake files/imports/symbols in an AI answer
|
|
10741
|
+
${cmd} verify-ai-output <answer.md> --json Hallucination report as JSON (exits 1 if issues)
|
|
10740
10742
|
${cmd} --init Write example config + .contextignore scaffold
|
|
10741
10743
|
${cmd} --help Show this message
|
|
10742
10744
|
${cmd} --version Show version
|
|
@@ -11858,6 +11860,45 @@ function main() {
|
|
|
11858
11860
|
process.exit(0);
|
|
11859
11861
|
}
|
|
11860
11862
|
|
|
11863
|
+
// `sigmap verify-ai-output <answer.md>` — Hallucination Guard (deterministic core)
|
|
11864
|
+
if (args[0] === 'verify-ai-output') {
|
|
11865
|
+
const target = args[1];
|
|
11866
|
+
const jsonOut = args.includes('--json');
|
|
11867
|
+
if (!target || target.startsWith('--')) {
|
|
11868
|
+
console.error('[sigmap] Usage: sigmap verify-ai-output <answer.md> [--json]');
|
|
11869
|
+
process.exit(1);
|
|
11870
|
+
}
|
|
11871
|
+
const absTarget = path.resolve(cwd, target);
|
|
11872
|
+
if (!fs.existsSync(absTarget)) {
|
|
11873
|
+
console.error(`[sigmap] file not found: ${target}`);
|
|
11874
|
+
process.exit(1);
|
|
11875
|
+
}
|
|
11876
|
+
|
|
11877
|
+
const answerText = fs.readFileSync(absTarget, 'utf8');
|
|
11878
|
+
const { verify } = requireSourceOrBundled('./src/verify/hallucination-guard');
|
|
11879
|
+
const { issues, summary } = verify(answerText, cwd);
|
|
11880
|
+
|
|
11881
|
+
if (jsonOut) {
|
|
11882
|
+
process.stdout.write(JSON.stringify({ file: path.relative(cwd, absTarget) || target, issues, summary }) + '\n');
|
|
11883
|
+
process.exit(summary.total > 0 ? 1 : 0);
|
|
11884
|
+
}
|
|
11885
|
+
|
|
11886
|
+
const rel = path.relative(cwd, absTarget) || target;
|
|
11887
|
+
if (summary.total === 0) {
|
|
11888
|
+
console.log(`[sigmap] ✓ ${rel} — no hallucinations detected (${summary.symbolsIndexed} symbols indexed)`);
|
|
11889
|
+
process.exit(0);
|
|
11890
|
+
}
|
|
11891
|
+
|
|
11892
|
+
const labels = { 'fake-file': 'Fake file', 'fake-import': 'Fake import', 'fake-symbol': 'Fake symbol' };
|
|
11893
|
+
console.log(`[sigmap] ✗ ${rel} — ${summary.total} issue${summary.total === 1 ? '' : 's'} found`);
|
|
11894
|
+
console.log(` fake-file: ${summary.byType['fake-file']} fake-import: ${summary.byType['fake-import']} fake-symbol: ${summary.byType['fake-symbol']}`);
|
|
11895
|
+
console.log('');
|
|
11896
|
+
for (const issue of issues) {
|
|
11897
|
+
console.log(` L${issue.line} [${labels[issue.type] || issue.type}] ${issue.message}`);
|
|
11898
|
+
}
|
|
11899
|
+
process.exit(1);
|
|
11900
|
+
}
|
|
11901
|
+
|
|
11861
11902
|
// Feature 1: `sigmap explain <file>` — why a file is included or excluded
|
|
11862
11903
|
if (args[0] === 'explain' || args.includes('--explain')) {
|
|
11863
11904
|
const target = args[0] === 'explain'
|
package/package.json
CHANGED
package/src/mcp/server.js
CHANGED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hallucination Guard — deterministic core (Phase 1 MVP).
|
|
5
|
+
*
|
|
6
|
+
* Given the text of an AI answer, flag claims that do not match the repo:
|
|
7
|
+
* - fake-file : a referenced path is not on disk
|
|
8
|
+
* - fake-import : a relative import does not resolve; a bare import is
|
|
9
|
+
* absent from package.json deps (builtins allow-listed)
|
|
10
|
+
* - fake-symbol : a called function/class is absent from the symbol index
|
|
11
|
+
*
|
|
12
|
+
* No network, no LLM. Reuses SigMap primitives (buildSigIndex) but every
|
|
13
|
+
* external dependency is injectable via `opts` so the core stays unit-testable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const parsers = require('./parsers');
|
|
19
|
+
|
|
20
|
+
const NODE_BUILTINS = new Set([
|
|
21
|
+
'fs', 'path', 'os', 'util', 'events', 'stream', 'http', 'https', 'crypto',
|
|
22
|
+
'child_process', 'url', 'querystring', 'assert', 'zlib', 'readline', 'net',
|
|
23
|
+
'tls', 'dns', 'buffer', 'process', 'vm', 'module', 'console', 'timers',
|
|
24
|
+
'string_decoder', 'perf_hooks', 'worker_threads', 'cluster', 'dgram', 'v8',
|
|
25
|
+
'tty', 'repl', 'async_hooks', 'inspector', 'fs/promises', 'path/posix',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const PY_BUILTINS = new Set([
|
|
29
|
+
'os', 'sys', 're', 'json', 'math', 'typing', 'collections', 'itertools',
|
|
30
|
+
'functools', 'datetime', 'pathlib', 'subprocess', 'abc', 'dataclasses',
|
|
31
|
+
'enum', 'io', 'time', 'random', 'logging', 'argparse', 'unittest', 'asyncio',
|
|
32
|
+
'copy', 'hashlib', 'threading', 'string', 'csv', 'glob', 'shutil', 'tempfile',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const LANG_GLOBALS = new Set([
|
|
36
|
+
// JS
|
|
37
|
+
'console', 'require', 'module', 'exports', 'process', 'Object', 'Array',
|
|
38
|
+
'String', 'Number', 'Boolean', 'Math', 'JSON', 'Date', 'Promise', 'Map',
|
|
39
|
+
'Set', 'WeakMap', 'WeakSet', 'RegExp', 'Error', 'Symbol', 'parseInt',
|
|
40
|
+
'parseFloat', 'isNaN', 'setTimeout', 'setInterval', 'clearTimeout', 'fetch',
|
|
41
|
+
'Buffer', 'Function', 'eval', 'encodeURIComponent', 'decodeURIComponent',
|
|
42
|
+
// Python
|
|
43
|
+
'print', 'len', 'range', 'str', 'int', 'float', 'dict', 'list', 'tuple',
|
|
44
|
+
'set', 'bool', 'open', 'enumerate', 'zip', 'map', 'filter', 'sorted',
|
|
45
|
+
'sum', 'min', 'max', 'abs', 'isinstance', 'super', 'type', 'getattr',
|
|
46
|
+
'setattr', 'hasattr',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const REL_EXTS = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.json', '.py', '.r', '.R', '.vue'];
|
|
50
|
+
const REL_INDEX = ['index.js', 'index.ts', 'index.tsx', 'index.jsx', '__init__.py'];
|
|
51
|
+
|
|
52
|
+
/** Build the set of known symbol identifiers from the SigMap signature index. */
|
|
53
|
+
function buildSymbolSet(cwd) {
|
|
54
|
+
const set = new Set();
|
|
55
|
+
let fileKeys = [];
|
|
56
|
+
try {
|
|
57
|
+
const { buildSigIndex } = require('../retrieval/ranker');
|
|
58
|
+
const idx = buildSigIndex(cwd);
|
|
59
|
+
fileKeys = [...idx.keys()];
|
|
60
|
+
for (const sigs of idx.values()) {
|
|
61
|
+
for (const sig of sigs) {
|
|
62
|
+
const cleaned = String(sig).replace(/\s*:\d+(?:-\d+)?\s*$/, '');
|
|
63
|
+
const ids = cleaned.match(/[A-Za-z_$][\w$]*/g) || [];
|
|
64
|
+
for (const id of ids) set.add(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (_) {}
|
|
68
|
+
return { set, fileKeys };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Load declared dependency names from package.json. */
|
|
72
|
+
function loadDeps(cwd) {
|
|
73
|
+
const deps = new Set();
|
|
74
|
+
let hasPkg = false;
|
|
75
|
+
try {
|
|
76
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
77
|
+
hasPkg = true;
|
|
78
|
+
for (const k of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
79
|
+
if (pkg[k] && typeof pkg[k] === 'object') {
|
|
80
|
+
for (const name of Object.keys(pkg[k])) deps.add(name);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (_) {}
|
|
84
|
+
return { deps, hasPkg };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Default file-existence check: resolve a referenced path against cwd. */
|
|
88
|
+
function defaultFileExists(cwd, ref) {
|
|
89
|
+
const clean = ref.replace(/^\.\//, '');
|
|
90
|
+
for (const c of [path.resolve(cwd, clean), path.resolve(cwd, ref)]) {
|
|
91
|
+
try {
|
|
92
|
+
if (fs.existsSync(c)) return true;
|
|
93
|
+
} catch (_) {}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Default relative-import resolver: fs candidates + basename match in index. */
|
|
99
|
+
function defaultRelativeResolvable(cwd, mod, fileBasenames) {
|
|
100
|
+
const base = path.resolve(cwd, mod);
|
|
101
|
+
for (const e of REL_EXTS) {
|
|
102
|
+
try {
|
|
103
|
+
if (fs.existsSync(base + e)) return true;
|
|
104
|
+
} catch (_) {}
|
|
105
|
+
}
|
|
106
|
+
for (const idx of REL_INDEX) {
|
|
107
|
+
try {
|
|
108
|
+
if (fs.existsSync(path.join(base, idx))) return true;
|
|
109
|
+
} catch (_) {}
|
|
110
|
+
}
|
|
111
|
+
// Fall back to basename match against the indexed file set (the answer's
|
|
112
|
+
// import is relative to a file we cannot know, so a name match is enough
|
|
113
|
+
// to avoid false positives).
|
|
114
|
+
const wantBase = path.basename(mod).replace(/\.[^.]+$/, '').toLowerCase();
|
|
115
|
+
return fileBasenames.has(wantBase);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Verify an AI answer against the repository.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} answerText
|
|
122
|
+
* @param {string} cwd
|
|
123
|
+
* @param {object} [opts]
|
|
124
|
+
* @param {Set<string>} [opts.symbolSet] override known symbols
|
|
125
|
+
* @param {Set<string>} [opts.deps] override package deps
|
|
126
|
+
* @param {boolean} [opts.hasPkg] whether a package.json exists
|
|
127
|
+
* @param {(ref: string) => boolean} [opts.fileExists] override file check
|
|
128
|
+
* @param {(mod: string) => boolean} [opts.relativeResolvable] override rel-import check
|
|
129
|
+
* @returns {{ issues: object[], summary: object }}
|
|
130
|
+
*/
|
|
131
|
+
function verify(answerText, cwd, opts = {}) {
|
|
132
|
+
let symbolSet = opts.symbolSet;
|
|
133
|
+
let fileBasenames = opts.fileBasenames;
|
|
134
|
+
if (!symbolSet) {
|
|
135
|
+
const built = buildSymbolSet(cwd);
|
|
136
|
+
symbolSet = built.set;
|
|
137
|
+
fileBasenames = new Set(built.fileKeys.map(
|
|
138
|
+
(k) => path.basename(k).replace(/\.[^.]+$/, '').toLowerCase()
|
|
139
|
+
));
|
|
140
|
+
}
|
|
141
|
+
if (!fileBasenames) fileBasenames = new Set();
|
|
142
|
+
|
|
143
|
+
let deps = opts.deps;
|
|
144
|
+
let hasPkg = opts.hasPkg;
|
|
145
|
+
if (!deps) {
|
|
146
|
+
const loaded = loadDeps(cwd);
|
|
147
|
+
deps = loaded.deps;
|
|
148
|
+
if (hasPkg === undefined) hasPkg = loaded.hasPkg;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fileExists = opts.fileExists || ((ref) => defaultFileExists(cwd, ref));
|
|
152
|
+
const relativeResolvable = opts.relativeResolvable
|
|
153
|
+
|| ((mod) => defaultRelativeResolvable(cwd, mod, fileBasenames));
|
|
154
|
+
|
|
155
|
+
const issues = [];
|
|
156
|
+
const dedupe = new Set();
|
|
157
|
+
const add = (issue) => {
|
|
158
|
+
const key = `${issue.type}::${issue.value}`;
|
|
159
|
+
if (dedupe.has(key)) return;
|
|
160
|
+
dedupe.add(key);
|
|
161
|
+
issues.push(issue);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// 1. fake-file
|
|
165
|
+
for (const { path: p, line } of parsers.extractFilePaths(answerText)) {
|
|
166
|
+
if (!fileExists(p)) {
|
|
167
|
+
add({ type: 'fake-file', value: p, line, message: `File not found on disk: ${p}` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. fake-import
|
|
172
|
+
for (const imp of parsers.extractImports(answerText)) {
|
|
173
|
+
if (imp.relative) {
|
|
174
|
+
if (!relativeResolvable(imp.module)) {
|
|
175
|
+
add({ type: 'fake-import', value: imp.module, line: imp.line, message: `Import does not resolve: ${imp.module}` });
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Bare module — only verifiable for JS when a package.json exists.
|
|
180
|
+
const top = imp.module.split('/')[0];
|
|
181
|
+
if (imp.kind === 'js') {
|
|
182
|
+
if (!hasPkg) continue;
|
|
183
|
+
if (NODE_BUILTINS.has(imp.module) || NODE_BUILTINS.has(top)) continue;
|
|
184
|
+
if (top.startsWith('@')) {
|
|
185
|
+
const scoped = imp.module.split('/').slice(0, 2).join('/');
|
|
186
|
+
if (deps.has(scoped) || deps.has(imp.module)) continue;
|
|
187
|
+
} else if (deps.has(top) || deps.has(imp.module)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
add({ type: 'fake-import', value: imp.module, line: imp.line, message: `Package not in dependencies: ${imp.module}` });
|
|
191
|
+
}
|
|
192
|
+
// Python bare imports: stdlib is unbounded offline — skip to keep precision.
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 3. fake-symbol
|
|
196
|
+
if (symbolSet.size > 0) {
|
|
197
|
+
for (const { name, line } of parsers.extractSymbols(answerText)) {
|
|
198
|
+
if (symbolSet.has(name)) continue;
|
|
199
|
+
if (LANG_GLOBALS.has(name) || NODE_BUILTINS.has(name) || PY_BUILTINS.has(name)) continue;
|
|
200
|
+
add({ type: 'fake-symbol', value: name, line, message: `Symbol not found in repo index: ${name}()` });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
issues.sort((a, b) => a.line - b.line);
|
|
205
|
+
|
|
206
|
+
const byType = { 'fake-file': 0, 'fake-import': 0, 'fake-symbol': 0 };
|
|
207
|
+
for (const i of issues) byType[i.type] = (byType[i.type] || 0) + 1;
|
|
208
|
+
|
|
209
|
+
const summary = {
|
|
210
|
+
total: issues.length,
|
|
211
|
+
byType,
|
|
212
|
+
clean: issues.length === 0,
|
|
213
|
+
symbolsIndexed: symbolSet.size,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return { issues, summary };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { verify, buildSymbolSet, loadDeps };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parsers for the Hallucination Guard (verify-ai-output).
|
|
5
|
+
*
|
|
6
|
+
* Extract the verifiable claims an AI answer makes about a codebase:
|
|
7
|
+
* - file paths it references
|
|
8
|
+
* - import / require statements it shows
|
|
9
|
+
* - function / class symbols it calls
|
|
10
|
+
* - fenced code blocks (so callers can scope checks to code vs prose)
|
|
11
|
+
*
|
|
12
|
+
* Everything here is deterministic and offline — pure string analysis.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Extensions we are confident name a source/code/config file (no slash required).
|
|
16
|
+
const KNOWN_CODE_EXT = new Set([
|
|
17
|
+
'js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'py', 'pyw', 'rb', 'go', 'rs',
|
|
18
|
+
'java', 'kt', 'swift', 'c', 'h', 'cpp', 'hpp', 'cs', 'php', 'r',
|
|
19
|
+
'vue', 'svelte', 'css', 'scss', 'less', 'html', 'json', 'yml', 'yaml',
|
|
20
|
+
'toml', 'xml', 'sql', 'graphql', 'gql', 'proto', 'tf', 'md', 'sh',
|
|
21
|
+
'gd', 'gdscript',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract fenced code blocks.
|
|
26
|
+
* @param {string} text
|
|
27
|
+
* @returns {{ lang: string, content: string, line: number }[]}
|
|
28
|
+
*/
|
|
29
|
+
function extractCodeBlocks(text) {
|
|
30
|
+
const blocks = [];
|
|
31
|
+
const lines = text.split('\n');
|
|
32
|
+
let inBlock = false;
|
|
33
|
+
let lang = '';
|
|
34
|
+
let buf = [];
|
|
35
|
+
let startLine = 0;
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
const m = lines[i].match(/^```(\w*)/);
|
|
38
|
+
if (m) {
|
|
39
|
+
if (!inBlock) {
|
|
40
|
+
inBlock = true;
|
|
41
|
+
lang = m[1] || '';
|
|
42
|
+
buf = [];
|
|
43
|
+
startLine = i + 2; // first content line (1-based)
|
|
44
|
+
} else {
|
|
45
|
+
blocks.push({ lang, content: buf.join('\n'), line: startLine });
|
|
46
|
+
inBlock = false;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (inBlock) buf.push(lines[i]);
|
|
51
|
+
}
|
|
52
|
+
return blocks;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract file-path references (deduped, first-seen line kept).
|
|
57
|
+
* A token counts as a path when it has a `.<letter…>` extension AND
|
|
58
|
+
* either contains a `/` or carries a known code/config extension.
|
|
59
|
+
* @param {string} text
|
|
60
|
+
* @returns {{ path: string, line: number }[]}
|
|
61
|
+
*/
|
|
62
|
+
function extractFilePaths(text) {
|
|
63
|
+
const lines = text.split('\n');
|
|
64
|
+
const seen = new Map();
|
|
65
|
+
const re = /(?:^|[\s`"'(\[<])([A-Za-z0-9_][\w./-]*\.[A-Za-z][A-Za-z0-9]*)/g;
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
let m;
|
|
69
|
+
re.lastIndex = 0;
|
|
70
|
+
while ((m = re.exec(line)) !== null) {
|
|
71
|
+
const p = m[1];
|
|
72
|
+
if (/^https?:/i.test(p)) continue;
|
|
73
|
+
const ext = (p.split('.').pop() || '').toLowerCase();
|
|
74
|
+
const hasSlash = p.includes('/');
|
|
75
|
+
if (!hasSlash && !KNOWN_CODE_EXT.has(ext)) continue;
|
|
76
|
+
if (!seen.has(p)) seen.set(p, i + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...seen.entries()].map(([p, line]) => ({ path: p, line }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract import / require statements.
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @returns {{ module: string, kind: 'js'|'py', relative: boolean, line: number, raw: string }[]}
|
|
86
|
+
*/
|
|
87
|
+
function extractImports(text) {
|
|
88
|
+
const lines = text.split('\n');
|
|
89
|
+
const out = [];
|
|
90
|
+
const push = (module, kind, line, raw) => {
|
|
91
|
+
if (!module) return;
|
|
92
|
+
out.push({ module, kind, relative: /^[./]/.test(module), line, raw: raw.trim() });
|
|
93
|
+
};
|
|
94
|
+
for (let i = 0; i < lines.length; i++) {
|
|
95
|
+
const line = lines[i];
|
|
96
|
+
let m;
|
|
97
|
+
// JS/TS: import ... from 'x' | export ... from 'x'
|
|
98
|
+
if ((m = line.match(/\b(?:import|export)\b[^'"]*\bfrom\s*['"]([^'"]+)['"]/))) {
|
|
99
|
+
push(m[1], 'js', i + 1, line);
|
|
100
|
+
} else if ((m = line.match(/\bimport\s*['"]([^'"]+)['"]/))) {
|
|
101
|
+
// side-effect import 'x'
|
|
102
|
+
push(m[1], 'js', i + 1, line);
|
|
103
|
+
}
|
|
104
|
+
// require('x') / dynamic import('x') — may co-occur, scan separately
|
|
105
|
+
const reqRe = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
106
|
+
let r;
|
|
107
|
+
while ((r = reqRe.exec(line)) !== null) push(r[1], 'js', i + 1, line);
|
|
108
|
+
|
|
109
|
+
// Python: from x import y | import x
|
|
110
|
+
if ((m = line.match(/^\s*from\s+([.\w]+)\s+import\b/))) {
|
|
111
|
+
push(m[1], 'py', i + 1, line);
|
|
112
|
+
} else if ((m = line.match(/^\s*import\s+([A-Za-z_][\w.]*)/))) {
|
|
113
|
+
push(m[1], 'py', i + 1, line);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extract function/class symbol references that look like calls.
|
|
121
|
+
* Restricted to backtick-wrapped calls (`foo(...)`) for high precision.
|
|
122
|
+
* @param {string} text
|
|
123
|
+
* @returns {{ name: string, line: number }[]}
|
|
124
|
+
*/
|
|
125
|
+
function extractSymbols(text) {
|
|
126
|
+
const lines = text.split('\n');
|
|
127
|
+
const out = [];
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const re = /`([A-Za-z_$][\w$]*)\s*\([^`]*\)`/g;
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
let m;
|
|
132
|
+
re.lastIndex = 0;
|
|
133
|
+
while ((m = re.exec(lines[i])) !== null) {
|
|
134
|
+
const name = m[1];
|
|
135
|
+
const key = name + '@' + (i + 1);
|
|
136
|
+
if (seen.has(key)) continue;
|
|
137
|
+
seen.add(key);
|
|
138
|
+
out.push({ name, line: i + 1 });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
extractCodeBlocks,
|
|
146
|
+
extractFilePaths,
|
|
147
|
+
extractImports,
|
|
148
|
+
extractSymbols,
|
|
149
|
+
};
|