tina4ruby 3.13.36 → 3.13.38

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.
@@ -13,8 +13,44 @@ end
13
13
  require "base64"
14
14
  require "securerandom"
15
15
  require "time"
16
+ require "socket"
17
+ require "timeout"
18
+ begin
19
+ require "openssl"
20
+ rescue LoadError
21
+ # openssl is part of stdlib; absence only affects TLS error classification
22
+ end
16
23
 
17
24
  module Tina4
25
+ # Raised on a messenger failure (base class).
26
+ class MessengerError < StandardError; end
27
+
28
+ # Raised when an IMAP read fails to connect, authenticate, or speak the
29
+ # protocol. Distinct from a successful fetch that simply has no messages —
30
+ # that still returns an empty result ([]/nil/0/{}), NOT an error.
31
+ #
32
+ # Subclasses MessengerError so existing `rescue Tina4::MessengerError`
33
+ # handlers still catch it.
34
+ class MessengerConnectionError < MessengerError; end
35
+
36
+ # Errors that mean "we could not talk to the mail server", as opposed to
37
+ # "we talked fine and the mailbox is empty". These must fail loud — LOG and
38
+ # RAISE — never be silently swallowed into an empty result. Mirrors the
39
+ # Python master's _IMAP_CONNECTION_ERRORS tuple.
40
+ #
41
+ # Built lazily so a missing net/imap gem (LoadError above) doesn't break
42
+ # loading this file.
43
+ IMAP_CONNECTION_ERRORS = [
44
+ SocketError, # DNS / host resolution failures
45
+ IOError, # closed/broken stream, EOF mid-conversation
46
+ SystemCallError, # Errno::ECONNREFUSED / ECONNRESET / ETIMEDOUT etc.
47
+ Timeout::Error, # connect/read timeout (Net::OpenTimeout descends from this)
48
+ MessengerError # our own protocol-failure signal (re-raised as-is)
49
+ ].tap do |errors|
50
+ errors << Net::IMAP::Error if defined?(Net::IMAP::Error)
51
+ errors << OpenSSL::SSL::SSLError if defined?(OpenSSL::SSL::SSLError)
52
+ end.freeze
53
+
18
54
  # Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
19
55
  #
20
56
  # Unified .env-driven configuration with constructor override.
@@ -120,9 +156,14 @@ module Tina4
120
156
 
121
157
  # ── IMAP operations ──────────────────────────────────────────────────
122
158
 
123
- # List messages in a folder
159
+ # List messages in a folder.
160
+ #
161
+ # Raises Tina4::MessengerConnectionError on a connection/auth/protocol
162
+ # failure (FAILS LOUD — never returns [] to hide it). A successful fetch
163
+ # from an empty folder returns [] (that is NOT an error).
124
164
  def inbox(folder: "INBOX", limit: 20, offset: 0)
125
- imap_connect do |imap|
165
+ imap = imap_open("inbox")
166
+ begin
126
167
  imap.select(folder)
127
168
  uids = imap.uid_search(["ALL"])
128
169
  uids = uids.reverse # newest first
@@ -131,15 +172,20 @@ module Tina4
131
172
 
132
173
  envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
133
174
  (envelopes || []).map { |msg| parse_envelope(msg) }
175
+ rescue *IMAP_CONNECTION_ERRORS => e
176
+ raise imap_fail("inbox", e)
177
+ ensure
178
+ imap_cleanup(imap)
134
179
  end
135
- rescue => e
136
- Tina4::Log.error("IMAP inbox failed: #{e.message}")
137
- []
138
180
  end
139
181
 
140
- # Read a single message by UID
182
+ # Read a single message by UID.
183
+ #
184
+ # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
185
+ # A successful fetch for a non-existent UID returns nil (that is NOT an error).
141
186
  def read(uid, folder: "INBOX", mark_read: true)
142
- imap_connect do |imap|
187
+ imap = imap_open("read")
188
+ begin
143
189
  imap.select(folder)
144
190
  data = imap.uid_fetch(uid, ["ENVELOPE", "FLAGS", "BODY[]", "RFC822.SIZE"])
145
191
  return nil if data.nil? || data.empty?
@@ -150,28 +196,38 @@ module Tina4
150
196
 
151
197
  msg = data.first
152
198
  parse_full_message(msg)
199
+ rescue *IMAP_CONNECTION_ERRORS => e
200
+ raise imap_fail("read", e)
201
+ ensure
202
+ imap_cleanup(imap)
153
203
  end
154
- rescue => e
155
- Tina4::Log.error("IMAP read failed: #{e.message}")
156
- nil
157
204
  end
