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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c59739f54cb0b198f538842c411554b101b4a3a836f641f57e8f1ebd97fca0de
4
+ data.tar.gz: 42b6bec77cc90b1a2f94b4d8a0782531f21d97ec543ad83df0cebcf67ca83cc9
5
+ SHA512:
6
+ metadata.gz: ee5af8e99281ac1f1946512a485171f6bf9ad3afdbc4eba3ba251f1694c5315668c0d5185895a057f0f38007c6451dff27f3fedf11635bde51dcace18326e188
7
+ data.tar.gz: e84ca3de964ffdf65a92f92d6ea095bf0282cc091e4c7b20183a30b50fdab65292e9140511e5e402ffe623962ff4c8b0456a562f06278cc75a8b5c69ea992ed8
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-23)
4
+
5
+ - Specs as praxis reads them: a README (or `*.judgelint.md`) with `paths:`
6
+ frontmatter, each top-level bullet a rule. The model reads the spec's prose
7
+ with the file and is asked whether the code breaks each bullet and whether
8
+ it follows it; optional `violation:` and `compliant:` lines sharpen a
9
+ bullet. Question rules in `.judgelint/rules/` for conventions a bullet
10
+ can't carry.
11
+ - Window, file, and directory scopes; `context:` files the model reads but
12
+ never judges; line ranges on findings.
13
+ - `check`, `test` (examples under `.judgelint/examples/`), `rules`,
14
+ `providers`, and `init`; `--base` and `--pr` for changes; text, JSON,
15
+ GitHub annotations, and SARIF.
16
+ - Every request planned and priced before it's sent, with `max_cost_usd`; a
17
+ cost line on every run; answers cached per model.
18
+ - 16 provider presets over the System One and OpenAI Decisions protocols, and
19
+ `models:` to average several.
20
+ - Inline `judgelint:disable` comments, with a required reason.
21
+ - No runtime dependencies; platform gems for macOS and Linux carry a native
22
+ binary built with Spinel.
23
+ - examples/scoop-society: praxis's TypeScript demo, checked against praxis's
24
+ verdicts.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JP Camara
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # judgelint
2
+
3
+ Lint for the conventions a linter can't check.
4
+
5
+ Write your team's conventions as bullets in a README. judgelint asks a System
6
+ One decision model, such as TypeSafe's Jev, whether each file breaks each
7
+ one, and reports violations with line ranges and exit codes, like a linter.
8
+ Answers take a few hundred milliseconds and cost a fraction of a cent.
9
+
10
+ Inspired by [praxis](https://github.com/zarpay/praxis-cli), which reads the
11
+ same kind of specs with chat models. judgelint trades praxis's written
12
+ findings for speed and cost, and reads praxis's specs as they are. Ruby with
13
+ no dependencies; also a native binary, built with
14
+ [Spinel](https://github.com/matz/spinel).
15
+
16
+ ## Quick start
17
+
18
+ ```sh
19
+ gem install judgelint
20
+ judgelint init # .judgelint.yml and an example spec
21
+ export TYPESAFE_API_KEY=...
22
+ judgelint test # judge the spec's examples
23
+ judgelint --base main # check what changed since main
24
+ judgelint # or every file the specs govern
25
+ ```
26
+
27
+ On [praxis's demo](examples/scoop-society) (long messages cut here):
28
+
29
+ ```text
30
+ $ judgelint src/services/apply-refund.ts src/features/flavor-of-day
31
+ src/services/apply-refund.ts
32
+ x error src/services/domain-failures-are-values-never-exceptions 1-21 0.95 Domain failures are values, never exceptions.
33
+ x error src/services/error-messages-are-written-for-the 1-21 0.94 Error messages are written for the API consumer.
34
+ x error src/services/every-service-has-a-doc-comment 1-21 0.97 Every service has a doc comment …
35
+ src/features/flavor-of-day
36
+ x error src/features/exactly-one-entry-point-index-ts 0.81 Exactly one entry point, `index.ts`, …
37
+ x error src/features/logic-files-are-verb-first-kebab 0.87 Logic files are verb-first kebab-case …
38
+ ? uncertain src/features/no-orphaned-files 0.60 No orphaned files.
39
+ ? uncertain src/features/the-feature-s-name-describes-the 0.44 The feature's name describes the capability …
40
+
41
+ 2 files, 5 violations, 2 uncertain
42
+ judgelint: 2 requests, 3,320 input tokens, $0.0001
43
+ ```
44
+
45
+ Exit status: 0 clean, 1 violations (or misjudged examples for `judgelint
46
+ test`), 2 for usage, configuration, or model errors. Everything that can be
47
+ checked is checked before the first paid request.
48
+
49
+ ## Specs
50
+
51
+ A README, or any `*.judgelint.md`, whose frontmatter has `paths:` is a spec.
52
+ Each top-level bullet is a rule.
53
+
54
+ ```markdown
55
+ ---
56
+ paths: ["src/services/*.ts"]
57
+ excludes: ["src/services/legacy-import.ts"]
58
+ ---
59
+ # Service conventions
60
+
61
+ - **Domain failures are values, never exceptions.** A service returns
62
+ `{ ok: false, error }` for anything a caller could plausibly cause.
63
+ - **No I/O except through the injected Store.** No timers, no fetch, no console.
64
+ ```
65
+
66
+ The model reads the spec's prose and the whole file, with numbered lines, and
67
+ is asked each bullet two ways: "Do lines 1-40 of the file clearly violate the
68
+ following team rule?" and "Do they follow it, or does it not apply?" The two
69
+ answers lean opposite ways, so their average is steadier than either. Rules
70
+ are named from their lead words (`src/services/no-i-o-except-through-the`),
71
+ and a bullet that says "should" or "prefer" is a warning, anything else an
72
+ error.
73
+
74
+ To sharpen a bullet, say what breaking and following it look like, on
75
+ indented lines under it:
76
+
77
+ ```markdown
78
+ - **No I/O except through the injected Store.** No timers, no fetch, no console.
79
+ violation: calls fetch, console, or a timer directly
80
+ compliant: reads and writes only through the Store it's given
81
+ ```
82
+
83
+ They make the model more decisive, and like any wording, they're worth
84
+ checking against examples.
85
+
86
+ | Frontmatter | |
87
+ |---|---|
88
+ | `paths`, `excludes` | Globs for the files the spec governs, from the project root. |
89
+ | `context` | Files the model reads alongside, such as shared types, but never judges. |
90
+ | `scope` | `window` (default) asks about each 40 lines, so findings name lines; `file` asks once per file; `directory` asks once per directory `paths` matches, with every file in it. Praxis's `cohort: by_file` and `by_directory` work too. |
91
+ | `severity`, `threshold` | Override the wording's severity, or the config's threshold, for every rule in the spec. |
92
+
93
+ A rule's answer is the probability the code breaks it: at or above the
94
+ threshold (0.7) it's a violation, within 0.3 below it's uncertain (reported,
95
+ never failing).
96
+
97
+ **When a bullet isn't enough**, write that rule as a question in
98
+ `.judgelint/rules/<id>.md`, with criteria the model weighs its answer against:
99
+
100
+ ```markdown
101
+ ---
102
+ question: Is any file in this directory unreachable from index.ts through its imports?
103
+ yes_means: A file nothing in the feature imports, or a directory with no index.ts at all.
104
+ no_means: Every file is imported, directly or indirectly, by index.ts.
105
+ scope: directory
106
+ paths: ["src/features/*"]
107
+ ---
108
+ Every file in a feature is reachable from index.ts.
109
+ ```
110
+
111
+ `fail_when: no` inverts a question like "Does this service validate its
112
+ input first?".
113
+
114
+ **Suppress** a finding where a person has decided the code is fine, with a
115
+ reason: `# judgelint:disable <rule> -- <reason>` covers the 40 lines it's in,
116
+ and `judgelint:disable-file` the whole file. Any comment style works.
117
+
118
+ ## Examples
119
+
120
+ Decision models classify well and apply policies less well, so the way to
121
+ know a rule works is to try it. Put examples under
122
+ `.judgelint/examples/<rule id>/pass/` (must not be violations) and `fail/`
123
+ (must be), and `judgelint test` reports what the model gets wrong:
124
+
125
+ ```text
126
+ lib/no-silent-rescues 9/10
127
+ ok pass 0.03 .judgelint/examples/lib/no-silent-rescues/pass/logs_and_reraises.rb
128
+ MISS pass 0.79 .judgelint/examples/lib/no-silent-rescues/pass/retries_then_raises.rb
129
+ ok fail 0.94 .judgelint/examples/lib/no-silent-rescues/fail/assigns_default.rb
130
+ ```
131
+
132
+ A miss means the bullet needs rewording or the convention is too broad for
133
+ one question. (Saying a retry that eventually raises is fine took this repo's
134
+ own rule, in [lib/README.md](lib/README.md), to 10/10.) The model sees each
135
+ example's path relative to `pass/` or `fail/`, so the directory never gives
136
+ the answer away. For a directory rule, each example is a directory.
137
+
138
+ ## In CI
139
+
140
+ ```sh
141
+ judgelint --base main # since the merge base with main, uncommitted work included
142
+ judgelint --pr # the pull request's base branch
143
+ judgelint --pr --format github # annotations on the changed lines (also: json, sarif)
144
+ ```
145
+
146
+ `--base` asks only about the 40-line windows a change touched, and about
147
+ directories with a changed file, so old code can't block new work. `--pr`
148
+ reads the base branch from the CI (GitHub Actions, GitLab, Buildkite,
149
+ Bitbucket, Azure Pipelines, or `JUDGELINT_BASE_BRANCH`), then `gh`. Check out
150
+ enough history for the merge base.
151
+
152
+ ## Configuration
153
+
154
+ `.judgelint.yml` has four settings:
155
+
156
+ ```yaml
157
+ models: [typesafe] # a model is provider[:model]; list two to average their answers
158
+ threshold: 0.7
159
+ ignore: ["vendor/**"] # files no rule judges, and READMEs that are never specs
160
+ max_cost_usd: 1.00 # refuse a run estimated over this, unless --yes
161
+ ```
162
+
163
+ Flags: `--base`, `--pr`, `--format`, `--model` (replaces `models` for one
164
+ run), `--no-cache`, `--yes`, `--config`.
165
+
166
+ Every run ends with its cost. Answers are cached in `.judgelint/cache.json`
167
+ by file, question, and model, so an unchanged file costs nothing; commit the
168
+ cache to share it.
169
+
170
+ **Two models are more certain than one.** On praxis's demo, Jev alone leaves
171
+ 9 to 11 findings uncertain; averaging Jev and Cloudflare's Clef leaves 4, with
172
+ the same 12 of 14 violations found and none false, for $0.02 a run
173
+ (`models: [typesafe, cloudflare]`). Each model's answers are cached on their
174
+ own, and all of a file's requests go out at once. The
175
+ [example](examples/scoop-society) compares the models one at a time.
176
+
177
+ ### Providers
178
+
179
+ `judgelint providers` lists them. Live-tested: `typesafe` (Jev, the default),
180
+ `openrouter`, `celeris`, `cloudflare`, and `openai` (its Decisions API). From
181
+ their docs: `perplexity`, `vercel`, `digitalocean`, `upstage`, `hanzo`,
182
+ `foundry`, `databricks`, and local `ollama`, `llamacpp`, `vllm`, and
183
+ `litellm`. Each reads its key from the usual variable (`TYPESAFE_API_KEY`,
184
+ `CLOUDFLARE_API_TOKEN`, and so on).
185
+
186
+ Any other System One or Decisions endpoint is a mapping in `models`:
187
+
188
+ ```yaml
189
+ models:
190
+ - api_base: https://gateway.example.com/${TEAM} # ${VAR} reads the environment
191
+ model: some-decider
192
+ api_key_env: GATEWAY_KEY
193
+ # path: /v1/systemone protocol: system_one|decisions auth: bearer|basic|none|header:<name>
194
+ # headers: {} provider_options: {} price_per_million: 0.04 batch_size: 32
195
+ ```
196
+
197
+ ## Native binary
198
+
199
+ ```sh
200
+ git clone https://github.com/matz/spinel && (cd spinel && make deps && make)
201
+ SPINEL=../spinel/bin/spinel rake spinel # build/judgelint
202
+ SPINEL=../spinel/bin/spinel rake test:spinel # the tests, against it too
203
+ ```
204
+
205
+ On macOS and Linux, `gem install` picks a platform gem with the binary
206
+ inside, statically linked, and links the `judgelint` command straight to it;
207
+ elsewhere, Windows included, the gem runs as plain Ruby. The binary starts in
208
+ 4 ms against CRuby's 110, and in a 300-file benchmark it was 1.4x faster than
209
+ CRuby on one thread and 2x on eight. Under Bundler, `bundle exec` spends about
210
+ 100 ms loading Bundler first; to skip it, link the binary
211
+ (`ln -s "$(bundle info judgelint --path)/libexec/judgelint" bin/judgelint`). The code stays inside what Spinel
212
+ compiles: plain classes instead of `Data.define`, a hand-written option
213
+ parser, no gems.
214
+
215
+ ## Development
216
+
217
+ ```sh
218
+ bundle install
219
+ bundle exec rake test
220
+ bundle exec rake example # re-ask Jev about praxis's demo and check it
221
+ ```
222
+
223
+ ## License
224
+
225
+ MIT
data/exe/judgelint ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # A platform gem (judgelint-*-arm64-darwin and so on) carries the Spinel-built
5
+ # binary in libexec/; run it when it's there. Spinel compiles this file too,
6
+ # and drops this branch.
7
+ if RUBY_ENGINE != "spinel"
8
+ native = File.expand_path("../libexec/judgelint", __dir__)
9
+ exec(native, *ARGV) if File.executable?(native)
10
+ end
11
+
12
+ require "judgelint"
13
+
14
+ exit Judgelint::CLI.new(ARGV).run
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Stores each answer under a hash of everything that produced it: the state
5
+ # the model saw (path and numbered contents), the question asked of it, and
6
+ # the model. Changing any of them is a miss. Safe to share across threads.
7
+ class Cache
8
+ def initialize(path)
9
+ @path = path
10
+ @entries = load_entries
11
+ @dirty = false
12
+ @lock = Mutex.new
13
+ end
14
+
15
+ def self.key(question_digest, state_digest, model_label)
16
+ Digest::SHA256.hexdigest([question_digest, state_digest, model_label].join(":"))
17
+ end
18
+
19
+ def fetch(key)
20
+ @lock.synchronize { @entries[key] }
21
+ end
22
+
23
+ def store(key, yes_probability)
24
+ @lock.synchronize do
25
+ @entries[key] = yes_probability
26
+ @dirty = true
27
+ end
28
+ end
29
+
30
+ def save
31
+ @lock.synchronize do
32
+ return unless @path && @dirty
33
+
34
+ FileUtils.mkdir_p(File.dirname(@path))
35
+ File.write(@path, "#{JSON.pretty_generate(@entries.sort.to_h)}\n")
36
+ @dirty = false
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def load_entries
43
+ return {} unless @path && File.exist?(@path)
44
+
45
+ data = JSON.parse(File.read(@path))
46
+ raise ConfigError, "#{@path} isn't a judgelint cache; delete it to start over" unless data.is_a?(Hash)
47
+
48
+ data
49
+ rescue JSON::ParserError
50
+ raise ConfigError, "#{@path} isn't valid JSON; delete it to start over"
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # The lines a change touched, from git: everything since the merge base with
5
+ # a ref, uncommitted work included. Old code outside the change isn't asked
6
+ # about, so it can't block new work.
7
+ module Changes
8
+ # A branch, tag, or commit (HEAD~1 too), and nothing git could read as an
9
+ # option or a range.
10
+ REF = %r{\A[A-Za-z0-9][A-Za-z0-9._/~^-]*\z}
11
+ HUNK = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
12
+
13
+ # Returns {path => [changed line numbers]}, or :all for a new file.
14
+ def self.since(ref)
15
+ check_ref(ref)
16
+ base = git("merge-base", ref, "HEAD").strip
17
+ changed = parse_diff(git("-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-ext-diff",
18
+ "--relative", "--diff-filter=d", base))
19
+ git("ls-files", "--others", "--exclude-standard").lines.each { |path| changed[path.chomp] = :all }
20
+ changed
21
+ end
22
+
23
+ # CI variables that name a pull request's base branch.
24
+ BASE_BRANCH_VARIABLES = %w[
25
+ JUDGELINT_BASE_BRANCH GITHUB_BASE_REF CI_MERGE_REQUEST_TARGET_BRANCH_NAME
26
+ BUILDKITE_PULL_REQUEST_BASE_BRANCH BITBUCKET_PR_DESTINATION_BRANCH SYSTEM_PULLREQUEST_TARGETBRANCH
27
+ ].freeze
28
+
29
+ # The pull request's base branch, fetched, as origin/<branch>: from
30
+ # JUDGELINT_BASE_BRANCH, then GitHub Actions, GitLab, Buildkite, Bitbucket,
31
+ # or Azure Pipelines, then the gh CLI.
32
+ def self.pr_base
33
+ branch = pr_branch
34
+ raise ConfigError, "Couldn't find the pull request's base branch" if branch.empty?
35
+
36
+ check_ref(branch)
37
+ git("fetch", "--no-tags", "--quiet", "origin", branch)
38
+ "origin/#{branch}"
39
+ end
40
+
41
+ def self.pr_branch
42
+ BASE_BRANCH_VARIABLES.each do |name|
43
+ # Buildkite sets "false" outside a pull request; Azure names a full ref.
44
+ branch = ENV.fetch(name, "").delete_prefix("refs/heads/")
45
+ return branch unless branch.empty? || branch == "false"
46
+ end
47
+ command("gh", "pr", "view", "--json", "baseRefName", "-q", ".baseRefName").strip
48
+ end
49
+
50
+ def self.parse_diff(diff)
51
+ changed = {}
52
+ path = nil
53
+ diff.each_line do |line|
54
+ if line.start_with?("+++ ")
55
+ target = line.chomp.delete_prefix("+++ ").delete_suffix("\t")
56
+ path = target == "/dev/null" ? nil : target.delete_prefix("b/")
57
+ elsif path && (hunk = HUNK.match(line))
58
+ start = hunk[1].to_i
59
+ count = hunk[2] ? hunk[2].to_i : 1
60
+ # A pure deletion marks the line above the gap, so it's still judged.
61
+ lines = count.zero? ? [[start, 1].max] : (start...(start + count)).to_a
62
+ (changed[path] ||= []).concat(lines)
63
+ end
64
+ end
65
+ changed
66
+ end
67
+
68
+ def self.check_ref(ref)
69
+ return if REF.match?(ref) && !ref.include?("..")
70
+
71
+ raise ConfigError, "Not a usable git ref: #{ref}"
72
+ end
73
+
74
+ def self.git(*args)
75
+ command("git", *args)
76
+ end
77
+
78
+ def self.command(*args)
79
+ out, err, status = Open3.capture3(*args)
80
+ raise ConfigError, "#{args.first(3).join(' ')} failed: #{err.strip.lines.first.to_s.strip}" unless status.success?
81
+
82
+ out
83
+ rescue SystemCallError => e
84
+ raise ConfigError, "Couldn't run #{args.first}: #{e.message}"
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Command line entry point. Exit codes: 0 clean, 1 violations (or missed
5
+ # examples for `test`), 2 usage or configuration error. Everything that can
6
+ # be checked is checked before the first paid request.
7
+ class CLI
8
+ COMMANDS = %w[check test rules providers init].freeze
9
+
10
+ HELP = <<~TEXT
11
+ Usage: judgelint [command] [options] [paths...]
12
+
13
+ Checks files against conventions written as bullets in READMEs, by asking a
14
+ System One decision model (Jev by default) whether the code breaks each one.
15
+
16
+ Commands:
17
+ check [paths...] Check the files the specs govern, or these (default).
18
+ test [rule ids...] Judge each rule's examples (.judgelint/examples/<id>/pass
19
+ and fail) and report what the model gets wrong.
20
+ rules List the rules and how many files each governs.
21
+ providers List the decision-model providers judgelint knows.
22
+ init Write .judgelint.yml and an example spec.
23
+
24
+ Options:
25
+ --base REF Check only what changed since the merge base with REF,
26
+ uncommitted work included
27
+ --pr --base the pull request's base branch, from CI variables
28
+ or the gh CLI
29
+ -f, --format FORMAT text, json, github (Actions annotations), or sarif
30
+ -m, --model MODEL Ask this model instead of the config's: a provider from
31
+ `judgelint providers`, optionally :model (cloudflare:clef)
32
+ -c, --config PATH Config file (default .judgelint.yml)
33
+ --no-cache Ask the models even when an answer is cached
34
+ -y, --yes Run even past max_cost_usd
35
+ -v, --version Print the version
36
+ -h, --help Print this help
37
+
38
+ Exit status: 0 clean, 1 violations or missed examples, 2 usage or config error.
39
+ TEXT
40
+
41
+ def initialize(argv, out: $stdout, err: $stderr)
42
+ @argv = argv.dup
43
+ @out = out
44
+ @err = err
45
+ @format = "text"
46
+ @config_path = nil
47
+ @base = nil
48
+ @pr = false
49
+ @yes = false
50
+ @overrides = {}
51
+ @usage = nil
52
+ @no_cache = false
53
+ end
54
+
55
+ def run
56
+ command = COMMANDS.include?(@argv.first) ? @argv.shift : "check"
57
+ @argv = parse_options(@argv)
58
+ return 0 if @done
59
+
60
+ case command
61
+ when "check" then check
62
+ when "test" then test
63
+ when "rules" then list_rules
64
+ when "providers" then list_providers
65
+ when "init" then init
66
+ end
67
+ rescue UsageError => e
68
+ @err.puts "judgelint: #{e.message}"
69
+ @err.puts "Run `judgelint --help` for usage."
70
+ 2
71
+ rescue Error => e
72
+ @err.puts "judgelint: #{e.message}"
73
+ # Requests that succeeded before the failure are still billed.
74
+ @err.puts @usage.line if @usage && @usage.requests.positive?
75
+ 2
76
+ end
77
+
78
+ private
79
+
80
+ # Spinel can't compile OptionParser, so the few options are parsed here.
81
+ VALUE_OPTIONS = {
82
+ "-c" => "config", "--config" => "config", "-f" => "format", "--format" => "format", "--base" => "base",
83
+ "-m" => "models", "--model" => "models"
84
+ }.freeze
85
+
86
+ def parse_options(argv)
87
+ rest = []
88
+ until argv.empty?
89
+ arg = argv.shift
90
+ name, inline = arg.start_with?("--") ? arg.split("=", 2) : [arg, nil]
91
+ if arg == "--"
92
+ rest.concat(argv)
93
+ argv = []
94
+ elsif VALUE_OPTIONS.key?(name)
95
+ value = inline || argv.shift
96
+ raise UsageError, "#{name} needs a value" if value.nil?
97
+
98
+ set_option(VALUE_OPTIONS.fetch(name), value)
99
+ elsif arg == "--no-cache"
100
+ @no_cache = true
101
+ elsif arg == "--pr"
102
+ @pr = true
103
+ elsif %w[-y --yes].include?(arg)
104
+ @yes = true
105
+ elsif %w[-v --version -h --help].include?(arg)
106
+ @out.puts(%w[-v --version].include?(arg) ? "judgelint #{VERSION}" : HELP)
107
+ @done = true
108
+ elsif arg.start_with?("-") && arg != "-"
109
+ raise UsageError, "unknown option #{arg}"
110
+ else
111
+ rest << arg
112
+ end
113
+ end
114
+ rest
115
+ end
116
+
117
+ def set_option(key, value)
118
+ case key
119
+ when "config" then @config_path = value
120
+ when "base" then @base = value
121
+ when "format"
122
+ raise UsageError, "--format is #{Reporter::FORMATS.join(', ')}, not #{value}" unless Reporter::FORMATS.include?(value)
123
+
124
+ @format = value
125
+ else @overrides[key] = value
126
+ end
127
+ end
128
+
129
+ def check
130
+ config, rules = load_rules
131
+ raise UsageError, "--base and --pr don't go together" if @base && @pr
132
+
133
+ base = @pr ? Changes.pr_base : @base
134
+ changes = base ? Changes.since(base) : nil
135
+ paths = check_paths(rules, changes).reject { |path| Glob.match_any?(config.ignore, path) }
136
+ directories = check_directories(rules).reject { |dir| Glob.match_any?(config.ignore, dir) }
137
+ units = paths.size + directories.size
138
+ runner = runner(config, rules)
139
+ jobs = runner.plan(paths, directories: directories, changes: changes)
140
+ guard_cost(config, runner, jobs)
141
+ verdicts = runner.execute(jobs).flatten
142
+ Reporter.new(@out, format: @format).check(verdicts, config: config, file_count: units, usage: @usage)
143
+ @err.puts @usage.line
144
+ verdicts.any?(&:failing?) ? 1 : 0
145
+ end
146
+
147
+ def check_paths(rules, changes)
148
+ if !@argv.empty?
149
+ paths = Glob.expand_arguments(@argv)
150
+ missing = paths.reject { |path| File.file?(path) }
151
+ raise ConfigError, "No such file: #{missing.join(', ')}" unless missing.empty?
152
+
153
+ paths = paths.select { |path| changes.key?(path) } if changes
154
+ elsif changes
155
+ paths = changes.keys.sort.select { |path| File.file?(path) }
156
+ else
157
+ paths = Glob.expand(rules.flat_map(&:paths))
158
+ end
159
+ paths.select { |path| rules.any? { |rule| !rule.directory? && rule.applies_to?(path) } }
160
+ end
161
+
162
+ # The directories directory rules govern; with paths named, those the
163
+ # paths are in or are. With a change, the runner keeps those it touched.
164
+ def check_directories(rules)
165
+ directories = rules.select(&:directory?).flat_map(&:directories).uniq.sort
166
+ return directories if @argv.empty?
167
+
168
+ named = @argv.map { |arg| arg.delete_prefix("./").delete_suffix("/") }
169
+ directories.select { |dir| named.any? { |path| path == dir || path.start_with?("#{dir}/") || dir.start_with?("#{path}/") } }
170
+ end
171
+
172
+ def test
173
+ config, rules = load_rules
174
+ unless @argv.empty?
175
+ unknown = @argv - rules.map(&:id)
176
+ raise ConfigError, "Unknown rules: #{unknown.join(', ')}" unless unknown.empty?
177
+
178
+ rules = rules.select { |rule| @argv.include?(rule.id) }
179
+ end
180
+
181
+ runner = runner(config, rules)
182
+ jobs = runner.plan_examples
183
+ untested = runner.untested
184
+ raise ConfigError, "No rule has examples yet. Add some under #{rules.first.examples_dir}/{pass,fail}/" if jobs.empty?
185
+
186
+ @err.puts "#{untested.size} #{untested.size == 1 ? 'rule has' : 'rules have'} no examples: #{untested.map(&:id).join(', ')}" unless untested.empty?
187
+ guard_cost(config, runner, jobs)
188
+ examples = runner.execute(jobs).flatten
189
+ Reporter.new(@out, format: @format).fixtures(examples, usage: @usage)
190
+ @err.puts @usage.line
191
+ examples.all?(&:correct?) ? 0 : 1
192
+ end
193
+
194
+ # Prices the planned requests and refuses a run over max_cost_usd.
195
+ def guard_cost(config, runner, jobs)
196
+ estimate = runner.estimated_cost(jobs)
197
+ return if estimate.nil? || estimate <= config.max_cost_usd || @yes
198
+
199
+ raise ConfigError, format("This run would send %d requests, about %s input tokens ($%.2f), over max_cost_usd " \
200
+ "($%.2f). Pass --yes to run it anyway.", runner.request_count(jobs),
201
+ Usage.delimit(runner.estimated_tokens(jobs)), estimate, config.max_cost_usd)
202
+ end
203
+
204
+ def list_rules
205
+ _config, rules = load_rules
206
+ rules.each do |rule|
207
+ count = rule.directory? ? "#{rule.directories.size} dirs" : "#{Glob.expand(rule.paths).count { |path| rule.applies_to?(path) }} files"
208
+ @out.puts format("%-24s %-8s %10s %s", rule.id, rule.severity, count, rule.question || rule.message)
209
+ end
210
+ 0
211
+ end
212
+
213
+ def list_providers
214
+ PROVIDERS.each do |name, preset|
215
+ model = preset["model"] || "(you choose)"
216
+ key = preset["api_key_env"] || "no key"
217
+ @out.puts format("%-13s %-28s %-22s %s", name, model, key, preset["api_base"] || "(your endpoint)")
218
+ @out.puts " needs #{preset['needs']}" if preset["needs"]
219
+ end
220
+ 0
221
+ end
222
+
223
+ def init
224
+ Scaffold.write(".", @out)
225
+ @err.puts "Next: set TYPESAFE_API_KEY, then run `judgelint test` to see the example rule judged."
226
+ 0
227
+ end
228
+
229
+ def load_rules
230
+ config = Config.load(path: @config_path, overrides: @overrides)
231
+ [config, Spec.discover(rules_dir: Config::RULES_DIR, default_threshold: config.threshold, ignore: config.ignore)]
232
+ end
233
+
234
+ def runner(config, rules)
235
+ @usage = Usage.new
236
+ clients = config.models.map { |endpoint| Client.new(endpoint, usage: @usage) }
237
+ Runner.new(config: config, rules: rules, clients: clients, cache: Cache.new(@no_cache ? nil : Config::CACHE))
238
+ end
239
+ end
240
+ end