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.
- checksums.yaml +4 -4
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +106 -2
- data/lib/tina4/database.rb +356 -46
- data/lib/tina4/dev_admin.rb +54 -11
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/mcp.rb +10 -3
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +238 -47
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +6 -4
- data/lib/tina4/orm.rb +13 -10
- data/lib/tina4/public/js/tina4-dev-admin.js +212 -212
- data/lib/tina4/public/js/tina4-dev-admin.min.js +212 -212
- data/lib/tina4/rack_app.rb +17 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +354 -18
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +11 -9
- metadata +6 -47
data/lib/tina4/messenger.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
#
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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)
|