sigmap 6.13.0 → 6.15.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 +42 -0
- package/README.md +11 -11
- package/gen-context.js +2032 -795
- package/package.json +3 -2
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/format/verify-report.js +164 -0
- package/src/mcp/handlers.js +44 -1
- package/src/mcp/server.js +4 -3
- package/src/mcp/tools.js +20 -2
- package/src/session/notes.js +99 -0
- package/src/verify/closest-match.js +145 -0
- package/src/verify/hallucination-guard.js +310 -0
- package/src/verify/parsers.js +200 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hallucination Guard — deterministic core (Reliable MVP, v6.15.0).
|
|
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-test-file : a referenced *test* path is not on disk (sub-type)
|
|
9
|
+
* - fake-import : a relative import does not resolve; a bare import is
|
|
10
|
+
* absent from package.json deps (builtins allow-listed)
|
|
11
|
+
* - fake-symbol : a called function/class is absent from the symbol index
|
|
12
|
+
* - fake-npm-script: `npm run X` where X is not a package.json script
|
|
13
|
+
*
|
|
14
|
+
* Each issue carries a `confidence` (detection certainty) and, where a near
|
|
15
|
+
* match exists, a heuristic `suggestion` ("Did you mean …?"). No network, no
|
|
16
|
+
* LLM. Reuses SigMap primitives (buildSigIndex) but every external dependency
|
|
17
|
+
* is injectable via `opts` so the core stays unit-testable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const parsers = require('./parsers');
|
|
23
|
+
const { closestMatch, buildSymbolCandidates, formatSuggestion } = require('./closest-match');
|
|
24
|
+
|
|
25
|
+
// A path that looks like a test file (JS/TS spec/test, Python test_/_test, or
|
|
26
|
+
// a tests/__tests__ directory). Used to flag fake-test-file separately.
|
|
27
|
+
const TEST_PATH_RE = /(?:\.(?:test|spec)\.[mc]?[jt]sx?$)|(?:(?:^|\/)__tests__\/)|(?:(?:^|\/)test_[^/]+\.py$)|(?:_test\.py$)|(?:(?:^|\/)tests?\/)/i;
|
|
28
|
+
function isTestPath(p) { return TEST_PATH_RE.test(p); }
|
|
29
|
+
|
|
30
|
+
const NODE_BUILTINS = new Set([
|
|
31
|
+
'fs', 'path', 'os', 'util', 'events', 'stream', 'http', 'https', 'crypto',
|
|
32
|
+
'child_process', 'url', 'querystring', 'assert', 'zlib', 'readline', 'net',
|
|
33
|
+
'tls', 'dns', 'buffer', 'process', 'vm', 'module', 'console', 'timers',
|
|
34
|
+
'string_decoder', 'perf_hooks', 'worker_threads', 'cluster', 'dgram', 'v8',
|
|
35
|
+
'tty', 'repl', 'async_hooks', 'inspector', 'fs/promises', 'path/posix',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const PY_BUILTINS = new Set([
|
|
39
|
+
'os', 'sys', 're', 'json', 'math', 'typing', 'collections', 'itertools',
|
|
40
|
+
'functools', 'datetime', 'pathlib', 'subprocess', 'abc', 'dataclasses',
|
|
41
|
+
'enum', 'io', 'time', 'random', 'logging', 'argparse', 'unittest', 'asyncio',
|
|
42
|
+
'copy', 'hashlib', 'threading', 'string', 'csv', 'glob', 'shutil', 'tempfile',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const LANG_GLOBALS = new Set([
|
|
46
|
+
// JS
|
|
47
|
+
'console', 'require', 'module', 'exports', 'process', 'Object', 'Array',
|
|
48
|
+
'String', 'Number', 'Boolean', 'Math', 'JSON', 'Date', 'Promise', 'Map',
|
|
49
|
+
'Set', 'WeakMap', 'WeakSet', 'RegExp', 'Error', 'Symbol', 'parseInt',
|
|
50
|
+
'parseFloat', 'isNaN', 'setTimeout', 'setInterval', 'clearTimeout', 'fetch',
|
|
51
|
+
'Buffer', 'Function', 'eval', 'encodeURIComponent', 'decodeURIComponent',
|
|
52
|
+
// Python
|
|
53
|
+
'print', 'len', 'range', 'str', 'int', 'float', 'dict', 'list', 'tuple',
|
|
54
|
+
'set', 'bool', 'open', 'enumerate', 'zip', 'map', 'filter', 'sorted',
|
|
55
|
+
'sum', 'min', 'max', 'abs', 'isinstance', 'super', 'type', 'getattr',
|
|
56
|
+
'setattr', 'hasattr',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const REL_EXTS = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.json', '.py', '.r', '.R', '.vue'];
|
|
60
|
+
const REL_INDEX = ['index.js', 'index.ts', 'index.tsx', 'index.jsx', '__init__.py'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the set of known symbol identifiers from the SigMap signature index,
|
|
64
|
+
* plus `{ name, file, line }` candidates (for closest-match suggestions).
|
|
65
|
+
*/
|
|
66
|
+
function buildSymbolSet(cwd) {
|
|
67
|
+
const set = new Set();
|
|
68
|
+
let fileKeys = [];
|
|
69
|
+
let symbolCandidates = [];
|
|
70
|
+
try {
|
|
71
|
+
const { buildSigIndex } = require('../retrieval/ranker');
|
|
72
|
+
const idx = buildSigIndex(cwd);
|
|
73
|
+
fileKeys = [...idx.keys()];
|
|
74
|
+
for (const sigs of idx.values()) {
|
|
75
|
+
for (const sig of sigs) {
|
|
76
|
+
const cleaned = String(sig).replace(/\s*:\d+(?:-\d+)?\s*$/, '');
|
|
77
|
+
const ids = cleaned.match(/[A-Za-z_$][\w$]*/g) || [];
|
|
78
|
+
for (const id of ids) set.add(id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
symbolCandidates = buildSymbolCandidates(idx);
|
|
82
|
+
} catch (_) {}
|
|
83
|
+
return { set, fileKeys, symbolCandidates };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Load declared dependency names from package.json. */
|
|
87
|
+
function loadDeps(cwd) {
|
|
88
|
+
const deps = new Set();
|
|
89
|
+
let hasPkg = false;
|
|
90
|
+
try {
|
|
91
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
92
|
+
hasPkg = true;
|
|
93
|
+
for (const k of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
94
|
+
if (pkg[k] && typeof pkg[k] === 'object') {
|
|
95
|
+
for (const name of Object.keys(pkg[k])) deps.add(name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch (_) {}
|
|
99
|
+
return { deps, hasPkg };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Load the set of npm script names declared in package.json. */
|
|
103
|
+
function loadScripts(cwd) {
|
|
104
|
+
const scripts = new Set();
|
|
105
|
+
try {
|
|
106
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
107
|
+
if (pkg.scripts && typeof pkg.scripts === 'object') {
|
|
108
|
+
for (const name of Object.keys(pkg.scripts)) scripts.add(name);
|
|
109
|
+
}
|
|
110
|
+
} catch (_) {}
|
|
111
|
+
return scripts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Default file-existence check: resolve a referenced path against cwd. */
|
|
115
|
+
function defaultFileExists(cwd, ref) {
|
|
116
|
+
const clean = ref.replace(/^\.\//, '');
|
|
117
|
+
for (const c of [path.resolve(cwd, clean), path.resolve(cwd, ref)]) {
|
|
118
|
+
try {
|
|
119
|
+
if (fs.existsSync(c)) return true;
|
|
120
|
+
} catch (_) {}
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Default relative-import resolver: fs candidates + basename match in index. */
|
|
126
|
+
function defaultRelativeResolvable(cwd, mod, fileBasenames) {
|
|
127
|
+
const base = path.resolve(cwd, mod);
|
|
128
|
+
for (const e of REL_EXTS) {
|
|
129
|
+
try {
|
|
130
|
+
if (fs.existsSync(base + e)) return true;
|
|
131
|
+
} catch (_) {}
|
|
132
|
+
}
|
|
133
|
+
for (const idx of REL_INDEX) {
|
|
134
|
+
try {
|
|
135
|
+
if (fs.existsSync(path.join(base, idx))) return true;
|
|
136
|
+
} catch (_) {}
|
|
137
|
+
}
|
|
138
|
+
// Fall back to basename match against the indexed file set (the answer's
|
|
139
|
+
// import is relative to a file we cannot know, so a name match is enough
|
|
140
|
+
// to avoid false positives).
|
|
141
|
+
const wantBase = path.basename(mod).replace(/\.[^.]+$/, '').toLowerCase();
|
|
142
|
+
return fileBasenames.has(wantBase);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Verify an AI answer against the repository.
|
|
147
|
+
*
|
|
148
|
+
* Each issue has the shape:
|
|
149
|
+
* { type, value, line, location, message, confidence, suggestion }
|
|
150
|
+
* where `confidence` is the *detection* certainty ('high' for path/dep/script
|
|
151
|
+
* checks, 'medium' for symbol checks) and `suggestion` is a heuristic
|
|
152
|
+
* closest-match hint (or null).
|
|
153
|
+
*
|
|
154
|
+
* @param {string} answerText
|
|
155
|
+
* @param {string} cwd
|
|
156
|
+
* @param {object} [opts]
|
|
157
|
+
* @param {Set<string>} [opts.symbolSet] override known symbols
|
|
158
|
+
* @param {Array} [opts.symbolCandidates] override { name, file, line } list
|
|
159
|
+
* @param {Array<string>} [opts.fileCandidates] override repo file paths (suggestions)
|
|
160
|
+
* @param {Set<string>} [opts.deps] override package deps
|
|
161
|
+
* @param {Set<string>} [opts.scripts] override package.json script names
|
|
162
|
+
* @param {boolean} [opts.hasPkg] whether a package.json exists
|
|
163
|
+
* @param {(ref: string) => boolean} [opts.fileExists] override file check
|
|
164
|
+
* @param {(mod: string) => boolean} [opts.relativeResolvable] override rel-import check
|
|
165
|
+
* @returns {{ issues: object[], summary: object }}
|
|
166
|
+
*/
|
|
167
|
+
function verify(answerText, cwd, opts = {}) {
|
|
168
|
+
let symbolSet = opts.symbolSet;
|
|
169
|
+
let fileBasenames = opts.fileBasenames;
|
|
170
|
+
let symbolCandidates = opts.symbolCandidates || [];
|
|
171
|
+
let fileCandidates = opts.fileCandidates || [];
|
|
172
|
+
if (!symbolSet) {
|
|
173
|
+
const built = buildSymbolSet(cwd);
|
|
174
|
+
symbolSet = built.set;
|
|
175
|
+
fileBasenames = new Set(built.fileKeys.map(
|
|
176
|
+
(k) => path.basename(k).replace(/\.[^.]+$/, '').toLowerCase()
|
|
177
|
+
));
|
|
178
|
+
symbolCandidates = built.symbolCandidates;
|
|
179
|
+
fileCandidates = built.fileKeys;
|
|
180
|
+
}
|
|
181
|
+
if (!fileBasenames) fileBasenames = new Set();
|
|
182
|
+
|
|
183
|
+
let deps = opts.deps;
|
|
184
|
+
let hasPkg = opts.hasPkg;
|
|
185
|
+
if (!deps) {
|
|
186
|
+
const loaded = loadDeps(cwd);
|
|
187
|
+
deps = loaded.deps;
|
|
188
|
+
if (hasPkg === undefined) hasPkg = loaded.hasPkg;
|
|
189
|
+
}
|
|
190
|
+
const scripts = opts.scripts || (hasPkg ? loadScripts(cwd) : new Set());
|
|
191
|
+
|
|
192
|
+
const fileExists = opts.fileExists || ((ref) => defaultFileExists(cwd, ref));
|
|
193
|
+
const relativeResolvable = opts.relativeResolvable
|
|
194
|
+
|| ((mod) => defaultRelativeResolvable(cwd, mod, fileBasenames));
|
|
195
|
+
|
|
196
|
+
// Pre-derive basename candidates for file suggestions (compare on basename so
|
|
197
|
+
// a wrong directory still surfaces the right file).
|
|
198
|
+
const fileBasenameCandidates = fileCandidates.map((f) => ({ name: path.basename(f), file: f }));
|
|
199
|
+
|
|
200
|
+
const issues = [];
|
|
201
|
+
const dedupe = new Set();
|
|
202
|
+
const add = (issue) => {
|
|
203
|
+
const key = `${issue.type}::${issue.value}`;
|
|
204
|
+
if (dedupe.has(key)) return;
|
|
205
|
+
dedupe.add(key);
|
|
206
|
+
if (!('suggestion' in issue)) issue.suggestion = null;
|
|
207
|
+
issue.location = `L${issue.line}`;
|
|
208
|
+
issues.push(issue);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// 1. fake-file / fake-test-file
|
|
212
|
+
for (const { path: p, line } of parsers.extractFilePaths(answerText)) {
|
|
213
|
+
if (fileExists(p)) continue;
|
|
214
|
+
const isTest = isTestPath(p);
|
|
215
|
+
const match = closestMatch(path.basename(p), fileBasenameCandidates, { minLen: 4 });
|
|
216
|
+
add({
|
|
217
|
+
type: isTest ? 'fake-test-file' : 'fake-file',
|
|
218
|
+
value: p,
|
|
219
|
+
line,
|
|
220
|
+
message: `${isTest ? 'Test file' : 'File'} not found on disk: ${p}`,
|
|
221
|
+
confidence: 'high',
|
|
222
|
+
suggestion: match ? formatSuggestion(match, false) : null,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 2. fake-import
|
|
227
|
+
for (const imp of parsers.extractImports(answerText)) {
|
|
228
|
+
if (imp.relative) {
|
|
229
|
+
if (!relativeResolvable(imp.module)) {
|
|
230
|
+
add({ type: 'fake-import', value: imp.module, line: imp.line, message: `Import does not resolve: ${imp.module}`, confidence: 'high' });
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// Bare module — only verifiable for JS when a package.json exists.
|
|
235
|
+
const top = imp.module.split('/')[0];
|
|
236
|
+
if (imp.kind === 'js') {
|
|
237
|
+
if (!hasPkg) continue;
|
|
238
|
+
if (NODE_BUILTINS.has(imp.module) || NODE_BUILTINS.has(top)) continue;
|
|
239
|
+
if (top.startsWith('@')) {
|
|
240
|
+
const scoped = imp.module.split('/').slice(0, 2).join('/');
|
|
241
|
+
if (deps.has(scoped) || deps.has(imp.module)) continue;
|
|
242
|
+
} else if (deps.has(top) || deps.has(imp.module)) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const match = closestMatch(top, [...deps], { minLen: 3 });
|
|
246
|
+
add({
|
|
247
|
+
type: 'fake-import',
|
|
248
|
+
value: imp.module,
|
|
249
|
+
line: imp.line,
|
|
250
|
+
message: `Package not in dependencies: ${imp.module}`,
|
|
251
|
+
confidence: 'high',
|
|
252
|
+
suggestion: match ? formatSuggestion({ name: match.name }, false) : null,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
// Python bare imports: stdlib is unbounded offline — skip to keep precision.
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 3. fake-symbol
|
|
259
|
+
if (symbolSet.size > 0) {
|
|
260
|
+
for (const { name, line } of parsers.extractSymbols(answerText)) {
|
|
261
|
+
if (symbolSet.has(name)) continue;
|
|
262
|
+
if (LANG_GLOBALS.has(name) || NODE_BUILTINS.has(name) || PY_BUILTINS.has(name)) continue;
|
|
263
|
+
const match = closestMatch(name, symbolCandidates, { minLen: 4 });
|
|
264
|
+
add({
|
|
265
|
+
type: 'fake-symbol',
|
|
266
|
+
value: name,
|
|
267
|
+
line,
|
|
268
|
+
message: `Symbol not found in repo index: ${name}()`,
|
|
269
|
+
confidence: 'medium',
|
|
270
|
+
suggestion: match ? formatSuggestion(match, true) : null,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 4. fake-npm-script
|
|
276
|
+
if (hasPkg && scripts.size > 0) {
|
|
277
|
+
for (const { name, line } of parsers.extractNpmScripts(answerText)) {
|
|
278
|
+
if (scripts.has(name)) continue;
|
|
279
|
+
const match = closestMatch(name, [...scripts], { minLen: 2 });
|
|
280
|
+
add({
|
|
281
|
+
type: 'fake-npm-script',
|
|
282
|
+
value: name,
|
|
283
|
+
line,
|
|
284
|
+
message: `npm script not in package.json: ${name}`,
|
|
285
|
+
confidence: 'high',
|
|
286
|
+
suggestion: match ? formatSuggestion({ name: match.name }, false) : null,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
issues.sort((a, b) => a.line - b.line);
|
|
292
|
+
|
|
293
|
+
const byType = {
|
|
294
|
+
'fake-file': 0, 'fake-test-file': 0, 'fake-import': 0,
|
|
295
|
+
'fake-symbol': 0, 'fake-npm-script': 0,
|
|
296
|
+
};
|
|
297
|
+
for (const i of issues) byType[i.type] = (byType[i.type] || 0) + 1;
|
|
298
|
+
|
|
299
|
+
const summary = {
|
|
300
|
+
total: issues.length,
|
|
301
|
+
byType,
|
|
302
|
+
clean: issues.length === 0,
|
|
303
|
+
symbolsIndexed: symbolSet.size,
|
|
304
|
+
withSuggestion: issues.filter((i) => i.suggestion).length,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return { issues, summary };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = { verify, buildSymbolSet, loadDeps, loadScripts, isTestPath };
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
// TS: import X = require('mod')
|
|
110
|
+
if ((m = line.match(/\bimport\s+[A-Za-z_$][\w$]*\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/))) {
|
|
111
|
+
push(m[1], 'js', i + 1, line);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Python: from x import y | import x
|
|
115
|
+
if ((m = line.match(/^\s*from\s+([.\w]+)\s+import\b/))) {
|
|
116
|
+
push(m[1], 'py', i + 1, line);
|
|
117
|
+
} else if ((m = line.match(/^\s*import\s+([A-Za-z_][\w.]*)/))) {
|
|
118
|
+
push(m[1], 'py', i + 1, line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Multi-line JS/TS imports, e.g.
|
|
123
|
+
// import {
|
|
124
|
+
// A as B,
|
|
125
|
+
// } from './mod';
|
|
126
|
+
// The per-line pass above misses these because `from '…'` sits on a later
|
|
127
|
+
// line. Trigger only when the opening line has no quote and no `from` yet,
|
|
128
|
+
// then gather forward until the source string appears.
|
|
129
|
+
for (let i = 0; i < lines.length; i++) {
|
|
130
|
+
const start = lines[i];
|
|
131
|
+
if (!/^\s*(?:import|export)\b/.test(start)) continue;
|
|
132
|
+
if (/['"]/.test(start) || /\bfrom\b/.test(start)) continue; // single-line, already handled
|
|
133
|
+
let joined = start;
|
|
134
|
+
for (let j = i + 1; j < Math.min(lines.length, i + 12); j++) {
|
|
135
|
+
joined += ' ' + lines[j];
|
|
136
|
+
const fm = joined.match(/\bfrom\s*['"]([^'"]+)['"]/);
|
|
137
|
+
if (fm) { push(fm[1], 'js', i + 1, start.trim()); break; }
|
|
138
|
+
if (/['"]/.test(lines[j]) && !/\bfrom\b/.test(joined)) break; // a string that isn't a source — bail
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Extract npm/pnpm/yarn script invocations (`npm run <name>`).
|
|
146
|
+
* Only the explicit `run` form is matched, to avoid confusing package-manager
|
|
147
|
+
* subcommands (`yarn add`, `pnpm install`) with script names.
|
|
148
|
+
* @param {string} text
|
|
149
|
+
* @returns {{ name: string, line: number }[]}
|
|
150
|
+
*/
|
|
151
|
+
function extractNpmScripts(text) {
|
|
152
|
+
const lines = text.split('\n');
|
|
153
|
+
const out = [];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
const re = /\b(?:npm|pnpm|yarn)\s+run(?:-script)?\s+([A-Za-z0-9:_-]+)/g;
|
|
156
|
+
for (let i = 0; i < lines.length; i++) {
|
|
157
|
+
let m;
|
|
158
|
+
re.lastIndex = 0;
|
|
159
|
+
while ((m = re.exec(lines[i])) !== null) {
|
|
160
|
+
const name = m[1];
|
|
161
|
+
if (seen.has(name)) continue;
|
|
162
|
+
seen.add(name);
|
|
163
|
+
out.push({ name, line: i + 1 });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Extract function/class symbol references that look like calls.
|
|
171
|
+
* Restricted to backtick-wrapped calls (`foo(...)`) for high precision.
|
|
172
|
+
* @param {string} text
|
|
173
|
+
* @returns {{ name: string, line: number }[]}
|
|
174
|
+
*/
|
|
175
|
+
function extractSymbols(text) {
|
|
176
|
+
const lines = text.split('\n');
|
|
177
|
+
const out = [];
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
const re = /`([A-Za-z_$][\w$]*)\s*\([^`]*\)`/g;
|
|
180
|
+
for (let i = 0; i < lines.length; i++) {
|
|
181
|
+
let m;
|
|
182
|
+
re.lastIndex = 0;
|
|
183
|
+
while ((m = re.exec(lines[i])) !== null) {
|
|
184
|
+
const name = m[1];
|
|
185
|
+
const key = name + '@' + (i + 1);
|
|
186
|
+
if (seen.has(key)) continue;
|
|
187
|
+
seen.add(key);
|
|
188
|
+
out.push({ name, line: i + 1 });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
extractCodeBlocks,
|
|
196
|
+
extractFilePaths,
|
|
197
|
+
extractImports,
|
|
198
|
+
extractSymbols,
|
|
199
|
+
extractNpmScripts,
|
|
200
|
+
};
|