Module: Envdoctor::Scanner

Defined in:
lib/envdoctor/scanner.rb

Overview

Core scanner: reconcile ENV usage in Ruby source against .env definitions. Local-first — no network, values never printed.

Defined Under Namespace

Classes: Finding, Origin

Constant Summary collapse

USAGE_PATTERNS =
[
  /\bENV\[\s*["']([A-Za-z_]\w*)["']\s*\]/,
  /\bENV\.fetch\(\s*["']([A-Za-z_]\w*)["']/
].freeze
ENV_LINE =
/\A\s*(?:export\s+)?([A-Za-z_]\w*)\s*=/.freeze

Class Method Summary collapse

Class Method Details

.discover_env_files(root) ⇒ Object



54
55
56
57
58
# File 'lib/envdoctor/scanner.rb', line 54

def discover_env_files(root)
  files = Dir.glob(File.join(root, ".env"))
  files += Dir.glob(File.join(root, ".env.*")).reject { |f| f.end_with?(".example") }
  files.sort
end

.discover_source_files(root) ⇒ Object



60
61
62
63
64
# File 'lib/envdoctor/scanner.rb', line 60

def discover_source_files(root)
  Dir.glob(File.join(root, "**", "*.rb")).reject do |p|
    p.split(File::SEPARATOR).any? { |part| %w[.git vendor node_modules].include?(part) }
  end.sort
end

.parse_env(path, content) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/envdoctor/scanner.rb', line 41

def parse_env(path, content)
  defined = {}
  content.split("\n").each_with_index do |raw, i|
    stripped = raw.strip
    next if stripped.empty? || stripped.start_with?("#")

    if (m = raw.match(ENV_LINE))
      defined[m[1]] ||= Origin.new(path, i + 1)
    end
  end
  defined
end

.relative(root, path) ⇒ Object



94
95
96
# File 'lib/envdoctor/scanner.rb', line 94

def relative(root, path)
  path.sub(/\A#{Regexp.escape(root)}#{Regexp.escape(File::SEPARATOR)}?/, "")
end

.scan(root) ⇒ Object



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/envdoctor/scanner.rb', line 66

def scan(root)
  defined = {}
  discover_env_files(root).each do |f|
    parse_env(relative(root, f), File.read(f)).each { |k, v| defined[k] ||= v }
  end

  used = {}
  discover_source_files(root).each do |f|
    scan_source(relative(root, f), File.read(f)).each { |k, v| used[k] ||= v }
  end

  findings = []
  used.keys.sort.each do |name|
    next if defined.key?(name)

    findings << Finding.new("undefined-in-source", "error", name,
                            "used in source code but not defined in any environment file",
                            used[name])
  end
  defined.keys.sort.each do |name|
    next if used.key?(name)

    findings << Finding.new("unused", "warning", name,
                            "defined but never referenced in source", defined[name])
  end
  findings
end

.scan_source(path, content) ⇒ Object



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/envdoctor/scanner.rb', line 25

def scan_source(path, content)
  text = strip_noise(content)
  used = {}
  USAGE_PATTERNS.each do |re|
    text.to_enum(:scan, re).each do
      match = Regexp.last_match
      name = match[1]
      next if used.key?(name)

      line = text[0...match.begin(0)].count("\n") + 1
      used[name] = Origin.new(path, line)
    end
  end
  used
end

.strip_noise(code) ⇒ Object

Blank comments and =begin/=end blocks, preserving line structure.



20
21
22
23
# File 'lib/envdoctor/scanner.rb', line 20

def strip_noise(code)
  code = code.gsub(/^=begin\b.*?^=end\b[^\n]*/m) { |m| m.gsub(/[^\n]/, " ") }
  code.gsub(/#[^\n]*/) { |m| " " * m.length }
end