judgelint 0.1.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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Files written by `judgelint init`.
5
+ module Scaffold
6
+ CONFIG = <<~YAML
7
+ # judgelint settings. See https://github.com/jpcamara/judgelint#configuration
8
+ models: [typesafe] # `judgelint providers` lists the others; list two to average them
9
+ # threshold: 0.7
10
+ # ignore: ["vendor/**"]
11
+ # max_cost_usd: 1.00
12
+ YAML
13
+
14
+ # A spec: each bullet is a convention judgelint checks. A README with
15
+ # paths: frontmatter works the same way, next to the code it governs.
16
+ SPEC = <<~MARKDOWN
17
+ ---
18
+ paths: ["**/*.rb"]
19
+ excludes: ["test/**", "spec/**"]
20
+ ---
21
+ # Ruby conventions
22
+
23
+ - **No silent rescues.** A rescue logs the error, re-raises it, or turns it
24
+ into a result the caller sees. Swallowed exceptions hide bugs until they
25
+ cost us data.
26
+ - **Error messages say what to do next.** A raised or returned error names
27
+ what was wrong and what would work instead.
28
+ MARKDOWN
29
+
30
+ PASS_EXAMPLE = <<~RUBY
31
+ def fetch_profile(id)
32
+ client.profile(id)
33
+ rescue Client::Timeout => e
34
+ logger.warn("profile \#{id} timed out: \#{e.message}")
35
+ raise
36
+ end
37
+ RUBY
38
+
39
+ FAIL_EXAMPLE = <<~RUBY
40
+ def fetch_profile(id)
41
+ client.profile(id)
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ RUBY
46
+
47
+ def self.write(root, out)
48
+ examples = ".judgelint/examples/ruby/no-silent-rescues"
49
+ files = {
50
+ Config::FILE => CONFIG,
51
+ ".judgelint/rules/ruby.md" => SPEC,
52
+ File.join(examples, "pass", "logs_and_reraises.rb") => PASS_EXAMPLE,
53
+ File.join(examples, "fail", "returns_nil.rb") => FAIL_EXAMPLE
54
+ }
55
+ files.each do |name, content|
56
+ path = root == "." ? name : File.join(root, name)
57
+ if File.exist?(path)
58
+ out.puts "exists #{path}"
59
+ else
60
+ FileUtils.mkdir_p(File.dirname(path))
61
+ File.write(path, content)
62
+ out.puts "create #{path}"
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # What the model is shown: one file, or every file in a directory, with each
5
+ # line numbered so a question can point at a range, followed by any context
6
+ # files the rule names, which are there to read and never judged.
7
+ #
8
+ # Inline suppressions turn a rule off where a person has decided the code is
9
+ # fine, and say why: a comment (#, //, --, /*, <!--, or ;) starting with
10
+ # `judgelint:disable <rule ids> -- <reason>` covers the window it falls in,
11
+ # and `judgelint:disable-file <rule ids> -- <reason>` covers the whole file.
12
+ # In a directory, either one covers the directory.
13
+ class Source
14
+ SUPPRESSION = %r{(?:#|//|--|/\*|<!--|;)\s*judgelint:(disable-file|disable)\b(.*)\z}
15
+ REASONED = %r{\A\s+([A-Za-z0-9_./, -]+?)\s+--\s+\S}
16
+
17
+ attr_reader :path, :shown_as, :line_count
18
+
19
+ def self.directory(dir, shown_as: dir, context: [])
20
+ files = Glob.expand([File.join(dir, "**", "*")])
21
+ new(dir, shown_as: shown_as, files: files, context: context, directory: true)
22
+ end
23
+
24
+ def initialize(path, shown_as: path, files: [path], context: [], directory: false)
25
+ @path = path
26
+ @shown_as = shown_as
27
+ @directory = directory
28
+ @files = files.map { |file| [file, File.read(file).lines] }
29
+ @context = context
30
+ @line_count = @files.sum { |_file, lines| lines.size }
31
+ @disabled = {}
32
+ @disabled_file = []
33
+ @files.each { |file, lines| find_suppressions(file, lines) }
34
+ end
35
+
36
+ def directory?
37
+ @directory
38
+ end
39
+
40
+ def state
41
+ body =
42
+ if directory?
43
+ sections = @files.map { |file, lines| "Path: #{shown(file)}\n\n#{numbered(lines)}" }
44
+ "Directory: #{shown_as}\n\n#{sections.join("\n")}"
45
+ else
46
+ "Path: #{shown_as}\n\n#{numbered(@files.first[1])}"
47
+ end
48
+ return body if @context.empty?
49
+
50
+ references = @context.map { |file| "Path: #{file}\n\n#{File.read(file)}" }
51
+ "#{body}\nContext, for reference only (not under review):\n\n#{references.join("\n")}"
52
+ end
53
+
54
+ # [first, last] ranges of +size+ lines that cover the file.
55
+ def windows(size)
56
+ (1..line_count).step(size).map { |first| [first, [first + size - 1, line_count].min] }
57
+ end
58
+
59
+ def suppressed?(rule_id, first, last)
60
+ return true if @disabled_file.include?(rule_id)
61
+
62
+ lines = @disabled[rule_id] || []
63
+ directory? ? !lines.empty? : lines.any? { |line| line.between?(first, last) }
64
+ end
65
+
66
+ def suppressed_ids
67
+ (@disabled.keys + @disabled_file).uniq
68
+ end
69
+
70
+ private
71
+
72
+ def numbered(lines)
73
+ lines.each_with_index.map { |line, index| "#{index + 1}: #{line.chomp}\n" }.join
74
+ end
75
+
76
+ # A file's path inside the directory, as the model sees it.
77
+ def shown(file)
78
+ "#{shown_as}/#{file.delete_prefix("#{path}/")}"
79
+ end
80
+
81
+ def find_suppressions(file, lines)
82
+ lines.each_with_index do |line, index|
83
+ match = SUPPRESSION.match(line.chomp)
84
+ next unless match
85
+
86
+ reasoned = REASONED.match(match[2])
87
+ raise ConfigError, "#{file}:#{index + 1}: judgelint:#{match[1]} needs rule ids and a reason after --" unless reasoned
88
+
89
+ ids = reasoned[1].split(/[\s,]+/).reject(&:empty?)
90
+ if match[1] == "disable-file"
91
+ @disabled_file.concat(ids)
92
+ else
93
+ ids.each { |id| (@disabled[id] ||= []) << index + 1 }
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Finds rules where a team already writes its conventions: a README (or any
5
+ # *.judgelint.md) whose frontmatter says which files it governs, with one
6
+ # convention per top-level bullet, as praxis reads them.
7
+ #
8
+ # ---
9
+ # paths: ["src/services/*.ts"]
10
+ # excludes: ["src/services/legacy-import.ts"]
11
+ # ---
12
+ # # Service conventions
13
+ #
14
+ # - **Domain failures are values, never exceptions.** A service returns
15
+ # { ok: false, error } for anything a caller could plausibly cause.
16
+ # - **No I/O except through the injected Store.** No timers, no fetch.
17
+ #
18
+ # Each bullet becomes a rule, with an id from its lead words, such as
19
+ # src/services/domain-failures-are-values-never-exceptions. The model sees
20
+ # the spec's prose alongside the file, and is asked whether the code breaks
21
+ # the bullet and whether it follows it. Indented `violation:` and
22
+ # `compliant:` lines under a bullet say what each looks like:
23
+ #
24
+ # - **No I/O except through the injected Store.**
25
+ # violation: calls fetch, console, or a timer directly
26
+ # compliant: reads and writes only through the Store it's given
27
+ #
28
+ # Files in .judgelint/rules/ work the same way, and one there with a
29
+ # question: in its frontmatter is a single rule asked exactly that way.
30
+ module Spec
31
+ PATTERNS = ["**/README.md", "**/*.judgelint.md"].freeze
32
+ KEYS = %w[paths excludes context scope cohort type severity threshold].freeze
33
+ COHORTS = { "by_file" => "file", "by_directory" => "directory" }.freeze
34
+ BULLET = /\A(?:[-*+]|\d+[.)])\s+(\S.*)\z/
35
+ CRITERION = /\A(violation|compliant):\s*(\S.*)\z/i
36
+ LEAD = /\A\*\*(.+?)\*\*/
37
+
38
+ # +readmes+ false looks in +rules_dir+ alone.
39
+ def self.discover(rules_dir:, default_threshold:, ignore: [], readmes: true)
40
+ # path.dup: Spinel can't yet share these Strings into the rules it builds.
41
+ found = Dir.glob(File.join(rules_dir, "*.md")).sort.flat_map do |path|
42
+ load(path.dup, default_threshold: default_threshold, required: true)
43
+ end
44
+ specs = readmes ? Glob.expand(PATTERNS) : []
45
+ specs = specs.reject { |path| path.start_with?("#{rules_dir}/") || Glob.match_any?(ignore, path) }
46
+ found += specs.flat_map { |path| load(path.dup, default_threshold: default_threshold, required: false) }
47
+ raise ConfigError, "No rules: add a README with paths: frontmatter, or run `judgelint init`" if found.empty?
48
+
49
+ found.group_by(&:question_key).each_value do |same|
50
+ raise ConfigError, "Rule ids collide: #{same.map(&:id).join(', ')}" if same.size > 1
51
+ end
52
+ found
53
+ end
54
+
55
+ # A README without frontmatter, or without paths:, isn't a spec.
56
+ def self.load(path, default_threshold:, required:)
57
+ match = Rule::FRONTMATTER.match(File.read(path))
58
+ unless match
59
+ raise ConfigError, "#{path}: rules start with a --- frontmatter block" if required
60
+
61
+ return []
62
+ end
63
+
64
+ data = Judgelint.parse_yaml(match[1], path)
65
+ body = match[2]
66
+ return [Rule.new(data, source: path, body: body.strip, default_threshold: default_threshold)] if data.key?("question")
67
+ return [] unless required || data.key?("paths")
68
+
69
+ unknown = data.keys - KEYS
70
+ raise ConfigError, "#{path}: unknown keys #{unknown.join(', ')}" unless unknown.empty?
71
+
72
+ shared = shared_settings(path, data)
73
+ found = bullets(body)
74
+ raise ConfigError, "#{path}: a rule needs a question, or bullets to turn into rules" if found.empty? && required
75
+
76
+ prose = body.strip
77
+ ids = []
78
+ found.map do |text, criteria|
79
+ id = unique("#{name(path)}/#{slug(text)}", ids)
80
+ rule = shared.merge("id" => id, "convention" => plain(text), "message" => lead(text))
81
+ rule["yes_means"] = plain(criteria["violation"]) if criteria["violation"]
82
+ rule["no_means"] = plain(criteria["compliant"]) if criteria["compliant"]
83
+ rule["severity"] = severity(text) unless data.key?("severity")
84
+ Rule.new(rule, source: path, default_threshold: default_threshold, prose: prose,
85
+ examples_dir: File.join(".judgelint", "examples", id))
86
+ end
87
+ end
88
+
89
+ def self.shared_settings(path, data)
90
+ settings = {}
91
+ %w[paths excludes context severity threshold].each { |key| settings[key] = data[key] if data.key?(key) }
92
+ if data.key?("cohort")
93
+ cohort = data["cohort"].to_s
94
+ raise ConfigError, "#{path}: cohort is by_file or by_directory, not #{cohort}" unless COHORTS.key?(cohort)
95
+
96
+ settings["scope"] = COHORTS.fetch(cohort)
97
+ end
98
+ settings["scope"] = data["scope"] if data.key?("scope")
99
+ settings
100
+ end
101
+
102
+ # The top-level bullets of the body, outside code fences, as [text,
103
+ # criteria]: the text with its continuation lines and nested bullets, and
104
+ # any indented violation: or compliant: lines.
105
+ def self.bullets(body)
106
+ found = []
107
+ current = nil
108
+ criteria = {}
109
+ criterion = nil
110
+ fenced = false
111
+ body.each_line do |raw|
112
+ line = raw.chomp
113
+ if line.lstrip.start_with?("```")
114
+ fenced = !fenced
115
+ next
116
+ end
117
+ next if fenced
118
+
119
+ bullet = BULLET.match(line)
120
+ if bullet
121
+ found << [current, criteria] if current
122
+ current = bullet[1]
123
+ criteria = {}
124
+ criterion = nil
125
+ elsif current && line.start_with?(" ", "\t") && !line.strip.empty?
126
+ nested = BULLET.match(line.strip)
127
+ text = nested ? nested[1] : line.strip
128
+ named = CRITERION.match(text)
129
+ if named
130
+ criterion = named[1].downcase
131
+ criteria[criterion] = named[2]
132
+ elsif criterion
133
+ criteria[criterion] = "#{criteria[criterion]} #{text}"
134
+ else
135
+ current = "#{current} #{text}"
136
+ end
137
+ elsif current && !line.strip.empty?
138
+ found << [current, criteria]
139
+ current = nil
140
+ end
141
+ end
142
+ found << [current, criteria] if current
143
+ found
144
+ end
145
+
146
+ # The bullet with its markdown emphasis removed: what the model is asked about.
147
+ def self.plain(text)
148
+ text.gsub("**", "").gsub(/\s+/, " ").strip
149
+ end
150
+
151
+ # The bold lead, or the first sentence: what judgelint prints for a finding.
152
+ def self.lead(text)
153
+ bold = LEAD.match(text)
154
+ sentence = bold ? bold[1] : plain(text)
155
+ sentence = sentence[0, sentence.index(/[.!?:](\s|\z)/) || sentence.size]
156
+ "#{sentence.strip.sub(/[.!?:]\z/, '')}."
157
+ end
158
+
159
+ def self.slug(text)
160
+ words = lead(text).downcase.scan(/[a-z0-9]+/).first(6)
161
+ words.empty? ? "rule" : words.join("-")
162
+ end
163
+
164
+ def self.unique(id, taken)
165
+ candidate = id
166
+ number = 1
167
+ while taken.include?(candidate)
168
+ number += 1
169
+ candidate = "#{id}-#{number}"
170
+ end
171
+ taken << candidate
172
+ candidate
173
+ end
174
+
175
+ # Spec names: a README's directory, a *.judgelint.md's stem, or a rules file's name.
176
+ def self.name(path)
177
+ dir = File.dirname(path)
178
+ return(dir == "." ? "root" : dir) if File.basename(path) == "README.md"
179
+
180
+ File.basename(path).delete_suffix(".md").delete_suffix(".judgelint")
181
+ end
182
+
183
+ # Praxis's reading: "should" and "prefer" are advice, anything else is a rule.
184
+ def self.severity(text)
185
+ advisory = /\b(should|prefer|ideally|consider)\b/i.match?(text)
186
+ advisory && !/\b(must|never|always)\b/i.match?(text) ? "warning" : "error"
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Requests and input tokens across a run, and what they cost. Decision
5
+ # models bill input only, so input tokens are the cost. Shared by every
6
+ # model's client in a run.
7
+ class Usage
8
+ attr_reader :requests, :input_tokens
9
+
10
+ def initialize
11
+ @requests = 0
12
+ @input_tokens = 0
13
+ @cost = 0.0
14
+ @unpriced = false
15
+ @lock = Mutex.new
16
+ end
17
+
18
+ # OpenRouter reports each request's cost; for the rest it's tokens x the
19
+ # model's price. A request with neither makes the run's cost unknown.
20
+ def record(data, price_per_million)
21
+ usage = data["usage"].is_a?(Hash) ? data["usage"] : {}
22
+ tokens = usage["input_tokens"].to_i
23
+ @lock.synchronize do
24
+ @requests += 1
25
+ @input_tokens += tokens
26
+ if usage["cost"].is_a?(Numeric)
27
+ @cost += usage["cost"].to_f
28
+ elsif price_per_million
29
+ @cost += tokens * price_per_million / 1_000_000.0
30
+ else
31
+ @unpriced = true
32
+ end
33
+ end
34
+ end
35
+
36
+ def cost
37
+ @unpriced ? nil : @cost
38
+ end
39
+
40
+ def to_h
41
+ { "requests" => requests, "input_tokens" => input_tokens, "cost_usd" => cost&.round(6) }
42
+ end
43
+
44
+ def line
45
+ cost_text = cost ? format("$%.4f", cost) : "cost unknown (no price for a model)"
46
+ "judgelint: #{requests} #{requests == 1 ? 'request' : 'requests'}, #{Usage.delimit(input_tokens)} input tokens, #{cost_text}"
47
+ end
48
+
49
+ def self.delimit(number)
50
+ number.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # One rule's outcome for one file.
5
+ #
6
+ # +status+ is :violation (at or above the rule's threshold), :uncertain
7
+ # (between warn_at and the threshold), :pass, :skipped (the file is too
8
+ # large to judge), :refused (the model declined the question), or
9
+ # :suppressed (a judgelint:disable comment covers it). Skipped and refused
10
+ # files are reported, never silently passed. +lines+ is the [first, last]
11
+ # range the verdict came from.
12
+ #
13
+ # An example's verdict also knows the answer it should have had: +expected+
14
+ # is :pass or :fail for files under rules/<id>/pass and rules/<id>/fail.
15
+ # Not Data.define: Spinel can't compile a Data or Struct in a program that also uses net/https and json.
16
+ class Verdict
17
+ attr_reader :rule, :path, :probability, :status, :lines, :expected
18
+
19
+ def initialize(rule:, path:, probability:, status:, lines: nil, expected: nil)
20
+ @rule = rule
21
+ @path = path
22
+ @probability = probability
23
+ @status = status
24
+ @lines = lines
25
+ @expected = expected
26
+ end
27
+
28
+ def as_example(expected)
29
+ Verdict.new(rule: rule, path: path, probability: probability, status: status, lines: lines, expected: expected)
30
+ end
31
+
32
+ # +probability+ is the probability the code breaks the rule.
33
+ def self.classify(rule, path, probability, warn_at:, lines: nil)
34
+ status =
35
+ if probability >= rule.threshold then :violation
36
+ elsif probability >= warn_at then :uncertain
37
+ else :pass
38
+ end
39
+ new(rule: rule, path: path, probability: probability, status: status, lines: lines)
40
+ end
41
+
42
+ def failing?
43
+ status == :violation && rule.error?
44
+ end
45
+
46
+ # A refused, skipped, or suppressed example is a miss either way: the rule
47
+ # didn't work on it.
48
+ def correct?
49
+ return false if %i[refused skipped suppressed].include?(status)
50
+
51
+ (status == :violation) == (expected == :fail)
52
+ end
53
+
54
+ def to_h
55
+ {
56
+ "path" => path, "lines" => lines, "rule" => rule.id, "severity" => rule.severity, "status" => status.to_s,
57
+ "probability" => probability&.round(3), "message" => rule.message
58
+ }.merge(expected ? { "expected" => expected.to_s, "correct" => correct? } : {})
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ VERSION = "0.1.0"
5
+ end
data/lib/judgelint.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "digest"
6
+ require "fileutils"
7
+ require "net/https"
8
+ require "open3"
9
+
10
+ require_relative "judgelint/version"
11
+ require_relative "judgelint/errors"
12
+ require_relative "judgelint/glob"
13
+ require_relative "judgelint/rule"
14
+ require_relative "judgelint/spec"
15
+ require_relative "judgelint/source"
16
+ require_relative "judgelint/question"
17
+ require_relative "judgelint/changes"
18
+ require_relative "judgelint/providers"
19
+ require_relative "judgelint/config"
20
+ require_relative "judgelint/cache"
21
+ require_relative "judgelint/usage"
22
+ require_relative "judgelint/pool"
23
+ require_relative "judgelint/verdict"
24
+ require_relative "judgelint/protocol"
25
+ require_relative "judgelint/client"
26
+ require_relative "judgelint/runner"
27
+ require_relative "judgelint/reporter"
28
+ require_relative "judgelint/scaffold"
29
+ require_relative "judgelint/cli"
30
+
31
+ # Lints files against conventions that only a reader can check. Each rule is a
32
+ # yes/no question that a System One decision model answers with a probability.
33
+ module Judgelint
34
+ NUMBER = /\A-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\z/
35
+
36
+ # Float(), without the rescue: answers nil for anything that isn't a number.
37
+ def self.number(value)
38
+ string = value.to_s.strip
39
+ NUMBER.match?(string) ? string.to_f : nil
40
+ end
41
+
42
+ def self.read_yaml(path, required:)
43
+ return parse_yaml(File.read(path), path) if File.exist?(path)
44
+ raise ConfigError, "No config file at #{path}" if required
45
+
46
+ {}
47
+ end
48
+
49
+ def self.parse_yaml(yaml, path)
50
+ data = YAML.safe_load(yaml) || {}
51
+ raise ConfigError, "#{path} must be a mapping" unless data.is_a?(Hash)
52
+
53
+ data
54
+ rescue Psych::SyntaxError => e
55
+ raise ConfigError, "#{path}: #{e.message}"
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: judgelint
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - JP Camara
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ judgelint checks files against conventions that only a reader can judge. Each
14
+ rule is a yes/no question answered by a fast decision model such as TypeSafe's
15
+ Jev over the System One protocol. It also compiles to a native binary with
16
+ Spinel.
17
+ email:
18
+ - johnpcamara@gmail.com
19
+ executables:
20
+ - judgelint
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - CHANGELOG.md
25
+ - LICENSE
26
+ - README.md
27
+ - exe/judgelint
28
+ - lib/judgelint.rb
29
+ - lib/judgelint/cache.rb
30
+ - lib/judgelint/changes.rb
31
+ - lib/judgelint/cli.rb
32
+ - lib/judgelint/client.rb
33
+ - lib/judgelint/config.rb
34
+ - lib/judgelint/errors.rb
35
+ - lib/judgelint/glob.rb
36
+ - lib/judgelint/pool.rb
37
+ - lib/judgelint/protocol.rb
38
+ - lib/judgelint/providers.rb
39
+ - lib/judgelint/question.rb
40
+ - lib/judgelint/reporter.rb
41
+ - lib/judgelint/rule.rb
42
+ - lib/judgelint/runner.rb
43
+ - lib/judgelint/scaffold.rb
44
+ - lib/judgelint/source.rb
45
+ - lib/judgelint/spec.rb
46
+ - lib/judgelint/usage.rb
47
+ - lib/judgelint/verdict.rb
48
+ - lib/judgelint/version.rb
49
+ homepage: https://github.com/jpcamara/judgelint
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ source_code_uri: https://github.com/jpcamara/judgelint
54
+ changelog_uri: https://github.com/jpcamara/judgelint/blob/main/CHANGELOG.md
55
+ rubygems_mfa_required: 'true'
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '3.2'
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 3.6.9
71
+ specification_version: 4
72
+ summary: Lint for conventions a linter can't check, judged by System One decision
73
+ models.
74
+ test_files: []