asgard 0.3.1 → 0.3.3

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,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.3"
5
5
  end
data/lib/asgard.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "asgard/kernel_methods"
5
5
  require_relative "asgard/shell"
6
6
  require_relative "asgard/base"
7
7
  require_relative "asgard/tasks"
8
+ require_relative "asgard/doctor"
8
9
 
9
10
  module Asgard
10
11
  class Error < StandardError; end
@@ -18,11 +19,16 @@ module Asgard
18
19
 
19
20
  # Main entry point invoked by the asgard executable.
20
21
  def self.run!(argv)
21
- abort "asgard: unknown command '#{argv.first}'" if argv.first&.start_with?("_")
22
+ first = argv.first
23
+ abort "asgard: unknown command '#{first}'" if first&.start_with?("_")
22
24
  if argv.include?("--version")
23
25
  puts Asgard::VERSION
24
26
  exit
25
27
  end
28
+ if argv.include?("--doctor")
29
+ Asgard::Doctor.new.run
30
+ exit
31
+ end
26
32
  task_file = find_task_file or abort "asgard: no .loki file found in #{Dir.pwd}"
27
33
  before = Asgard::Base.subclasses.dup
28
34
  load task_file
@@ -34,5 +40,7 @@ module Asgard
34
40
  abort "asgard: circular dependency — #{e.message}"
35
41
  rescue Error => e
36
42
  abort "asgard: #{e.message}"
43
+ rescue Interrupt
44
+ exit(130)
37
45
  end
38
46
  end
data/quality.loki CHANGED
@@ -3,44 +3,41 @@
3
3
 
4
4
  class Tasks
5
5
  desc "Run the test suite"
6
- def test
6
+ def test_check
7
7
  output = `bundle exec ruby -Ilib:test test/test_asgard.rb 2>&1`
8
- @test_result = $?.success? ? :pass : :fail
9
- if @test_result == :fail
10
- lines = output.lines
11
- start = lines.index { |l| l =~ /^\s+1\) / } || 0
12
- finish = lines.rindex { |l| l =~ /\d+ runs,/ } || -1
13
- print lines[start..finish].join
14
- end
8
+ result = $?.success? ? :pass : :fail
9
+ File.write("test_output.txt", output)
10
+ summary = output.lines.reverse.find { |l| l =~ /\d+ runs,/ }&.strip || result.to_s
11
+ puts "Tests: #{summary} (see test_output.txt)"
12
+ result
15
13
  end
16
14
 
17
- desc "Run all quality gates (tests, RuboCop, Flog) in parallel"
18
- depends_on [:test, :rubocop, :flog_check]
19
- def quality
20
- results = { tests: @test_result, rubocop: @rubocop_result, flog: @flog_result }
15
+ desc "Run the test suite with verbose output"
16
+ def test_verbose
17
+ sh "bundle exec ruby -Ilib:test test/test_asgard.rb -v"
18
+ end
21
19
 
