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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/exe/judgelint +14 -0
- data/lib/judgelint/cache.rb +53 -0
- data/lib/judgelint/changes.rb +87 -0
- data/lib/judgelint/cli.rb +240 -0
- data/lib/judgelint/client.rb +182 -0
- data/lib/judgelint/config.rb +162 -0
- data/lib/judgelint/errors.rb +14 -0
- data/lib/judgelint/glob.rb +31 -0
- data/lib/judgelint/pool.rb +41 -0
- data/lib/judgelint/protocol.rb +88 -0
- data/lib/judgelint/providers.rb +78 -0
- data/lib/judgelint/question.rb +100 -0
- data/lib/judgelint/reporter.rb +147 -0
- data/lib/judgelint/rule.rb +147 -0
- data/lib/judgelint/runner.rb +274 -0
- data/lib/judgelint/scaffold.rb +67 -0
- data/lib/judgelint/source.rb +98 -0
- data/lib/judgelint/spec.rb +189 -0
- data/lib/judgelint/usage.rb +53 -0
- data/lib/judgelint/verdict.rb +61 -0
- data/lib/judgelint/version.rb +5 -0
- data/lib/judgelint.rb +57 -0
- metadata +74 -0
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
|