158
205
 
159
- # Count unread messages
206
+ # Count unread messages.
207
+ #
208
+ # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
209
+ # A successful query with no unseen messages returns 0 (NOT an error).
160
210
  def unread(folder: "INBOX")
161
- imap_connect do |imap|
211
+ imap = imap_open("unread")
212
+ begin
162
213
  imap.select(folder)
163
214
  uids = imap.uid_search(["UNSEEN"])
164
215
  uids.length
216
+ rescue *IMAP_CONNECTION_ERRORS => e
217
+ raise imap_fail("unread", e)
218
+ ensure
219
+ imap_cleanup(imap)
165
220
  end
166
- rescue => e
167
- Tina4::Log.error("IMAP unread count failed: #{e.message}")
168
- 0
169
221
  end
170
222
 
171
- # Search messages with filters
223
+ # Search messages with filters.
224
+ #
225
+ # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
226
+ # A successful search with no matches returns [] (NOT an error).
172
227
  def search(folder: "INBOX", subject: nil, sender: nil, since: nil,
173
228
  before: nil, unseen_only: false, limit: 20)
174
- imap_connect do |imap|
229
+ imap = imap_open("search")
230
+ begin
175
231
  imap.select(folder)
176
232
  criteria = build_search_criteria(
177
233
  subject: subject, sender: sender, since: since,
@@ -184,21 +240,26 @@ module Tina4
184
240
 
185
241
  envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
186
242
  (envelopes || []).map { |msg| parse_envelope(msg) }
243
+ rescue *IMAP_CONNECTION_ERRORS => e
244
+ raise imap_fail("search", e)
245
+ ensure
246
+ imap_cleanup(imap)
187
247
  end
188
- rescue => e
189
- Tina4::Log.error("IMAP search failed: #{e.message}")
190
- []
191
248
  end
192
249
 
193
- # List all IMAP folders
250
+ # List all IMAP folders.
251
+ #
252
+ # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
194
253
  def folders
195
- imap_connect do |imap|
254
+ imap = imap_open("folders")
255
+ begin
196
256
  boxes = imap.list("", "*")
197
257
  (boxes || []).map(&:name)
258
+ rescue *IMAP_CONNECTION_ERRORS => e
259
+ raise imap_fail("folders", e)
260
+ ensure
261
+ imap_cleanup(imap)
198
262
  end
199
- rescue => e
200
- Tina4::Log.error("IMAP folders failed: #{e.message}")
201
- []
202
263
  end
203
264
 
204
265
  # Mark a message as read (set \Seen flag).
@@ -393,6 +454,50 @@ module Tina4
393
454
 
394
455
  # ── IMAP helpers ─────────────────────────────────────────────────────
395
456
 
457
+ # Open and authenticate an IMAP connection. On a connection/auth/protocol
458
+ # failure this FAILS LOUD — logs and raises MessengerConnectionError —
459
+ # rather than swallowing the error into an empty result.
460
+ def imap_open(method)
461
+ imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
462
+ imap.login(@username, @password)
463
+ imap
464
+ rescue *IMAP_CONNECTION_ERRORS => e
465
+ raise imap_fail(method, e)
466
+ end
467
+
468
+ # Best-effort teardown of an IMAP connection. Never raises.
469
+ def imap_cleanup(imap)
470
+ return if imap.nil?
471
+
472
+ begin
473
+ imap.logout
474
+ rescue StandardError
475
+ # ignore — already closing
476
+ end
477
+ begin
478
+ imap.disconnect
479
+ rescue StandardError
480
+ # ignore — already closed
481
+ end
482
+ end
483
+
484
+ # Log an IMAP connection/protocol failure and return the error to raise.
485
+ # A genuinely empty mailbox is NOT an error and never reaches here.
486
+ # Re-raises a MessengerError as-is; otherwise wraps in
487
+ # MessengerConnectionError. Callers do `raise imap_fail(name, exc)`.
488
+ def imap_fail(method, exc)
489
+ Tina4::Log.error(
490
+ "Messenger IMAP #{method}() failed: #{exc.class}: #{exc.message}"
491
+ )
492
+ return exc if exc.is_a?(MessengerError)
493
+
494
+ MessengerConnectionError.new("IMAP #{method} failed: #{exc.message}")
495
+ end
496
+
497
+ # Connect, yield, then tear down — used by the mutators / connectivity test
498
+ # that keep their own result-style error handling (mark_read,
499
+ # test_imap_connection). Read methods use imap_open + ensure directly so
500
+ # they can fail loud.
396
501
  def imap_connect(&block)
397
502
  imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
398
503
  imap.login(@username, @password)
data/lib/tina4/metrics.rb CHANGED
@@ -353,6 +353,124 @@ module Tina4
353
353
  result
354
354
  end
355
355
 
356
+ # ── Top Offenders (CLI + dashboard) ──────────────────────────
357
+
358
+ # Severity ranking for sorting (higher = more severe).
359
+ SEVERITY_RANK = { "error" => 2, "warn" => 1, "info" => 0 }.freeze
360
+
361
+ # Rank the worst code-quality issues into a single "top offenders" list.
362
+ #
363
+ # Reuses full_analysis (does NOT re-analyze). Each offender is a hash:
364
+ # {"file", "line", "kind", "severity", "score", "detail"}
365
+ #
366
+ # Rules (one offender per matching condition):
367
+ # - function complexity > 10 → kind "complexity"
368
+ # severity "error" if >20 else "warn"; score = complexity
369
+ # - file loc > 500 → kind "large_file" (warn); score = loc/100
370
+ # - file functions > 20 → kind "too_many_functions" (warn); score = functions/4
371
+ # - file maintainability < 40 → kind "low_maintainability"
372
+ # severity "error" if <20 else "warn"; score = (50 - mi)
373
+ # - file has_tests false → kind "untested" (info); score = loc/100
374
+ #
375
+ # Sorted by (severity rank, score) DESCENDING and truncated to `top`.
376
+ #
377
+ # Returns {"offenders" => [...], "summary" => {...}} where summary carries
378
+ # the headline numbers the CLI prints (files_analyzed, total_functions,
379
+ # avg_complexity, avg_maintainability, scan_mode, scan_root, and the total
380
+ # offender count before truncation).
381
+ def self.offenders(root = 'src', top = 20)
382
+ analysis = full_analysis(root)
383
+ if analysis.key?("error")
384
+ return { "offenders" => [], "summary" => { "error" => analysis["error"] } }
385
+ end
386
+
387
+ items = []
388
+
389
+ # Function-level: cyclomatic complexity.
390
+ (analysis["most_complex_functions"] || []).each do |fn|
391
+ cc = fn["complexity"]
392
+ next unless cc > 10
393
+ items << {
394
+ "file" => fn["file"],
395
+ "line" => fn["line"],
396
+ "kind" => "complexity",
397
+ "severity" => cc > 20 ? "error" : "warn",
398
+ "score" => cc.to_f,
399
+ "detail" => "#{fn['name']} — cyclomatic complexity #{cc}"
400
+ }
401
+ end
402
+
403
+ # File-level rules.
404
+ (analysis["file_metrics"] || []).each do |fm|
405
+ path = fm["path"]
406
+ loc = fm["loc"]
407
+ funcs = fm["functions"]
408
+ mi = fm["maintainability"]
409
+
410
+ if loc > 500
411
+ items << {
412
+ "file" => path,
413
+ "line" => 1,
414
+ "kind" => "large_file",
415
+ "severity" => "warn",
416
+ "score" => loc / 100.0,
417
+ "detail" => "#{loc} LOC (max 500)"
418
+ }
419
+ end
420
+
421
+ if funcs > 20
422
+ items << {
423
+ "file" => path,
424
+ "line" => 1,
425
+ "kind" => "too_many_functions",
426
+ "severity" => "warn",
427
+ "score" => funcs / 4.0,
428
+ "detail" => "#{funcs} functions (max 20)"
429
+ }
430
+ end
431
+
432
+ if mi < 40
433
+ items << {
434
+ "file" => path,
435
+ "line" => 1,
436
+ "kind" => "low_maintainability",
437
+ "severity" => mi < 20 ? "error" : "warn",
438
+ "score" => 50 - mi,
439
+ "detail" => "maintainability index #{mi} (min 40)"
440
+ }
441
+ end
442
+
443
+ if fm["has_tests"] == false
444
+ items << {
445
+ "file" => path,
446
+ "line" => 1,
447
+ "kind" => "untested",
448
+ "severity" => "info",
449
+ "score" => loc / 100.0,
450
+ "detail" => "no referencing test"
451
+ }
452
+ end
453
+ end
454
+
455
+ # Sort by (severity rank, score) DESCENDING — stable so insertion order
456
+ # breaks ties deterministically.
457
+ items = items.each_with_index.sort_by do |o, idx|
458
+ [-SEVERITY_RANK[o["severity"]], -o["score"], idx]
459
+ end.map(&:first)
460
+
461
+ summary = {
462
+ "files_analyzed" => analysis["files_analyzed"],
463
+ "total_functions" => analysis["total_functions"],
464
+ "avg_complexity" => analysis["avg_complexity"],
465
+ "avg_maintainability" => analysis["avg_maintainability"],
466
+ "scan_mode" => analysis["scan_mode"],
467
+ "scan_root" => analysis["scan_root"],
468
+ "total_offenders" => items.length
469
+ }
470
+
471
+ { "offenders" => items.first(top), "summary" => summary }
472
+ end
473
+
356
474
  # ── File Detail ─────────────────────────────────────────────
357
475
 
358
476
  def self.file_detail(file_path)
@@ -423,64 +541,137 @@ module Tina4
423
541
 
424
542
  private_class_method
425
543
 
544
+ # Check whether a source file has a test that actually exercises it.
545
+ #
546
+ # PRECISE detection (a bare word-mention is NOT enough — that over-reported
547
+ # badly: `sqlite3_adapter.rb` looked "tested" because some spec merely said
548
+ # "sqlite3_adapter"):
549
+ #
550
+ # 1. Filename — a dedicated `<module>_spec.rb` / `<module>_test.rb` /
551
+ # `test_<module>.rb` for THIS exact module (NOT the parent directory —
552
+ # one `database_spec.rb` must not mark every file under `database/`
553
+ # tested).
554
+ # 2. Require — a spec that actually requires this file: its require path
555
+ # (`require "tina4/database/sqlite"` / `require_relative ".../sqlite"`)
556
+ # matched by the basename of a require target. A constant/class that is
557
+ # genuinely DEFINED in this file (top-level class/module) referenced by
558
+ # a spec also counts.
559
+ #
560
+ # Returns true only on a real, file-specific signal — so the "untested"
561
+ # offenders surfaced by `tina4 metrics` and the dashboard "T" badge are
562
+ # trustworthy. (If you wire real coverage data later, prefer it over this.)
426
563
  def self._has_matching_test(rel_path)
427
564
  require 'set'
428
565
 
429
566
  name = File.basename(rel_path, '.rb')
430
- # Parent directory name (e.g. "database" from "database/sqlite3_adapter.rb")
431
- parent_dir = File.dirname(rel_path)
432
- parent_module = (parent_dir != '.' && !parent_dir.empty?) ? File.basename(parent_dir) : ''
433
-
434
- # Stage 1: Filename matching — name_spec, name_test, test_name patterns
435
- test_dirs = ['spec', 'spec/tina4', 'test', 'tests']
436
- test_dirs.each do |td|
437
- patterns = [
438
- "#{td}/#{name}_spec.rb",
439
- "#{td}/#{name}s_spec.rb",
440
- "#{td}/#{name}_test.rb",
441
- "#{td}/test_#{name}.rb",
442
- ]
443
- # Also check parent-named tests (spec/database_spec.rb covers database/sqlite3_adapter.rb)
444
- if parent_module && !parent_module.empty? && parent_module != name
445
- patterns << "#{td}/#{parent_module}_spec.rb"
446
- patterns << "#{td}/#{parent_module}s_spec.rb"
447
- patterns << "#{td}/#{parent_module}_test.rb"
448
- patterns << "#{td}/test_#{parent_module}.rb"
449
- end
450
- return true if patterns.any? { |p| File.exist?(p) }
451
- end
452
-
453
- # Build a dotted/slashed require path for import matching
454
- # e.g. "lib/tina4/database/sqlite3_adapter.rb" → "tina4/database/sqlite3_adapter"
455
- path_without_ext = rel_path.sub(/\.rb$/, '')
456
- # Strip leading lib/ prefix if present
457
- require_path = path_without_ext.sub(%r{^lib/}, '')
458
-
459
- # Build CamelCase class name from snake_case module name
460
- # e.g. "sqlite3_adapter" "Sqlite3Adapter"
461
- class_name = name.split('_').map(&:capitalize).join
462
-
463
- # Stage 2+3: Content scan — check if any spec/test file references this module
464
- scan_dirs = ['spec', 'test', 'tests']
465
- scan_dirs.each do |td|
466
- next unless Dir.exist?(td)
467
- Dir.glob(File.join(td, '**', '*.rb')).each do |test_file|
468
- content = begin
469
- File.read(test_file, encoding: 'utf-8')
470
- rescue StandardError
471
- next
567
+
568
+ # Require path WITHOUT extension, leading lib/ stripped:
569
+ # "lib/tina4/database/sqlite.rb" -> "tina4/database/sqlite"
570
+ require_path = rel_path.sub(/\.rb$/, '').sub(%r{^lib/}, '')
571
+
572
+ # Constants (classes/modules) DEFINED at the top level of this file — a
573
+ # spec referencing one of them genuinely exercises this file. Names only,
574
+ # distinctive (>3 chars, leading uppercase); bare module-name words and
575
+ # guessed CamelCase are too loose to trust.
576
+ defined_symbols = _defined_constants(rel_path)
577
+
578
+ # Search roots: CWD plus (in framework-fallback mode) the repo root that
579
+ # owns spec/ — walk up from the scan root to find it.
580
+ search_roots = ['.']
581
+ if @last_scan_root && !@last_scan_root.empty?
582
+ scan_root = @last_scan_root
583
+ 5.times do
584
+ if %w[spec test tests].any? { |d| Dir.exist?(File.join(scan_root, d)) }
585
+ search_roots << scan_root
586
+ break
587
+ end
588
+ parent = File.dirname(scan_root)
589
+ break if parent == scan_root
590
+ scan_root = parent
591
+ end
592
+ end
593
+ search_roots.uniq!
594
+
595
+ test_dirs = %w[spec test tests]
596
+
597
+ # Stage 1: a dedicated spec/test FILE named for THIS module (no parent-dir
598
+ # blanket match).
599
+ filename_patterns = [
600
+ "#{name}_spec.rb",
601
+ "#{name}s_spec.rb",
602
+ "#{name}_test.rb",
603
+ "test_#{name}.rb",
604
+ ]
605
+ search_roots.each do |root|
606
+ test_dirs.each do |td|
607
+ filename_patterns.each do |fn|
608
+ return true if File.exist?(File.join(root, td, fn))
609
+ end
610
+ end
611
+ end
612
+
613
+ # Stage 2: a spec that actually REQUIRES this module (precise — matched by
614
+ # the require target's basename / tail of the require path), or references
615
+ # a constant defined in it. NO bare word-of-the-module-name match.
616
+ require_regexps = []
617
+ unless require_path.empty?
618
+ # require "…/<module>" or require_relative "…/<module>" — match the
619
+ # require string ending in this file's require path or basename.
620
+ rp = Regexp.escape(require_path)
621
+ nm = Regexp.escape(name)
622
+ require_regexps << /(?:require|require_relative)\s+['"][^'"]*#{rp}['"]/
623
+ require_regexps << %r{(?:require|require_relative)\s+['"][^'"]*/#{nm}['"]}
624
+ end
625
+ unless defined_symbols.empty?
626
+ sym_alt = defined_symbols.map { |s| Regexp.escape(s) }.join('|')
627
+ require_regexps << /\b(?:#{sym_alt})\b/
628
+ end
629
+
630
+ return false if require_regexps.empty?
631
+
632
+ search_roots.each do |root|
633
+ test_dirs.each do |td|
634
+ dir = File.join(root, td)
635
+ next unless Dir.exist?(dir)
636
+ Dir.glob(File.join(dir, '**', '*.rb')).each do |test_file|
637
+ content = begin
638
+ File.read(test_file, encoding: 'utf-8')
639
+ rescue StandardError
640
+ next
641
+ end
642
+ return true if require_regexps.any? { |re| content.match?(re) }
472
643
  end
473
- # Stage 2: require/require_relative path matching
474
- return true if !require_path.empty? && content.include?(require_path)
475
- # Stage 3: class name or module name mention
476
- return true if content.match?(/\b#{Regexp.escape(class_name)}\b/)
477
- return true if content.match?(/\b#{Regexp.escape(name)}\b/i)
478
644
  end
479
645
  end
480
646
 
481
647
  false
482
648
  end
483
649
 
650
+ # Top-level class/module names defined in the file at rel_path (resolved
651
+ # against the last scan root when present). Distinctive names only:
652
+ # leading-uppercase, longer than 3 chars.
653
+ def self._defined_constants(rel_path)
654
+ src_file = if @last_scan_root && !@last_scan_root.empty? && !File.exist?(rel_path)
655
+ File.join(@last_scan_root, rel_path)
656
+ else
657
+ rel_path
658
+ end
659
+ symbols = Set.new
660
+ content = begin
661
+ File.read(src_file, encoding: 'utf-8')
662
+ rescue StandardError
663
+ return symbols
664
+ end
665
+ content.each_line do |line|
666
+ stripped = line.strip
667
+ m = stripped.match(/\A(?:class|module)\s+([A-Z][A-Za-z0-9_]*)/)
668
+ next unless m
669
+ const = m[1]
670
+ symbols.add(const) if const.length > 3
671
+ end
672
+ symbols
673
+ end
674
+
484
675
  def self._files_hash(root)
485
676
  md5 = Digest::MD5.new
486
677
  root_path = Pathname.new(root)