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.
@@ -1,21 +1,31 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Hallucination Guard — deterministic core (Phase 1 MVP).
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 : 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
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
- * No network, no LLM. Reuses SigMap primitives (buildSigIndex) but every
13
- * external dependency is injectable via `opts` so the core stays unit-testable.
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
- /** Build the set of known symbol identifiers from the SigMap signature index. */
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 (!fileExists(p)) {
167
- add({ type: 'fake-file', value: p, line, message: `File not found on disk: ${p}` });
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
- add({ type: 'fake-import', value: imp.module, line: imp.line, message: `Package not in dependencies: ${imp.module}` });
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
- add({ type: 'fake-symbol', value: name, line, message: `Symbol not found in repo index: ${name}()` });
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 = { 'fake-file': 0, 'fake-import': 0, 'fake-symbol': 0 };
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 };
@@ -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
  };