thuban 0.3.2 → 0.3.3

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,64 @@
1
+ const path = require('path');
2
+
3
+ class MonitorNotifier {
4
+ constructor({ alertManager, store, repoConfig }) {
5
+ this.alertManager = alertManager;
6
+ this.store = store;
7
+ this.repoConfig = repoConfig;
8
+ }
9
+
10
+ async notify(diff, run) {
11
+ const channels = this.repoConfig.notification?.channels || ['inbox'];
12
+ const notifications = [];
13
+
14
+ if (!diff.hasNewIssues) {
15
+ return notifications;
16
+ }
17
+
18
+ const summary = `Found ${diff.added.length} new issue${diff.added.length === 1 ? '' : 's'} in ${this.repoConfig.repoPath}`;
19
+ const payload = {
20
+ timestamp: new Date().toISOString(),
21
+ repoId: this.repoConfig.repoId,
22
+ repoPath: this.repoConfig.repoPath,
23
+ channels,
24
+ summary,
25
+ newIssues: diff.added.slice(0, 10).map(issue => ({
26
+ file: issue.file,
27
+ line: issue.line,
28
+ severity: issue.severity,
29
+ category: issue.category,
30
+ message: issue.message,
31
+ })),
32
+ runTimestamp: run.timestamp,
33
+ };
34
+
35
+ if (channels.includes('inbox')) {
36
+ this.store.appendNotification(payload);
37
+ notifications.push({ channel: 'inbox', delivered: true });
38
+ }
39
+
40
+ if (channels.includes('console') && this.alertManager) {
41
+ await this.alertManager.sendAlert({
42
+ level: 'warning',
43
+ category: 'scheduled-scan',
44
+ title: 'Thuban monitor detected new issues',
45
+ message: summary,
46
+ file: path.join(this.repoConfig.repoPath, '.'),
47
+ details: payload,
48
+ });
49
+ notifications.push({ channel: 'console', delivered: true });
50
+ }
51
+
52
+ if (channels.includes('webhook')) {
53
+ notifications.push({ channel: 'webhook', delivered: false, stub: true });
54
+ }
55
+
56
+ if (channels.includes('email')) {
57
+ notifications.push({ channel: 'email', delivered: false, stub: true });
58
+ }
59
+
60
+ return notifications;
61
+ }
62
+ }
63
+
64
+ module.exports = MonitorNotifier;
@@ -0,0 +1,103 @@
1
+ const path = require('path');
2
+
3
+ const AlertManager = require('./alert-manager.js');
4
+ const MonitorStore = require('./monitor-store.js');
5
+ const MonitorNotifier = require('./monitor-notifier.js');
6
+ const { runScanPipeline } = require('./scan-runner.js');
7
+ const { diffRuns } = require('./scan-diff.js');
8
+ const { collectFiles } = require('./file-collector.js');
9
+
10
+ function resolveIntervalMs(frequency, intervalMs) {
11
+ if (frequency === 'weekly') return 7 * 24 * 60 * 60 * 1000;
12
+ if (frequency === 'custom') return Math.max(60 * 1000, Number(intervalMs) || 24 * 60 * 60 * 1000);
13
+ return 24 * 60 * 60 * 1000;
14
+ }
15
+
16
+ class MonitorService {
17
+ constructor(options = {}) {
18
+ this.store = options.store || new MonitorStore();
19
+ this.timers = new Map();
20
+ }
21
+
22
+ configureRepo({ repoPath, frequency = 'daily', intervalMs, notification }) {
23
+ const repoId = this.store.getRepoId(repoPath);
24
+ const effectiveIntervalMs = resolveIntervalMs(frequency, intervalMs);
25
+ const repoConfig = this.store.upsertRepo({
26
+ repoId,
27
+ repoPath,
28
+ frequency,
29
+ intervalMs: effectiveIntervalMs,
30
+ notification,
31
+ nextRunAt: new Date(Date.now() + effectiveIntervalMs).toISOString(),
32
+ });
33
+ return repoConfig;
34
+ }
35
+
36
+ async runRepoScan(repoConfig) {
37
+ const rootPath = path.resolve(repoConfig.repoPath);
38
+ const files = this._collectFiles(rootPath);
39
+ const startedAt = Date.now();
40
+ const pipeline = await runScanPipeline({ rootPath, files });
41
+ const run = {
42
+ repoId: repoConfig.repoId,
43
+ repoPath: rootPath,
44
+ timestamp: new Date().toISOString(),
45
+ durationMs: Date.now() - startedAt,
46
+ frequency: repoConfig.frequency,
47
+ metrics: pipeline.metrics,
48
+ summary: pipeline.summary,
49
+ issues: pipeline.issues,
50
+ };
51
+
52
+ const previousRun = this.store.getLatestRun(repoConfig.repoId);
53
+ const diff = diffRuns(previousRun, run);
54
+ run.diff = diff;
55
+
56
+ const runPath = this.store.saveRun(repoConfig.repoId, run);
57
+ const updatedRepo = this.store.upsertRepo({
58
+ ...repoConfig,
59
+ lastRunAt: run.timestamp,
60
+ nextRunAt: new Date(Date.now() + repoConfig.intervalMs).toISOString(),
61
+ });
62
+
63
+ const notifier = new MonitorNotifier({
64
+ alertManager: new AlertManager({ rootPath, consoleOutput: true, fileOutput: true }),
65
+ store: this.store,
66
+ repoConfig: updatedRepo,
67
+ });
68
+ run.notifications = await notifier.notify(diff, run);
69
+
70
+ return { run, runPath, previousRun, diff };
71
+ }
72
+
73
+ start(repoConfig, { once = false } = {}) {
74
+ const execute = async () => {
75
+ try {
76
+ await this.runRepoScan(repoConfig);
77
+ } catch (error) {
78
+ console.error(`[THUBAN MONITOR] Scheduled scan failed for ${repoConfig.repoPath}: ${error.message}`);
79
+ }
80
+ };
81
+
82
+ execute();
83
+ if (once) return;
84
+
85
+ const timer = setInterval(execute, repoConfig.intervalMs);
86
+ this.timers.set(repoConfig.repoId, timer);
87
+ return timer;
88
+ }
89
+
90
+ stopAll() {
91
+ for (const timer of this.timers.values()) clearInterval(timer);
92
+ this.timers.clear();
93
+ }
94
+
95
+ _collectFiles(rootPath) {
96
+ return collectFiles(rootPath);
97
+ }
98
+ }
99
+
100
+ module.exports = {
101
+ MonitorService,
102
+ resolveIntervalMs,
103
+ };
@@ -0,0 +1,117 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+
6
+ const THUBAN_DIR = path.join(os.homedir(), '.thuban');
7
+ const MONITOR_DIR = path.join(THUBAN_DIR, 'monitor');
8
+ const CONFIG_FILE = path.join(MONITOR_DIR, 'config.json');
9
+ const RUNS_DIR = path.join(MONITOR_DIR, 'runs');
10
+ const NOTIFICATIONS_FILE = path.join(MONITOR_DIR, 'notifications.json');
11
+
12
+ function ensureDir(dirPath) {
13
+ if (!fs.existsSync(dirPath)) {
14
+ fs.mkdirSync(dirPath, { recursive: true });
15
+ }
16
+ }
17
+
18
+ function safeReadJson(filePath, fallback) {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
21
+ } catch (error) {
22
+ return fallback;
23
+ }
24
+ }
25
+
26
+ class MonitorStore {
27
+ constructor() {
28
+ ensureDir(THUBAN_DIR);
29
+ ensureDir(MONITOR_DIR);
30
+ ensureDir(RUNS_DIR);
31
+ }
32
+
33
+ getRepoId(repoPath) {
34
+ return crypto.createHash('sha256').update(path.resolve(repoPath)).digest('hex').substring(0, 12);
35
+ }
36
+
37
+ loadConfig() {
38
+ return safeReadJson(CONFIG_FILE, { version: 1, repos: [] });
39
+ }
40
+
41
+ saveConfig(config) {
42
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
43
+ }
44
+
45
+ upsertRepo(repoConfig) {
46
+ const config = this.loadConfig();
47
+ const repoId = repoConfig.repoId || this.getRepoId(repoConfig.repoPath);
48
+ const nextRepo = {
49
+ repoId,
50
+ repoPath: path.resolve(repoConfig.repoPath),
51
+ frequency: repoConfig.frequency || 'daily',
52
+ intervalMs: repoConfig.intervalMs,
53
+ notification: repoConfig.notification || { channels: ['inbox'] },
54
+ createdAt: repoConfig.createdAt || new Date().toISOString(),
55
+ updatedAt: new Date().toISOString(),
56
+ enabled: repoConfig.enabled !== false,
57
+ lastRunAt: repoConfig.lastRunAt || null,
58
+ nextRunAt: repoConfig.nextRunAt || null,
59
+ };
60
+
61
+ const index = config.repos.findIndex(repo => repo.repoId === repoId);
62
+ if (index >= 0) config.repos[index] = { ...config.repos[index], ...nextRepo };
63
+ else config.repos.push(nextRepo);
64
+
65
+ this.saveConfig(config);
66
+ return nextRepo;
67
+ }
68
+
69
+ listRepos() {
70
+ return this.loadConfig().repos || [];
71
+ }
72
+
73
+ getRepo(repoIdOrPath) {
74
+ const resolved = repoIdOrPath ? path.resolve(repoIdOrPath) : null;
75
+ return this.listRepos().find(repo => repo.repoId === repoIdOrPath || repo.repoPath === resolved) || null;
76
+ }
77
+
78
+ getRepoRunDir(repoId) {
79
+ const dirPath = path.join(RUNS_DIR, repoId);
80
+ ensureDir(dirPath);
81
+ return dirPath;
82
+ }
83
+
84
+ saveRun(repoId, run) {
85
+ const runDir = this.getRepoRunDir(repoId);
86
+ const filePath = path.join(runDir, `${run.timestamp.replace(/[:.]/g, '-')}.json`);
87
+ fs.writeFileSync(filePath, JSON.stringify(run, null, 2), 'utf-8');
88
+ return filePath;
89
+ }
90
+
91
+ listRuns(repoId) {
92
+ const runDir = this.getRepoRunDir(repoId);
93
+ return fs.readdirSync(runDir)
94
+ .filter(file => file.endsWith('.json'))
95
+ .sort()
96
+ .map(file => safeReadJson(path.join(runDir, file), null))
97
+ .filter(Boolean);
98
+ }
99
+
100
+ getLatestRun(repoId) {
101
+ const runs = this.listRuns(repoId);
102
+ return runs.length ? runs[runs.length - 1] : null;
103
+ }
104
+
105
+ appendNotification(notification) {
106
+ const payload = safeReadJson(NOTIFICATIONS_FILE, { notifications: [] });
107
+ payload.notifications.push(notification);
108
+ fs.writeFileSync(NOTIFICATIONS_FILE, JSON.stringify(payload, null, 2), 'utf-8');
109
+ }
110
+
111
+ getNotifications(limit = 20) {
112
+ const payload = safeReadJson(NOTIFICATIONS_FILE, { notifications: [] });
113
+ return payload.notifications.slice(-limit).reverse();
114
+ }
115
+ }
116
+
117
+ module.exports = MonitorStore;
@@ -29,7 +29,7 @@ class PreCommitGate {
29
29
  encoding: 'utf-8',
30
30
  timeout: 3000,
31
31
  });
