stackharbinger 0.3.0 → 1.0.0

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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Harbinger
4
+ module Analyzers
5
+ class GoDetector
6
+ def initialize(project_path)
7
+ @project_path = project_path
8
+ end
9
+
10
+ def detect
11
+ version = detect_from_go_mod ||
12
+ detect_from_go_work ||
13
+ detect_from_go_version_file ||
14
+ detect_from_docker_compose ||
15
+ detect_from_shell
16
+
17
+ normalize_version(version) if version
18
+ end
19
+
20
+ def go_detected?
21
+ File.exist?(File.join(@project_path, "go.mod")) ||
22
+ File.exist?(File.join(@project_path, "go.work")) ||
23
+ File.exist?(File.join(@project_path, ".go-version"))
24
+ end
25
+
26
+ private
27
+
28
+ def detect_from_go_mod
29
+ go_mod_path = File.join(@project_path, "go.mod")
30
+ return nil unless File.exist?(go_mod_path)
31
+
32
+ content = File.read(go_mod_path)
33
+ # Match "go 1.21" or "go 1.21.0" format
34
+ match = content.match(/^go\s+([\d.]+)/m)
35
+ match[1] if match
36
+ end
37
+
38
+ def detect_from_go_work
39
+ go_work_path = File.join(@project_path, "go.work")
40
+ return nil unless File.exist?(go_work_path)
41
+
42
+ content = File.read(go_work_path)
43
+ # Match "go 1.21" or "go 1.21.0" format
44
+ match = content.match(/^go\s+([\d.]+)/m)
45
+ match[1] if match
46
+ end
47
+
48
+ def detect_from_go_version_file
49
+ go_version_path = File.join(@project_path, ".go-version")
50
+ return nil unless File.exist?(go_version_path)
51
+
52
+ version = File.read(go_version_path).strip
53
+ version unless version.empty?
54
+ end
55
+
56
+ def detect_from_docker_compose
57
+ docker_compose_path = File.join(@project_path, "docker-compose.yml")
58
+ return nil unless File.exist?(docker_compose_path)
59
+
60
+ content = File.read(docker_compose_path)
61
+ # Match golang:1.21, golang:1.21.0, golang:1.21-alpine, etc.
62
+ match = content.match(/golang:([\d.]+)/)
63
+ match[1] if match
64
+ end
65
+
66
+ def detect_from_shell
67
+ # Only try shell detection if Go project files exist
68
+ return nil unless go_detected?
69
+
70
+ version_output = `go version 2>/dev/null`.strip
71
+ return nil if version_output.empty?
72
+
73
+ # Parse "go version go1.21.0 darwin/amd64" format
74
+ match = version_output.match(/go version go([\d.]+)/)
75
+ match[1] if match
76
+ rescue StandardError
77
+ nil
78
+ end
79
+
80
+ def normalize_version(version)
81
+ # Remove any trailing qualifiers like -alpine, -rc1, etc.
82
+ version.gsub(/-(alpine|rc\d+|beta\d*).*/, "")
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "docker_compose_detector"
4
+
5
+ module Harbinger
6
+ module Analyzers
7
+ # Detects MongoDB version from projects
8
+ class MongoDetector
9
+ attr_reader :project_path
10
+
11
+ def initialize(project_path)
12
+ @project_path = project_path
13
+ end
14
+
15
+ # Main detection method - returns version string or nil
16
+ def detect
17
+ return nil unless mongo_detected?
18
+
19
+ # Try docker-compose.yml first
20
+ version = detect_from_docker_compose
21
+ return version if version
22
+
23
+ # Try shell command
24
+ version = detect_from_shell
25
+ return version if version
26
+
27
+ # Fallback to gem version
28
+ detect_from_gemfile_lock
29
+ end
30
+
31
+ # Check if MongoDB is used in this project
32
+ def mongo_detected?
33
+ gemfile_has_mongo? || docker_compose_has_mongo?
34
+ end
35
+
36
+ private
37
+
38
+ def detect_from_docker_compose
39
+ docker = DockerComposeDetector.new(project_path)
40
+ docker.image_version("mongo")
41
+ end
42
+
43
+ def detect_from_shell
44
+ # Try mongosh first (modern shell, MongoDB 5+)
45
+ output = `mongosh --version 2>&1`.strip
46
+ return output if $CHILD_STATUS.success? && output.match?(/^\d+\.\d+/)
47
+
48
+ # Try legacy mongo shell
49
+ output = `mongo --version 2>&1`.strip
50
+ if $CHILD_STATUS.success?
51
+ match = output.match(/MongoDB shell version v?(\d+\.\d+(?:\.\d+)?)/)
52
+ return match[1] if match
53
+ end
54
+
55
+ # Fall back to mongod (server)
56
+ output = `mongod --version 2>&1`.strip
57
+ return nil unless $CHILD_STATUS.success?
58
+
59
+ match = output.match(/db version v(\d+\.\d+(?:\.\d+)?)/)
60
+ match[1] if match
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ def detect_from_gemfile_lock
66
+ content = read_gemfile_lock
67
+ return nil unless content
68
+
69
+ # Look for mongoid gem version first (most common for Rails)
70
+ match = content.match(/^\s{4}mongoid\s+\(([^)]+)\)/)
71
+ return "#{match[1]} (mongoid gem)" if match
72
+
73
+ # Fall back to mongo gem
74
+ match = content.match(/^\s{4}mongo\s+\(([^)]+)\)/)
75
+ return "#{match[1]} (mongo gem)" if match
76
+
77
+ nil
78
+ end
79
+
80
+ def gemfile_has_mongo?
81
+ content = read_gemfile_lock
82
+ return false unless content
83
+
84
+ content.include?("mongoid (") || content.include?("mongo (")
85
+ end
86
+
87
+ def docker_compose_has_mongo?
88
+ docker = DockerComposeDetector.new(project_path)
89
+ return false unless docker.docker_compose_exists?
90
+
91
+ docker.image_version("mongo") != nil
92
+ end
93
+
94
+ def read_gemfile_lock
95
+ path = File.join(project_path, "Gemfile.lock")
96
+ return nil unless File.exist?(path)
97
+
98
+ File.read(path)
99
+ rescue StandardError
100
+ nil
101
+ end
102
+ end
103
+ end
104
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "database_detector"
4
+ require_relative "docker_compose_detector"
4
5
 
