Zion Boggan
repos/TreeTrace/src/hallucinate.js
zionboggan.com ↗
451 lines · javascript
History for this file →
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
}