ocak 0.1.0 → 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.
@@ -1,12 +1,137 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require_relative 'monorepo_detector'
4
5
 
5
6
  module Ocak
6
7
  class StackDetector
8
+ include MonorepoDetector
9
+
7
10
  Result = Struct.new(:language, :framework, :test_command, :lint_command,
8
11
  :format_command, :security_commands, :setup_command,
9
12
  :monorepo, :packages)
13
+ LANGUAGE_RULES = [
14
+ ['ruby', ['Gemfile']],
15
+ ['typescript', ['tsconfig.json']],
16
+ ['javascript', ['package.json']],
17
+ ['python', ['pyproject.toml', 'setup.py', 'requirements.txt']],
18
+ ['rust', ['Cargo.toml']],
19
+ ['go', ['go.mod']],
20
+ ['java', ['pom.xml', 'build.gradle']],
21
+ ['elixir', ['mix.exs']]
22
+ ].freeze
23
+ FRAMEWORK_RULES = {
24
+ 'ruby' => [
25
+ [:dep_in_file, 'Gemfile', 'rails', 'rails'],
26
+ [:dep_in_file, 'Gemfile', 'sinatra', 'sinatra'],
27
+ [:dep_in_file, 'Gemfile', 'hanami', 'hanami']
28
+ ],
29
+ 'javascript' => [
30
+ [:pkg_has, 'next', 'next'], [:pkg_has, '@remix-run/react', 'remix'],
31
+ [:pkg_has, 'nuxt', 'nuxt'], [:pkg_has, 'svelte', 'svelte'],
32
+ [:pkg_has, '@sveltejs/kit', 'svelte'], [:pkg_has, 'react', 'react'],
33
+ [:pkg_has, 'vue', 'vue'], [:pkg_has, 'express', 'express']
34
+ ],
35
+ 'python' => [
36
+ [:file_exists, 'manage.py', 'django'], [:pip_has, 'django', 'django'],
37
+ [:pip_has, 'flask', 'flask'], [:pip_has, 'fastapi', 'fastapi']
38
+ ],
39
+ 'rust' => [
40
+ [:file_contains, 'Cargo.toml', 'actix-web', 'actix'],
41
+ [:file_contains, 'Cargo.toml', 'axum', 'axum'],
42
+ [:file_contains, 'Cargo.toml', 'rocket', 'rocket']
43
+ ],
44
+ 'go' => [
45
+ [:file_contains, 'go.mod', 'gin-gonic', 'gin'],
46
+ [:file_contains, 'go.mod', 'labstack/echo', 'echo'],
47
+ [:file_contains, 'go.mod', 'gofiber/fiber', 'fiber'],
48
+ [:file_contains, 'go.mod', 'go-chi/chi', 'chi']
49
+ ],
50
+ 'elixir' => [[:dep_in_file, 'mix.exs', 'phoenix', 'phoenix']]
51
+ }.freeze
52
+ TOOL_RULES = {
53
+ test: {
54
+ 'ruby' => [
55
+ [:dep_in_file, 'Gemfile', 'rspec', 'bundle exec rspec'],
56
+ [:always, 'bundle exec rake test']
57
+ ],
58
+ 'javascript' => [
59
+ [:pkg_has, 'vitest', 'npx vitest run'],
60
+ [:pkg_has, 'jest', 'npx jest'],
61
+ [:always, 'npm test']
62
+ ],
63
+ 'python' => [
64
+ [:file_contains, 'pyproject.toml', 'pytest', 'pytest'],
65
+ [:always, 'python -m pytest']
66
+ ],
67
+ 'rust' => [[:always, 'cargo test']],
68
+ 'go' => [[:always, 'go test ./...']],
69
+ 'java' => [[:file_exists, 'gradlew', './gradlew test'], [:always, 'mvn test']],
70
+ 'elixir' => [[:always, 'mix test']]
71
+ },
72
+ lint: {
73
+ 'ruby' => [[:dep_in_file, 'Gemfile', 'rubocop', 'bundle exec rubocop -A']],
74
+ 'javascript' => [
75
+ [:pkg_has, 'biome', 'npx biome check --write'],
76
+ [:pkg_has, '@biomejs/biome', 'npx biome check --write'],
77
+ [:pkg_has, 'eslint', 'npx eslint --fix .']
78
+ ],
79
+ 'python' => [
80
+ [:file_contains, 'pyproject.toml', 'ruff', 'ruff check --fix .'],
81
+ [:always, 'flake8']
82
+ ],
83
+ 'rust' => [[:always, 'cargo clippy --fix --allow-dirty']],
84
+ 'go' => [[:always, 'golangci-lint run']],
85
+ 'elixir' => [[:always, 'mix credo']]
86
+ },
87
+ format: {
88
+ 'javascript' => [
89
+ [:pkg_has, 'biome', nil],
90
+ [:pkg_has, '@biomejs/biome', nil],
91
+ [:pkg_has, 'prettier', 'npx prettier --write .']
92
+ ],
93
+ 'python' => [
94
+ [:file_contains, 'pyproject.toml', 'ruff', 'ruff format .'],
95
+ [:file_contains, 'pyproject.toml', 'black', 'black .']
96
+ ],
97
+ 'rust' => [[:always, 'cargo fmt']],
98
+ 'go' => [[:always, 'gofmt -w .']],
99
+ 'elixir' => [[:always, 'mix format']]
100
+ },
101
+ security: {
102
+ 'ruby' => [
103
+ [:dep_in_file, 'Gemfile', 'brakeman', 'bundle exec brakeman -q'],
104
+ [:dep_in_file, 'Gemfile', 'bundler-audit', 'bundle exec bundler-audit check']
105
+ ],
106
+ 'javascript' => [[:always, 'npm audit --omit=dev']],
107
+ 'python' => [
108
+ [:file_contains, 'pyproject.toml', 'bandit', 'bandit -r .'],
109
+ [:file_contains, 'pyproject.toml', 'safety', 'safety check']
110
+ ],
111
+ 'rust' => [[:file_contains, 'Cargo.toml', 'cargo-audit', 'cargo audit']],
112
+ 'go' => [[:always, 'gosec ./...']]
113
+ },
114
+ setup: {
115
+ 'ruby' => [[:file_exists, 'Gemfile', 'bundle install']],
116
+ 'javascript' => [
117
+ [:file_exists, 'package-lock.json', 'npm install'],
118
+ [:file_exists, 'yarn.lock', 'yarn install'],
119
+ [:file_exists, 'pnpm-lock.yaml', 'pnpm install'],
120
+ [:file_exists, 'package.json', 'npm install']
121
+ ],
122
+ 'python' => [
123
+ [:file_exists, 'pyproject.toml', 'pip install -e .'],
124
+ [:file_exists, 'requirements.txt', 'pip install -r requirements.txt']
125
+ ],
126
+ 'rust' => [[:file_exists, 'Cargo.toml', 'cargo fetch']],
127
+ 'go' => [[:file_exists, 'go.mod', 'go mod download']],
128
+ 'elixir' => [[:file_exists, 'mix.exs', 'mix deps.get']],
129
+ 'java' => [
130
+ [:file_exists, 'gradlew', './gradlew dependencies'],
131
+ [:file_exists, 'pom.xml', 'mvn dependency:resolve']
132
+ ]
133
+ }
134
+ }.freeze
10
135
 