5
6
  module Harbinger
6
7
  module Analyzers
@@ -10,7 +11,13 @@ module Harbinger
10
11
  protected
11
12
 
12
13
  def adapter_name
13
- ["mysql2", "trilogy"]
14
+ %w[mysql2 trilogy]
15
+ end
16
+
17
+ def detect_from_docker_compose
18
+ docker = DockerComposeDetector.new(project_path)
19
+ # Try mysql first, then mariadb
20
+ docker.image_version("mysql") || docker.image_version("mariadb")
14
21
  end
15
22
 
16
23
  def detect_from_shell
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "docker_compose_detector"
4
+ require "json"
5
+
6
+ module Harbinger
7
+ module Analyzers
8
+ class NodeDetector
9
+ def initialize(project_path)
10
+ @project_path = project_path
11
+ end
12
+
13
+ def detect
14
+ detect_from_version_files ||
15
+ detect_from_package_json ||
16
+ detect_from_docker_compose ||
17
+ (nodejs_detected? ? detect_from_shell : nil)
18
+ end
19
+
20
+ def nodejs_detected?
21
+ File.exist?(File.join(project_path, "package.json")) ||
22
+ File.exist?(File.join(project_path, "package-lock.json")) ||
23
+ File.exist?(File.join(project_path, ".nvmrc")) ||
24
+ File.exist?(File.join(project_path, ".node-version")) ||
25
+ File.exist?(File.join(project_path, "node_modules"))
26
+ rescue StandardError
27
+ false
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :project_path
33
+
34
+ def detect_from_version_files
35
+ [".nvmrc", ".node-version"].each do |filename|
36
+ file_path = File.join(project_path, filename)
37
+ next unless File.exist?(file_path)
38
+
39
+ content = File.read(file_path).strip
40
+ next if content.empty?
41
+
42
+ return extract_version(content)
43
+ end
44
+
45
+ nil
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ def detect_from_package_json
51
+ file_path = File.join(project_path, "package.json")
52
+ return nil unless File.exist?(file_path)
53
+
54
+ content = File.read(file_path)
55
+ package = JSON.parse(content)
56
+
57
+ engines = package.dig("engines", "node")
58
+ return nil unless engines
59
+
60
+ extract_version(engines)
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ def detect_from_docker_compose
66
+ docker = DockerComposeDetector.new(project_path)
67
+ docker.image_version("node")
68
+ end
69
+
70
+ def detect_from_shell
71
+ output = `node --version 2>&1`.strip
72
+ return nil unless $CHILD_STATUS.success?
73
+
74
+ # Parse: "v18.16.0" (note the 'v' prefix)
75
+ match = output.match(/^v?(\d+\.\d+(?:\.\d+)?)/)
76
+ match[1] if match
77
+ rescue StandardError
78
+ nil
79
+ end
80
+
81
+ def extract_version(version_string)
82
+ # Remove 'v' prefix (Node.js convention)
83
+ version = version_string.sub(/^v/, "")
84
+
85
+ # Strip constraint operators: >=18.0.0, ^18.0.0, ~18.0.0
86
+ version = version.gsub(/^[><=~^!\s]+/, "")
87
+
88
+ # Handle ranges like ">=14.0.0 <20.0.0" - extract first version
89
+ version = version.split(/\s+/).first if version.include?(" ")
90
+
91
+ # Handle .x suffix (e.g., "18.x" => "18")
92
+ version = version.sub(/\.x$/, "")
93
+
94
+ # Handle LTS names like "lts/hydrogen" (hydrogen=18, gallium=16, fermium=14)
95
+ if version =~ /lts\/(hydrogen|gallium|fermium)/i
96
+ lts_name = $1.downcase
97
+ return case lts_name
98
+ when "hydrogen" then "18"
99
+ when "gallium" then "16"
100
+ when "fermium" then "14"
101
+ else nil
102
+ end
103
+ end
104
+
105
+ version
106
+ end
107
+ end
108
+ end
109
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "database_detector"
4
+ require_relative "docker_compose_detector"
4
5
 
