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