ruby_workspace_manager 0.2.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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+
6
+ module Rwm
7
+ module Commands
8
+ class Init
9
+ GEMFILE_TEMPLATE = <<~GEMFILE
10
+ # frozen_string_literal: true
11
+
12
+ source "https://rubygems.org"
13
+
14
+ gem "ruby_workspace_manager"
15
+ GEMFILE
16
+
17
+ RAKEFILE_TEMPLATE = <<~RAKEFILE
18
+ # frozen_string_literal: true
19
+
20
+ task :bootstrap do
21
+ puts "Add your workspace-level bootstrap steps here."
22
+ puts "This task runs during `rwm bootstrap` — use it for binstubs, shared tooling, etc."
23
+ end
24
+ RAKEFILE
25
+
26
+ def initialize(argv)
27
+ @argv = argv
28
+ @vscode = false
29
+ parse_options
30
+ end
31
+
32
+ def run
33
+ root = Dir.pwd
34
+
35
+ create_directories(root)
36
+ create_gemfile(root)
37
+ create_rakefile(root)
38
+ update_gitignore(root)
39
+
40
+ if @vscode
41
+ generate_vscode_workspace(root)
42
+ end
43
+
44
+ puts "Workspace initialized. Running bootstrap..."
45
+ puts
46
+
47
+ # Call bootstrap as the last step
48
+ require "rwm/commands/bootstrap"
49
+ Commands::Bootstrap.new([]).run
50
+ end
51
+
52
+ private
53
+
54
+ def parse_options
55
+ OptionParser.new do |opts|
56
+ opts.on("--vscode", "Generate VSCode .code-workspace file") do
57
+ @vscode = true
58
+ end
59
+ end.parse!(@argv)
60
+ end
61
+
62
+ def create_directories(root)
63
+ %w[libs apps].each do |dir|
64
+ path = File.join(root, dir)
65
+ unless File.directory?(path)
66
+ FileUtils.mkdir_p(path)
67
+ puts "Created #{dir}/"
68
+ end
69
+ end
70
+ end
71
+
72
+ def create_gemfile(root)
73
+ path = File.join(root, "Gemfile")
74
+ return if File.exist?(path)
75
+
76
+ File.write(path, GEMFILE_TEMPLATE)
77
+ puts "Created Gemfile"
78
+ end
79
+
80
+ def create_rakefile(root)
81
+ path = File.join(root, "Rakefile")
82
+ return if File.exist?(path)
83
+
84
+ File.write(path, RAKEFILE_TEMPLATE)
85
+ puts "Created Rakefile"
86
+ end
87
+
88
+ def generate_vscode_workspace(root)
89
+ VscodeWorkspace.new(root).generate([])
90
+ puts "Created #{File.basename(root)}.code-workspace"
91
+ end
92
+
93
+ def update_gitignore(root)
94
+ path = File.join(root, ".gitignore")
95
+ entry = ".rwm/"
96
+
97
+ if File.exist?(path)
98
+ content = File.read(path)
99
+ return if content.lines.any? { |line| line.strip == entry }
100
+
101
+ File.open(path, "a") do |f|
102
+ f.puts unless content.end_with?("\n")
103
+ f.puts entry
104
+ end
105
+ puts "Added #{entry} to .gitignore"
106
+ else
107
+ File.write(path, "#{entry}\n")
108
+ puts "Created .gitignore"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwm
4
+ module Commands
5
+ class List
6
+ def initialize(argv)
7
+ @argv = argv
8
+ end
9
+
10
+ def run
11
+ workspace = Workspace.find
12
+ packages = workspace.packages
13
+
14
+ if packages.empty?
15
+ puts "No packages found."
16
+ return 0
17
+ end
18
+
19
+ graph = DependencyGraph.load(workspace)
20
+
21
+ # Calculate column widths
22
+ name_width = [packages.map { |p| p.name.length }.max, 4].max
23
+ type_width = 4
24
+ path_width = [packages.map { |p| p.relative_path(workspace.root).length }.max, 4].max
25
+
26
+ # Header
27
+ puts format("%-#{name_width}s %-#{type_width}s %-#{path_width}s %s",
28
+ "Name", "Type", "Path", "Dependencies")
29
+ puts format("%-#{name_width}s %-#{type_width}s %-#{path_width}s %s",
30
+ "-" * name_width, "-" * type_width, "-" * path_width, "-" * 12)
31
+
32
+ # Rows
33
+ packages.each do |pkg|
34
+ deps = graph.dependencies(pkg.name)
35
+ dep_str = deps.empty? ? "(none)" : deps.sort.join(", ")
36
+
37
+ puts format("%-#{name_width}s %-#{type_width}s %-#{path_width}s %s",
38
+ pkg.name, pkg.type, pkg.relative_path(workspace.root), dep_str)
39
+ end
40
+
41
+ 0
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rwm
6
+ module Commands
7
+ class New
8
+ def initialize(argv)
9
+ @argv = argv
10
+ end
11
+
12
+ def run
13
+ type = @argv.shift
14
+ name = @argv.shift
15
+
16
+ unless %w[app lib].include?(type)
17
+ $stderr.puts "Usage: rwm new <app|lib> <name>"
18
+ return 1
19
+ end
20
+
21
+ unless name && name.match?(/\A[a-z][a-z0-9_]*\z/)
22
+ $stderr.puts "Package name must start with a lowercase letter and contain only lowercase letters, digits, and underscores."
23
+ return 1
24
+ end
25
+
26
+ workspace = Workspace.find
27
+ dir = type == "lib" ? "libs" : "apps"
28
+ pkg_path = File.join(workspace.root, dir, name)
29
+
30
+ raise PackageExistsError, name if File.directory?(pkg_path)
31
+
32
+ scaffold(pkg_path, name, type)
33
+ update_vscode_workspace(workspace)
34
+
35
+ puts "Created #{type} '#{name}' at #{dir}/#{name}/"
36
+ puts
37
+ puts "Next steps:"
38
+ puts " cd #{dir}/#{name}"
39
+ puts " bundle install"
40
+ puts " rwm graph # rebuild the dependency graph"
41
+ 0
42
+ end
43
+
44
+ private
45
+
46
+ def update_vscode_workspace(workspace)
47
+ vscode = VscodeWorkspace.new(workspace.root)
48
+ return unless File.exist?(vscode.file_path)
49
+
50
+ # Use a fresh Workspace to pick up the newly scaffolded package
51
+ fresh_workspace = Workspace.find(workspace.root)
52
+ VscodeWorkspace.new(fresh_workspace.root).generate(fresh_workspace.packages)
53
+ end
54
+
55
+ def scaffold(pkg_path, name, type)
56
+ FileUtils.mkdir_p(File.join(pkg_path, "lib", name))
57
+ FileUtils.mkdir_p(File.join(pkg_path, "spec"))
58
+
59
+ write_gemfile(pkg_path, name)
60
+ write_gemspec(pkg_path, name, type)
61
+ write_rakefile(pkg_path, name)
62
+ write_lib_entry(pkg_path, name)
63
+ write_spec_helper(pkg_path)
64
+ end
65
+
66
+ def write_gemfile(pkg_path, name)
67
+ File.write(File.join(pkg_path, "Gemfile"), <<~GEMFILE)
68
+ # frozen_string_literal: true
69
+
70
+ source "https://rubygems.org"
71
+
72
+ gemspec
73
+
74
+ group :development, :test do
75
+ gem "rake"
76
+ gem "rspec"
77
+ gem "ruby_workspace_manager"
78
+ end
79
+
80
+ require "rwm/gemfile"
81
+ # rwm_lib "some_dependency"
82
+ GEMFILE
83
+ end
84
+
85
+ def write_gemspec(pkg_path, name, type)
86
+ lines = [
87
+ '# frozen_string_literal: true',
88
+ '',
89
+ 'Gem::Specification.new do |spec|',
90
+ " spec.name = \"#{name}\"",
91
+ ' spec.version = "0.1.0"',
92
+ ' spec.authors = ["TODO: Your name"]',
93
+ " spec.summary = \"TODO: Summary of #{name}\"",
94
+ '',
95
+ ]
96
+ lines << ' spec.files = Dir.glob("lib/**/*")' if type == "lib"
97
+ lines.concat([
98
+ ' spec.require_paths = ["lib"]',
99
+ ' spec.required_ruby_version = ">= 3.4.0"',
100
+ 'end',
101
+ '',
102
+ ])
103
+ File.write(File.join(pkg_path, "#{name}.gemspec"), lines.join("\n"))
104
+ end
105
+
106
+ def write_rakefile(pkg_path, name)
107
+ File.write(File.join(pkg_path, "Rakefile"), <<~RAKEFILE)
108
+ # frozen_string_literal: true
109
+
110
+ require "rwm/rake"
111
+
112
+ cacheable_task :spec do
113
+ sh "bundle exec rspec"
114
+ end
115
+
116
+ task :bootstrap do
117
+ puts "Add bootstrap steps for #{name} here."
118
+ end
119
+
120
+ task default: :spec
121
+ RAKEFILE
122
+ end
123
+
124
+ def write_lib_entry(pkg_path, name)
125
+ File.write(File.join(pkg_path, "lib", "#{name}.rb"), <<~RUBY)
126
+ # frozen_string_literal: true
127
+
128
+ module #{camelize(name)}
129
+ end
130
+ RUBY
131
+ end
132
+
133
+ def write_spec_helper(pkg_path)
134
+ File.write(File.join(pkg_path, "spec", "spec_helper.rb"), <<~RUBY)
135
+ # frozen_string_literal: true
136
+
137
+ RSpec.configure do |config|
138
+ config.expect_with :rspec do |expectations|
139
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
140
+ end
141
+ end
142
+ RUBY
143
+ end
144
+
145
+ def camelize(name)
146
+ name.split("_").map(&:capitalize).join
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rwm
6
+ module Commands
7
+ class Run
8
+ def initialize(argv)
9
+ @argv = argv
10
+ @affected_only = false
11
+ @committed_only = false
12
+ @no_cache = false
13
+ @buffered = false
14
+ @concurrency = nil
15
+ parse_options
16
+ end
17
+
18
+ def run
19
+ task = @argv.shift
20
+
21
+ unless task
22
+ $stderr.puts "Usage: rwm run <task> [<package>] [--affected] [--no-cache] [--buffered] [--concurrency N]"
23
+ return 1
24
+ end
25
+
26
+ package_name = @argv.shift
27
+
28
+ workspace = Workspace.find
29
+ graph = DependencyGraph.load(workspace)
30
+
31
+ packages = if package_name
32
+ pkg = workspace.find_package(package_name)
33
+ unless pkg
34
+ $stderr.puts "Unknown package: #{package_name}"
35
+ return 1
36
+ end
37
+ [pkg]
38
+ elsif @affected_only
39
+ detector = AffectedDetector.new(workspace, graph, committed_only: @committed_only)
40
+ affected = detector.affected_packages
41
+ if affected.empty?
42
+ puts "No affected packages. Nothing to run."
43
+ return 0
44
+ end
45
+ puts "Running on #{affected.size} affected package(s)..."
46
+ affected
47
+ else
48
+ workspace.packages
49
+ end
50
+
51
+ if packages.empty?
52
+ puts "No packages found."
53
+ return 0
54
+ end
55
+
56
+ # Filter to packages that have the requested rake task
57
+ runnable = packages.select { |pkg| pkg.has_rake_task?(task) }
58
+ if runnable.empty?
59
+ puts "No packages with a `#{task}` rake task found."
60
+ return 0
61
+ end
62
+
63
+ # Auto-detect cacheable tasks unless --no-cache
64
+ cache = TaskCache.new(workspace, graph) unless @no_cache
65
+ if cache
66
+ cacheable, not_cacheable = runnable.partition { |pkg| cache.cacheable?(pkg, task) }
67
+ cached, uncached = cacheable.partition { |pkg| cache.cached?(pkg, task) }
68
+ cached.each { |pkg| puts "[#{pkg.name}] cached" }
69
+ runnable = uncached + not_cacheable
70
+ end
71
+
72
+ if runnable.empty?
73
+ puts "All packages cached. Nothing to run."
74
+ return 0
75
+ end
76
+
77
+ puts "Running `rake #{task}` across #{runnable.size} package(s)..."
78
+ puts
79
+
80
+ runner_opts = { packages: runnable, buffered: @buffered }
81
+ runner_opts[:concurrency] = @concurrency if @concurrency
82
+ runner = TaskRunner.new(graph, **runner_opts)
83
+ runner.run_task(task)
84
+
85
+ # Store cache for successful cacheable packages
86
+ if cache
87
+ runner.results.each do |result|
88
+ next unless result.success
89
+
90
+ pkg = workspace.find_package(result.package_name)
91
+ cache.store(pkg, task) if cache.cacheable?(pkg, task)
92
+ end
93
+ end
94
+
95
+ puts
96
+ if runner.success?
97
+ puts "All packages passed."
98
+ 0
99
+ else
100
+ failed = runner.failed_results
101
+ $stderr.puts "#{failed.size} package(s) failed:"
102
+ failed.each { |r| $stderr.puts " - #{r.package_name}" }
103
+ 1
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def parse_options
110
+ parser = OptionParser.new do |opts|
111
+ opts.on("--affected", "Only run on affected packages") do
112
+ @affected_only = true
113
+ end
114
+ opts.on("--committed", "Only consider committed changes (with --affected)") do
115
+ @committed_only = true
116
+ end
117
+ opts.on("--no-cache", "Bypass task caching even for cacheable tasks") do
118
+ @no_cache = true
119
+ end
120
+ opts.on("--buffered", "Buffer output per-package and print on completion") do
121
+ @buffered = true
122
+ end
123
+ opts.on("--concurrency N", Integer, "Max parallel workers (default: processor count)") do |n|
124
+ @concurrency = n
125
+ end
126
+ end
127
+
128
+ parser.order!(@argv)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rwm
4
+ class ConventionChecker
5
+ def initialize(graph)
6
+ @graph = graph
7
+ end
8
+
9
+ def check!
10
+ violations = []
11
+ violations.concat(check_no_lib_depends_on_app)
12
+ violations.concat(check_no_app_depends_on_app)
13
+ violations.concat(check_no_cycles)
14
+
15
+ raise ConventionError, violations unless violations.empty?
16
+
17
+ true
18
+ end
19
+
20
+ def check
21
+ check!
22
+ []
23
+ rescue ConventionError => e
24
+ e.violations
25
+ end
26
+
27
+ private
28
+
29
+ def check_no_lib_depends_on_app
30
+ violations = []
31
+
32
+ @graph.packages.each do |name, pkg|
33
+ next unless pkg.lib?
34
+
35
+ @graph.dependencies(name).each do |dep_name|
36
+ dep = @graph.packages[dep_name]
37
+ next unless dep&.app?
38
+
39
+ violations << "lib '#{name}' depends on app '#{dep_name}' — libs cannot depend on apps"
40
+ end
41
+ end
42
+
43
+ violations
44
+ end
45
+
46
+ def check_no_app_depends_on_app
47
+ violations = []
48
+
49
+ @graph.packages.each do |name, pkg|
50
+ next unless pkg.app?
51
+
52
+ @graph.dependencies(name).each do |dep_name|
53
+ dep = @graph.packages[dep_name]
54
+ next unless dep&.app?
55
+
56
+ violations << "app '#{name}' depends on app '#{dep_name}' — apps cannot depend on other apps"
57
+ end
58
+ end
59
+
60
+ violations
61
+ end
62
+
63
+ def check_no_cycles
64
+ @graph.topological_order
65
+ []
66
+ rescue CycleError => e
67
+ e.cycles.map { |c| "Dependency cycle: #{c}" }
68
+ end
69
+ end
70
+ end