thuban 0.3.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,507 @@
1
+ /**
2
+ * @thuban-dna
3
+ * @purpose Detect AI hallucinations, phantom imports, invented APIs, and code drift
4
+ * @module thuban
5
+ * @layer application
6
+ * @imports-from fs, path
7
+ * @exports-to cli, tech-debt-analyzer, code-scanner
8
+ * @side-effects File system reads, Console output
9
+ * @critical-for Core differentiator — no other tool detects AI-generated code issues
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ class HallucinationDetector {
16
+ constructor(config = {}) {
17
+ this.config = {
18
+ rootPath: config.rootPath || process.cwd(),
19
+ ...config
20
+ };
21
+
22
+ // Known Node.js built-in modules
23
+ this.builtins = new Set([
24
+ 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster',
25
+ 'console', 'constants', 'crypto', 'dgram', 'diagnostics_channel',
26
+ 'dns', 'domain', 'events', 'fs', 'http', 'http2', 'https',
27
+ 'inspector', 'module', 'net', 'os', 'path', 'perf_hooks',
28
+ 'process', 'punycode', 'querystring', 'readline', 'repl',
29
+ 'stream', 'string_decoder', 'sys', 'timers', 'tls', 'trace_events',
30
+ 'tty', 'url', 'util', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib',
31
+ 'node:fs', 'node:path', 'node:os', 'node:crypto', 'node:http',
32
+ 'node:https', 'node:events', 'node:util', 'node:stream',
33
+ 'node:child_process', 'node:url', 'node:querystring', 'node:buffer',
34
+ 'node:net', 'node:tls', 'node:dns', 'node:readline', 'node:zlib',
35
+ 'node:worker_threads', 'node:cluster', 'node:vm', 'node:assert',
36
+ 'node:timers', 'node:timers/promises', 'node:test',
37
+ 'fs/promises', 'stream/promises', 'timers/promises', 'dns/promises',
38
+ 'readline/promises',
39
+ ]);
40
+
41
+ // Common phantom packages that LLMs hallucinate
42
+ this.phantomPackages = new Set([
43
+ 'node-fetch-extra', 'express-async', 'mongoose-v2',
44
+ 'react-native-web-view', 'axios-retry-enhanced',
45
+ 'lodash-extended', 'moment-timezone-v2', 'socket.io-extra',
46
+ 'graphql-tools-v2', 'next-auth-v5', 'prisma-client-v2',
47
+ 'tailwind-utils', 'react-query-v4', 'jest-extended-v2',
48
+ ]);
49
+
50
+ // Common hallucinated API patterns (methods that don't exist)
51
+ this.phantomAPIs = [
52
+ { pattern: /fs\.readFileAsync\s*\(/, suggestion: 'Use fs.promises.readFile() or fs.readFileSync()', name: 'fs.readFileAsync', id: 'HALL_API001' },
53
+ { pattern: /fs\.writeFileAsync\s*\(/, suggestion: 'Use fs.promises.writeFile() or fs.writeFileSync()', name: 'fs.writeFileAsync', id: 'HALL_API002' },
54
+ { pattern: /fs\.existsAsync\s*\(/, suggestion: 'Use fs.promises.access() or fs.existsSync()', name: 'fs.existsAsync', id: 'HALL_API003' },
55
+ { pattern: /path\.exists\s*\(/, suggestion: 'Use fs.existsSync() — path module has no exists()', name: 'path.exists', id: 'HALL_API004' },
56
+ { pattern: /path\.isFile\s*\(/, suggestion: 'Use fs.statSync().isFile() — path module has no isFile()', name: 'path.isFile', id: 'HALL_API005' },
57
+ { pattern: /path\.isDirectory\s*\(/, suggestion: 'Use fs.statSync().isDirectory()', name: 'path.isDirectory', id: 'HALL_API006' },
58
+ { pattern: /console\.success\s*\(/, suggestion: 'Use console.log() — console.success() does not exist', name: 'console.success', id: 'HALL_API007' },
59
+ { pattern: /console\.verbose\s*\(/, suggestion: 'Use console.log() or console.debug()', name: 'console.verbose', id: 'HALL_API008' },
60
+ { pattern: /JSON\.tryParse\s*\(/, suggestion: 'Use try/catch with JSON.parse()', name: 'JSON.tryParse', id: 'HALL_API009' },
61
+ { pattern: /JSON\.safeStringify\s*\(/, suggestion: 'Use JSON.stringify() with a replacer', name: 'JSON.safeStringify', id: 'HALL_API010' },
62
+ { pattern: /Array\.flatten\s*\(/, suggestion: 'Use Array.prototype.flat()', name: 'Array.flatten', id: 'HALL_API011' },
63
+ { pattern: /Object\.deepClone\s*\(/, suggestion: 'Use structuredClone() or JSON.parse(JSON.stringify())', name: 'Object.deepClone', id: 'HALL_API012' },
64
+ { pattern: /Object\.deepMerge\s*\(/, suggestion: 'Use a library like lodash.merge or write a custom function', name: 'Object.deepMerge', id: 'HALL_API013' },
65
+ { pattern: /String\.prototype\.replaceAll\s*&&.*polyfill/i, suggestion: 'replaceAll() is native since ES2021 — no polyfill needed', name: 'replaceAll polyfill', id: 'HALL_API014' },
66
+ { pattern: /require\s*\(\s*['"]fs\/sync['"]\s*\)/, suggestion: 'fs/sync is not a real module — use fs directly', name: 'fs/sync', id: 'HALL_API015' },
67
+ { pattern: /require\s*\(\s*['"]http\/server['"]\s*\)/, suggestion: 'http/server is not a real module — use http.createServer()', name: 'http/server', id: 'HALL_API016' },
68
+ { pattern: /process\.env\.get\s*\(/, suggestion: 'Use process.env.VAR_NAME — process.env is a plain object, not a Map', name: 'process.env.get()', id: 'HALL_API017' },
69
+ { pattern: /process\.exit\s*\(\s*['"]/, suggestion: 'process.exit() takes a number, not a string', name: 'process.exit(string)', id: 'HALL_API018' },
70
+ ];
71
+
72
+ // Patterns suggesting AI-generated code with common issues
73
+ this.aiCodeSmells = [
74
+ { pattern: /\/\/\s*TODO:?\s*(implement|add|fix|complete|finish)\s*(this|here|later)/i, id: 'AI_SMELL001', name: 'Placeholder TODO', message: 'AI-generated placeholder — likely unfinished implementation' },
75
+ { pattern: /throw new Error\(['"]Not implemented['"]\)/, id: 'AI_SMELL002', name: 'Not Implemented', message: 'Stub implementation — function body was not generated' },
76
+ { pattern: /\/\/\s*\.\.\.\s*(rest|remaining|more|other|additional)/i, id: 'AI_SMELL003', name: 'Truncated Code', message: 'AI output was truncated — code is incomplete' },
77
+ { pattern: /\/\/\s*(your|replace|insert|put)\s+(code|logic|implementation|api[_\s]?key)/i, id: 'AI_SMELL004', name: 'Template Placeholder', message: 'Template placeholder left in code — needs real implementation' },
78
+ { pattern: /['"]your-api-key-here['"]|['"]sk-[.]{3,}['"]|['"]xxx+['"]/i, id: 'AI_SMELL005', name: 'Dummy Credential', message: 'Placeholder credential — will fail at runtime' },
79
+ { pattern: /example\.com|test@test\.com|john@doe\.com|foo@bar\.com/i, id: 'AI_SMELL006', name: 'Example Domain', message: 'Example domain/email in production code' },
80
+ { pattern: /localhost:\d{4}(?!.*(?:dev|test|local|development))/i, id: 'AI_SMELL007', name: 'Hardcoded Localhost', message: 'Hardcoded localhost URL — will fail in production' },
81
+ ];
82
+
83
+ // Deprecated/removed Node.js APIs that LLMs still suggest
84
+ this.deprecatedAPIs = [
85
+ { pattern: /new Buffer\s*\(/, suggestion: 'Use Buffer.from(), Buffer.alloc(), or Buffer.allocUnsafe()', since: 'Node 6', name: 'new Buffer()', id: 'DEPR_API001' },
86
+ { pattern: /require\s*\(\s*['"]sys['"]\s*\)/, suggestion: 'Use require("util") — sys was removed in Node 1.0', since: 'Node 1.0', name: 'require("sys")', id: 'DEPR_API002' },
87
+ { pattern: /fs\.exists\s*\((?!Sync)/, suggestion: 'Use fs.access() or fs.stat() — fs.exists() is deprecated', since: 'Node 1.0', name: 'fs.exists()', id: 'DEPR_API003' },
88
+ { pattern: /domain\.create\s*\(/, suggestion: 'Use async_hooks or try/catch — domain module is deprecated', since: 'Node 4', name: 'domain.create()', id: 'DEPR_API004' },
89
+ { pattern: /require\s*\(\s*['"]punycode['"]\s*\)/, suggestion: 'Use userland punycode package — built-in is deprecated', since: 'Node 7', name: 'require("punycode")', id: 'DEPR_API005' },
90
+ { pattern: /url\.parse\s*\(/, suggestion: 'Use new URL() — url.parse() is legacy', since: 'Node 11', name: 'url.parse()', id: 'DEPR_API006' },
91
+ { pattern: /querystring\.(parse|stringify)\s*\(/, suggestion: 'Use URLSearchParams — querystring is legacy', since: 'Node 14', name: 'querystring', id: 'DEPR_API007' },
92
+ { pattern: /util\.isArray\s*\(/, suggestion: 'Use Array.isArray() — util type checks are deprecated', since: 'Node 4', name: 'util.isArray()', id: 'DEPR_API008' },
93
+ { pattern: /util\.isFunction\s*\(/, suggestion: 'Use typeof fn === "function"', since: 'Node 4', name: 'util.isFunction()', id: 'DEPR_API009' },
94
+ { pattern: /util\.pump\s*\(/, suggestion: 'Use stream.pipeline() — util.pump() was removed', since: 'Node 1.0', name: 'util.pump()', id: 'DEPR_API010' },
95
+ ];
96
+ }
97
+
98
+ /**
99
+ * Full hallucination scan across all files
100
+ */
101
+ async scan(files) {
102
+ const startTime = Date.now();
103
+ const results = {
104
+ phantomImports: [],
105
+ phantomAPIs: [],
106
+ aiSmells: [],
107
+ deprecatedAPIs: [],
108
+ unresolvedDeps: [],
109
+ stats: {
110
+ filesScanned: 0,
111
+ totalIssues: 0,
112
+ scanTime: 0,
113
+ },
114
+ };
115
+
116
+ // Load package.json dependencies
117
+ const declaredDeps = this._loadDeclaredDeps();
118
+
119
+ // Load .thubanrc.json ignore rules
120
+ const ignoreFiles = this._loadIgnoreRules();
121
+
122
+ for (const file of files) {
123
+ try {
124
+ const ext = path.extname(file).toLowerCase();
125
+ if (!['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'].includes(ext)) continue;
126
+
127
+ const relPath = path.relative(this.config.rootPath, file);
128
+
129
+ // Skip files in the ignore list
130
+ if (ignoreFiles.some(pattern => relPath.includes(pattern) || file.includes(pattern))) continue;
131
+
132
+ const content = fs.readFileSync(file, 'utf-8');
133
+ const lines = content.split('\n');
134
+
135
+ // Build set of lines with thuban-ignore comments
136
+ const ignoredLines = new Set();
137
+ for (let i = 0; i < lines.length; i++) {
138
+ if (lines[i].includes('thuban-ignore')) {
139
+ ignoredLines.add(i + 1); // 1-based
140
+ ignoredLines.add(i + 2); // Also ignore next line
141
+ }
142
+ }
143
+
144
+ // Skip files that are pattern definition files (contain regex pattern arrays)
145
+ // These files define what to look for, not actual violations
146
+ const isPatternFile = this._isPatternDefinitionFile(content);
147
+
148
+ results.stats.filesScanned++;
149
+
150
+ // 1. Phantom imports — packages that don't exist
151
+ this._checkPhantomImports(relPath, content, lines, declaredDeps, results, ignoredLines);
152
+
153
+ // 2. Phantom APIs — methods that don't exist on known objects
154
+ if (!isPatternFile) {
155
+ this._checkPhantomAPIs(relPath, content, lines, results, ignoredLines);
156
+ }
157
+
158
+ // 3. AI code smells — patterns typical of AI-generated code
159
+ this._checkAISmells(relPath, content, lines, results, ignoredLines);
160
+
161
+ // 4. Deprecated APIs — APIs that LLMs still suggest
162
+ if (!isPatternFile) {
163
+ this._checkDeprecatedAPIs(relPath, content, lines, results, ignoredLines);
164
+ }
165
+
166
+ } catch (e) { /* skip unreadable */ }
167
+ }
168
+
169
+ results.stats.totalIssues =
170
+ results.phantomImports.length +
171
+ results.phantomAPIs.length +
172
+ results.aiSmells.length +
173
+ results.deprecatedAPIs.length +
174
+ results.unresolvedDeps.length;
175
+
176
+ results.stats.scanTime = Date.now() - startTime;
177
+
178
+ return results;
179
+ }
180
+
181
+ /**
182
+ * Format results for terminal output
183
+ */
184
+ formatReport(results) {
185
+ const C = {
186
+ reset: '\x1b[0m', bold: '\x1b[1m',
187
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
188
+ cyan: '\x1b[36m', gray: '\x1b[90m', magenta: '\x1b[35m',
189
+ };
190
+
191
+ let output = '';
192
+ output += `\n${C.magenta} ╔══════════════════════════════════════════════╗${C.reset}\n`;
193
+ output += `${C.magenta} ║${C.bold} THUBAN HALLUCINATION REPORT ${C.reset}${C.magenta}║${C.reset}\n`;
194
+ output += `${C.magenta} ╚══════════════════════════════════════════════╝${C.reset}\n\n`;
195
+
196
+ if (results.stats.totalIssues === 0) {
197
+ output += ` ${C.green}${C.bold}No hallucinations detected.${C.reset} ${C.gray}Clean codebase.${C.reset}\n\n`;
198
+ return output;
199
+ }
200
+
201
+ // Phantom imports
202
+ if (results.phantomImports.length > 0) {
203
+ output += ` ${C.red}${C.bold}Phantom Imports (${results.phantomImports.length})${C.reset}\n`;
204
+ output += ` ${C.gray}Packages that don't exist in package.json or node_modules${C.reset}\n\n`;
205
+ for (const item of results.phantomImports.slice(0, 10)) {
206
+ output += ` ${C.red}✗${C.reset} ${C.cyan}${item.file}${C.reset}:${item.line}\n`;
207
+ output += ` ${C.yellow}${item.module}${C.reset} ${C.gray}— ${item.reason}${C.reset}\n`;
208
+ }
209
+ if (results.phantomImports.length > 10) {
210
+ output += ` ${C.gray}... and ${results.phantomImports.length - 10} more${C.reset}\n`;
211
+ }
212
+ output += '\n';
213
+ }
214
+
215
+ // Phantom APIs
216
+ if (results.phantomAPIs.length > 0) {
217
+ output += ` ${C.red}${C.bold}Phantom APIs (${results.phantomAPIs.length})${C.reset}\n`;
218
+ output += ` ${C.gray}Methods/properties that don't exist on their objects${C.reset}\n\n`;
219
+ for (const item of results.phantomAPIs.slice(0, 10)) {
220
+ const ruleId = item.id ? `${C.gray}[${item.id}]${C.reset} ` : '';
221
+ output += ` ${C.red}✗${C.reset} ${ruleId}${C.cyan}${item.file}${C.reset}:${item.line}\n`;
222
+ output += ` ${C.yellow}${item.name}${C.reset} ${C.gray}— ${item.suggestion}${C.reset}\n`;
223
+ output += ` ${C.gray}Suppress: // thuban-ignore ${item.id || 'HALL_API'}${C.reset}\n`;
224
+ }
225
+ if (results.phantomAPIs.length > 10) {
226
+ output += ` ${C.gray}... and ${results.phantomAPIs.length - 10} more${C.reset}\n`;
227
+ }
228
+ output += '\n';
229
+ }
230
+
231
+ // AI code smells
232
+ if (results.aiSmells.length > 0) {
233
+ output += ` ${C.yellow}${C.bold}AI Code Smells (${results.aiSmells.length})${C.reset}\n`;
234
+ output += ` ${C.gray}Patterns typical of AI-generated code that needs review${C.reset}\n\n`;
235
+ for (const item of results.aiSmells.slice(0, 10)) {
236
+ output += ` ${C.yellow}!${C.reset} ${C.cyan}${item.file}${C.reset}:${item.line}\n`;
237
+ output += ` ${C.gray}${item.message}${C.reset}\n`;
238
+ }
239
+ if (results.aiSmells.length > 10) {
240
+ output += ` ${C.gray}... and ${results.aiSmells.length - 10} more${C.reset}\n`;
241
+ }
242
+ output += '\n';
243
+ }
244
+
245
+ // Deprecated APIs
246
+ if (results.deprecatedAPIs.length > 0) {
247
+ output += ` ${C.yellow}${C.bold}Deprecated APIs (${results.deprecatedAPIs.length})${C.reset}\n`;
248
+ output += ` ${C.gray}APIs that LLMs still suggest but are deprecated or removed${C.reset}\n\n`;
249
+ for (const item of results.deprecatedAPIs.slice(0, 10)) {
250
+ output += ` ${C.yellow}⚠${C.reset} ${C.cyan}${item.file}${C.reset}:${item.line}\n`;
251
+ output += ` ${C.yellow}${item.name}${C.reset} ${C.gray}(deprecated since ${item.since})${C.reset}\n`;
252
+ output += ` ${C.green}→${C.reset} ${C.gray}${item.suggestion}${C.reset}\n`;
253
+ }
254
+ if (results.deprecatedAPIs.length > 10) {
255
+ output += ` ${C.gray}... and ${results.deprecatedAPIs.length - 10} more${C.reset}\n`;
256
+ }
257
+ output += '\n';
258
+ }
259
+
260
+ // Summary
261
+ output += ` ${C.bold}Summary:${C.reset} ${C.cyan}${results.stats.filesScanned}${C.reset} files scanned, `;
262
+ output += `${results.stats.totalIssues > 0 ? C.red : C.green}${results.stats.totalIssues}${C.reset} hallucination issues found `;
263
+ output += `${C.gray}(${results.stats.scanTime}ms)${C.reset}\n\n`;
264
+
265
+ return output;
266
+ }
267
+
268
+ // ─── Private Methods ─────────────────────────────────────
269
+
270
+ _loadDeclaredDeps() {
271
+ const deps = new Set();
272
+ // Collect all package.json files — root + subdirectories (monorepo support)
273
+ this._packageJsonPaths = new Map(); // dir -> Set<dep names>
274
+ this._collectPackageJsonDeps(this.config.rootPath, deps, 0);
275
+ return deps;
276
+ }
277
+
278
+ /**
279
+ * Recursively collect deps from all package.json files in the project.
280
+ * Handles monorepos: workspaces, packages/*, apps/*, etc.
281
+ * Max depth prevents scanning into node_modules.
282
+ */
283
+ _collectPackageJsonDeps(dir, globalDeps, depth) {
284
+ if (depth > 5) return; // Don't go too deep
285
+ try {
286
+ const pkgPath = path.join(dir, 'package.json');
287
+ if (fs.existsSync(pkgPath)) {
288
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
289
+ const localDeps = new Set();
290
+ for (const name of Object.keys(pkg.dependencies || {})) { globalDeps.add(name); localDeps.add(name); }
291
+ for (const name of Object.keys(pkg.devDependencies || {})) { globalDeps.add(name); localDeps.add(name); }
292
+ for (const name of Object.keys(pkg.peerDependencies || {})) { globalDeps.add(name); localDeps.add(name); }
293
+ this._packageJsonPaths.set(dir, localDeps);
294
+ }
295
+
296
+ // Scan subdirectories for more package.json files
297
+ // Skip node_modules, .git, dist, build, coverage
298
+ const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.cache', 'vendor']);
299
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
300
+ for (const entry of entries) {
301
+ if (entry.isDirectory() && !skipDirs.has(entry.name) && !entry.name.startsWith('.')) {
302
+ this._collectPackageJsonDeps(path.join(dir, entry.name), globalDeps, depth + 1);
303
+ }
304
+ }
305
+ } catch (e) { /* skip unreadable dirs */ }
306
+ }
307
+
308
+ _checkPhantomImports(relPath, content, lines, declaredDeps, results, ignoredLines = new Set()) {
309
+ // Match require() and import statements
310
+ const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
311
+ const importRegex = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
312
+ const dynamicImportRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
313
+
314
+ const allImportRegexes = [requireRegex, importRegex, dynamicImportRegex];
315
+
316
+ for (const regex of allImportRegexes) {
317
+ let match;
318
+ while ((match = regex.exec(content)) !== null) {
319
+ const moduleName = match[1];
320
+
321
+ // Skip relative imports
322
+ if (moduleName.startsWith('.') || moduleName.startsWith('/')) continue;
323
+
324
+ // Skip builtins
325
+ const baseModule = moduleName.split('/')[0];
326
+ if (this.builtins.has(moduleName) || this.builtins.has(baseModule)) continue;
327
+
328
+ // Skip scoped packages base
329
+ const scopedBase = moduleName.startsWith('@')
330
+ ? moduleName.split('/').slice(0, 2).join('/')
331
+ : baseModule;
332
+
333
+ // Check if declared in any package.json (root or workspace)
334
+ if (!declaredDeps.has(scopedBase)) {
335
+ // Check if it physically exists in any node_modules (root or nearest parent)
336
+ const nmExists = this._findInNodeModules(file, scopedBase);
337
+ if (!nmExists) {
338
+ // Find line number
339
+ const lineIdx = this._findLineNumber(content, match.index);
340
+
341
+ // Skip if on an ignored line
342
+ if (ignoredLines.has(lineIdx)) continue;
343
+
344
+ // Check if it's a known phantom
345
+ const isKnownPhantom = this.phantomPackages.has(moduleName);
346
+
347
+ results.phantomImports.push({
348
+ file: relPath,
349
+ line: lineIdx,
350
+ module: moduleName,
351
+ reason: isKnownPhantom
352
+ ? 'Known AI-hallucinated package — does not exist on npm'
353
+ : 'Not in package.json and not in node_modules',
354
+ severity: isKnownPhantom ? 'critical' : 'high',
355
+ fixable: false,
356
+ });
357
+ }
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ _checkPhantomAPIs(relPath, content, lines, results, ignoredLines = new Set()) {
364
+ for (const api of this.phantomAPIs) {
365
+ let match;
366
+ const regex = new RegExp(api.pattern.source, api.pattern.flags + (api.pattern.flags.includes('g') ? '' : 'g'));
367
+ while ((match = regex.exec(content)) !== null) {
368
+ const lineIdx = this._findLineNumber(content, match.index);
369
+ if (ignoredLines.has(lineIdx)) continue;
370
+ // Skip if inside a string literal or regex pattern (common in pattern definition files)
371
+ const line = lines[lineIdx - 1] || '';
372
+ if (this._isInsidePattern(line, match[0])) continue;
373
+ results.phantomAPIs.push({
374
+ file: relPath,
375
+ line: lineIdx,
376
+ name: api.name,
377
+ id: api.id,
378
+ suggestion: api.suggestion,
379
+ severity: 'high',
380
+ fixable: true,
381
+ fixAction: 'replace_phantom_api',
382
+ });
383
+ }
384
+ }
385
+ }
386
+
387
+ _checkAISmells(relPath, content, lines, results, ignoredLines = new Set()) {
388
+ for (const smell of this.aiCodeSmells) {
389
+ let match;
390
+ const regex = new RegExp(smell.pattern.source, smell.pattern.flags + (smell.pattern.flags.includes('g') ? '' : 'g'));
391
+ while ((match = regex.exec(content)) !== null) {
392
+ const lineIdx = this._findLineNumber(content, match.index);
393
+ if (ignoredLines.has(lineIdx)) continue;
394
+ results.aiSmells.push({
395
+ file: relPath,
396
+ line: lineIdx,
397
+ id: smell.id,
398
+ name: smell.name,
399
+ message: smell.message,
400
+ severity: 'warning',
401
+ code: lines[lineIdx - 1]?.trim().substring(0, 100),
402
+ });
403
+ }
404
+ }
405
+ }
406
+
407
+ _checkDeprecatedAPIs(relPath, content, lines, results, ignoredLines = new Set()) {
408
+ for (const api of this.deprecatedAPIs) {
409
+ let match;
410
+ const regex = new RegExp(api.pattern.source, api.pattern.flags + (api.pattern.flags.includes('g') ? '' : 'g'));
411
+ while ((match = regex.exec(content)) !== null) {
412
+ const lineIdx = this._findLineNumber(content, match.index);
413
+ if (ignoredLines.has(lineIdx)) continue;
414
+ const line = lines[lineIdx - 1] || '';
415
+ if (this._isInsidePattern(line, match[0])) continue;
416
+ results.deprecatedAPIs.push({
417
+ file: relPath,
418
+ line: lineIdx,
419
+ name: api.name,
420
+ id: api.id,
421
+ suggestion: api.suggestion,
422
+ since: api.since,
423
+ severity: 'warning',
424
+ fixable: true,
425
+ fixAction: 'update_deprecated_api',
426
+ });
427
+ }
428
+ }
429
+ }
430
+
431
+ _findLineNumber(content, charIndex) {
432
+ let line = 1;
433
+ for (let i = 0; i < charIndex && i < content.length; i++) {
434
+ if (content[i] === '\n') line++;
435
+ }
436
+ return line;
437
+ }
438
+
439
+ /**
440
+ * Detect if a file is a pattern definition file (like this detector itself)
441
+ * These files contain regex patterns that reference the APIs they detect
442
+ */
443
+ _isPatternDefinitionFile(content) {
444
+ // Count regex pattern definitions — if file has many, it's likely a detector/rule file
445
+ const regexPatterns = (content.match(/pattern:\s*\//g) || []).length;
446
+ const phantomRefs = (content.match(/phantom|hallucin/gi) || []).length;
447
+ return regexPatterns > 5 && phantomRefs > 2;
448
+ }
449
+
450
+ /**
451
+ * Check if a match is inside a regex pattern, string literal, or comment
452
+ */
453
+ _isInsidePattern(line, matchText) {
454
+ const trimmed = line.trim();
455
+ // Inside a regex pattern definition
456
+ if (/pattern:\s*\//.test(trimmed)) return true;
457
+ // Inside a comment
458
+ if (trimmed.startsWith('//') || trimmed.startsWith('*')) return true;
459
+ // Inside a string that's defining a name/suggestion/message
460
+ if (/(?:name|suggestion|message|description):\s*['"]/.test(trimmed)) return true;
461
+ return false;
462
+ }
463
+
464
+ /**
465
+ * Load ignore rules from .thubanrc.json
466
+ */
467
+ _loadIgnoreRules() {
468
+ const ignoreFiles = [];
469
+ try {
470
+ const rcPath = path.join(this.config.rootPath, '.thubanrc.json');
471
+ const rc = JSON.parse(fs.readFileSync(rcPath, 'utf-8'));
472
+ if (rc.hallucination?.ignore) {
473
+ ignoreFiles.push(...rc.hallucination.ignore);
474
+ }
475
+ if (rc.ignore) {
476
+ ignoreFiles.push(...rc.ignore);
477
+ }
478
+ } catch (e) { /* no config file */ }
479
+ return ignoreFiles;
480
+ }
481
+
482
+ /**
483
+ * Walk up directory tree from file location to project root,
484
+ * checking each node_modules directory for the package.
485
+ * This handles monorepos where deps are installed at workspace level.
486
+ */
487
+ _findInNodeModules(filePath, packageName) {
488
+ let dir = path.dirname(filePath);
489
+ const root = this.config.rootPath;
490
+
491
+ // Walk up from file to root, checking each node_modules
492
+ while (dir.length >= root.length) {
493
+ const nmPath = path.join(dir, 'node_modules', packageName);
494
+ try {
495
+ if (fs.existsSync(nmPath)) return true;
496
+ } catch (e) { /* skip */ }
497
+
498
+ const parent = path.dirname(dir);
499
+ if (parent === dir) break; // reached filesystem root
500
+ dir = parent;
501
+ }
502
+
503
+ return false;
504
+ }
505
+ }
506
+
507
+ module.exports = HallucinationDetector;