asgard 0.3.1 → 0.3.2

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.
@@ -29,11 +29,15 @@ module Kernel
29
29
  end
30
30
  module_function :loki_up
31
31
 
32
- def import(path)
32
+ # +from+ is the call-site location used to resolve a relative +path+. It
33
+ # defaults to the immediate caller, but is exposed as a keyword so a
34
+ # wrapper (e.g. Asgard::Doctor's import tracer, prepended via `super`)
35
+ # can thread through the *real* caller instead of its own frame.
36
+ def import(path, from: caller_locations(1, 1).first)
33
37
  path = path.to_s
34
38
  raise ArgumentError, "import: path must end with .loki (got #{path.inspect})" unless path.end_with?(".loki")
35
39
  unless File.absolute_path?(path)
36
- caller_dir = File.dirname(caller_locations(1, 1).first.absolute_path)
40
+ caller_dir = File.dirname(from.absolute_path)
37
41
  path = File.expand_path(path, caller_dir)
38
42
  end
39
43
  paths = path =~ /[*?\[{]/ ? Dir.glob(path) : [path]
data/lib/asgard/tasks.rb CHANGED
@@ -26,4 +26,10 @@ class Tasks < Asgard::Base
26
26
  default: false,
27
27
  desc: "Show asgard version and exit"
28
28
  no_negate :version
29
+
30
+ class_option :doctor,
31
+ type: :boolean,
32
+ default: false,
33
+ desc: "Diagnose .loki resolution, imports, and task definitions for the CWD, then exit"
34
+ no_negate :doctor
29
35
  end
@@ -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.2"
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
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,46 @@
1
+ # frozen_string_literal: true
2
+ # Rails-specific quality gate tasks — imported by .loki only when the Rails
3
+ # constant is defined.
4
+ #
5
+ # `quality` (in quality.loki) discovers every *_check task at run time by
6
+ # introspecting Tasks.all_commands, rather than a fixed depends_on list — so
7
+ # any *_check task defined here is automatically picked up by `asgard
8
+ # quality` too, with no need to redeclare or extend anything.
9
+ #
10
+ # Note on rubocop-rails: it's a RuboCop plugin, not a standalone CLI, so it
11
+ # has no *_check task of its own here. Add the gem to the app's Gemfile and
12
+ # `require: rubocop-rails` in its .rubocop.yml — the existing rubocop_check
13
+ # (quality.loki) picks up the added Rails cops automatically.
14
+
15
+ class Tasks
16
+ desc "Check for Rails security vulnerabilities with Brakeman"
17
+ def brakeman_check
18
+ output = `bundle exec brakeman -q 2>&1`
19
+ result = $?.success? ? :pass : :fail
20
+ File.write("brakeman_output.txt", output)
21
+ summary = output.lines.reverse.find { |l| l =~ /warnings? found/i }&.strip || result.to_s
22
+ puts "Brakeman: #{summary} (see brakeman_output.txt)"
23
+ result
24
+ end
25
+
26
+ desc "Check for Rails anti-patterns with RailsBestPractices"
27
+ def rails_best_practices_check
28
+ output = `bundle exec rails_best_practices --silent --without-color . 2>&1`
29
+ result = $?.success? ? :pass : :fail
30
+ File.write("rails_best_practices_output.txt", output)
31
+ summary = output.lines.reverse.find { |l| l =~ /warning/i }&.strip || result.to_s
32
+ puts "RailsBestPractices: #{summary} (see rails_best_practices_output.txt)"
33
+ result
34
+ end
35
+
36
+ desc "Check for database schema issues with ActiveRecordDoctor"
37
+ def active_record_doctor_check
38
+ output = `bundle exec rake active_record_doctor 2>&1`
39
+ result = $?.success? ? :pass : :fail
40
+ File.write("active_record_doctor_output.txt", output)
41
+ issue_count = output.lines.count { |l| l.strip != "" }
42
+ summary = result == :pass ? "no issues found" : "#{issue_count} issue line(s) found"
43
+ puts "ActiveRecordDoctor: #{summary} (see active_record_doctor_output.txt)"
44
+ result
45
+ end
46
+ 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.2
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
@@ -92,6 +80,7 @@ files:
92
80
  - docs/variables.md
93
81
  - examples/.env
94
82
  - examples/.loki
83
+ - examples/bad.loki
95
84
  - examples/concurrent.loki
96
85
  - examples/db_subcommands.loki
97
86
  - examples/env_usage.loki
@@ -101,15 +90,25 @@ files:
101
90
  - examples/subdir/import_demo.loki
102
91
  - examples/subdir/import_up_demo.loki
103
92
  - gem_tasks.loki
93
+ - git.loki
104
94
  - lib/asgard.rb
105
95
  - lib/asgard/base.rb
96
+ - lib/asgard/base/dependency_graph.rb
97
+ - lib/asgard/base/dispatch.rb
98
+ - lib/asgard/base/registry.rb
99
+ - lib/asgard/base/task_dsl.rb
100
+ - lib/asgard/doctor.rb
101
+ - lib/asgard/doctor/report.rb
102
+ - lib/asgard/doctor/task_sections.rb
106
103
  - lib/asgard/kernel_methods.rb
107
104
  - lib/asgard/shell.rb
108
105
  - lib/asgard/tasks.rb
109
106
  - lib/asgard/version.rb
110
107
  - mkdocs.yml
111
108
  - quality.loki
109
+ - quality_rails.loki
112
110
  - sig/asgard.rbs
111
+ - xyzzy.loki
113
112
  homepage: https://github.com/madbomber/asgard
114
113
  licenses:
115
114
  - MIT
@@ -132,7 +131,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
132
131
  - !ruby/object:Gem::Version
133
132
  version: '0'
134
133
  requirements: []
135
- rubygems_version: 4.0.12
134
+ rubygems_version: 4.0.19
136
135
  specification_version: 4
137
136
  summary: A powerful Ruby-based task runner
138
137
  test_files: []