| 1 | import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs'; |
| 2 | import { isAbsolute, join, resolve, sep } from 'node:path'; |
| 3 | import { truncate } from './util.js'; |
| 4 | import { SCHEMA_VERSION } from './config.js'; |
| 5 | |
| 6 | const NODE_BUILTINS = new Set([ |
| 7 | 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', 'constants', |
| 8 | 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain', 'events', 'fs', 'http', |
| 9 | 'http2', 'https', 'inspector', 'module', 'net', 'os', 'path', 'perf_hooks', 'process', |
| 10 | 'punycode', 'querystring', 'readline', 'repl', 'stream', 'string_decoder', 'sys', |
| 11 | 'timers', 'tls', 'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib', |
| 12 | ]); |
| 13 | |
| 14 | const PY_STDLIB = new Set([ |
| 15 | 'os', 'sys', 're', 'json', 'math', 'random', 'datetime', 'time', 'collections', 'itertools', |
| 16 | 'functools', 'typing', 'pathlib', 'subprocess', 'logging', 'argparse', 'unittest', 'asyncio', |
| 17 | 'io', 'abc', 'enum', 'dataclasses', 'copy', 'hashlib', 'base64', 'csv', 'sqlite3', 'socket', |
| 18 | 'threading', 'multiprocessing', 'shutil', 'glob', 'tempfile', 'traceback', 'inspect', 'string', |
| 19 | 'textwrap', 'decimal', 'fractions', 'statistics', 'struct', 'pickle', 'http', 'urllib', 'xml', |
| 20 | 'html', 'email', 'warnings', 'contextlib', 'operator', 'weakref', 'gc', 'platform', 'signal', |
| 21 | ]); |
| 22 | |
| 23 | const KNOWN_FILE_EXTENSIONS = new Set([ |
| 24 | 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'mts', 'cts', 'd.ts', |
| 25 | 'py', 'pyi', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'scala', 'clj', 'cljs', |
| 26 | 'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'm', 'mm', 'swift', 'php', 'cs', |
| 27 | 'lua', 'pl', 'pm', 'r', 'jl', 'dart', 'ex', 'exs', 'erl', 'hrl', 'elm', 'hs', |
| 28 | 'json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', |
| 29 | 'xml', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'svg', 'vue', 'svelte', 'astro', |
| 30 | 'md', 'mdx', 'markdown', 'rst', 'txt', 'csv', 'tsv', 'sql', 'graphql', 'gql', |
| 31 | 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'dockerfile', 'lock', 'gradle', |
| 32 | 'gitignore', 'gitattributes', 'npmrc', 'nvmrc', 'editorconfig', 'eslintrc', 'prettierrc', |
| 33 | 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'pdf', 'proto', 'tf', 'tfvars', |
| 34 | ]); |
| 35 | |
| 36 | const AMBIGUOUS_BARE_EXTENSIONS = new Set(['env']); |
| 37 | |
| 38 | const KNOWN_EXTENSIONLESS_FILES = new Set([ |
| 39 | 'dockerfile', 'makefile', 'readme', 'license', 'licence', 'notice', 'changelog', |
| 40 | 'authors', 'contributing', 'codeowners', 'procfile', 'rakefile', 'gemfile', |
| 41 | 'pipfile', 'brewfile', 'vagrantfile', 'jenkinsfile', 'gnumakefile', |
| 42 | '.env', '.gitignore', '.gitattributes', '.npmrc', '.nvmrc', '.editorconfig', |
| 43 | '.dockerignore', '.eslintrc', '.prettierrc', '.babelrc', '.bashrc', '.zshrc', |
| 44 | ]); |
| 45 | |
| 46 | const FILE_TOKEN_RE = /(?:[\w@./+-]*\/)?[\w@.+-]+\.[A-Za-z][A-Za-z0-9]{0,9}\b/g; |
| 47 | const PATHISH_TOKEN_RE = /(?:\.{0,2}\/)?[\w@.+-]+(?:\/[\w@.+-]+)+\/?/g; |
| 48 | const BAREWORD_TOKEN_RE = /(?:^|[\s'"`([{])(\.?[A-Za-z][\w.-]*)(?=$|[\s'"`)\]},.;:])/g; |
| 49 | const REL_PREFIX_RE = /^(?:\.\/|\.\.\/)/; |
| 50 | const URL_LIKE_RE = /:\/\ |
| 51 | const VERSION_LIKE_RE = /^\d+(?:\.\d+)+$/; |
| 52 | const FILE_OP_VERB_RE = /\b(?:open|edit|read|cat|touch|create|write|delete|rm|view|append|chmod|mv|cp|run)\b/i; |
| 53 | const FILE_OP_GOVERNS_RE = |
| 54 | /\b(?:open|edit|read|cat|touch|create|write|delete|rm|view|append|chmod|mv|cp|run)\s+(?:the\s+|a\s+|an\s+|your\s+|this\s+|that\s+|my\s+|our\s+|its\s+)?(?:new\s+|existing\s+|file\s+|path\s+|module\s+)?["'`(]?$/i; |
| 55 | const RATIO_LIKE_RE = /^\d+\/\d+$/; |
| 56 | const KNOWN_DIR_PREFIXES = new Set([ |
| 57 | 'src', 'lib', 'libs', 'test', 'tests', 'spec', 'specs', 'dist', 'build', |
| 58 | 'bin', 'cmd', 'pkg', 'internal', 'app', 'apps', 'api', 'web', 'www', |
| 59 | 'server', 'client', 'common', 'shared', 'utils', 'util', 'helpers', |
| 60 | 'config', 'configs', 'scripts', 'tools', 'docs', 'doc', 'examples', |
| 61 | 'example', 'fixtures', 'mocks', 'stubs', 'public', 'static', 'assets', |
| 62 | 'styles', 'components', 'pages', 'routes', 'models', 'views', 'controllers', |
| 63 | 'services', 'middleware', 'plugins', 'modules', '.github', '.circleci', |
| 64 | ]); |
| 65 | const JS_IMPORT_RE = |
| 66 | /\b(?:import|export)\b[^;\n]*?\bfrom\s*['"]([^'"\n]+)['"]|\brequire\(\s*['"]([^'"\n]+)['"]\s*\)|\bimport\(\s*['"]([^'"\n]+)['"]\s*\)/g; |
| 67 | const PY_IMPORT_RE = /^[ \t]*(?:from\s+([A-Za-z_][\w.]*)\s+import\b|import\s+([A-Za-z_][\w.]*(?:\s*,\s*[A-Za-z_][\w.]*)*))/gm; |
| 68 | |
| 69 | const EVIDENCE_CAP = 120; |
| 70 | const MAX_TEXT_SCAN = 20000; |
| 71 | |
| 72 | function readPackageNames(projectDir) { |
| 73 | const names = new Set(); |
| 74 | const pkgPath = join(projectDir, 'package.json'); |
| 75 | if (existsSync(pkgPath)) { |
| 76 | try { |
| 77 | const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); |
| 78 | for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { |
| 79 | if (pkg[field] && typeof pkg[field] === 'object') { |
| 80 | for (const name of Object.keys(pkg[field])) names.add(name); |
| 81 | } |
| 82 | } |
| 83 | } catch { |
| 84 | |
| 85 | } |
| 86 | } |
| 87 | return names; |
| 88 | } |
| 89 | |
| 90 | function readLockfilePackages(projectDir) { |
| 91 | const names = new Set(); |
| 92 | const lockPath = join(projectDir, 'package-lock.json'); |
| 93 | if (existsSync(lockPath)) { |
| 94 | try { |
| 95 | const lock = JSON.parse(readFileSync(lockPath, 'utf8')); |
| 96 | if (lock.packages && typeof lock.packages === 'object') { |
| 97 | for (const key of Object.keys(lock.packages)) { |
| 98 | const idx = key.lastIndexOf('node_modules/'); |
| 99 | if (idx >= 0) names.add(key.slice(idx + 'node_modules/'.length)); |
| 100 | } |
| 101 | } |
| 102 | if (lock.dependencies && typeof lock.dependencies === 'object') { |
| 103 | for (const name of Object.keys(lock.dependencies)) names.add(name); |
| 104 | } |
| 105 | } catch { |
| 106 | |
| 107 | } |
| 108 | } |
| 109 | return names; |
| 110 | } |
| 111 | |
| 112 | function readPyRequirements(projectDir) { |
| 113 | const names = new Set(); |
| 114 | for (const file of ['requirements.txt', 'pyproject.toml', 'Pipfile']) { |
| 115 | const p = join(projectDir, file); |
| 116 | if (!existsSync(p)) continue; |
| 117 | try { |
| 118 | const text = readFileSync(p, 'utf8'); |
| 119 | for (const m of text.matchAll(/^[ \t]*['"]?([A-Za-z][\w.-]+)['"]?\s*(?:[=<>~!]=?|@|\s*=\s*)/gm)) { |
| 120 | names.add(m[1].toLowerCase()); |
| 121 | } |
| 122 | } catch { |
| 123 | |
| 124 | } |
| 125 | } |
| 126 | return names; |
| 127 | } |
| 128 | |
| 129 | function packageRoot(spec) { |
| 130 | if (spec.startsWith('@')) { |
| 131 | const parts = spec.split('/'); |
| 132 | return parts.slice(0, 2).join('/'); |
| 133 | } |
| 134 | return spec.split('/')[0]; |
| 135 | } |
| 136 | |
| 137 | function collectCreatedFiles(tree, projectDir) { |
| 138 | const created = new Set(); |
| 139 | for (const node of tree.nodes) { |
| 140 | for (const a of node.actions || []) { |
| 141 | if (!a.file || typeof a.file !== 'string') continue; |
| 142 | if (a.tool === 'Write') { |
| 143 | created.add(normalizeFileKey(a.file)); |
| 144 | } else if (a.tool === 'Edit' || a.tool === 'NotebookEdit') { |
| 145 | if (fileExists(projectDir, a.file)) created.add(normalizeFileKey(a.file)); |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | return created; |
| 150 | } |
| 151 | |
| 152 | function normalizeFileKey(p) { |
| 153 | return p.replace(/^\.?\ |
| 154 | } |
| 155 | |
| 156 | function tokenExtension(tok) { |
| 157 | const base = tok.split('/').pop(); |
| 158 | const dot = base.lastIndexOf('.'); |
| 159 | if (dot <= 0) return ''; |
| 160 | return base.slice(dot + 1).toLowerCase(); |
| 161 | } |
| 162 | |
| 163 | function hasSlash(tok) { |
| 164 | return tok.includes('/'); |
| 165 | } |
| 166 | |
| 167 | function looksLikeFileToken(tok) { |
| 168 | if (tok.length < 3 || tok.length > 200) return false; |
| 169 | if (URL_LIKE_RE.test(tok)) return false; |
| 170 | if (VERSION_LIKE_RE.test(tok)) return false; |
| 171 | const ext = tokenExtension(tok); |
| 172 | if (!ext || ext.length > 10) return false; |
| 173 | if (hasSlash(tok)) return true; |
| 174 | if (!KNOWN_FILE_EXTENSIONS.has(ext)) return false; |
| 175 | if (AMBIGUOUS_BARE_EXTENSIONS.has(ext) && !tok.startsWith('.')) return false; |
| 176 | return true; |
| 177 | } |
| 178 | |
| 179 | function hasRealFileSignal(tok, context) { |
| 180 | if (REL_PREFIX_RE.test(tok)) return true; |
| 181 | const first = tok.split('/')[0].toLowerCase(); |
| 182 | if (first.length > 1 && first.startsWith('.')) return true; |
| 183 | if (KNOWN_DIR_PREFIXES.has(first)) return true; |
| 184 | if (FILE_OP_GOVERNS_RE.test(context || '')) return true; |
| 185 | return false; |
| 186 | } |
| 187 | |
| 188 | function looksLikeExtensionlessFile(tok, context) { |
| 189 | if (tok.length < 3 || tok.length > 200) return false; |
| 190 | if (URL_LIKE_RE.test(tok)) return false; |
| 191 | const lower = tok.toLowerCase().replace(/^\.\ |
| 192 | if (KNOWN_EXTENSIONLESS_FILES.has(lower)) { |
| 193 | if (lower.startsWith('.')) return true; |
| 194 | return FILE_OP_GOVERNS_RE.test(context || ''); |
| 195 | } |
| 196 | if (hasSlash(tok) && !tokenExtension(tok)) { |
| 197 | if (!(/^(?:\.{0,2}\/)?[\w@.+-]+(?:\/[\w@.+-]+)+\/?$/.test(tok))) return false; |
| 198 | if (RATIO_LIKE_RE.test(tok)) return false; |
| 199 | if (!hasRealFileSignal(tok, context)) return false; |
| 200 | return true; |
| 201 | } |
| 202 | return false; |
| 203 | } |
| 204 | |
| 205 | function withinProjectDir(projectDir, target) { |
| 206 | const root = resolve(projectDir); |
| 207 | const resolved = resolve(target); |
| 208 | return resolved === root || resolved.startsWith(root + sep); |
| 209 | } |
| 210 | |
| 211 | function resolveInProject(projectDir, rel) { |
| 212 | const clean = rel.replace(/^\.\ |
| 213 | const target = isAbsolute(clean) ? clean : resolve(projectDir, clean); |
| 214 | if (!withinProjectDir(projectDir, target)) return null; |
| 215 | return target; |
| 216 | } |
| 217 | |
| 218 | function fileExists(projectDir, rel) { |
| 219 | const target = resolveInProject(projectDir, rel); |
| 220 | if (!target) return true; |
| 221 | try { |
| 222 | if (existsSync(target)) return true; |
| 223 | } catch { |
| 224 | |
| 225 | } |
| 226 | const base = rel.replace(/^\.\ |
| 227 | return globByBasename(projectDir, base); |
| 228 | } |
| 229 | |
| 230 | const GLOB_SKIP_DIRS = new Set(['node_modules', '.git', '.treetrace', '.hg', '.svn', 'dist', 'build', 'coverage']); |
| 231 | const GLOB_MAX_DIRS = 4000; |
| 232 | |
| 233 | function globByBasename(projectDir, base) { |
| 234 | if (!base) return false; |
| 235 | let visited = 0; |
| 236 | const stack = [projectDir]; |
| 237 | while (stack.length) { |
| 238 | const dir = stack.pop(); |
| 239 | if (++visited > GLOB_MAX_DIRS) return false; |
| 240 | let entries; |
| 241 | try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; } |
| 242 | for (const ent of entries) { |
| 243 | if (ent.isDirectory()) { |
| 244 | if (GLOB_SKIP_DIRS.has(ent.name) || ent.name.startsWith('.git')) continue; |
| 245 | const child = join(dir, ent.name); |
| 246 | if (withinProjectDir(projectDir, child)) stack.push(child); |
| 247 | } else if (ent.isFile() && ent.name === base) { |
| 248 | return true; |
| 249 | } |
| 250 | } |
| 251 | } |
| 252 | return false; |
| 253 | } |
| 254 | |
| 255 | function collectFileReferences(tree) { |
| 256 | const refs = []; |
| 257 | const seen = new Set(); |
| 258 | const push = (raw, nodeId) => { |
| 259 | const tok = raw.trim().replace(/^['"`(]+|['"`),.;:]+$/g, ''); |
| 260 | if (!looksLikeFileToken(tok)) return; |
| 261 | const key = normalizeFileKey(tok); |
| 262 | if (seen.has(key)) return; |
| 263 | seen.add(key); |
| 264 | refs.push({ token: tok, key, nodeId }); |
| 265 | }; |
| 266 | const pushExtensionless = (raw, nodeId, context) => { |
| 267 | const tok = raw.trim().replace(/^['"`(]+|['"`),.;:]+$/g, ''); |
| 268 | if (tokenExtension(tok) && !KNOWN_EXTENSIONLESS_FILES.has(tok.toLowerCase().replace(/^\.\ |
| 269 | if (!looksLikeExtensionlessFile(tok, context)) return; |
| 270 | const key = normalizeFileKey(tok); |
| 271 | if (seen.has(key)) return; |
| 272 | seen.add(key); |
| 273 | refs.push({ token: tok, key, nodeId }); |
| 274 | }; |
| 275 | const CTX_BEFORE = 40; |
| 276 | const preamble = (text, tokenStart) => text.slice(Math.max(0, tokenStart - CTX_BEFORE), tokenStart); |
| 277 | for (const node of tree.nodes) { |
| 278 | if (node.status === 'abandoned') continue; |
| 279 | const text = String(node.text || '').slice(0, MAX_TEXT_SCAN); |
| 280 | for (const m of text.matchAll(FILE_TOKEN_RE)) push(m[0], node.id); |
| 281 | for (const m of text.matchAll(PATHISH_TOKEN_RE)) pushExtensionless(m[0], node.id, preamble(text, m.index)); |
| 282 | for (const m of text.matchAll(BAREWORD_TOKEN_RE)) { |
| 283 | pushExtensionless(m[1], node.id, preamble(text, m.index + (m[0].length - m[1].length))); |
| 284 | } |
| 285 | for (const a of node.actions || []) { |
| 286 | const body = `${a.input || ''}`.slice(0, MAX_TEXT_SCAN); |
| 287 | for (const m of body.matchAll(FILE_TOKEN_RE)) push(m[0], node.id); |
| 288 | for (const m of body.matchAll(PATHISH_TOKEN_RE)) pushExtensionless(m[0], node.id, preamble(body, m.index)); |
| 289 | if (a.narration && typeof a.narration === 'string') { |
| 290 | const narr = a.narration.slice(0, MAX_TEXT_SCAN); |
| 291 | for (const m of narr.matchAll(FILE_TOKEN_RE)) push(m[0], node.id); |
| 292 | } |
| 293 | if (a.file && typeof a.file === 'string' && |
| 294 | (a.tool === 'Write' || a.tool === 'Edit' || a.tool === 'NotebookEdit')) { |
| 295 | push(a.file, node.id); |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | return refs; |
| 300 | } |
| 301 | |
| 302 | function collectImportReferences(tree) { |
| 303 | const refs = []; |
| 304 | const seen = new Set(); |
| 305 | const push = (spec, lang, nodeId) => { |
| 306 | if (!spec) return; |
| 307 | if (isRelativeOrLocalSpec(spec)) return; |
| 308 | const root = packageRoot(spec); |
| 309 | if (!root) return; |
| 310 | const key = `${lang}:${root}`; |
| 311 | if (seen.has(key)) return; |
| 312 | seen.add(key); |
| 313 | refs.push({ spec: root, lang, nodeId }); |
| 314 | }; |
| 315 | for (const node of tree.nodes) { |
| 316 | if (node.status === 'abandoned') continue; |
| 317 | const sources = [String(node.text || '')]; |
| 318 | for (const a of node.actions || []) { |
| 319 | if (a.input) sources.push(String(a.input)); |
| 320 | if (a.command) sources.push(String(a.command)); |
| 321 | } |
| 322 | for (const src of sources) { |
| 323 | const text = src.slice(0, MAX_TEXT_SCAN); |
| 324 | for (const m of text.matchAll(JS_IMPORT_RE)) push(m[1] || m[2] || m[3], 'js', node.id); |
| 325 | for (const m of text.matchAll(PY_IMPORT_RE)) { |
| 326 | if (m[1]) push(m[1], 'py', node.id); |
| 327 | if (m[2]) for (const piece of m[2].split(',')) push(piece.trim(), 'py', node.id); |
| 328 | } |
| 329 | } |
| 330 | } |
| 331 | return refs; |
| 332 | } |
| 333 | |
| 334 | function isRelativeOrLocalSpec(spec) { |
| 335 | return REL_PREFIX_RE.test(spec) || spec.startsWith('/') || spec.startsWith('node:'); |
| 336 | } |
| 337 | |
| 338 | const WELL_KNOWN_LIBRARY_STEMS = new Set([ |
| 339 | 'cytoscape', 'd3', 'three', 'whisper', 'numpy', 'pandas', 'scipy', 'sklearn', |
| 340 | 'tensorflow', 'torch', 'pytorch', 'keras', 'matplotlib', 'seaborn', 'react', |
| 341 | 'vue', 'svelte', 'angular', 'jquery', 'lodash', 'underscore', 'moment', 'axios', |
| 342 | 'express', 'flask', 'django', 'fastapi', 'requests', 'pillow', 'opencv', 'cv2', |
| 343 | 'transformers', 'langchain', 'openai', 'anthropic', 'redux', 'webpack', 'rollup', |
| 344 | 'vite', 'babel', 'eslint', 'prettier', 'jest', 'mocha', 'chai', 'pytest', |
| 345 | 'bootstrap', 'tailwind', 'chartjs', 'plotly', 'leaflet', 'mapbox', 'socketio', |
| 346 | ]); |
| 347 | |
| 348 | function isDeclaredLibraryName(token, pkgNames, lockNames, pyNames) { |
| 349 | if (hasSlash(token)) return false; |
| 350 | const base = token.split('/').pop(); |
| 351 | const dot = base.lastIndexOf('.'); |
| 352 | if (dot <= 0) return false; |
| 353 | const stem = base.slice(0, dot).toLowerCase(); |
| 354 | if (!stem) return false; |
| 355 | const hasManifest = pkgNames.size > 0 || lockNames.size > 0 || pyNames.size > 0; |
| 356 | if (hasManifest) { |
| 357 | for (const name of pkgNames) if (packageRoot(name).toLowerCase() === stem) return true; |
| 358 | for (const name of lockNames) if (packageRoot(name).toLowerCase() === stem) return true; |
| 359 | if (pyNames.has(stem)) return true; |
| 360 | return false; |
| 361 | } |
| 362 | return WELL_KNOWN_LIBRARY_STEMS.has(stem); |
| 363 | } |
| 364 | |
| 365 | export function detectHallucinations(tree, projectDir, opts = {}) { |
| 366 | const hallucinations = []; |
| 367 | if (!projectDir || !existsSync(projectDir)) { |
| 368 | return { schemaVersion: SCHEMA_VERSION, verifiedAgainstWorkingTree: false, hallucinations, summary: emptySummary() }; |
| 369 | } |
| 370 | |
| 371 | const created = collectCreatedFiles(tree, projectDir); |
| 372 | const pkgNames = readPackageNames(projectDir); |
| 373 | const lockNames = readLockfilePackages(projectDir); |
| 374 | const pyNames = readPyRequirements(projectDir); |
| 375 | const hasManifest = pkgNames.size > 0 || lockNames.size > 0 || pyNames.size > 0; |
| 376 | |
| 377 | for (const ref of collectFileReferences(tree)) { |
| 378 | if (created.has(ref.key)) continue; |
| 379 | if (fileExists(projectDir, ref.token)) continue; |
| 380 | if (isDeclaredLibraryName(ref.token, pkgNames, lockNames, pyNames)) continue; |
| 381 | hallucinations.push({ |
| 382 | category: 'hallucinated_file_or_path', |
| 383 | reference: truncate(ref.token, EVIDENCE_CAP), |
| 384 | nodeId: ref.nodeId, |
| 385 | evidence: `Referenced "${truncate(ref.token, EVIDENCE_CAP)}" which does not exist in the working tree and was not created during the session.`, |
| 386 | evalCandidate: { |
| 387 | type: 'reference_existence_check', |
| 388 | task: 'Verify a file or path exists in the working tree before editing or relying on it.', |
| 389 | target: truncate(ref.token, EVIDENCE_CAP), |
| 390 | }, |
| 391 | }); |
| 392 | } |
| 393 | |
| 394 | for (const ref of collectImportReferences(tree)) { |
| 395 | if (isRelativeOrLocalSpec(ref.spec)) continue; |
| 396 | if (ref.lang === 'js') { |
| 397 | if (NODE_BUILTINS.has(ref.spec) || NODE_BUILTINS.has(ref.spec.replace(/^node:/, ''))) continue; |
| 398 | if (pkgNames.has(ref.spec) || lockNames.has(ref.spec)) continue; |
| 399 | if (!hasManifest) continue; |
| 400 | } else { |
| 401 | if (PY_STDLIB.has(ref.spec)) continue; |
| 402 | if (pyNames.has(ref.spec.toLowerCase())) continue; |
| 403 | if (pyNames.size === 0) continue; |
| 404 | } |
| 405 | hallucinations.push({ |
| 406 | category: 'hallucinated_import_or_package', |
| 407 | reference: truncate(ref.spec, EVIDENCE_CAP), |
| 408 | nodeId: ref.nodeId, |
| 409 | evidence: `Imported "${truncate(ref.spec, EVIDENCE_CAP)}" (${ref.lang}) which is not a declared dependency or a standard-library module.`, |
| 410 | evalCandidate: { |
| 411 | type: 'import_existence_check', |
| 412 | task: 'Verify an import or package is declared as a dependency before relying on it.', |
| 413 | target: truncate(ref.spec, EVIDENCE_CAP), |
| 414 | }, |
| 415 | }); |
| 416 | } |
| 417 | |
| 418 | return { |
| 419 | schemaVersion: SCHEMA_VERSION, |
| 420 | verifiedAgainstWorkingTree: true, |
| 421 | manifestSeen: hasManifest, |
| 422 | hallucinations, |
| 423 | summary: summarize(hallucinations), |
| 424 | }; |
| 425 | } |
| 426 | |
| 427 | function emptySummary() { |
| 428 | return { total: 0, byCategory: { hallucinated_file_or_path: 0, hallucinated_import_or_package: 0 } }; |
| 429 | } |
| 430 | |
| 431 | function summarize(hallucinations) { |
| 432 | const summary = emptySummary(); |
| 433 | summary.total = hallucinations.length; |
| 434 | for (const h of hallucinations) { |
| 435 | if (summary.byCategory[h.category] !== undefined) summary.byCategory[h.category]++; |
| 436 | } |
| 437 | return summary; |
| 438 | } |
| 439 | |
| 440 | export function renderHallucinationsJson(tree, projectDir, opts = {}) { |
| 441 | const result = detectHallucinations(tree, projectDir, opts); |
| 442 | return { |
| 443 | schemaVersion: SCHEMA_VERSION, |
| 444 | project: { name: opts.projectName || null, generatedAt: opts.generatedAt || null }, |
| 445 | verifiedAgainstWorkingTree: result.verifiedAgainstWorkingTree, |
| 446 | manifestSeen: result.manifestSeen || false, |
| 447 | summary: result.summary, |
| 448 | hallucinations: result.hallucinations, |
| 449 | note: 'File and path existence and import and package declaration are checked deterministically against the working tree and manifests. Per-symbol and per-API resolution inside a module is not attempted.', |
| 450 | }; |
| 451 | } |