11
136
  def initialize(project_dir)
12
137
  @dir = project_dir
@@ -14,15 +139,16 @@ module Ocak
14
139
 
15
140
  def detect
16
141
  lang = detect_language
142
+ key = rules_key(lang)
17
143
  mono = detect_monorepo
18
144
  Result.new(
19
145
  language: lang,
20
- framework: detect_framework(lang),
21
- test_command: detect_test_command(lang),
22
- lint_command: detect_lint_command(lang),
23
- format_command: detect_format_command(lang),
24
- security_commands: detect_security_commands(lang),
25
- setup_command: detect_setup_command(lang),
146
+ framework: first_match(FRAMEWORK_RULES[key]),
147
+ test_command: first_match(TOOL_RULES[:test][key]),
148
+ lint_command: first_match(TOOL_RULES[:lint][key]),
149
+ format_command: first_match(TOOL_RULES[:format][key]),
150
+ security_commands: all_matches(TOOL_RULES[:security][key]),
151
+ setup_command: first_match(TOOL_RULES[:setup][key]),
26
152
  monorepo: mono[:detected],
27
153
  packages: mono[:packages]
28
154
  )
@@ -31,295 +157,37 @@ module Ocak
31
157
  private
32
158
 
33
159
  def detect_language
34
- return 'ruby' if exists?('Gemfile')
35
- return 'typescript' if exists?('tsconfig.json')
36
- return 'javascript' if exists?('package.json')
37
- return 'python' if exists?('pyproject.toml') || exists?('setup.py') || exists?('requirements.txt')
38
- return 'rust' if exists?('Cargo.toml')
39
- return 'go' if exists?('go.mod')
40
- return 'java' if exists?('pom.xml') || exists?('build.gradle')
41
- return 'elixir' if exists?('mix.exs')
42
-
160
+ LANGUAGE_RULES.each { |lang, files| return lang if files.any? { |f| exists?(f) } }
43
161
  'unknown'