32
- const extensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.mjs', '.cjs'];
32
+ const extensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.mjs', '.cjs', '.php', '.rb'];
33
33
  return output
34
34
  .split('\n')
35
35
  .filter(f => f.trim() && extensions.some(ext => f.endsWith(ext)))
@@ -0,0 +1,50 @@
1
+ function toMap(issues = []) {
2
+ const map = new Map();
3
+ for (const issue of issues) {
4
+ map.set(issue.fingerprint, issue);
5
+ }
6
+ return map;
7
+ }
8
+
9
+ function diffRuns(previousRun, currentRun) {
10
+ const previousIssues = previousRun?.issues || [];
11
+ const currentIssues = currentRun?.issues || [];
12
+ const previousMap = toMap(previousIssues);
13
+ const currentMap = toMap(currentIssues);
14
+
15
+ const added = [];
16
+ const resolved = [];
17
+
18
+ for (const issue of currentIssues) {
19
+ if (!previousMap.has(issue.fingerprint)) added.push(issue);
20
+ }
21
+
22
+ for (const issue of previousIssues) {
23
+ if (!currentMap.has(issue.fingerprint)) resolved.push(issue);
24
+ }
25
+
26
+ const previousMetrics = previousRun?.metrics || {};
27
+ const currentMetrics = currentRun?.metrics || {};
28
+
29
+ return {
30
+ added,
31
+ resolved,
32
+ unchangedCount: currentIssues.length - added.length,
33
+ metricsDelta: {
34
+ issueCount: (currentMetrics.issueCount || 0) - (previousMetrics.issueCount || 0),
35
+ hallucinations: (currentMetrics.hallucinations || 0) - (previousMetrics.hallucinations || 0),
36
+ secrets: (currentMetrics.secrets || 0) - (previousMetrics.secrets || 0),
37
+ techDebtScore: currentMetrics.techDebtScore == null || previousMetrics.techDebtScore == null
38
+ ? null
39
+ : currentMetrics.techDebtScore - previousMetrics.techDebtScore,
40
+ techDebtHours: currentMetrics.techDebtHours == null || previousMetrics.techDebtHours == null
41
+ ? null
42
+ : currentMetrics.techDebtHours - previousMetrics.techDebtHours,
43
+ },
44
+ hasNewIssues: added.length > 0,
45
+ };
46
+ }
47
+
48
+ module.exports = {
49
+ diffRuns,
50
+ };
@@ -0,0 +1,172 @@
1
+ const path = require('path');
2
+
3
+ const CodeScanner = require('./code-scanner.js');
4
+ const HallucinationDetector = require('./hallucination-detector.js');
5
+ const SecretScanner = require('./secret-scanner.js');
6
+ const DriftDetector = require('./drift-detector.js');
7
+ const TechDebtAnalyzer = require('./tech-debt-analyzer.js');
8
+
9
+ function normalizeIssue(rootPath, issue) {
10
+ const fileValue = issue.file || issue.path || '';
11
+ const relativeFile = path.isAbsolute(fileValue)
12
+ ? path.relative(rootPath, fileValue)
13
+ : fileValue;
14
+
15
+ return {
16
+ file: relativeFile,
17
+ line: issue.line || null,
18
+ severity: issue.severity || 'unknown',
19
+ category: issue.category || 'unknown',
20
+ id: issue.id || null,
21
+ message: issue.message || '',
22
+ suggestion: issue.suggestion || null,
23
+ fixable: issue.fixable !== false,
24
+ };
25
+ }
26
+
27
+ function fingerprintIssue(issue) {
28
+ return [
29
+ issue.file || '',
30
+ issue.category || '',
31
+ issue.id || '',
32
+ String(issue.line || ''),
33
+ (issue.message || '').substring(0, 160),
34
+ ].join('|');
35
+ }
36
+
37
+ function summarizeIssues(issues) {
38
+ const summary = {
39
+ total: issues.length,
40
+ bySeverity: {},
41
+ byCategory: {},
42
+ };
43
+
44
+ for (const issue of issues) {
45
+ summary.bySeverity[issue.severity] = (summary.bySeverity[issue.severity] || 0) + 1;
46
+ summary.byCategory[issue.category] = (summary.byCategory[issue.category] || 0) + 1;
47
+ }
48
+
49
+ return summary;
50
+ }
51
+
52
+ async function runScanPipeline({ rootPath, files, ignorePatterns = [] }) {
53
+ const scanner = new CodeScanner({
54
+ rootPath,
55
+ ignorePatterns: ['node_modules/**', '.git/**', 'dist/**', ...ignorePatterns],
56
+ });
57
+ const hallucinationDetector = new HallucinationDetector({ rootPath });
58
+ const secretScanner = new SecretScanner({ rootPath });
59
+ const driftDetector = new DriftDetector({ rootPath });
60
+ const techDebtAnalyzer = new TechDebtAnalyzer({ rootPath });
61
+
62
+ const [scanResults, hallResults, secretResults, driftResults, debtResults] = await Promise.all([
63
+ scanner.scanFiles(files),
64
+ hallucinationDetector.scan(files),
65
+ secretScanner.scanFiles(files),
66
+ driftDetector.detectAll ? driftDetector.detectAll() : Promise.resolve({ issues: [] }),
67
+ techDebtAnalyzer.analyze ? techDebtAnalyzer.analyze(files) : Promise.resolve(null),
68
+ ]);
69
+
70
+ const issues = [];
71
+
72
+ for (const [file, fileResults] of Object.entries(scanResults || {})) {
73
+ if (!fileResults || !fileResults.issues) continue;
74
+ for (const issue of fileResults.issues) {
75
+ issues.push(normalizeIssue(rootPath, { file, ...issue }));
76
+ }
77
+ }
78
+
79
+ for (const issue of secretResults?.issues || []) {
80
+ issues.push(normalizeIssue(rootPath, issue));
81
+ }
82
+
83
+ for (const issue of driftResults?.issues || []) {
84
+ issues.push(normalizeIssue(rootPath, issue));
85
+ }
86
+
87
+ for (const item of hallResults?.phantomAPIs || []) {
88
+ issues.push(normalizeIssue(rootPath, {
89
+ file: item.file,
90
+ line: item.line,
91
+ severity: 'critical',
92
+ category: 'hallucination',
93
+ id: item.id || 'HALL_API',
94
+ message: `${item.name} — this API does not exist`,
95
+ suggestion: item.suggestion,
96
+ fixable: false,
97
+ }));
98
+ }
99
+
100
+ for (const item of hallResults?.phantomImports || []) {
101
+ issues.push(normalizeIssue(rootPath, {
102
+ file: item.file,
103
+ line: item.line,
104
+ severity: 'critical',
105
+ category: 'hallucination',
106
+ id: item.id || 'HALL_IMPORT',
107
+ message: `${item.module} — ${item.reason}`,
108
+ suggestion: item.suggestion || null,
109
+ fixable: false,
110
+ }));
111
+ }
112
+
113
+ for (const item of hallResults?.deprecatedAPIs || []) {
114
+ issues.push(normalizeIssue(rootPath, {
115
+ file: item.file,
116
+ line: item.line,
117
+ severity: 'high',
118
+ category: 'deprecated',
119
+ id: item.id || 'DEPR_API',
120
+ message: `${item.name} — deprecated since ${item.since}`,
121
+ suggestion: item.suggestion,
122
+ }));
123
+ }
124
+
125
+ for (const item of hallResults?.aiSmells || []) {
126
+ issues.push(normalizeIssue(rootPath, {
127
+ file: item.file,
128
+ line: item.line,
129
+ severity: 'warning',
130
+ category: 'ai_smell',
131
+ id: item.id,
132
+ message: item.message,
133
+ }));
134
+ }
135
+
136
+ const uniqueIssues = [];
137
+ const seen = new Set();
138
+ for (const issue of issues) {
139
+ const fingerprint = fingerprintIssue(issue);
140
+ if (seen.has(fingerprint)) continue;
141
+ seen.add(fingerprint);
142
+ uniqueIssues.push({ ...issue, fingerprint });
143
+ }
144
+
145
+ const metrics = {
146
+ filesScanned: files.length,
147
+ issueCount: uniqueIssues.length,
148
+ hallucinations: uniqueIssues.filter(issue => issue.category === 'hallucination').length,
149
+ secrets: uniqueIssues.filter(issue => issue.category === 'secret' || String(issue.id || '').startsWith('SECRET')).length,
150
+ techDebtScore: debtResults?.summary?.score ?? debtResults?.score ?? null,
151
+ techDebtHours: debtResults?.summary?.estimatedHours ?? debtResults?.estimatedHours ?? null,
152
+ };
153
+
154
+ return {
155
+ issues: uniqueIssues,
156
+ summary: summarizeIssues(uniqueIssues),
157
+ metrics,
158
+ raw: {
159
+ scanResults,
160
+ hallResults,
161
+ secretResults,
162
+ driftResults,
163
+ debtResults,
164
+ },
165
+ };
166
+ }
167
+
168
+ module.exports = {
169
+ runScanPipeline,
170
+ fingerprintIssue,
171
+ summarizeIssues,
172
+ };