sigmap 6.14.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 +30 -0
- package/README.md +11 -11
- package/gen-context.js +2018 -822
- 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 +109 -18
- package/src/verify/parsers.js +51 -0
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Hallucination Guard — deterministic core (
|
|
4
|
+
* Hallucination Guard — deterministic core (Reliable MVP, v6.15.0).
|
|
5
5
|
*
|
|
6
6
|
* Given the text of an AI answer, flag claims that do not match the repo:
|
|
7
|
-
* - fake-file
|
|
8
|
-
* - fake-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
11
13
|
*
|
|
12
|
-
*
|
|
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.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
const fs = require('fs');
|
|
17
21
|
const path = require('path');
|
|
18
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); }
|
|
19
29
|
|
|
20
30
|
const NODE_BUILTINS = new Set([
|
|
21
31
|
'fs', 'path', 'os', 'util', 'events', 'stream', 'http', 'https', 'crypto',
|
|
@@ -49,10 +59,14 @@ const LANG_GLOBALS = new Set([
|
|
|
49
59
|
const REL_EXTS = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.json', '.py', '.r', '.R', '.vue'];
|
|
50
60
|
const REL_INDEX = ['index.js', 'index.ts', 'index.tsx', 'index.jsx', '__init__.py'];
|
|
51
61
|
|
|
52
|
-
/**
|
|
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
|
+
*/
|
|
53
66
|
function buildSymbolSet(cwd) {
|
|
54
67
|
const set = new Set();
|
|
55
68
|
let fileKeys = [];
|
|
69
|
+
let symbolCandidates = [];
|
|
56
70
|
try {
|
|
57
71
|
const { buildSigIndex } = require('../retrieval/ranker');
|
|
58
72
|
const idx = buildSigIndex(cwd);
|
|
@@ -64,8 +78,9 @@ function buildSymbolSet(cwd) {
|
|
|
64
78
|
for (const id of ids) set.add(id);
|
|
65
79
|
}
|
|
66
80
|
}
|
|
81
|
+
symbolCandidates = buildSymbolCandidates(idx);
|
|
67
82
|
} catch (_) {}
|
|
68
|
-
return { set, fileKeys };
|
|
83
|
+
return { set, fileKeys, symbolCandidates };
|
|
69
84
|
}
|
|
70
85
|
|
|
71
86
|
/** Load declared dependency names from package.json. */
|
|
@@ -84,6 +99,18 @@ function loadDeps(cwd) {
|
|
|
84
99
|
return { deps, hasPkg };
|
|
85
100
|
}
|
|
86
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
|
+
|
|
87
114
|
/** Default file-existence check: resolve a referenced path against cwd. */
|
|
88
115
|
function defaultFileExists(cwd, ref) {
|
|
89
116
|
const clean = ref.replace(/^\.\//, '');
|
|
@@ -118,11 +145,20 @@ function defaultRelativeResolvable(cwd, mod, fileBasenames) {
|
|
|
118
145
|
/**
|
|
119
146
|
* Verify an AI answer against the repository.
|
|
120
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
|
+
*
|
|
121
154
|
* @param {string} answerText
|
|
122
155
|
* @param {string} cwd
|
|
123
156
|
* @param {object} [opts]
|
|
124
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)
|
|
125
160
|
* @param {Set<string>} [opts.deps] override package deps
|
|
161
|
+
* @param {Set<string>} [opts.scripts] override package.json script names
|
|
126
162
|
* @param {boolean} [opts.hasPkg] whether a package.json exists
|
|
127
163
|
* @param {(ref: string) => boolean} [opts.fileExists] override file check
|
|
128
164
|
* @param {(mod: string) => boolean} [opts.relativeResolvable] override rel-import check
|
|
@@ -131,12 +167,16 @@ function defaultRelativeResolvable(cwd, mod, fileBasenames) {
|
|
|
131
167
|
function verify(answerText, cwd, opts = {}) {
|
|
132
168
|
let symbolSet = opts.symbolSet;
|
|
133
169
|
let fileBasenames = opts.fileBasenames;
|
|
170
|
+
let symbolCandidates = opts.symbolCandidates || [];
|
|
171
|
+
let fileCandidates = opts.fileCandidates || [];
|
|
134
172
|
if (!symbolSet) {
|
|
135
173
|
const built = buildSymbolSet(cwd);
|
|
136
174
|
symbolSet = built.set;
|
|
137
175
|
fileBasenames = new Set(built.fileKeys.map(
|
|
138
176
|
(k) => path.basename(k).replace(/\.[^.]+$/, '').toLowerCase()
|
|
139
177
|
));
|
|
178
|
+
symbolCandidates = built.symbolCandidates;
|
|
179
|
+
fileCandidates = built.fileKeys;
|
|
140
180
|
}
|
|
141
181
|
if (!fileBasenames) fileBasenames = new Set();
|
|
142
182
|
|
|
@@ -147,32 +187,47 @@ function verify(answerText, cwd, opts = {}) {
|
|
|
147
187
|
deps = loaded.deps;
|
|
148
188
|
if (hasPkg === undefined) hasPkg = loaded.hasPkg;
|
|
149
189
|
}
|
|
190
|
+
const scripts = opts.scripts || (hasPkg ? loadScripts(cwd) : new Set());
|
|
150
191
|
|
|
151
192
|
const fileExists = opts.fileExists || ((ref) => defaultFileExists(cwd, ref));
|
|
152
193
|
const relativeResolvable = opts.relativeResolvable
|
|
153
194
|
|| ((mod) => defaultRelativeResolvable(cwd, mod, fileBasenames));
|
|
154
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
|
+
|
|
155
200
|
const issues = [];
|
|
156
201
|
const dedupe = new Set();
|
|
157
202
|
const add = (issue) => {
|
|
158
203
|
const key = `${issue.type}::${issue.value}`;
|
|
159
204
|
if (dedupe.has(key)) return;
|
|
160
205
|
dedupe.add(key);
|
|
206
|
+
if (!('suggestion' in issue)) issue.suggestion = null;
|
|
207
|
+
issue.location = `L${issue.line}`;
|
|
161
208
|
issues.push(issue);
|
|
162
209
|
};
|
|
163
210
|
|
|
164
|
-
// 1. fake-file
|
|
211
|
+
// 1. fake-file / fake-test-file
|
|
165
212
|
for (const { path: p, line } of parsers.extractFilePaths(answerText)) {
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
}
|
|
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
|
+
});
|
|
169
224
|
}
|
|
170
225
|
|
|
171
226
|
// 2. fake-import
|
|
172
227
|
for (const imp of parsers.extractImports(answerText)) {
|
|
173
228
|
if (imp.relative) {
|
|
174
229
|
if (!relativeResolvable(imp.module)) {
|
|
175
|
-
add({ type: 'fake-import', value: imp.module, line: imp.line, message: `Import does not resolve: ${imp.module}
|
|
230
|
+
add({ type: 'fake-import', value: imp.module, line: imp.line, message: `Import does not resolve: ${imp.module}`, confidence: 'high' });
|
|
176
231
|
}
|
|
177
232
|
continue;
|
|
178
233
|
}
|
|
@@ -187,7 +242,15 @@ function verify(answerText, cwd, opts = {}) {
|
|
|
187
242
|
} else if (deps.has(top) || deps.has(imp.module)) {
|
|
188
243
|
continue;
|
|
189
244
|
}
|
|
190
|
-
|
|
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
|
+
});
|
|
191
254
|
}
|
|
192
255
|
// Python bare imports: stdlib is unbounded offline — skip to keep precision.
|
|
193
256
|
}
|
|
@@ -197,13 +260,40 @@ function verify(answerText, cwd, opts = {}) {
|
|
|
197
260
|
for (const { name, line } of parsers.extractSymbols(answerText)) {
|
|
198
261
|
if (symbolSet.has(name)) continue;
|
|
199
262
|
if (LANG_GLOBALS.has(name) || NODE_BUILTINS.has(name) || PY_BUILTINS.has(name)) continue;
|
|
200
|
-
|
|
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
|
+
});
|
|
201
288
|
}
|
|
202
289
|
}
|
|
203
290
|
|
|
204
291
|
issues.sort((a, b) => a.line - b.line);
|
|
205
292
|
|
|
206
|
-
const byType = {
|
|
293
|
+
const byType = {
|
|
294
|
+
'fake-file': 0, 'fake-test-file': 0, 'fake-import': 0,
|
|
295
|
+
'fake-symbol': 0, 'fake-npm-script': 0,
|
|
296
|
+
};
|
|
207
297
|
for (const i of issues) byType[i.type] = (byType[i.type] || 0) + 1;
|
|
208
298
|
|
|
209
299
|
const summary = {
|
|
@@ -211,9 +301,10 @@ function verify(answerText, cwd, opts = {}) {
|
|
|
211
301
|
byType,
|
|
212
302
|
clean: issues.length === 0,
|
|
213
303
|
symbolsIndexed: symbolSet.size,
|
|
304
|
+
withSuggestion: issues.filter((i) => i.suggestion).length,
|
|
214
305
|
};
|
|
215
306
|
|
|
216
307
|
return { issues, summary };
|
|
217
308
|
}
|
|
218
309
|
|
|
219
|
-
module.exports = { verify, buildSymbolSet, loadDeps };
|
|
310
|
+
module.exports = { verify, buildSymbolSet, loadDeps, loadScripts, isTestPath };
|
package/src/verify/parsers.js
CHANGED
|
@@ -106,6 +106,11 @@ function extractImports(text) {
|
|
|
106
106
|
let r;
|
|
107
107
|
while ((r = reqRe.exec(line)) !== null) push(r[1], 'js', i + 1, line);
|
|
108
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
|
+
|
|
109
114
|
// Python: from x import y | import x
|
|
110
115
|
if ((m = line.match(/^\s*from\s+([.\w]+)\s+import\b/))) {
|
|
111
116
|
push(m[1], 'py', i + 1, line);
|
|
@@ -113,6 +118,51 @@ function extractImports(text) {
|
|
|
113
118
|
push(m[1], 'py', i + 1, line);
|
|
114
119
|
}
|
|
115
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
|
+
}
|
|
116
166
|
return out;
|
|
117
167
|
}
|
|
118
168
|
|
|
@@ -146,4 +196,5 @@ module.exports = {
|
|
|
146
196
|
extractFilePaths,
|
|
147
197
|
extractImports,
|
|
148
198
|
extractSymbols,
|
|
199
|
+
extractNpmScripts,
|
|
149
200
|
};
|