thuban 0.4.6 → 0.4.7
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.
- package/dist/cli.js +1 -1
- package/dist/package.json +2 -1
- package/dist/packages/scanner/feedback-client.js +1 -0
- package/dist/packages/scanner/hallucination-detector.js +1 -1
- package/dist/packages/scanner/investor-report.js +1 -0
- package/dist/packages/scanner/python_ast_helper.py +426 -0
- package/dist/packages/scanner/slack-notifier.js +1 -0
- package/dist/packages/scanner/support-bot.js +1 -0
- package/dist/packages/scanner/support-knowledge.js +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Thuban Python AST Helper
|
|
4
|
+
|
|
5
|
+
Reads Python source from stdin, parses it with the built-in `ast` module,
|
|
6
|
+
and prints a single-line JSON report to stdout describing:
|
|
7
|
+
|
|
8
|
+
- unused_import Imported names never referenced anywhere in the file
|
|
9
|
+
- unused_variable Local variables assigned in a function but never read
|
|
10
|
+
- high_complexity Functions whose McCabe cyclomatic complexity exceeds
|
|
11
|
+
COMPLEXITY_THRESHOLD
|
|
12
|
+
- dead_code Statements that are unreachable because they follow
|
|
13
|
+
an unconditional return/raise/break/continue in the
|
|
14
|
+
same block
|
|
15
|
+
- bare_except `except:` clauses with no exception type
|
|
16
|
+
- mutable_default_arg Function parameters defaulting to a mutable literal
|
|
17
|
+
(list/dict/set) or list()/dict()/set() call
|
|
18
|
+
|
|
19
|
+
This script is invoked by packages/scanner/python-ast-analyzer.js as a
|
|
20
|
+
subprocess (one call per Python file). It must never raise past main() -
|
|
21
|
+
on any parse failure it prints {"ok": false, "error": "..."} and exits 0,
|
|
22
|
+
so the caller can treat AST analysis as a best-effort supplement.
|
|
23
|
+
|
|
24
|
+
Output contract (stdout, single JSON object):
|
|
25
|
+
{"ok": true, "issues": [{"type": str, "line": int, "name": str|null, "message": str}, ...]}
|
|
26
|
+
{"ok": false, "error": str}
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import ast
|
|
30
|
+
import json
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
COMPLEXITY_THRESHOLD = 10
|
|
34
|
+
|
|
35
|
+
TERMINATOR_TYPES = (ast.Return, ast.Raise, ast.Continue, ast.Break)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
39
|
+
# Unused imports
|
|
40
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
class _UnusedImportChecker:
|
|
43
|
+
def __init__(self, tree):
|
|
44
|
+
self.tree = tree
|
|
45
|
+
self.imports = [] # [{'name', 'line', 'source'}]
|
|
46
|
+
self.used_names = set()
|
|
47
|
+
self.dunder_all_names = set()
|
|
48
|
+
|
|
49
|
+
def collect(self):
|
|
50
|
+
for node in ast.walk(self.tree):
|
|
51
|
+
if isinstance(node, ast.Import):
|
|
52
|
+
for alias in node.names:
|
|
53
|
+
if alias.name == '*':
|
|
54
|
+
continue
|
|
55
|
+
bound = alias.asname or alias.name.split('.')[0]
|
|
56
|
+
self.imports.append({'name': bound, 'line': node.lineno, 'source': ''})
|
|
57
|
+
elif isinstance(node, ast.ImportFrom):
|
|
58
|
+
if node.module == '__future__':
|
|
59
|
+
continue
|
|
60
|
+
for alias in node.names:
|
|
61
|
+
if alias.name == '*':
|
|
62
|
+
continue
|
|
63
|
+
bound = alias.asname or alias.name
|
|
64
|
+
self.imports.append({'name': bound, 'line': node.lineno, 'source': node.module or ''})
|
|
65
|
+
elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
|
|
66
|
+
self.used_names.add(node.id)
|
|
67
|
+
elif isinstance(node, ast.Assign):
|
|
68
|
+
for target in node.targets:
|
|
69
|
+
if isinstance(target, ast.Name) and target.id == '__all__' and \
|
|
70
|
+
isinstance(node.value, (ast.List, ast.Tuple, ast.Set)):
|
|
71
|
+
for elt in node.value.elts:
|
|
72
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
73
|
+
self.dunder_all_names.add(elt.value)
|
|
74
|
+
|
|
75
|
+
def unused(self):
|
|
76
|
+
issues = []
|
|
77
|
+
seen = set()
|
|
78
|
+
for imp in self.imports:
|
|
79
|
+
name = imp['name']
|
|
80
|
+
if name.startswith('_'):
|
|
81
|
+
continue
|
|
82
|
+
if name in self.used_names or name in self.dunder_all_names:
|
|
83
|
+
continue
|
|
84
|
+
key = (name, imp['line'])
|
|
85
|
+
if key in seen:
|
|
86
|
+
continue
|
|
87
|
+
seen.add(key)
|
|
88
|
+
source_suffix = " from '%s'" % imp['source'] if imp['source'] else ''
|
|
89
|
+
issues.append({
|
|
90
|
+
'type': 'unused_import',
|
|
91
|
+
'line': imp['line'],
|
|
92
|
+
'name': name,
|
|
93
|
+
'message': "Unused import: '%s'%s" % (name, source_suffix),
|
|
94
|
+
})
|
|
95
|
+
return issues
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
99
|
+
# Per-function analysis: complexity, unused locals, mutable defaults
|
|
100
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
class _ComplexityVisitor(ast.NodeVisitor):
|
|
103
|
+
"""Counts McCabe decision points within a single function's own body,
|
|
104
|
+
stopping at nested function/lambda boundaries (those are analyzed
|
|
105
|
+
independently when ast.walk() reaches them on its own)."""
|
|
106
|
+
|
|
107
|
+
def __init__(self):
|
|
108
|
+
self.complexity = 1
|
|
109
|
+
|
|
110
|
+
def visit_If(self, node):
|
|
111
|
+
self.complexity += 1
|
|
112
|
+
self.generic_visit(node)
|
|
113
|
+
|
|
114
|
+
def visit_For(self, node):
|
|
115
|
+
self.complexity += 1
|
|
116
|
+
self.generic_visit(node)
|
|
117
|
+
|
|
118
|
+
def visit_AsyncFor(self, node):
|
|
119
|
+
self.complexity += 1
|
|
120
|
+
self.generic_visit(node)
|
|
121
|
+
|
|
122
|
+
def visit_While(self, node):
|
|
123
|
+
self.complexity += 1
|
|
124
|
+
self.generic_visit(node)
|
|
125
|
+
|
|
126
|
+
def visit_IfExp(self, node):
|
|
127
|
+
self.complexity += 1
|
|
128
|
+
self.generic_visit(node)
|
|
129
|
+
|
|
130
|
+
def visit_ExceptHandler(self, node):
|
|
131
|
+
self.complexity += 1
|
|
132
|
+
self.generic_visit(node)
|
|
133
|
+
|
|
134
|
+
def visit_BoolOp(self, node):
|
|
135
|
+
self.complexity += max(len(node.values) - 1, 0)
|
|
136
|
+
self.generic_visit(node)
|
|
137
|
+
|
|
138
|
+
def visit_comprehension(self, node):
|
|
139
|
+
self.complexity += len(node.ifs)
|
|
140
|
+
self.generic_visit(node)
|
|
141
|
+
|
|
142
|
+
# Nested scopes get their own complexity computation - don't descend.
|
|
143
|
+
def visit_FunctionDef(self, node):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def visit_AsyncFunctionDef(self, node):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def visit_Lambda(self, node):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _compute_complexity(fn):
|
|
154
|
+
visitor = _ComplexityVisitor()
|
|
155
|
+
for stmt in fn.body:
|
|
156
|
+
visitor.visit(stmt)
|
|
157
|
+
return visitor.complexity
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_mutable_default(default):
|
|
161
|
+
if isinstance(default, (ast.List, ast.Dict, ast.Set)):
|
|
162
|
+
return True
|
|
163
|
+
if isinstance(default, ast.Call) and isinstance(default.func, ast.Name):
|
|
164
|
+
return default.func.id in ('list', 'dict', 'set')
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _mutable_defaults(fn):
|
|
169
|
+
issues = []
|
|
170
|
+
args = fn.args
|
|
171
|
+
positional = list(getattr(args, 'posonlyargs', [])) + list(args.args)
|
|
172
|
+
if args.defaults:
|
|
173
|
+
for arg, default in zip(reversed(positional), reversed(args.defaults)):
|
|
174
|
+
if _is_mutable_default(default):
|
|
175
|
+
issues.append({
|
|
176
|
+
'type': 'mutable_default_arg',
|
|
177
|
+
'line': default.lineno,
|
|
178
|
+
'name': arg.arg,
|
|
179
|
+
'message': "Mutable default argument '%s' in function '%s' - shared across all calls" % (arg.arg, fn.name),
|
|
180
|
+
})
|
|
181
|
+
for arg, default in zip(args.kwonlyargs, args.kw_defaults or []):
|
|
182
|
+
if default is not None and _is_mutable_default(default):
|
|
183
|
+
issues.append({
|
|
184
|
+
'type': 'mutable_default_arg',
|
|
185
|
+
'line': default.lineno,
|
|
186
|
+
'name': arg.arg,
|
|
187
|
+
'message': "Mutable default argument '%s' in function '%s' - shared across all calls" % (arg.arg, fn.name),
|
|
188
|
+
})
|
|
189
|
+
return issues
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _LocalAssignCollector(ast.NodeVisitor):
|
|
193
|
+
"""Collects simple (single-Name-target) local assignments made directly
|
|
194
|
+
within a function's own body - not inside nested function/lambda
|
|
195
|
+
scopes, which own their own locals."""
|
|
196
|
+
|
|
197
|
+
def __init__(self):
|
|
198
|
+
self.assigned = {} # name -> first assignment line
|
|
199
|
+
self.declared_global_nonlocal = set()
|
|
200
|
+
|
|
201
|
+
def visit_Assign(self, node):
|
|
202
|
+
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
203
|
+
name = node.targets[0].id
|
|
204
|
+
self.assigned.setdefault(name, node.lineno)
|
|
205
|
+
self.generic_visit(node)
|
|
206
|
+
|
|
207
|
+
def visit_AnnAssign(self, node):
|
|
208
|
+
if isinstance(node.target, ast.Name) and node.value is not None:
|
|
209
|
+
self.assigned.setdefault(node.target.id, node.lineno)
|
|
210
|
+
self.generic_visit(node)
|
|
211
|
+
|
|
212
|
+
def visit_For(self, node):
|
|
213
|
+
if isinstance(node.target, ast.Name):
|
|
214
|
+
self.assigned.setdefault(node.target.id, node.lineno)
|
|
215
|
+
self.generic_visit(node)
|
|
216
|
+
|
|
217
|
+
def visit_With(self, node):
|
|
218
|
+
for item in node.items:
|
|
219
|
+
if item.optional_vars is not None and isinstance(item.optional_vars, ast.Name):
|
|
220
|
+
self.assigned.setdefault(item.optional_vars.id, node.lineno)
|
|
221
|
+
self.generic_visit(node)
|
|
222
|
+
|
|
223
|
+
def visit_Global(self, node):
|
|
224
|
+
self.declared_global_nonlocal.update(node.names)
|
|
225
|
+
|
|
226
|
+
def visit_Nonlocal(self, node):
|
|
227
|
+
self.declared_global_nonlocal.update(node.names)
|
|
228
|
+
|
|
229
|
+
def visit_FunctionDef(self, node):
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def visit_AsyncFunctionDef(self, node):
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
def visit_Lambda(self, node):
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class _LoadNameCollector(ast.NodeVisitor):
|
|
240
|
+
"""Collects every Name read (Load context) anywhere within a subtree,
|
|
241
|
+
INCLUDING nested functions/lambdas - a variable captured by a closure
|
|
242
|
+
still counts as used."""
|
|
243
|
+
|
|
244
|
+
def __init__(self):
|
|
245
|
+
self.loaded = set()
|
|
246
|
+
|
|
247
|
+
def visit_Name(self, node):
|
|
248
|
+
if isinstance(node.ctx, ast.Load):
|
|
249
|
+
self.loaded.add(node.id)
|
|
250
|
+
self.generic_visit(node)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _unused_locals(fn):
|
|
254
|
+
assign_collector = _LocalAssignCollector()
|
|
255
|
+
for stmt in fn.body:
|
|
256
|
+
assign_collector.visit(stmt)
|
|
257
|
+
|
|
258
|
+
load_collector = _LoadNameCollector()
|
|
259
|
+
load_collector.visit(fn)
|
|
260
|
+
|
|
261
|
+
issues = []
|
|
262
|
+
for name, line in assign_collector.assigned.items():
|
|
263
|
+
if name.startswith('_'):
|
|
264
|
+
continue
|
|
265
|
+
if name in assign_collector.declared_global_nonlocal:
|
|
266
|
+
continue
|
|
267
|
+
if name in load_collector.loaded:
|
|
268
|
+
continue
|
|
269
|
+
issues.append({
|
|
270
|
+
'type': 'unused_variable',
|
|
271
|
+
'line': line,
|
|
272
|
+
'name': name,
|
|
273
|
+
'message': "Variable '%s' is assigned but never used" % name,
|
|
274
|
+
})
|
|
275
|
+
return issues
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
279
|
+
# Dead code (unreachable statements)
|
|
280
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def _check_block(stmts, issues):
|
|
283
|
+
for i, stmt in enumerate(stmts):
|
|
284
|
+
if isinstance(stmt, TERMINATOR_TYPES):
|
|
285
|
+
if i + 1 < len(stmts):
|
|
286
|
+
nxt = stmts[i + 1]
|
|
287
|
+
kind = type(stmt).__name__.lower()
|
|
288
|
+
issues.append({
|
|
289
|
+
'type': 'dead_code',
|
|
290
|
+
'line': nxt.lineno,
|
|
291
|
+
'name': None,
|
|
292
|
+
'message': "Unreachable code after '%s' statement" % kind,
|
|
293
|
+
})
|
|
294
|
+
break # only report the first unreachable run per block
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class _DeadCodeVisitor(ast.NodeVisitor):
|
|
298
|
+
def __init__(self):
|
|
299
|
+
self.issues = []
|
|
300
|
+
|
|
301
|
+
def visit_Module(self, node):
|
|
302
|
+
_check_block(node.body, self.issues)
|
|
303
|
+
self.generic_visit(node)
|
|
304
|
+
|
|
305
|
+
def visit_FunctionDef(self, node):
|
|
306
|
+
_check_block(node.body, self.issues)
|
|
307
|
+
self.generic_visit(node)
|
|
308
|
+
|
|
309
|
+
def visit_AsyncFunctionDef(self, node):
|
|
310
|
+
_check_block(node.body, self.issues)
|
|
311
|
+
self.generic_visit(node)
|
|
312
|
+
|
|
313
|
+
def visit_If(self, node):
|
|
314
|
+
_check_block(node.body, self.issues)
|
|
315
|
+
_check_block(node.orelse, self.issues)
|
|
316
|
+
self.generic_visit(node)
|
|
317
|
+
|
|
318
|
+
def visit_For(self, node):
|
|
319
|
+
_check_block(node.body, self.issues)
|
|
320
|
+
_check_block(node.orelse, self.issues)
|
|
321
|
+
self.generic_visit(node)
|
|
322
|
+
|
|
323
|
+
def visit_AsyncFor(self, node):
|
|
324
|
+
_check_block(node.body, self.issues)
|
|
325
|
+
_check_block(node.orelse, self.issues)
|
|
326
|
+
self.generic_visit(node)
|
|
327
|
+
|
|
328
|
+
def visit_While(self, node):
|
|
329
|
+
_check_block(node.body, self.issues)
|
|
330
|
+
_check_block(node.orelse, self.issues)
|
|
331
|
+
self.generic_visit(node)
|
|
332
|
+
|
|
333
|
+
def visit_Try(self, node):
|
|
334
|
+
_check_block(node.body, self.issues)
|
|
335
|
+
for handler in node.handlers:
|
|
336
|
+
_check_block(handler.body, self.issues)
|
|
337
|
+
_check_block(node.orelse, self.issues)
|
|
338
|
+
_check_block(node.finalbody, self.issues)
|
|
339
|
+
self.generic_visit(node)
|
|
340
|
+
|
|
341
|
+
def visit_With(self, node):
|
|
342
|
+
_check_block(node.body, self.issues)
|
|
343
|
+
self.generic_visit(node)
|
|
344
|
+
|
|
345
|
+
def visit_AsyncWith(self, node):
|
|
346
|
+
_check_block(node.body, self.issues)
|
|
347
|
+
self.generic_visit(node)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
351
|
+
# Suspicious patterns: bare except
|
|
352
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
def _find_bare_except(tree):
|
|
355
|
+
issues = []
|
|
356
|
+
for node in ast.walk(tree):
|
|
357
|
+
if isinstance(node, ast.ExceptHandler) and node.type is None:
|
|
358
|
+
issues.append({
|
|
359
|
+
'type': 'bare_except',
|
|
360
|
+
'line': node.lineno,
|
|
361
|
+
'name': None,
|
|
362
|
+
'message': "Bare 'except:' catches all exceptions including SystemExit/KeyboardInterrupt - catch specific exception types instead",
|
|
363
|
+
})
|
|
364
|
+
return issues
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
368
|
+
# Entry point
|
|
369
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
def main():
|
|
372
|
+
source = sys.stdin.read()
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
tree = ast.parse(source)
|
|
376
|
+
except SyntaxError as e:
|
|
377
|
+
print(json.dumps({'ok': False, 'error': 'SyntaxError: %s' % e}))
|
|
378
|
+
return
|
|
379
|
+
except Exception as e: # pragma: no cover - defensive
|
|
380
|
+
print(json.dumps({'ok': False, 'error': str(e)}))
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
issues = []
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
importer = _UnusedImportChecker(tree)
|
|
387
|
+
importer.collect()
|
|
388
|
+
issues.extend(importer.unused())
|
|
389
|
+
except Exception:
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
for node in ast.walk(tree):
|
|
394
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
395
|
+
issues.extend(_mutable_defaults(node))
|
|
396
|
+
complexity = _compute_complexity(node)
|
|
397
|
+
if complexity > COMPLEXITY_THRESHOLD:
|
|
398
|
+
issues.append({
|
|
399
|
+
'type': 'high_complexity',
|
|
400
|
+
'line': node.lineno,
|
|
401
|
+
'name': node.name,
|
|
402
|
+
'message': "Function '%s' has cyclomatic complexity %d (threshold %d)" % (
|
|
403
|
+
node.name, complexity, COMPLEXITY_THRESHOLD),
|
|
404
|
+
})
|
|
405
|
+
issues.extend(_unused_locals(node))
|
|
406
|
+
except Exception:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
dead_code_visitor = _DeadCodeVisitor()
|
|
411
|
+
dead_code_visitor.visit(tree)
|
|
412
|
+
issues.extend(dead_code_visitor.issues)
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
issues.extend(_find_bare_except(tree))
|
|
418
|
+
except Exception:
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
issues.sort(key=lambda i: i.get('line') or 0)
|
|
422
|
+
print(json.dumps({'ok': True, 'issues': issues}))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == '__main__':
|
|
426
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const https=require("https"),url=require("url");class SlackNotifier{constructor(e={}){this.webhookUrl=e.webhookUrl||process.env.SLACK_WEBHOOK_URL||null,this.alertsWebhookUrl=e.alertsWebhookUrl||process.env.SLACK_WEBHOOK_URL_ALERTS||this.webhookUrl,this.enabled=!!this.webhookUrl,this.appName=e.appName||"Thuban",this.iconEmoji=e.iconEmoji||":shield:"}async _send(e,t){return t?new Promise(n=>{try{const s=new URL(t),o=JSON.stringify(e),r=https.request({hostname:s.hostname,path:s.pathname,method:"POST",headers:{"Content-Type":"application/json","Content-Length":Buffer.byteLength(o)},timeout:1e4},e=>{let t="";e.on("data",e=>t+=e),e.on("end",()=>n({ok:200===e.statusCode,status:e.statusCode,body:t}))});r.on("error",e=>n({ok:!1,error:e.message})),r.on("timeout",()=>{r.destroy(),n({ok:!1,error:"timeout"})}),r.write(o),r.end()}catch(e){n({ok:!1,error:e.message})}}):{ok:!1,error:"No webhook URL configured"}}async notifyScanComplete({projectName:e,trustScore:t,grade:n,issueCount:s,criticalCount:o,userId:r,scanId:a}){const i="A+"===n||"A"===n?":white_check_mark:":"B"===n?":large_yellow_circle:":"C"===n?":warning:":":red_circle:";return this._send({username:this.appName,icon_emoji:this.iconEmoji,blocks:[{type:"header",text:{type:"plain_text",text:`${i} Scan Complete: ${e||"Unknown"}`,emoji:!0}},{type:"section",fields:[{type:"mrkdwn",text:`*Trust Score:*\n${t}/100`},{type:"mrkdwn",text:`*Grade:*\n${n}`},{type:"mrkdwn",text:`*Issues:*\n${s} total`},{type:"mrkdwn",text:`*Critical:*\n${o||0}`}]},...r?[{type:"context",elements:[{type:"mrkdwn",text:`User: ${r} | Scan: \`${a||"N/A"}\``}]}]:[]]},this.webhookUrl)}async notifyNewUser({email:e,source:t,timestamp:n}){return this._send({username:this.appName,icon_emoji:":tada:",blocks:[{type:"header",text:{type:"plain_text",text:":tada: New Thuban User!",emoji:!0}},{type:"section",text:{type:"mrkdwn",text:`*Email:* ${e||"Anonymous"}\n*Source:* ${t||"CLI"}\n*Time:* ${n||(new Date).toISOString()}`}}]},this.alertsWebhookUrl)}async notifyFeedback({category:e,text:t,systemInfo:n,timestamp:s}){const o="bug"===e?":bug:":"feature"===e?":bulb:":":speech_balloon:";return this._send({username:this.appName,icon_emoji:o,blocks:[{type:"header",text:{type:"plain_text",text:`${o} Feedback: ${e||"General"}`,emoji:!0}},{type:"section",text:{type:"mrkdwn",text:(t||"").substring(0,1500)}},...n?[{type:"context",elements:[{type:"mrkdwn",text:`OS: ${n.os||"?"} | Node: ${n.nodeVersion||"?"} | Thuban: ${n.thubanVersion||"?"}`}]}]:[]]},this.alertsWebhookUrl)}async notifyAlert({title:e,message:t,severity:n,projectName:s}){const o="critical"===n?":rotating_light:":"high"===n?":red_circle:":"medium"===n?":warning:":":information_source:";return this._send({username:this.appName,icon_emoji:":rotating_light:",blocks:[{type:"header",text:{type:"plain_text",text:`${o} ${e}`,emoji:!0}},{type:"section",text:{type:"mrkdwn",text:t}},...s?[{type:"context",elements:[{type:"mrkdwn",text:`Project: ${s} | ${(new Date).toISOString()}`}]}]:[]]},this.alertsWebhookUrl)}async notifyDailySummary({totalUsers:e,activeToday:t,scansToday:n,totalScans:s,estimatedMRR:o,topIssues:r}){return this._send({username:this.appName,icon_emoji:":bar_chart:",blocks:[{type:"header",text:{type:"plain_text",text:":bar_chart: Thuban Daily Summary",emoji:!0}},{type:"section",fields:[{type:"mrkdwn",text:`*Total Users:*\n${e||0}`},{type:"mrkdwn",text:`*Active Today:*\n${t||0}`},{type:"mrkdwn",text:`*Scans Today:*\n${n||0}`},{type:"mrkdwn",text:`*Total Scans:*\n${s||0}`}]},{type:"section",fields:[{type:"mrkdwn",text:`*Est. MRR:*\n$${o||0}`},{type:"mrkdwn",text:`*Top Issue:*\n${r||"N/A"}`}]},{type:"divider"},{type:"context",elements:[{type:"mrkdwn",text:`Generated ${(new Date).toISOString()} | <https://thuban-alpha.vercel.app/dashboard|View Dashboard>`}]}]},this.webhookUrl)}async sendMessage(e,t){return this._send({username:this.appName,icon_emoji:this.iconEmoji,text:e,...t?{channel:t}:{}},this.webhookUrl)}async test(){return this.sendMessage(":white_check_mark: Thuban Slack integration is working! Dashboard: https://thuban-alpha.vercel.app")}}module.exports=SlackNotifier;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const fs=require("fs"),path=require("path"),os=require("os"),https=require("https"),{execSync:execSync}=require("child_process"),readline=require("readline"),KB=require("./support-knowledge.js"),C={reset:"[0m",bold:"[1m",red:"[31m",green:"[32m",yellow:"[33m",blue:"[34m",cyan:"[36m",gray:"[90m"};function ok(e){return` ${C.green}✓${C.reset} ${e}`}function warn(e){return` ${C.yellow}⚠${C.reset} ${e}`}function fail(e){return` ${C.red}✗${C.reset} ${e}`}function safeExec(e){try{return execSync(e,{encoding:"utf8",timeout:1e4,stdio:["pipe","pipe","pipe"]}).trim()}catch{return null}}function parseSemver(e){const o=(e||"").match(/(\d+)\.(\d+)\.(\d+)/);return o?{major:Number(o[1]),minor:Number(o[2]),patch:Number(o[3])}:null}function countFiles(e){try{return fs.readdirSync(e).filter(o=>{try{return fs.statSync(path.join(e,o)).isFile()}catch{return!1}}).length}catch{return-1}}function wordOverlap(e,o){const s=new Set(e.toLowerCase().split(/\s+/).filter(Boolean)),t=new Set(o.toLowerCase().split(/\s+/).filter(Boolean));if(0===s.size||0===t.size)return 0;let n=0;for(const e of s)t.has(e)&&n++;return n/Math.max(s.size,t.size)}class SupportBot{constructor(e={}){this.rootPath=e.rootPath||process.cwd(),this.verbose=e.verbose||!1,this.smart=e.smart||!1,this._thubanVersion=null}async semanticQuery(e){const o=process.env.OPENAI_API_KEY,s=process.env.PINECONE_API_KEY;if(!o||!s){try{const o=await this._httpPost("europe-west2-orion-os-479912.cloudfunctions.net","/thuban-api/support/query",{},{query:e,topK:3});if(o.data&&o.data.results)return o.data.results}catch{}return null}try{const t=await this._httpPost("api.openai.com","/v1/embeddings",{Authorization:`Bearer ${o}`},{model:"text-embedding-3-small",input:e});if(!t.data||!t.data.data)return null;const n=t.data.data[0].embedding,r=await this._httpPost("thuban-kb-es8yjbx.svc.aped-4627-b74a.pinecone.io","/query",{"Api-Key":s},{vector:n,topK:3,includeMetadata:!0});return r.data&&r.data.matches?r.data.matches.map(e=>({score:e.score,type:e.metadata.type,...e.metadata})):null}catch(e){return this.verbose&&console.error(`${C.gray} Semantic search error: ${e.message}${C.reset}`),null}}_httpPost(e,o,s,t){return new Promise((n,r)=>{const a=JSON.stringify(t),l=https.request({hostname:e,path:o,method:"POST",timeout:1e4,headers:{...s,"Content-Type":"application/json","Content-Length":Buffer.byteLength(a)}},e=>{let o="";e.on("data",e=>o+=e),e.on("end",()=>{try{n({status:e.statusCode,data:JSON.parse(o)})}catch{n({status:e.statusCode,data:o})}})});l.on("error",r),l.on("timeout",()=>{l.destroy(),r(new Error("timeout"))}),l.write(a),l.end()})}async diagnose(){const e=[],o=[],s=[],t={},n=safeExec("node --version"),r=parseSemver(n);t.nodeVersion=n||"unknown",r&&r.major>=16?e.push(ok(`Node.js ${n} (requires >= 16)`)):r?s.push(fail(`Node.js ${n} — version 16+ required`)):s.push(fail("Node.js not found"));const a=safeExec("npm --version");t.npmVersion=a||"unknown",a?e.push(ok(`npm v${a}`)):o.push(warn("npm not found — install via Node.js installer"));const l=safeExec("git --version"),i=l?l.replace(/^git version\s*/,""):null;t.gitVersion=i||"unknown",i?e.push(ok(`Git v${i}`)):o.push(warn("Git not found — some features require Git"));const c=this._readThubanVersion();t.thubanVersion=c||"unknown",c?e.push(ok(`Thuban v${c}`)):o.push(warn("Could not determine Thuban version")),this._thubanVersion=c;const u=os.platform(),d=os.arch(),g=os.release();t.os=u,t.arch=d,t.release=g;const p=this._formatOS(u,g,d);e.push(ok(`OS: ${p}`));try{fs.accessSync(this.rootPath,fs.constants.R_OK),e.push(ok("Target path exists and is readable"))}catch{s.push(fail(`Target path not readable: ${this.rootPath}`))}const $=countFiles(this.rootPath);$>=0&&(0===$?o.push(warn("Target directory is empty")):this.verbose&&e.push(ok(`${$} file(s) in target directory`))),fs.existsSync(path.join(this.rootPath,"package.json"))?e.push(ok("package.json found")):s.push(fail("No package.json found in target directory")),this._hasCodeFiles(this.rootPath)||o.push(warn("No .js or .ts files found in target directory"));const h=this._checkDiskSpace();!1===h.ok?o.push(warn(`Low disk space: ${h.free} available`)):h.free&&this.verbose&&e.push(ok(`Disk space: ${h.free} available`)),/\s/.test(this.rootPath)&&o.push(warn("Path contains spaces — may cause issues with some commands"));try{fs.accessSync(this.rootPath,fs.constants.W_OK),this.verbose&&e.push(ok("Write permissions on target directory"))}catch{o.push(warn("No write permissions on target directory"))}const f={passed:e,warnings:o,failed:s,systemInfo:t};return this._printDiagnosticReport(f),f}_printDiagnosticReport(e){console.log(""),console.log(` ${C.bold}THUBAN SYSTEM DIAGNOSTIC${C.reset}`),console.log(` ${C.gray}────────────────────────${C.reset}`),console.log("");for(const o of e.passed)console.log(o);for(const o of e.warnings)console.log(o);for(const o of e.failed)console.log(o);console.log("");const o=[];e.passed.length&&o.push(`${C.green}${e.passed.length} passed${C.reset}`),e.warnings.length&&o.push(`${C.yellow}${e.warnings.length} warning${1!==e.warnings.length?"s":""}${C.reset}`),e.failed.length&&o.push(`${C.red}${e.failed.length} failed${C.reset}`),console.log(` ${o.join(", ")}`),console.log("")}query(e){return this._queryLocal(e)}async queryAsync(e){const o=this._queryLocal(e);if(o.confidence>=.7)return o;if(this.smart){const o=await this.semanticQuery(e);if(o&&o.length>0&&o[0].score>.35){const e=o[0];return{type:e.type||"faq",data:{question:e.question||e.title||e.name||"",answer:e.answer||e.fix||e.description||"",category:e.category||"general",source:"pinecone",score:e.score,alternatives:o.slice(1).map(e=>({type:e.type,title:e.question||e.title||e.name||"",score:e.score}))},confidence:Math.min(e.score+.3,1)}}}return o}_queryLocal(e){if(!e||"string"!=typeof e)return{type:"none",data:null,confidence:0};const o=e.trim().toLowerCase();if(!o)return{type:"none",data:null,confidence:0};if(KB.patterns&&Array.isArray(KB.patterns))for(const e of KB.patterns)if(e.regex&&new RegExp(e.regex,"i").test(o))return{type:e.type||"troubleshooting",data:e,confidence:1};let s=null,t=0;if(KB.faq&&Array.isArray(KB.faq))for(const e of KB.faq){const n=e.keywords||[];let r=0;for(const e of n)if(o===e.toLowerCase()){r=1;break}if(r<1)for(const e of n)(o.includes(e.toLowerCase())||e.toLowerCase().includes(o))&&(r=Math.max(r,.7));r<.7&&e.question&&wordOverlap(o,e.question)>.5&&(r=Math.max(r,.5)),r>t&&(t=r,s=e)}let n=null,r=0;if(KB.errors&&Array.isArray(KB.errors))for(const e of KB.errors){const s=(e.code||"").toLowerCase(),t=(e.message||"").toLowerCase();if(s&&o.includes(s)){n=e,r=1;break}if(t&&o.includes(t)&&(n=e,r=Math.max(r,.7)),e.keywords)for(const s of e.keywords)o.includes(s.toLowerCase())&&(r=Math.max(r,.5),n=e)}let a=null,l=0;if(KB.commands&&Array.isArray(KB.commands))for(const e of KB.commands){const s=(e.name||"").toLowerCase();if(s&&o.includes(s)){a=e,l=o===s?1:.7;break}if(e.aliases)for(const s of e.aliases)o.includes(s.toLowerCase())&&(a=e,l=.7)}const i=[{type:"faq",data:s,confidence:t},{type:"error",data:n,confidence:r},{type:"command",data:a,confidence:l}].filter(e=>null!==e.data);return i.sort((e,o)=>o.confidence-e.confidence),i.length>0&&i[0].confidence>0?i[0]:{type:"none",data:{suggestions:(KB.faq||[]).slice(0,3).map(e=>({question:e.question,keywords:e.keywords}))},confidence:0}}async startInteractive(){const e=this._readThubanVersion()||"0.0.0",o=readline.createInterface({input:process.stdin,output:process.stdout});console.log(""),console.log(` ${C.cyan}╔══════════════════════════════════════╗${C.reset}`),console.log(` ${C.cyan}║${C.reset} ${C.bold}THUBAN Support Assistant${C.reset} ${C.cyan}║${C.reset}`),console.log(` ${C.cyan}║${C.reset} v${e} · Type 'help' or 'exit' ${C.cyan}║${C.reset}`),console.log(` ${C.cyan}╚══════════════════════════════════════╝${C.reset}`),console.log(""),console.log(` ${C.bold}Quick actions:${C.reset}`),console.log(` ${C.cyan}[1]${C.reset} Run system diagnostic`),console.log(` ${C.cyan}[2]${C.reset} Installation help`),console.log(` ${C.cyan}[3]${C.reset} Scan not working`),console.log(` ${C.cyan}[4]${C.reset} Understanding results`),console.log(` ${C.cyan}[5]${C.reset} Monitor/watch mode guide`),console.log(` ${C.cyan}[6]${C.reset} Commands reference`),console.log(` ${C.cyan}[7]${C.reset} Report a bug (thuban feedback)`),console.log("");const s={1:"diagnose",2:"installation",3:"scan-not-working",4:"understanding-results",5:"monitor-mode",6:"commands",7:"report-bug"},t=()=>new Promise(e=>{o.question(` ${C.bold}You:${C.reset} `,o=>{e(o?o.trim():"")})}),n=()=>new Promise(e=>{o.question(`\n ${C.gray}Did this help? (y/n/more):${C.reset} `,o=>{e(o?o.trim().toLowerCase():"")})});let r=!0;for(;r;){const e=await t();if(!e)continue;const o=e.toLowerCase();if("exit"===o||"quit"===o){console.log(`\n ${C.cyan}Goodbye! Run 'thuban support' anytime.${C.reset}\n`),r=!1;break}if("help"===o){this._printHelp();continue}if("diagnose"===o||"1"===o){await this.diagnose();continue}if(s[o]&&"1"!==o){const e=s[o];if("report-bug"===e){console.log(`\n ${C.cyan}To report a bug, run:${C.reset}`),console.log(" thuban feedback 'describe your issue'\n");continue}this._handleSlugQuery(e);const t=await n();this._handleFollowUp(t,e);continue}const a=this.query(e);if(this._printQueryResult(a),a.confidence<.5)console.log(`\n ${C.yellow}I couldn't find an exact match. Try:${C.reset}`),console.log(` • ${C.cyan}thuban support --diagnose${C.reset} (run diagnostics)`),console.log(` • ${C.cyan}thuban feedback 'describe your issue'${C.reset} (report to the team)`);else{const e=await n();this._handleFollowUp(e,null)}}o.close()}runTroubleshootingFlow(e){if(!KB.troubleshooting||!Array.isArray(KB.troubleshooting))return void console.log(`\n ${C.red}No troubleshooting flows available.${C.reset}\n`);const o=KB.troubleshooting.find(o=>o.slug===e);if(!o){console.log(`\n ${C.red}Unknown troubleshooting flow: ${e}${C.reset}`),console.log(" Available flows:");for(const e of KB.troubleshooting)console.log(` • ${C.cyan}${e.slug}${C.reset} — ${e.title||e.slug}`);return void console.log("")}console.log(""),console.log(` ${C.bold}${o.title||o.slug}${C.reset}`),console.log(` ${C.gray}${"─".repeat(40)}${C.reset}`),console.log("");const s=o.steps||[],t=[];for(let e=0;e<s.length;e++){const o=s[e],n=e+1;if(console.log(` ${C.bold}Step ${n}:${C.reset} ${o.description||o.text||""}`),o.command&&this._isAllowedDiagnosticCommand(o.command)){console.log(` ${C.gray}Command: ${o.command}${C.reset}`);const e=safeExec(o.command);null!==e?(console.log(` ${C.green}Result:${C.reset} ${e}`),t.push({step:n,command:o.command,output:e,status:"ok"})):(console.log(` ${C.red}Command failed or timed out${C.reset}`),t.push({step:n,command:o.command,output:null,status:"failed"}))}o.note&&console.log(` ${C.gray}Note: ${o.note}${C.reset}`),o.fix&&console.log(` ${C.cyan}Fix: ${o.fix}${C.reset}`),console.log("")}console.log(` ${C.bold}Summary${C.reset}`),console.log(` ${C.gray}${"─".repeat(40)}${C.reset}`);const n=t.filter(e=>"ok"===e.status).length,r=t.filter(e=>"failed"===e.status).length;0===r&&t.length>0?console.log(` ${C.green}All ${n} diagnostic check(s) passed.${C.reset}`):r>0&&console.log(` ${C.red}${r} check(s) failed.${C.reset} Review the steps above for fixes.`),o.resolution&&console.log(`\n ${C.cyan}Suggested resolution:${C.reset} ${o.resolution}`),console.log("")}_readThubanVersion(){const e=[path.join(__dirname,"package.json"),path.join(__dirname,"..","package.json"),path.join(__dirname,"..","..","package.json")];for(const o of e)try{const e=fs.readFileSync(o,"utf8"),s=JSON.parse(e);if(s.version)return s.version}catch{}return null}_formatOS(e,o,s){return`${{win32:"Windows",darwin:"macOS",linux:"Linux",freebsd:"FreeBSD"}[e]||e} ${o} (${s})`}_hasCodeFiles(e){try{return fs.readdirSync(e).some(e=>/\.(js|ts|jsx|tsx|mjs|cjs)$/.test(e))}catch{return!1}}_checkDiskSpace(){const e="win32"===os.platform();try{if(e){const e=safeExec(`wmic logicaldisk where "DeviceID='${(path.parse(this.rootPath).root||"C:\\").charAt(0)}:'" get FreeSpace /format:value`);if(e){const o=e.match(/FreeSpace=(\d+)/);if(o){const e=parseInt(o[1],10);return{ok:e>536870912,free:`${(e/1073741824).toFixed(1)} GB`}}}}else{const e=safeExec(`df -h "${this.rootPath}" 2>/dev/null`);if(e){const o=e.split("\n");if(o.length>=2){return{ok:!0,free:o[1].split(/\s+/)[3]||"unknown"}}}}}catch{}return{ok:null,free:null}}_isAllowedDiagnosticCommand(e){if(!e||"string"!=typeof e)return!1;const o=e.trim().toLowerCase();return["node --version","node -v","npm --version","npm -v","npm ls","npm list","git --version","git status","git log","df ","df -h","wmic logicaldisk","cat package.json","type package.json","ls ","dir ","uname","whoami","echo "].some(e=>o.startsWith(e.toLowerCase()))}_printHelp(){console.log(""),console.log(` ${C.bold}Available commands:${C.reset}`),console.log(` ${C.cyan}help${C.reset} — Show this help message`),console.log(` ${C.cyan}diagnose${C.reset} — Run full system diagnostic`),console.log(` ${C.cyan}1-7${C.reset} — Quick action shortcuts`),console.log(` ${C.cyan}exit${C.reset} — Exit the support assistant`),console.log(""),console.log(` ${C.bold}Or type any question:${C.reset}`),console.log(' "How do I scan a project?"'),console.log(' "What does error E001 mean?"'),console.log(' "scan command options"'),console.log("")}_handleSlugQuery(e){if(KB.troubleshooting&&Array.isArray(KB.troubleshooting)){const o=KB.troubleshooting.find(o=>o.slug===e);if(o){if(console.log(""),console.log(` ${C.bold}${o.title||o.slug}${C.reset}`),console.log(` ${C.gray}${"─".repeat(40)}${C.reset}`),o.summary&&console.log(`\n ${o.summary}`),o.steps&&o.steps.length>0){console.log("");for(let e=0;e<o.steps.length;e++){const s=o.steps[e];console.log(` ${C.cyan}${e+1}.${C.reset} ${s.description||s.text||""}`),s.fix&&console.log(` ${C.gray}→ ${s.fix}${C.reset}`)}}return void console.log("")}}const o=this.query(e);this._printQueryResult(o)}_printQueryResult(e){if(console.log(""),"none"===e.type){if(e.data&&e.data.suggestions&&e.data.suggestions.length>0){console.log(` ${C.yellow}No exact match found. Did you mean:${C.reset}`);for(const o of e.data.suggestions)console.log(` • ${o.question||o.keywords?.join(", ")||"(untitled)"}`)}else console.log(` ${C.yellow}No results found.${C.reset}`);return void console.log("")}const o=e.data;switch(e.type){case"faq":console.log(` ${C.bold}${o.question||"FAQ"}${C.reset}`),o.answer&&console.log(` ${o.answer}`),o.example&&console.log(`\n ${C.gray}Example:${C.reset} ${o.example}`);break;case"error":console.log(` ${C.bold}${C.red}Error: ${o.code||"Unknown"}${C.reset}`),o.message&&console.log(` ${o.message}`),o.cause&&console.log(`\n ${C.yellow}Cause:${C.reset} ${o.cause}`),o.fix&&console.log(` ${C.green}Fix:${C.reset} ${o.fix}`);break;case"command":if(console.log(` ${C.bold}${C.cyan}${o.name||"Command"}${C.reset}`),o.description&&console.log(` ${o.description}`),o.usage&&console.log(`\n ${C.gray}Usage:${C.reset} ${o.usage}`),o.options&&Array.isArray(o.options)){console.log(`\n ${C.bold}Options:${C.reset}`);for(const e of o.options)console.log(` ${C.cyan}${e.flag||e.name}${C.reset} ${e.description||""}`)}break;case"troubleshooting":if(console.log(` ${C.bold}${o.title||o.slug||"Troubleshooting"}${C.reset}`),o.response&&console.log(` ${o.response}`),o.steps&&Array.isArray(o.steps))for(const e of o.steps)console.log(` • ${e}`);break;default:console.log(` ${C.gray}(${e.type})${C.reset} ${JSON.stringify(o)}`)}e.confidence<1&&e.confidence>=.5&&console.log(`\n ${C.gray}(confidence: ${Math.round(100*e.confidence)}% — results may be approximate)${C.reset}`),console.log("")}_handleFollowUp(e,o){if(e)switch(e){case"y":case"yes":console.log(`\n ${C.green}Great! Anything else?${C.reset}\n`);break;case"n":case"no":console.log(`\n ${C.yellow}Sorry about that. Try describing your issue differently,`),console.log(` or run '${C.cyan}thuban feedback${C.reset}' to report it.${C.reset}\n`);break;case"more":this._showRelatedEntries(o)}}_showRelatedEntries(e){if(!KB.faq||!Array.isArray(KB.faq))return void console.log(`\n ${C.gray}No additional entries available.${C.reset}\n`);const o=KB.faq.filter(o=>o.slug!==e).slice(0,5);if(0!==o.length){console.log(`\n ${C.bold}Related topics:${C.reset}`);for(const e of o)console.log(` • ${e.question||e.keywords?.join(", ")||e.slug||"(untitled)"}`);console.log("")}else console.log(`\n ${C.gray}No additional entries available.${C.reset}\n`)}}module.exports=SupportBot;
|