5
6
  module Harbinger
6
7
  module Analyzers
@@ -12,6 +13,11 @@ module Harbinger
12
13
  "postgresql"
13
14
  end
14
15
 
16
+ def detect_from_docker_compose
17
+ docker = DockerComposeDetector.new(project_path)
18
+ docker.image_version("postgres")
19
+ end
20
+
15
21
  def detect_from_shell
16
22
  # Skip shell command if database is remote
17
23
  # (shell gives client version, not server version)
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Harbinger
4
+ module Analyzers
5
+ class PythonDetector
6
+ def initialize(project_path)
7
+ @project_path = project_path
8
+ end
9
+
10
+ def detect
11
+ detect_from_pyproject_toml ||
12
+ detect_from_python_version ||
13
+ detect_from_pyvenv_cfg ||
14
+ (python_detected? ? detect_from_shell : nil)
15
+ end
16
+
17
+ def python_detected?
18
+ File.exist?(File.join(project_path, "pyproject.toml")) ||
19
+ File.exist?(File.join(project_path, "requirements.txt")) ||
20
+ File.exist?(File.join(project_path, ".python-version")) ||
21
+ File.exist?(File.join(project_path, "setup.py")) ||
22
+ File.exist?(File.join(project_path, "setup.cfg")) ||
23
+ venv_exists?
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :project_path
29
+
30
+ def detect_from_pyproject_toml
31
+ file_path = File.join(project_path, "pyproject.toml")
32
+ return nil unless File.exist?(file_path)
33
+
34
+ content = File.read(file_path)
35
+
36
+ # Try [project] requires-python = ">=3.11"
37
+ match = content.match(/requires-python\s*=\s*["']([^"']+)["']/)
38
+ return extract_version(match[1]) if match
39
+
40
+ # Try [tool.poetry.dependencies] python = "^3.11"
41
+ match = content.match(/\[tool\.poetry\.dependencies\].*?python\s*=\s*["']([^"']+)["']/m)
42
+ return extract_version(match[1]) if match
43
+
44
+ nil
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ def detect_from_python_version
50
+ file_path = File.join(project_path, ".python-version")
51
+ return nil unless File.exist?(file_path)
52
+
53
+ content = File.read(file_path).strip
54
+ extract_version(content)
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def detect_from_pyvenv_cfg
60
+ ["venv/pyvenv.cfg", ".venv/pyvenv.cfg"].each do |cfg_path|
61
+ file_path = File.join(project_path, cfg_path)
62
+ next unless File.exist?(file_path)
63
+
64
+ content = File.read(file_path)
65
+ # Parse: "version = 3.11.5"
66
+ match = content.match(/version\s*=\s*(\d+\.\d+(?:\.\d+)?)/)
67
+ return match[1] if match
68
+ end
69
+
70
+ nil
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ def detect_from_shell
76
+ # Try python3 first (more reliable on multi-Python systems)
77
+ output = `python3 --version 2>&1`.strip
78
+ if $CHILD_STATUS.success?
79
+ match = output.match(/Python\s+(\d+\.\d+(?:\.\d+)?)/)
80
+ return match[1] if match
81
+ end
82
+
83
+ # Fall back to python
84
+ output = `python --version 2>&1`.strip
85
+ return nil unless $CHILD_STATUS.success?
86
+
87
+ match = output.match(/Python\s+(\d+\.\d+(?:\.\d+)?)/)
88
+ match[1] if match
89
+ rescue StandardError
90
+ nil
91
+ end
92
+
93
+ def extract_version(version_string)
94
+ # Strip constraint operators: >=3.11, ^3.11, ~3.11, >3.11, <4.0
95
+ version = version_string.gsub(/^[><=~^!\s]+/, "")
96
+
97
+ # Handle ranges like ">=3.9,<4.0" - extract first version
98
+ version = version.split(",").first.strip if version.include?(",")
99
+
100
+ # Remove "python-" prefix if present (from .python-version)
101
+ version.sub(/^python-/, "")
102
+ end
103
+
104
+ def venv_exists?
105
+ File.exist?(File.join(project_path, "venv/pyvenv.cfg")) ||
106
+ File.exist?(File.join(project_path, ".venv/pyvenv.cfg"))
107
+ end
108
+ end
109
+ end
110
+ end
@@ -31,7 +31,11 @@ module Harbinger
31
31
 