44
162
  end
45
163
 
46
- def detect_framework(lang)
47
- case lang
48
- when 'ruby' then detect_ruby_framework
49
- when 'typescript', 'javascript' then detect_js_framework
50
- when 'python' then detect_python_framework
51
- when 'rust' then detect_rust_framework
52
- when 'go' then detect_go_framework
53
- when 'elixir' then 'phoenix' if gemfile_has?('mix.exs', 'phoenix')
54
- end
55
- end
56
-
57
- def detect_test_command(lang)
58
- case lang
59
- when 'ruby'
60
- gemfile_has?('Gemfile', 'rspec') ? 'bundle exec rspec' : 'bundle exec rake test'
61
- when 'typescript', 'javascript'
62
- return 'npx vitest run' if pkg_has?('vitest')
63
- return 'npx jest' if pkg_has?('jest')
64
-
65
- 'npm test'
66
- when 'python'
67
- return 'pytest' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('pytest')
68
-
69
- 'python -m pytest'
70
- when 'rust' then 'cargo test'
71
- when 'go' then 'go test ./...'
72
- when 'java' then exists?('gradlew') ? './gradlew test' : 'mvn test'
73
- when 'elixir' then 'mix test'
74
- end
75
- end
76
-
77
- def detect_lint_command(lang)
78
- case lang
79
- when 'ruby'
80
- 'bundle exec rubocop -A' if gemfile_has?('Gemfile', 'rubocop')
81
- when 'typescript', 'javascript'
82
- return 'npx biome check --write' if pkg_has?('biome') || pkg_has?('@biomejs/biome')
83
-
84
- 'npx eslint --fix .' if pkg_has?('eslint')
85
- when 'python'
86
- return 'ruff check --fix .' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('ruff')
87
-
88
- 'flake8'
89
- when 'rust' then 'cargo clippy --fix --allow-dirty'
90
- when 'go' then 'golangci-lint run'
91
- when 'elixir' then 'mix credo'
92
- end
93
- end
94
-
95
- def detect_format_command(lang)
96
- case lang
97
- when 'ruby' then nil # rubocop handles formatting
98
- when 'typescript', 'javascript'
99
- return nil if pkg_has?('biome') || pkg_has?('@biomejs/biome') # biome handles both
100
-
101
- 'npx prettier --write .' if pkg_has?('prettier')
102
- when 'python'
103
- return 'ruff format .' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('ruff')
104
-
105
- 'black .' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('black')
106
- when 'rust' then 'cargo fmt'
107
- when 'go' then 'gofmt -w .'
108
- when 'elixir' then 'mix format'
109
- end
110
- end
111
-
112
- def detect_security_commands(lang)
113
- cmds = []
114
- case lang
115
- when 'ruby'
116
- cmds << 'bundle exec brakeman -q' if gemfile_has?('Gemfile', 'brakeman')
117
- cmds << 'bundle exec bundler-audit check' if gemfile_has?('Gemfile', 'bundler-audit')
118
- when 'typescript', 'javascript'
119
- cmds << 'npm audit --omit=dev'
120
- when 'python'
121
- cmds << 'bandit -r .' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('bandit')
122
- cmds << 'safety check' if exists?('pyproject.toml') && read_file('pyproject.toml').include?('safety')
123
- when 'rust'
124
- cmds << 'cargo audit' if read_file('Cargo.toml').include?('cargo-audit')
125
- when 'go'
126
- cmds << 'gosec ./...'
127
- end
128
- cmds
129
- end
130
-
131
- def detect_setup_command(lang)
132
- case lang
133
- when 'ruby' then 'bundle install' if exists?('Gemfile')
134
- when 'typescript', 'javascript' then detect_js_setup_command
135
- when 'python' then detect_python_setup_command
136
- when 'rust' then 'cargo fetch' if exists?('Cargo.toml')
137
- when 'go' then 'go mod download' if exists?('go.mod')
138
- when 'elixir' then 'mix deps.get' if exists?('mix.exs')
139
- when 'java' then detect_java_setup_command
140
- end
141
- end
142
-
143
- def detect_js_setup_command
144
- return 'npm install' if exists?('package-lock.json')
145
- return 'yarn install' if exists?('yarn.lock')
146
- return 'pnpm install' if exists?('pnpm-lock.yaml')
147
-
148
- 'npm install' if exists?('package.json')
149
- end
150
-
151
- def detect_python_setup_command
152
- return 'pip install -e .' if exists?('pyproject.toml')
153
-
154
- 'pip install -r requirements.txt' if exists?('requirements.txt')
155
- end
156
-
157
- def detect_java_setup_command
158
- return './gradlew dependencies' if exists?('gradlew')
159
-
160
- 'mvn dependency:resolve' if exists?('pom.xml')
161
- end
162
-
163
- # Monorepo detection
164
-
165
- def detect_monorepo
166
- packages = []
167
- packages.concat(detect_npm_workspaces)
168
- packages.concat(detect_pnpm_workspaces)
169
- packages.concat(detect_cargo_workspaces)
170
- packages.concat(detect_go_workspaces)
171
- packages.concat(detect_lerna_packages)
172
- packages.concat(detect_convention_packages) if packages.empty?
173
- packages.uniq!
174
- { detected: packages.any?, packages: packages }
175
- end
176
-
177
- def detect_npm_workspaces
178
- return [] unless exists?('package.json')
179
-
180
- pkg = begin
181
- JSON.parse(read_file('package.json'))
182
- rescue JSON::ParserError
183
- {}
184
- end
185
- workspaces = pkg['workspaces']
186
- workspaces = workspaces['packages'] if workspaces.is_a?(Hash)
187
- return [] unless workspaces.is_a?(Array) && workspaces.any?
188
-
189
- expand_workspace_globs(workspaces)
190
- end
191
-
192
- def detect_pnpm_workspaces
193
- return [] unless exists?('pnpm-workspace.yaml')
194
-
195
- content = read_file('pnpm-workspace.yaml')
196
- globs = content.scan(/^\s*-\s*['"]?([^'"#\n]+)/).flatten.map(&:strip)
197
- expand_workspace_globs(globs)
198
- end
199
-
200
- def detect_cargo_workspaces
201
- return [] unless exists?('Cargo.toml') && read_file('Cargo.toml').include?('[workspace]')
202
-
203
- read_file('Cargo.toml').scan(/members\s*=\s*\[(.*?)\]/m).flatten.flat_map do |members|
204
- globs = members.scan(/"([^"]+)"/).flatten
205
- expand_workspace_globs(globs)
206
- end
207
- end
208
-
209
- def detect_go_workspaces
210
- return [] unless exists?('go.work')
211
-
212
- read_file('go.work').scan(/use\s+(\S+)/).flatten.select do |pkg|
213
- Dir.exist?(File.join(@dir, pkg))
214
- end
215
- end
216
-
217
- def detect_lerna_packages
218
- return [] unless exists?('lerna.json')
164
+ def rules_key(lang) = lang == 'typescript' ? 'javascript' : lang
165
+ def first_match(rules) = rules&.find { |rule| match_rule?(rule) }&.last
166
+ def all_matches(rules) = (rules || []).filter_map { |rule| rule.last if match_rule?(rule) }
219
167
 
220
- lerna = begin
221
- JSON.parse(read_file('lerna.json'))
222
- rescue JSON::ParserError
223
- {}
168
+ def match_rule?(rule)
169
+ case rule.first
170
+ when :dep_in_file then gemfile_has?(rule[1], rule[2])
171
+ when :pkg_has then pkg_has?(rule[1])
172
+ when :pip_has then pip_has?(rule[1])
173
+ when :file_contains then read_file(rule[1]).include?(rule[2])
174
+ when :file_exists then exists?(rule[1])
175
+ when :always then true
224
176
  end
225
- expand_workspace_globs(lerna['packages'] || ['packages/*'])
226
177
  end
227
178
 
228
- def detect_convention_packages
229
- packages = []
230
- %w[packages apps services modules libs].each do |candidate|
231
- path = File.join(@dir, candidate)
232
- next unless Dir.exist?(path)
233
-
234
- subdirs = Dir.entries(path).reject { |e| e.start_with?('.') }.select do |e|
235
- File.directory?(File.join(path, e))
236
- end
237
- packages.concat(subdirs.map { |s| "#{candidate}/#{s}" }) if subdirs.size > 1
238
- end
239
- packages
240
- end
241
-
242
- def expand_workspace_globs(globs)
243
- globs.flat_map do |glob|
244
- pattern = File.join(@dir, glob)
245
- Dir.glob(pattern).select { |p| File.directory?(p) }.map do |p|
246
- p.sub("#{@dir}/", '')
247
- end
248
- end
249
- end
250
-
251
- # Framework detection helpers
252
-
253
- def detect_ruby_framework
254
- return 'rails' if gemfile_has?('Gemfile', 'rails')
255
- return 'sinatra' if gemfile_has?('Gemfile', 'sinatra')
256
- return 'hanami' if gemfile_has?('Gemfile', 'hanami')
257
-
258
- nil
259
- end
260
-
261
- def detect_js_framework
262
- return 'next' if pkg_has?('next')
263
- return 'remix' if pkg_has?('@remix-run/react')
264
- return 'nuxt' if pkg_has?('nuxt')
265
- return 'svelte' if pkg_has?('svelte') || pkg_has?('@sveltejs/kit')
266
- return 'react' if pkg_has?('react')
267
- return 'vue' if pkg_has?('vue')
268
- return 'express' if pkg_has?('express')
269
-
270
- nil
271
- end
272
-
273
- def detect_python_framework
274
- return 'django' if exists?('manage.py') || pip_has?('django')
275
- return 'flask' if pip_has?('flask')
276
- return 'fastapi' if pip_has?('fastapi')
277
-
278
- nil
279
- end
280
-
281
- def detect_rust_framework
282
- content = read_file('Cargo.toml')
283
- return 'actix' if content.include?('actix-web')
284
- return 'axum' if content.include?('axum')
285
- return 'rocket' if content.include?('rocket')
286
-
287
- nil
288
- end
289
-
290
- def detect_go_framework
291
- content = read_file('go.mod')
292
- return 'gin' if content.include?('gin-gonic')
293
- return 'echo' if content.include?('labstack/echo')
294
- return 'fiber' if content.include?('gofiber/fiber')
295
- return 'chi' if content.include?('go-chi/chi')
296
-
297
- nil
298
- end
299
-
300
- # File helpers
301
-
302
- def exists?(filename)
303
- File.exist?(File.join(@dir, filename))
304
- end
305
-
306
- def read_file(filename)
307
- path = File.join(@dir, filename)
308
- File.exist?(path) ? File.read(path) : ''
309
- end
310
-
311
- def gemfile_has?(file, gem_name)
312
- read_file(file).match?(/['"]#{Regexp.escape(gem_name)}[\w-]*['"]/)
313
- end
179
+ def exists?(filename) = File.exist?(File.join(@dir, filename))
180
+ def read_file(filename) = File.join(@dir, filename).then { |p| File.exist?(p) ? File.read(p) : '' }
181
+ def gemfile_has?(file, gem_name) = read_file(file).match?(/['"]#{Regexp.escape(gem_name)}[\w-]*['"]/)
314
182
 
315
183
  def pkg_has?(package)
316
184
  @pkg_json ||= begin
317
185
  raw = read_file('package.json')
318
186
  raw.empty? ? {} : JSON.parse(raw)
319
- rescue JSON::ParserError
187
+ rescue JSON::ParserError => e
188
+ warn("Failed to parse package.json: #{e.message}")
320
189
  {}
321
190
  end
322
-
323
191
  deps = (@pkg_json['dependencies'] || {}).merge(@pkg_json['devDependencies'] || {})
324
192
  deps.key?(package)
325
193
  end
@@ -42,7 +42,8 @@ module Ocak
42
42
  when 'result' then parse_result(data)
43
43
  else []
44
44
  end
45
- rescue JSON::ParserError
45
+ rescue JSON::ParserError => e
46
+ @logger.debug("Failed to parse stream JSON line: #{e.message}")
46
47
  []
47
48
  end
48
49
 
data/lib/ocak.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ocak
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
 
6
6
  def self.root
7
7
  File.expand_path('..', __dir__)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ocak
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clay Harmon
@@ -56,6 +56,7 @@ files:
56
56
  - lib/ocak/commands/clean.rb
57
57
  - lib/ocak/commands/debt.rb
58
58
  - lib/ocak/commands/design.rb
59
+ - lib/ocak/commands/hiz.rb
59
60
  - lib/ocak/commands/init.rb
60
61
  - lib/ocak/commands/resume.rb
61
62
  - lib/ocak/commands/run.rb
@@ -64,6 +65,8 @@ files:
64
65
  - lib/ocak/issue_fetcher.rb
65
66
  - lib/ocak/logger.rb
66
67
  - lib/ocak/merge_manager.rb
68
+ - lib/ocak/monorepo_detector.rb
69
+ - lib/ocak/pipeline_executor.rb
67
70
  - lib/ocak/pipeline_runner.rb
68
71
  - lib/ocak/pipeline_state.rb
69
72
  - lib/ocak/planner.rb