22
- if @flog_failures&.any?
23
- puts "\nFlog failures (must be refactored):"
24
- @flog_failures.each { |v| puts " #{v}" }
25
- end
20
+ desc "Run every *_check quality gate task in parallel"
21
+ # A Proc defers resolution to validate_deps!, after every .loki file has
22
+ # loaded so a *_check task from another file (e.g. quality_rails.loki's
23
+ # brakeman_check) is picked up too, with no need to redeclare anything here.
24
+ depends_on -> { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
25
+ def quality
26
+ check_tasks = self.class._deps.fetch(:quality, []).flatten.map(&:to_s)
26
27
 
27
- puts "\n#{"=" * 60}"
28
- puts "Quality Summary"
29
- puts "=" * 60
30
- results.each { |gate, status| puts " [#{status == :pass ? "PASS" : "FAIL"}] #{gate}" }
31
- puts "=" * 60
28
+ results = check_tasks.to_h { |name| [quality_gate_label(name), dep_result(name)] }
32
29
 
33
- abort "\nQuality gate failed" if results.values.any?(:fail)
34
- puts "\nAll quality gates passed."
30
+ print_quality_summary(results)
35
31
  end
36
32
 
37
33
  desc "Check code style with RuboCop"
38
- def rubocop
34
+ def rubocop_check
39
35
  output = `RUBOCOP_CACHE_ROOT=tmp/rubocop_cache bundle exec rubocop 2>&1`
40
- @rubocop_result = $?.success? ? :pass : :fail
41
- if @rubocop_result == :fail
42
- output.each_line { |l| print l if l =~ /:\d+:\d+: [CWEF]: / }
43
- end
36
+ result = $?.success? ? :pass : :fail
37
+ File.write("rubocop_output.txt", output)
38
+ summary = output.lines.find { |l| l =~ /files inspected/ }&.strip || result.to_s
39
+ puts "RuboCop: #{summary} (see rubocop_output.txt)"
40
+ result
44
41
  end
45
42
 
46
43
  desc "Auto-correct RuboCop offenses"
@@ -58,18 +55,207 @@ class Tasks
58
55
  flogger = Flog.new(all: true)
59
56
  flogger.flog(*Dir.glob("lib/**/*.rb"))
60
57
 
61
- warnings = []
62
- @flog_failures = []
58
+ warnings = []
59
+ failures = []
63
60
 
64
61
  flogger.each_by_score do |method_name, score|
65
62
  next if method_name.end_with?("#none")
66
63
  if score > method_fail
67
- @flog_failures << "#{"%.1f" % score}: #{method_name}"
64
+ failures << "#{"%.1f" % score}: #{method_name}"
68
65
  elsif score > method_warn
69
66
  warnings << "#{"%.1f" % score}: #{method_name}"
70
67
  end
71
68
  end
72
69
 
73
- @flog_result = @flog_failures.empty? ? :pass : :fail
70
+ result = failures.empty? ? :pass : :fail
71
+
72
+ lines = ["Flog warnings (#{method_warn}–#{method_fail}):"]
73
+ lines.concat(warnings.empty? ? [" (none)"] : warnings.map { |w| " #{w}" })
74
+ lines << ""
75
+ lines << "Flog failures (>= #{method_fail}):"
76
+ lines.concat(failures.empty? ? [" (none)"] : failures.map { |f| " #{f}" })
77
+ File.write("flog_output.txt", "#{lines.join("\n")}\n")
78
+
79
+ puts "Flog: #{failures.size} failure(s), #{warnings.size} warning(s) (see flog_output.txt)"
80
+ result
81
+ end
82
+
83
+ desc "Check for structural code duplication with Flay (mass >= 150)"
84
+ def flay_check
85
+ require "flay"
86
+
87
+ mass_threshold = 150
88
+
89
+ flay = Flay.new(mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60)
90
+ flay.process(*Dir.glob("lib/**/*.rb"))
91
+ flay.analyze
92
+
93
+ result = flay.hashes.empty? ? :pass : :fail
94
+
95
+ File.open("flay_output.txt", "w") do |f|
96
+ if result == :pass
97
+ f.puts "Flay: no structural duplication detected (mass >= #{mass_threshold})"
98
+ else
99
+ flay.report(f)
100
+ end
101
+ end
102
+ puts "Flay: #{flay.hashes.size} duplication pattern(s) found (mass >= #{mass_threshold}) (see flay_output.txt)"
103
+ result
104
+ end
105
+
106
+ desc "Check code smells with Reek"
107
+ def reek_check
108
+ output = `bundle exec reek lib 2>&1`
109
+ result = $?.success? ? :pass : :fail
110
+ File.write("reek_output.txt", output)
111
+ summary = output.lines.reverse.find { |l| l =~ /total warnings?/ }&.strip || result.to_s
112
+ puts "Reek: #{summary} (see reek_output.txt)"
113
+ result
114
+ end
115
+
116
+ desc "Check spelling with typos"
117
+ def typos_check
118
+ return skip_typos unless command_available?("typos")
119
+
120
+ output = `typos 2>&1`
121
+ result = $?.success? ? :pass : :fail
122
+ File.write("typos_output.txt", output)
123
+ count = output.lines.count { |l| l.start_with?("error:") }
124
+ summary = result == :pass ? "no typos found" : "#{count} typo(s) found"
125
+ puts "Typos: #{summary} (see typos_output.txt)"
126
+ result
127
+ end
128
+
129
+ desc "Auto-correct typos"
130
+ def typos_fix
131
+ return skip_typos unless command_available?("typos")
132
+
133
+ sh "typos -w"
134
+ end
135
+
136
+ desc "Check ERB templates for style and safety issues with ERBLint"
137
+ def erb_lint_check
138
+ return skip_erb_lint if Dir.glob("**/*.erb").empty?
139
+
140
+ output = `bundle exec erb_lint --lint-all --enable-all-linters 2>&1`
141
+ result = $?.success? ? :pass : :fail
142
+ File.write("erb_lint_output.txt", output)
143
+ summary = output.lines.reverse.find { |l| l =~ /error\(s\)|no errors were found/i }&.strip || result.to_s
144
+ puts "ERBLint: #{summary} (see erb_lint_output.txt)"
145
+ result
146
+ end
147
+
148
+ desc "Check for performance suggestions with Fasterer"
149
+ def fasterer_check
150
+ output = `bundle exec fasterer lib 2>&1`.gsub(/\e\[\d+m/, "")
151
+ result = $?.success? ? :pass : :warn
152
+ File.write("fasterer_output.txt", output)
153
+ summary = output.lines.find { |l| l =~ /files? inspected/ }&.strip || result.to_s
154
+ puts "Fasterer: #{summary} (see fasterer_output.txt)"
155
+ result
156
+ end
157
+
158
+ desc "Check Gemfile.lock for known vulnerabilities with bundler-audit"
159
+ def bundler_audit_check
160
+ output = `bundle exec bundle-audit check --update 2>&1`
161
+ result = $?.success? ? :pass : :fail
162
+ File.write("bundler_audit_output.txt", output)
163
+ summary = output.lines.reverse.find { |l| l =~ /vulnerabilit/i }&.strip || result.to_s
164
+ puts "Bundler Audit: #{summary} (see bundler_audit_output.txt)"
165
+ result
166
+ end
167
+
168
+ desc "Check architecture boundaries with ArchSpec"
169
+ def archspec_check
170
+ return skip_archspec unless command_available?("archspec") && File.exist?("Archspec.rb")
171
+
172
+ output = `archspec check 2>&1`
173
+ result = $?.success? ? :pass : :fail
174
+ File.write("archspec_output.txt", output)
175
+ summary = output.lines.reverse.find { |l| l =~ /architecture violations? found|ArchSpec passed/i }&.strip ||
176
+ result.to_s
177
+ puts "ArchSpec: #{summary} (see archspec_output.txt)"
178
+ result
179
+ end
180
+
181
+ # Curated display labels for the *_check tasks defined in this file. A
182
+ # *_check task from elsewhere (e.g. quality_rails.loki's brakeman_check,
183
+ # loaded automatically once Rails is defined) isn't listed here — it just
184
+ # falls back to a titleized version of its own name in quality_gate_label.
185
+ QUALITY_GATE_LABELS = {
186
+ "test_check" => "Tests + Coverage",
187
+ "rubocop_check" => "RuboCop",
188
+ "flog_check" => "Flog Complexity",
189
+ "flay_check" => "Flay Duplication",
190
+ "reek_check" => "Reek Smells",
191
+ "erb_lint_check" => "ERBLint",
192
+ "archspec_check" => "ArchSpec",
193
+ }.freeze
194
+
195
+ no_commands do
196
+ # true if +name+ resolves to an executable file somewhere on PATH.
197
+ def command_available?(name)
198
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
199
+ File.executable?(File.join(dir, name))
200
+ end
201
+ end
202
+
203
+ def quality_gate_label(check_name)
204
+ QUALITY_GATE_LABELS.fetch(check_name) do
205
+ check_name.delete_suffix("_check").split("_").map(&:capitalize).join(" ")
206
+ end
207
+ end
208
+
209
+ def skip_typos
210
+ puts "Typos: SKIPPED — `typos` command not found. Install it with `brew install typos-cli`."
211
+ :skip
212
+ end
213
+
214
+ def skip_erb_lint
215
+ puts "ERBLint: SKIPPED — no .erb files found."
216
+ :skip
217
+ end
218
+
219
+ def skip_archspec
220
+ reason = command_available?("archspec") ? "no Archspec.rb found" : "`archspec` command not found"
221
+ puts "ArchSpec: SKIPPED — #{reason}. Install it with `gem install archspec` " \
222
+ "and run `archspec init` to generate one."
223
+ :skip
224
+ end
225
+
226
+ # Status badges. Only :fail is blocking (aborts the quality task);
227
+ # :skip (a required tool isn't installed) and :warn (the check ran but
228
+ # has non-blocking suggestions, e.g. a performance-hint gate) are not.
229
+ STATUS_BADGES = {
230
+ pass: ["PASS", 32], # green
231
+ fail: ["FAIL", 31], # red
232
+ warn: ["WARN", 33], # yellow
233
+ skip: ["SKIP", 36], # cyan
234
+ }.freeze
235
+
236
+ # Colorized status badges plus a pass/fail/warn/skip tally; aborts only
237
+ # if any gate failed — skip and warn are informational, not blocking.
238
+ def print_quality_summary(results)
239
+ colorize_badge = ->(code, text) { "\e[#{code}m#{text}\e[0m" }
240
+ width = results.keys.map(&:length).max
241
+
242
+ puts "\n#{"=" * 60}"
243
+ puts "Quality Gate Summary"
244
+ puts "=" * 60
245
+ results.each do |label, status|
246
+ text, code = STATUS_BADGES.fetch(status)
247
+ puts " [#{colorize_badge.call(code, text)}] #{label.ljust(width)}"
248
+ end
249
+ puts "-" * 60
250
+
251
+ counts = results.values.tally
252
+ failed = counts[:fail] || 0
253
+ tally = STATUS_BADGES.keys.filter_map { |s| "#{counts[s]} #{s}" if counts[s]&.positive? }.join(", ")
254
+ puts " #{colorize_badge.call(failed.zero? ? 32 : 31, tally)}"
255
+ puts "=" * 60
256
+
257
+ abort "\n#{colorize_badge.call(31, "Quality gate failed.")}" unless failed.zero?
258
+ puts "\n#{colorize_badge.call(32, "All quality gates passed.")}"
259
+ end
74
260
  end
75
261
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ # Rails-specific quality gate tasks — imported by .loki only when RAILS_ROOT
3
+ # is set. asgard runs as a separate process outside the app it's checking,
4
+ # so `defined?(Rails)` never sees the app's Rails constant.
5
+ #
6
+ # `quality` (in quality.loki) discovers every *_check task at run time by
7
+ # introspecting Tasks.all_commands, rather than a fixed depends_on list — so
8
+ # any *_check task defined here is automatically picked up by `asgard
9
+ # quality` too, with no need to redeclare or extend anything.
10
+ #
11
+ # Note on rubocop-rails: it's a RuboCop plugin, not a standalone CLI, so it
12
+ # has no *_check task of its own here. Add the gem to the app's Gemfile and
13
+ # `require: rubocop-rails` in its .rubocop.yml — the existing rubocop_check
14
+ # (quality.loki) picks up the added Rails cops automatically.
15
+
16
+ class Tasks
17
+ desc "Check for Rails security vulnerabilities with Brakeman"
18
+ def brakeman_check
19
+ output = `bundle exec brakeman -q 2>&1`
20
+ result = $?.success? ? :pass : :fail
21
+ File.write("brakeman_output.txt", output)
22
+ summary = output.lines.reverse.find { |l| l =~ /warnings? found/i }&.strip || result.to_s
23
+ puts "Brakeman: #{summary} (see brakeman_output.txt)"
24
+ result
25
+ end
26
+
27
+ desc "Check for Rails anti-patterns with RailsBestPractices"
28
+ def rails_best_practices_check
29
+ output = `bundle exec rails_best_practices --silent --without-color . 2>&1`
30
+ result = $?.success? ? :pass : :fail
31
+ File.write("rails_best_practices_output.txt", output)
32
+ summary = output.lines.reverse.find { |l| l =~ /warning/i }&.strip || result.to_s
33
+ puts "RailsBestPractices: #{summary} (see rails_best_practices_output.txt)"
34
+ result
35
+ end
36
+
37
+ desc "Check for database schema issues with ActiveRecordDoctor"
38
+ def active_record_doctor_check
39
+ output = `bundle exec rake active_record_doctor 2>&1`
40
+ result = $?.success? ? :pass : :fail
41
+ File.write("active_record_doctor_output.txt", output)
42
+ issue_count = output.lines.count { |l| l.strip != "" }
43
+ summary = result == :pass ? "no issues found" : "#{issue_count} issue line(s) found"
44
+ puts "ActiveRecordDoctor: #{summary} (see active_record_doctor_output.txt)"
45
+ result
46
+ end
47
+ end
data/xyzzy.loki ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ # example of an overwritten task detected by (--doctor) — imported by .loki
3
+
4
+ class Tasks
5
+ desc "Push the current branch to its remote"
6
+ def xyzzy = puts <<~MAGIC
7
+ You are at a fork in the yellow brick road to AI.
8
+ To the left is python, the favorite programming language of snake charmers.
9
+ To the right is Ruby, the best computer programming language of all time.
10
+ Which way do you go?
11
+ MAGIC
12
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asgard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.0'
26
- - !ruby/object:Gem::Dependency
27
- name: dagwood
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '1.0'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '1.0'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: dotenv
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -65,7 +51,9 @@ files:
65
51
  - ".envrc"
66
52
  - ".github/workflows/deploy-github-pages.yml"
67
53
  - ".loki"
54
+ - ".reek.yml"
68
55
  - ".rubocop.yml"
56
+ - Archspec.rb
69
57
  - CHANGELOG.md
70
58
  - CLAUDE.md
71
59
  - COMMITS.md
@@ -74,6 +62,7 @@ files:
74
62
  - bin/asgard
75
63
  - bin/console
76
64
  - bin/setup
65
+ - doc_tasks.loki
77
66
  - docs/api.md
78
67
  - docs/assets/css/custom.css
79
68
  - docs/assets/images/asgard.jpg
@@ -92,8 +81,11 @@ files:
92
81
  - docs/variables.md
93
82
  - examples/.env
94
83
  - examples/.loki
84
+ - examples/bad.loki
95
85
  - examples/concurrent.loki
96
86
  - examples/db_subcommands.loki
87
+ - examples/depends_on_block/bad/.loki
88
+ - examples/depends_on_block/good/.loki
97
89
  - examples/env_usage.loki
98
90
  - examples/kitchen_sink.loki
99
91
  - examples/server_subcommands.loki
@@ -101,15 +93,25 @@ files:
101
93
  - examples/subdir/import_demo.loki
102
94
  - examples/subdir/import_up_demo.loki
103
95
  - gem_tasks.loki
96
+ - git.loki
104
97
  - lib/asgard.rb
105
98
  - lib/asgard/base.rb
99
+ - lib/asgard/base/dependency_graph.rb
100
+ - lib/asgard/base/dispatch.rb
101
+ - lib/asgard/base/registry.rb
102
+ - lib/asgard/base/task_dsl.rb
103
+ - lib/asgard/doctor.rb
104
+ - lib/asgard/doctor/report.rb
105
+ - lib/asgard/doctor/task_sections.rb
106
106
  - lib/asgard/kernel_methods.rb
107
107
  - lib/asgard/shell.rb
108
108
  - lib/asgard/tasks.rb
109
109
  - lib/asgard/version.rb
110
110
  - mkdocs.yml
111
111
  - quality.loki
112
+ - quality_rails.loki
112
113
  - sig/asgard.rbs
114
+ - xyzzy.loki
113
115
  homepage: https://github.com/madbomber/asgard
114
116
  licenses:
115
117
  - MIT
@@ -132,7 +134,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
132
134
  - !ruby/object:Gem::Version
133
135
  version: '0'
134
136
  requirements: []
135
- rubygems_version: 4.0.12
137
+ rubygems_version: 4.0.19
136
138
  specification_version: 4
137
139
  summary: A powerful Ruby-based task runner
138
140
  test_files: []