32
32
  content = File.read(file_path)
33
33
  match = content.match(/^\s*rails\s+\(([^)]+)\)/)
34
- match ? match[1] : nil
34
+ return nil unless match
35
+
36
+ version_string = match[1]
37
+ # Strip version constraint operators (>=, ~>, =, etc.) and extract actual version
38
+ version_string.sub(/^[><=~!\s]+/, "").strip
35
39
  rescue StandardError
36
40
  nil
37
41
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "docker_compose_detector"
4
+
5
+ module Harbinger
6
+ module Analyzers
7
+ # Detects Redis version from projects
8
+ class RedisDetector
9
+ attr_reader :project_path
10
+
11
+ def initialize(project_path)
12
+ @project_path = project_path
13
+ end
14
+
15
+ # Main detection method - returns version string or nil
16
+ def detect
17
+ return nil unless redis_detected?
18
+
19
+ # Try docker-compose.yml first
20
+ version = detect_from_docker_compose
21
+ return version if version
22
+
23
+ # Try shell command
24
+ version = detect_from_shell
25
+ return version if version
26
+
27
+ # Fallback to gem version
28
+ detect_from_gemfile_lock
29
+ end
30
+
31
+ # Check if Redis is used in this project
32
+ def redis_detected?
33
+ gemfile_has_redis? || docker_compose_has_redis?
34
+ end
35
+
36
+ private
37
+
38
+ def detect_from_docker_compose
39
+ docker = DockerComposeDetector.new(project_path)
40
+ docker.image_version("redis")
41
+ end
42
+
43
+ def detect_from_shell
44
+ # Try redis-cli first (more commonly available)
45
+ output = `redis-cli -v 2>&1`.strip
46
+ if $CHILD_STATUS.success?
47
+ # Parse: "redis-cli 7.0.5"
48
+ match = output.match(/redis-cli\s+(\d+\.\d+(?:\.\d+)?)/)
49
+ return match[1] if match
50
+ end
51
+
52
+ # Fall back to redis-server
53
+ output = `redis-server --version 2>&1`.strip
54
+ return nil unless $CHILD_STATUS.success?
55
+
56
+ # Parse: "Redis server v=7.2.4 sha=..."
57
+ match = output.match(/v=(\d+\.\d+(?:\.\d+)?)/)
58
+ match[1] if match
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ def detect_from_gemfile_lock
64
+ content = read_gemfile_lock
65
+ return nil unless content
66
+
67
+ # Look for redis gem version
68
+ match = content.match(/^\s{4}redis\s+\(([^)]+)\)/)
69
+ return "#{match[1]} (gem)" if match
70
+
71
+ nil
72
+ end
73
+
74
+ def gemfile_has_redis?
75
+ content = read_gemfile_lock
76
+ return false unless content
77
+
78
+ content.include?("redis (")
79
+ end
80
+
81
+ def docker_compose_has_redis?
82
+ docker = DockerComposeDetector.new(project_path)
83
+ return false unless docker.docker_compose_exists?
84
+
85
+ docker.image_version("redis") != nil
86
+ end
87
+
88
+ def read_gemfile_lock
89
+ path = File.join(project_path, "Gemfile.lock")
90
+ return nil unless File.exist?(path)
91
+
92
+ File.read(path)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+ end
97
+ end
98
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "docker_compose_detector"
4
+
3
5
  module Harbinger
4
6
  module Analyzers
5
7
  class RubyDetector
@@ -10,7 +12,8 @@ module Harbinger
10
12
  def detect
11
13
  detect_from_ruby_version ||
12
14
  detect_from_gemfile ||
13
- detect_from_gemfile_lock
15
+ detect_from_gemfile_lock ||
16
+ detect_from_dockerfile
14
17
  end
15
18
 
16
19
  def ruby_detected?
@@ -61,6 +64,11 @@ module Harbinger
61
64
  # Remove patch level suffix (e.g., "p223")
62
65
  version.sub(/p\d+$/, "")
63
66
  end
67
+
68
+ def detect_from_dockerfile
69
+ docker = DockerComposeDetector.new(project_path)
70
+ docker.ruby_version_from_dockerfile
71
+ end
64
72
  end
65
73
  end
66
74
  end