judgelint 0.1.0-aarch64-linux → 0.1.2-aarch64-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 236b98e6eeba314b1a69758ce7db0a6067fcd8aef217e9f997719318c5e1e47f
4
- data.tar.gz: 4f5b1ee0f8cdea9fe698acfa295e36c7f5610c26a50631808876c7fec953a7dc
3
+ metadata.gz: f250b95eae84ad4f916187117e3ba6760cefd6a318cc0051839110e3a172556d
4
+ data.tar.gz: 2bc5acbc13242b5b2bf5a9f95105c4a3721e70cdb59370a05326216765287f89
5
5
  SHA512:
6
- metadata.gz: b29d71ec222699bf0022ceb6ccea9f947eacbf7eec96d8822c8dcd1a9b19e6739ad0f8ee4c062e7a578bee232fb48a20af49ca74ff19fbc0b59c11cdc8d1bdee
7
- data.tar.gz: 66535560c0208535f73c4825f4d6d8ab4729902bdadf3f18abb9305af19f83d6d6bbed8dd7d0d56fef1577aac9e21a26c49fc6114fc3c9d3d4d2736ec3ce9cbd
6
+ metadata.gz: 31762ed63ccbe74e274344d9470cc7a182ff47cd3f43458ebe8af00b9ab8f39bb6030ba43b13e1a70a91dd2feddc32001fa138bfc07c94460403326028364eeb
7
+ data.tar.gz: 722d7ecfdb11f31b60f23775cb3b38be64421ed35888f9e0f407f50d6e3606419d72aee378ac7d5bde96f8fb9ed11e303373370c01727a13309e9bfb1a9b8d9a
data/CHANGELOG.md CHANGED
@@ -1,24 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 (2026-10-24)
4
+
5
+ - Each bullet is one choice between breaking the rule, following it, and the
6
+ rule not applying, in place of two yes/no questions. On the demo app, Jev
7
+ finds 13 of 14 known violations, up from 12, with half the questions.
8
+ - Findings name the line that breaks the rule, or the file in a directory,
9
+ when the model is sure of one.
10
+ - Both the System One and OpenAI Decisions protocols send choice questions.
11
+
12
+ ## 0.1.1 (2026-10-23)
13
+
14
+ - `judgehint:` comments replace `judgelint:disable`. A hint explains why code
15
+ breaks a rule. judgelint asks each rule whether the hint gives a reason that
16
+ applies to it, and reports the finding as excused if it does.
17
+ - Each GitHub release has native binaries for macOS and Linux. Install them
18
+ with Homebrew (`brew install jpcamara/tap/judgelint`) or `install.sh`.
19
+
3
20
  ## 0.1.0 (2026-10-23)
4
21
 
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.
22
+ - Specs: a README or `*.judgelint.md` with `paths:` in its frontmatter,
23
+ where each top-level bullet is a rule. The model reads
24
+ the spec with the file, and each bullet is asked both ways and averaged.
25
+ `violation:` and `compliant:` lines under a bullet describe it more
26
+ precisely. Rules that don't fit in a bullet can be written as questions in
27
+ `.judgelint/rules/`.
28
+ - Rules can apply to every 40 lines, a whole file, or a whole directory.
29
+ `context:` adds files the model reads but doesn't judge.
30
+ - Commands: `check`, `test`, `rules`, `providers`, and `init`. `--base` and
31
+ `--pr` check only what changed. Output as text, JSON, GitHub annotations,
32
+ or SARIF.
33
+ - Requests are planned and priced before any are sent, and `max_cost_usd`
34
+ stops an expensive run. Every run prints its cost, and answers are cached
35
+ per model.
36
+ - 16 providers over the System One and OpenAI Decisions APIs. List more than
37
+ one in `models:` to average their answers.
38
+ - `judgelint:disable` comments, with a required reason.
39
+ - No runtime dependencies. The macOS and Linux gems include a native binary
40
+ built with Spinel.
41
+ - `examples/scoop-society`, a TypeScript demo app with known violations.
data/README.md CHANGED
@@ -1,55 +1,66 @@
1
1
  # judgelint
2
2
 
3
- Lint for the conventions a linter can't check.
3
+ judgelint checks code against conventions written in plain English. You write
4
+ them as bullets in a README. judgelint asks a decision model, such as
5
+ TypeSafe's Jev, whether each file breaks each bullet, and reports violations
6
+ with line numbers and an exit code like any other linter. Each answer takes a
7
+ few hundred milliseconds and costs a fraction of a cent.
4
8
 
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
+ ## Install
9
10
 
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).
11
+ ```sh
12
+ brew install jpcamara/tap/judgelint
13
+ # or
14
+ curl -fsSL https://raw.githubusercontent.com/jpcamara/judgelint/main/install.sh | sh
15
+ ```
15
16
 
16
- ## Quick start
17
+ The script installs to `~/.local/bin`, or to `$JUDGELINT_INSTALL_DIR`. The
18
+ binaries are on the [releases page](https://github.com/jpcamara/judgelint/releases)
19
+ for macOS and Linux, on arm64 and x86_64. Linux needs glibc 2.35 or newer
20
+ (Ubuntu 22.04, Debian 12). `gem install judgelint` works too, and runs as
21
+ plain Ruby on Windows.
22
+
23
+ ## Getting started
17
24
 
18
25
  ```sh
19
- gem install judgelint
20
- judgelint init # .judgelint.yml and an example spec
26
+ judgelint init # writes .judgelint.yml and an example spec
21
27
  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
28
+ judgelint test # checks the example spec against its examples
29
+ judgelint # checks every file a spec covers
30
+ judgelint --base main # checks only what changed since main
25
31
  ```
26
32
 
27
- On [praxis's demo](examples/scoop-society) (long messages cut here):
33
+ Here's a run on the [demo app](examples/scoop-society), with the messages shortened:
28
34
 
29
35
  ```text
30
36
  $ judgelint src/services/apply-refund.ts src/features/flavor-of-day
31
37
  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 …
38
+ ? uncertain src/services/a-service-module-exports-exactly-one 1-21 0.47 A service module exports exactly one entry point.
39
+ x error src/services/domain-failures-are-values-never-exceptions 13 1.00 Domain failures are values, never exceptions.
40
+ x error src/services/error-messages-are-written-for-the 13 1.00 Error messages are written for the API consumer.
41
+ ? uncertain src/services/input-is-validated-before-any-work 10 0.51 Input is validated before any work happens.
42
+ x error src/services/every-service-has-a-doc-comment 1-21 1.00 Every service has a doc comment …
35
43
  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 …
44
+ x error src/features/exactly-one-entry-point-index-ts old-rotation.ts 1.00 Exactly one entry point, `index.ts`, …
45
+ ? uncertain src/features/types-live-in-a-dedicated-feature flavor.ts 0.53 Types live in a dedicated `<feature>-types.ts` file.
46
+ x error src/features/logic-files-are-verb-first-kebab flavor.ts 0.99 Logic files are verb-first kebab-case …
47
+ x error src/features/no-orphaned-files old-rotation.ts 0.79 No orphaned files.
40
48
 
41
- 2 files, 5 violations, 2 uncertain
42
- judgelint: 2 requests, 3,320 input tokens, $0.0001
49
+ 2 files, 6 violations, 3 uncertain
50
+ judgelint: 2 requests, 6,437 input tokens, $0.0003
43
51
  ```
44
52
 
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.
53
+ A finding names the line that breaks the rule, or the file in a directory,
54
+ when the model is sure of one. Otherwise it shows the lines it was asked
55
+ about.
56
+
57
+ It exits 1 when there are violations, and 2 on a usage, config, or model
58
+ error. Config problems are caught before any request is sent.
48
59
 
49
- ## Specs
60
+ ## Writing specs
50
61
 
51
- A README, or any `*.judgelint.md`, whose frontmatter has `paths:` is a spec.
52
- Each top-level bullet is a rule.
62
+ A spec is a README, or any file ending in `.judgelint.md`, with `paths:` in
63
+ its frontmatter. Each top-level bullet is a rule.
53
64
 
54
65
  ```markdown
55
66
  ---
@@ -63,16 +74,13 @@ excludes: ["src/services/legacy-import.ts"]
63
74
  - **No I/O except through the injected Store.** No timers, no fetch, no console.
64
75
  ```
65
76
 
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.
77
+ The model gets the spec and the numbered file. For each bullet, it chooses
78
+ whether the code breaks it, follows it, or isn't covered by it, and which line
79
+ breaks it most clearly. A rule's id comes from the start of its bullet, like
80
+ `src/services/no-i-o-except-through-the`. Bullets that say "should" or
81
+ "prefer" are warnings, and the rest are errors.
73
82
 
74
- To sharpen a bullet, say what breaking and following it look like, on
75
- indented lines under it:
83
+ If the model misreads a bullet, describe breaking and following it:
76
84
 
77
85
  ```markdown
78
86
  - **No I/O except through the injected Store.** No timers, no fetch, no console.
@@ -80,22 +88,24 @@ indented lines under it:
80
88
  compliant: reads and writes only through the Store it's given
81
89
  ```
82
90
 
83
- They make the model more decisive, and like any wording, they're worth
84
- checking against examples.
91
+ They make the model more decisive. A loose description can also flag code
92
+ you meant to allow, so check them against examples.
85
93
 
86
- | Frontmatter | |
94
+ | Frontmatter | What it does |
87
95
  |---|---|
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. |
96
+ | `paths`, `excludes` | Globs for the files the spec covers, relative to the project root. |
97
+ | `context` | Files the model reads along with each file, like shared types. They're never judged. |
98
+ | `scope` | `window`, the default, asks about every 40 lines so findings have line numbers. `file` asks once per file. `directory` asks once per directory, shows the model every file in it, and names the file that breaks the rule. |
99
+ | `severity`, `threshold` | Set the severity or threshold for every rule in the spec. |
92
100
 
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).
101
+ Each answer is the probability that the code breaks the rule. At 0.7 or above
102
+ it's a violation. Between 0.4 and 0.7 it's reported as uncertain, which
103
+ doesn't fail the run.
96
104
 
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:
105
+ ### Rules written as questions
106
+
107
+ When a convention doesn't fit in a bullet, write it as a question in
108
+ `.judgelint/rules/<id>.md`:
99
109
 
100
110
  ```markdown
101
111
  ---
@@ -108,19 +118,29 @@ paths: ["src/features/*"]
108
118
  Every file in a feature is reachable from index.ts.
109
119
  ```
110
120
 
111
- `fail_when: no` inverts a question like "Does this service validate its
112
- input first?".
121
+ Add `fail_when: no` when a "no" is the violation, as in "Does this service
122
+ validate its input first?"
123
+
124
+ ## Explaining exceptions
125
+
126
+ When code breaks a rule on purpose, explain why in a `judgehint:` comment. Any
127
+ comment style works.
128
+
129
+ ```ts
130
+ // judgehint: throwing is intentional. Only the nightly job calls this, after
131
+ // checking the review, so a bad id here is a programmer error.
132
+ ```
113
133
 
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.
134
+ For each rule, judgelint asks whether a hint in the same 40 lines gives a
135
+ reason the code can break it. If it does, the finding is reported as excused
136
+ and doesn't fail the run. A hint only excuses the rule it explains, and a hint
137
+ with no reason, like "this is intentional", doesn't excuse anything.
117
138
 
118
- ## Examples
139
+ ## Testing rules
119
140
 
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:
141
+ Test each rule against examples. Put files that follow it in
142
+ `.judgelint/examples/<rule id>/pass/` and files that break it in `fail/`.
143
+ `judgelint test` shows which ones the model gets wrong:
124
144
 
125
145
  ```text
126
146
  lib/no-silent-rescues 9/10
@@ -129,61 +149,52 @@ lib/no-silent-rescues 9/10
129
149
  ok fail 0.94 .judgelint/examples/lib/no-silent-rescues/fail/assigns_default.rb
130
150
  ```
131
151
 
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.
152
+ When an example fails, reword the bullet or split it into narrower rules. The
153
+ model doesn't see the `pass/` or `fail/` part of the path. For a directory
154
+ rule, each example is a directory.
137
155
 
138
- ## In CI
156
+ ## CI
139
157
 
140
158
  ```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)
159
+ judgelint --base main # changes since the merge base with main, plus uncommitted work
160
+ judgelint --pr # changes in the current pull request
161
+ judgelint --pr --format github # GitHub annotations on the changed lines (json and sarif work too)
144
162
  ```
145
163
 
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.
164
+ These only ask about the 40-line windows that changed, so existing code
165
+ doesn't block a new pull request. `--pr` gets the base branch from GitHub
166
+ Actions, GitLab, Buildkite, Bitbucket, Azure Pipelines,
167
+ `JUDGELINT_BASE_BRANCH`, or `gh`. Fetch enough history for git to find the
168
+ merge base.
151
169
 
152
170
  ## Configuration
153
171
 
154
172
  `.judgelint.yml` has four settings:
155
173
 
156
174
  ```yaml
157
- models: [typesafe] # a model is provider[:model]; list two to average their answers
175
+ models: [typesafe] # provider or provider:model; list two to average them
158
176
  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
177
+ ignore: ["vendor/**"] # files no rule checks, and READMEs that aren't specs
178
+ max_cost_usd: 1.00 # refuse a run estimated to cost more, unless --yes
161
179
  ```
162
180
 
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.
181
+ Every run prints what it cost. Answers are cached in `.judgelint/cache.json`,
182
+ so a file that hasn't changed costs nothing. Commit the cache to share it.
183
+ `judgelint --help` lists the flags.
169
184
 
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.
185
+ List two models to average their answers, like `models: [typesafe,
186
+ cloudflare]`. Each model's answers are cached separately.
176
187
 
177
188
  ### Providers
178
189
 
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).
190
+ `judgelint providers` lists them. Tested against the live APIs: `typesafe`
191
+ (Jev, the default), `openrouter`, `celeris`, `cloudflare`, and `openai`.
192
+ Set up from their docs: `perplexity`, `vercel`, `digitalocean`, `upstage`,
193
+ `hanzo`, `foundry`, `databricks`, `ollama`, `llamacpp`, `vllm`, and
194
+ `litellm`. Each one reads its key from the provider's usual variable, like
195
+ `TYPESAFE_API_KEY`.
185
196
 
186
- Any other System One or Decisions endpoint is a mapping in `models`:
197
+ For any other System One or Decisions endpoint, add a mapping to `models`:
187
198
 
188
199
  ```yaml
189
200
  models:
@@ -194,32 +205,26 @@ models:
194
205
  # headers: {} provider_options: {} price_per_million: 0.04 batch_size: 32
195
206
  ```
196
207
 
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
- ```
208
+ ## Building from source
204
209
 
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
210
+ judgelint is written in Ruby and compiled with
211
+ [Spinel](https://github.com/matz/spinel).
216
212
 
217
213
  ```sh
218
214
  bundle install
219
215
  bundle exec rake test
220
- bundle exec rake example # re-ask Jev about praxis's demo and check it
216
+ git clone https://github.com/matz/spinel ../spinel && (cd ../spinel && make deps && make)
217
+ SPINEL=../spinel/bin/spinel bundle exec rake spinel # builds build/judgelint
218
+ SPINEL=../spinel/bin/spinel bundle exec rake test:spinel # runs the tests against the binary
219
+ bundle exec rake example # asks Jev about the demo app again
221
220
  ```
222
221
 
222
+ ## Credits
223
+
224
+ judgelint was inspired by [praxis](https://github.com/zarpay/praxis-cli), and
225
+ it reads praxis specs, including `cohort: by_file` and `by_directory`. The
226
+ demo app in [examples/scoop-society](examples/scoop-society) comes from praxis.
227
+
223
228
  ## License
224
229
 
225
- MIT
230
+ MIT. The demo app keeps praxis's MIT license.
data/lib/judgelint/cli.rb CHANGED
@@ -10,8 +10,8 @@ module Judgelint
10
10
  HELP = <<~TEXT
11
11
  Usage: judgelint [command] [options] [paths...]
12
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.
13
+ Checks code against the conventions in your specs. For each file and each
14
+ rule, it asks a decision model (Jev by default) whether the code breaks it.
15
15
 
16
16
  Commands:
17
17
  check [paths...] Check the files the specs govern, or these (default).
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Judgelint
4
- # The two request shapes decision models are served with. judgelint asks only
5
- # yes/no questions, so each renders a Question as that protocol's yes/no
6
- # type and reads back the probability of yes. A nil probability is a refusal.
4
+ # The two request shapes decision models are served with. Each renders a
5
+ # Question as a yes/no question or a choice, and reads back the probability
6
+ # of yes, or a choice's probabilities as {option => probability}. A nil
7
+ # answer is a refusal.
7
8
  module Protocol
8
- # TypeSafe's System One: questions keyed by name, yes/no is "noul".
9
+ # TypeSafe's System One: questions keyed by name, yes/no is "noul", and a
10
+ # choice's options are its criteria.
9
11
  module SystemOne
10
12
  def self.payload(model, state, questions)
11
13
  rendered = {}
@@ -16,6 +18,8 @@ module Judgelint
16
18
  end
17
19
 
18
20
  def self.render(question)
21
+ return { "type" => "choice", "instructions" => question.instructions, "criteria" => question.options } if question.choice?
22
+
19
23
  rendered = { "type" => "noul", "instructions" => question.instructions }
20
24
  criteria = {}
21
25
  criteria["true"] = question.yes_means if question.yes_means
@@ -30,14 +34,18 @@ module Judgelint
30
34
 
31
35
  probabilities = {}
32
36
  questions.each do |question|
33
- probabilities[question.key] = Protocol.probability(answers[question.key], "noul", question)
37
+ answer = answers[question.key]
38
+ probabilities[question.key] =
39
+ question.choice? ? Protocol.distribution(answer, answer.is_a?(Hash) ? answer["probabilities"] : nil, question)
40
+ : Protocol.probability(answer, "noul", question)
34
41
  end
35
42
  probabilities
36
43
  end
37
44
  end
38
45
 
39
46
  # OpenAI's Decisions API: questions are a list with names, yes/no is
40
- # "predicate" with no criteria field, and answers come back as a list.
47
+ # "predicate" with no criteria field, a choice lists its options as
48
+ # {value, description}, and answers come back as a list.
41
49
  module Decisions
42
50
  # Decisions' input is text, so a structured state goes as its JSON.
43
51
  def self.payload(model, state, questions)
@@ -45,8 +53,16 @@ module Judgelint
45
53
  { "model" => model, "input" => input, "questions" => questions.map { |question| render(question) } }
46
54
  end
47
55
 
48
- # Criteria become lines of the instructions, as RubyLLM and LiteLLM do.
56
+ # A yes/no question's criteria become lines of its instructions, as
57
+ # RubyLLM and LiteLLM do.
49
58
  def self.render(question)
59
+ if question.choice?
60
+ choices = question.options.map do |value, description|
61
+ description ? { "value" => value, "description" => description } : { "value" => value }
62
+ end
63
+ return { "type" => "choice", "name" => question.key, "instructions" => question.instructions, "choices" => choices }
64
+ end
65
+
50
66
  lines = [question.instructions]
51
67
  lines << "Yes: #{question.yes_means}" if question.yes_means
52
68
  lines << "No: #{question.no_means}" if question.no_means
@@ -68,10 +84,19 @@ module Judgelint
68
84
  probabilities = {}
69
85
  questions.each do |question|
70
86
  answer = by_name.key?(question.key) ? by_name[question.key] : unnamed.shift
71
- probabilities[question.key] = Protocol.probability(answer, "probability", question)
87
+ probabilities[question.key] =
88
+ question.choice? ? Protocol.distribution(answer, listed(answer), question) : Protocol.probability(answer, "probability", question)
72
89
  end
73
90
  probabilities
74
91
  end
92
+
93
+ # [{value, probability}] as {value => probability}.
94
+ def self.listed(answer)
95
+ list = answer.is_a?(Hash) ? answer["probabilities"] : nil
96
+ return nil unless list.is_a?(Array)
97
+
98
+ list.to_h { |entry| [entry["value"].to_s, entry["probability"]] }
99
+ end
75
100
  end
76
101
 
77
102
  ALL = { "system_one" => SystemOne, "decisions" => Decisions }.freeze
@@ -84,5 +109,14 @@ module Judgelint
84
109
 
85
110
  value.to_f
86
111
  end
112
+
113
+ def self.distribution(answer, probabilities, question)
114
+ return nil if answer.is_a?(Hash) && answer["type"] == "refusal"
115
+ raise ApiError, "No choice answer for #{question.key}: #{answer.inspect}" unless probabilities.is_a?(Hash)
116
+
117
+ found = {}
118
+ question.options.each_key { |option| found[option] = probabilities[option].to_f }
119
+ found
120
+ end
87
121
  end
88
122
  end
@@ -4,39 +4,75 @@ module Judgelint
4
4
  # One rule asked about one range of a file. The model sees the whole file;
5
5
  # the question points at the lines.
6
6
  #
7
- # A spec's bullet is asked twice: whether the code breaks it, and, with the
8
- # answer flipped, whether the code follows it. Decision models lean toward
9
- # no on the first and yes on the second, so the average is steadier than
10
- # either.
7
+ # A spec's bullet is a choice between breaking the rule, following it, and
8
+ # the rule not applying. With the three side by side, a decision model
9
+ # commits to an answer, where a yes/no question gets a hedge. The same
10
+ # request asks which line (or, for a directory, which file) breaks the rule
11
+ # most clearly, so a finding can name it.
12
+ #
13
+ # A rule written as a question is asked yes or no, as written. Code with a
14
+ # judgehint comment is also asked, yes or no, whether the hint excuses it.
11
15
  class Question
12
16
  BREAKS = "The code clearly breaks the rule."
13
- FOLLOWS = "The code follows the rule, or the rule doesn't apply to it."
17
+ FOLLOWS = "The code follows the rule."
18
+ NOT_APPLICABLE = "The rule doesn't apply to this code."
19
+ EXCUSED = "A judgehint comment is about this rule, and gives a reason the code may break it."
20
+ NOT_EXCUSED = "No judgehint comment is about this rule. Any judgehint is about a different rule, or gives no reason."
14
21
 
15
- attr_reader :rule, :first, :last
22
+ attr_reader :rule, :first, :last, :places
16
23
 
17
- def initialize(rule, first, last, inverted: false)
24
+ # +kind+ is :verdict, :where, or :excuse. +places+ are a :where question's
25
+ # options: line ids (L12) or the directory's files, as the model sees them.
26
+ def initialize(rule, first, last, kind: :verdict, places: [])
18
27
  @rule = rule
19
28
  @first = first
20
29
  @last = last
21
- @inverted = inverted
30
+ @kind = kind
31
+ @places = places
32
+ end
33
+
34
+ def excuse?
35
+ @kind == :excuse
36
+ end
37
+
38
+ def where?
39
+ @kind == :where
40
+ end
41
+
42
+ # A spec's bullet and its location are choices; everything else is yes or no.
43
+ def choice?
44
+ where? || (@kind == :verdict && !rule.convention.nil?)
22
45
  end
23
46
 
24
47
  def key
25
48
  base = "#{rule.question_key}.#{first}-#{last}"
26
- return base unless rule.convention
49
+ return "#{base}.excused" if excuse?
50
+ return "#{base}.where" if where?
51
+
52
+ base
53
+ end
54
+
55
+ # A choice's options, as {name => description or nil}.
56
+ def options
57
+ return places.to_h { |place| [place, nil] } if where?
27
58
 
28
- @inverted ? "#{base}.follows" : "#{base}.breaks"
59
+ { "breaks" => rule.yes_means || BREAKS, "follows" => rule.no_means || FOLLOWS, "not_applicable" => NOT_APPLICABLE }
29
60
  end
30
61
 
31
- # The probability the code breaks the rule, from the probability of yes.
32
- def violation(yes_probability)
33
- @inverted ? 1.0 - yes_probability : rule.violation_probability(yes_probability)
62
+ # The probability the code breaks the rule, from a verdict's answer: a
63
+ # choice's probabilities, or a yes/no question's probability of yes.
64
+ def violation(answer)
65
+ return answer["breaks"].to_f if answer.is_a?(Hash)
66
+
67
+ rule.violation_probability(answer)
34
68
  end
35
69
 
36
70
  # "Answer yes only when..." keeps the model from flagging code the rule
37
71
  # doesn't cover.
38
72
  def instructions
39
- return convention_instructions if rule.convention
73
+ return excuse_instructions if excuse?
74
+ return where_instructions if where?
75
+ return "How #{rule.scope == 'file' ? 'does' : 'do'} #{subject} relate to the following team rule? Rule: #{rule.convention}" if rule.convention
40
76
 
41
77
  text = rule.window? ? "Look at lines #{first}-#{last} of the file; the rest is context. #{rule.question}" : rule.question
42
78
  return text unless rule.fail_when == "yes"
@@ -47,54 +83,41 @@ module Judgelint
47
83
  end
48
84
 
49
85
  def yes_means
50
- return rule.yes_means unless rule.convention
86
+ return EXCUSED if excuse?
87
+ return nil if choice?
51
88
 
52
- @inverted ? rule.no_means || FOLLOWS : rule.yes_means || BREAKS
89
+ rule.yes_means
53
90
  end
54
91
 
55
92
  def no_means
56
- return rule.no_means unless rule.convention
93
+ return NOT_EXCUSED if excuse?
94
+ return nil if choice?
57
95
 
58
- @inverted ? rule.yes_means || BREAKS : rule.no_means || FOLLOWS
96
+ rule.no_means
59
97
  end
60
98
 
61
99
  def digest
62
- @digest ||= Digest::SHA256.hexdigest(JSON.generate([instructions, yes_means.to_s, no_means.to_s]))
100
+ @digest ||= Digest::SHA256.hexdigest(JSON.generate([instructions, yes_means.to_s, no_means.to_s,
101
+ choice? ? options.to_a : []]))
63
102
  end
64
103
 
65
104
  private
66
105
 
67
- # The spec's prose is in the state as team_rules, so the bullet is "the
68
- # following team rule".
69
- def convention_instructions
70
- ask =
71
- case rule.scope
72
- when "window"
73
- if @inverted
74
- "Do lines #{first}-#{last} of the file follow the following team rule, or does it not apply to them? " \
75
- "Answer false only when those lines clearly break it."
76
- else
77
- "Do lines #{first}-#{last} of the file clearly violate the following team rule? Answer true only " \
78
- "when the code breaks the rule, not when the rule does not apply to it."
79
- end
80
- when "file"
81
- if @inverted
82
- "Does the file follow the following team rule, or does it not apply to it? Answer false only when " \
83
- "the file clearly breaks it."
84
- else
85
- "Does the file clearly violate the following team rule? Answer true only when the code breaks the " \
86
- "rule, not when the rule does not apply to it."
87
- end
88
- else
89
- if @inverted
90
- "Do the files follow the following team rule, or does it not apply to them? Answer false only when " \
91
- "they clearly break it."
92
- else
93
- "Do the files clearly violate the following team rule? Answer true only when the code breaks the " \
94
- "rule, not when the rule does not apply to it."
95
- end
96
- end
97
- "#{ask} Rule: #{rule.convention}"
106
+ def subject
107
+ { "window" => "lines #{first}-#{last} of the file", "file" => "the file", "directory" => "the files" }
108
+ .fetch(rule.scope)
109
+ end
110
+
111
+ def where_instructions
112
+ which = rule.directory? ? "Which file" : "Which line, given by its number as L<number>,"
113
+ "#{which} most clearly breaks the following team rule? Rule: #{rule.convention}"
114
+ end
115
+
116
+ def excuse_instructions
117
+ where = { "window" => "in lines #{first}-#{last} of the file", "file" => "in the file", "directory" => "in the files" }
118
+ .fetch(rule.scope)
119
+ "Does a judgehint comment #{where} explain why the code intentionally departs from the following team " \
120
+ "rule, with a reason specific to this rule? Rule: #{rule.convention || rule.message}"
98
121
  end
99
122
  end
100
123
  end
@@ -5,8 +5,8 @@ module Judgelint
5
5
  # goes to stdout; anything else goes to stderr.
6
6
  class Reporter
7
7
  FORMATS = %w[text json github sarif].freeze
8
- MARKS = { violation: "x", uncertain: "?", skipped: "-", refused: "-", suppressed: "-" }.freeze
9
- STATUSES = %w[violation uncertain pass skipped refused suppressed].freeze
8
+ MARKS = { violation: "x", uncertain: "?", skipped: "-", refused: "-", excused: "-" }.freeze
9
+ STATUSES = %w[violation uncertain pass skipped refused excused].freeze
10
10
 
11
11
  def initialize(out, format:)
12
12
  @out = out
@@ -46,21 +46,27 @@ module Judgelint
46
46
  def text(verdicts, file_count)
47
47
  shown = verdicts.reject { |verdict| verdict.status == :pass }
48
48
  width = shown.map { |verdict| verdict.rule.id.size }.max
49
+ places = [7, *shown.map { |verdict| range(verdict).size }].max
49
50
  shown.group_by(&:path).each do |path, group|
50
51
  @out.puts path
51
- group.each { |verdict| @out.puts " #{line(verdict, width)}" }
52
+ group.each { |verdict| @out.puts " #{line(verdict, width, places)}" }
52
53
  end
53
54
  @out.puts "" unless shown.empty?
54
55
  @out.puts summary(verdicts, file_count)
55
56
  end
56
57
 
57
- def line(verdict, width)
58
+ def line(verdict, width, places)
58
59
  label = verdict.status == :violation ? verdict.rule.severity : verdict.status.to_s
59
- format("%s %-10s %-#{width}s %-7s %s %s", MARKS.fetch(verdict.status), label, verdict.rule.id,
60
+ format("%s %-10s %-#{width}s %-#{places}s %s %s", MARKS.fetch(verdict.status), label, verdict.rule.id,
60
61
  range(verdict), probability(verdict), verdict.rule.message)
61
62
  end
62
63
 
64
+ # The line that breaks the rule, or the range asked about; for a
65
+ # directory, the file in it.
63
66
  def range(verdict)
67
+ return verdict.file.delete_prefix("#{verdict.path}/") if verdict.file
68
+ return verdict.line.to_s if verdict.line
69
+
64
70
  verdict.lines ? verdict.lines.join("-") : ""
65
71
  end
66
72
 
@@ -76,7 +82,7 @@ module Judgelint
76
82
  parts << "#{counts[:uncertain]} uncertain" if counts[:uncertain]
77
83
  parts << "#{counts[:skipped]} skipped (too large)" if counts[:skipped]
78
84
  parts << "#{counts[:refused]} refused by the model" if counts[:refused]
79
- parts << "#{counts[:suppressed]} suppressed" if counts[:suppressed]
85
+ parts << "#{counts[:excused]} excused by judgehints" if counts[:excused]
80
86
  parts.join(", ")
81
87
  end
82
88
 
@@ -107,8 +113,12 @@ module Judgelint
107
113
  level = { violation: verdict.rule.error? ? "error" : "warning", uncertain: "notice" }[verdict.status]
108
114
  return unless level
109
115
 
110
- properties = ["file=#{escape(verdict.path, true)}"]
111
- properties += ["line=#{verdict.lines[0]}", "endLine=#{verdict.lines[1]}"] if verdict.lines
116
+ properties = ["file=#{escape(verdict.file || verdict.path, true)}"]
117
+ if verdict.line
118
+ properties << "line=#{verdict.line}"
119
+ elsif verdict.lines
120
+ properties += ["line=#{verdict.lines[0]}", "endLine=#{verdict.lines[1]}"]
121
+ end
112
122
  properties << "title=#{escape("judgelint #{verdict.rule.id}", true)}"
113
123
  @out.puts "::#{level} #{properties.join(',')}::#{escape("#{verdict.rule.message} (#{probability(verdict)})", false)}"
114
124
  end
@@ -135,12 +145,16 @@ module Judgelint
135
145
  end
136
146
 
137
147
  def sarif_result(verdict)
138
- region = verdict.lines ? { "startLine" => verdict.lines[0], "endLine" => verdict.lines[1] } : {}
148
+ region =
149
+ if verdict.line then { "startLine" => verdict.line }
150
+ elsif verdict.lines then { "startLine" => verdict.lines[0], "endLine" => verdict.lines[1] }
151
+ else {}
152
+ end
139
153
  level = verdict.status == :uncertain ? "note" : (verdict.rule.error? ? "error" : "warning")
140
154
  {
141
155
  "ruleId" => verdict.rule.id, "level" => level,
142
156
  "message" => { "text" => "#{verdict.rule.message} (#{probability(verdict)})" },
143
- "locations" => [{ "physicalLocation" => { "artifactLocation" => { "uri" => verdict.path }, "region" => region } }]
157
+ "locations" => [{ "physicalLocation" => { "artifactLocation" => { "uri" => verdict.file || verdict.path }, "region" => region } }]
144
158
  }
145
159
  end
146
160
  end
@@ -14,18 +14,17 @@ module Judgelint
14
14
  # One file's work: the questions to ask, each model's answers (from the
15
15
  # cache or the model), and the requests still to send.
16
16
  class Job
17
- attr_reader :source, :state, :state_digest, :questions, :suppressed, :answers, :requests, :expected
17
+ attr_reader :source, :state, :state_digest, :questions, :answers, :requests, :expected
18
18
  attr_accessor :skipped
19
19
 
20
20
  # The state is built and hashed once: it's the whole file, and every
21
21
  # question's cache key needs its hash. Rules from a spec read its prose
22
22
  # alongside the file, as team_rules.
23
- def initialize(source, questions, suppressed, prose: nil, expected: nil)
23
+ def initialize(source, questions, prose: nil, expected: nil)
24
24
  @source = source
25
25
  @state = prose ? { "team_rules" => prose, "file" => source.state } : source.state
26
26
  @state_digest = Digest::SHA256.hexdigest(JSON.generate(@state))
27
27
  @questions = questions
28
- @suppressed = suppressed
29
28
  @answers = {} # model index => {question key => probability or nil}
30
29
  @requests = []
31
30
  @expected = expected
@@ -44,6 +43,25 @@ module Judgelint
44
43
  end
45
44
  end
46
45
 
46
+ # One range's averaged answers: the probability it breaks the rule, and
47
+ # where, when the model is sure enough of one line or file.
48
+ class Window
49
+ attr_reader :first, :last, :probability, :place
50
+
51
+ def initialize(first, last, probability, place)
52
+ @first = first
53
+ @last = last
54
+ @probability = probability
55
+ @place = place
56
+ end
57
+ end
58
+
59
+ # A finding names a line or file when it gets at least this share of the
60
+ # "where" answer.
61
+ PLACE_AT = 0.5
62
+ # A choice takes at most this many options.
63
+ MAX_OPTIONS = 255
64
+
47
65
  # +clients+ has one Client per model in config.models.
48
66
  def initialize(config:, rules:, clients:, cache:)
49
67
  @config = config
@@ -143,32 +161,35 @@ module Judgelint
143
161
  end
144
162
 
145
163
  def plan_file(source, rules, lines, expected: nil)
146
- unknown = source.suppressed_ids - @rules.map(&:id)
147
- raise ConfigError, "#{source.path}: judgelint:disable names unknown rules #{unknown.join(', ')}" unless unknown.empty?
148
-
149
164
  questions = {}
150
- suppressed = []
151
165
  rules.each do |rule|
152
166
  ranges = rule.ranges(source, Config::WINDOW_LINES).select { |first, last| touched?(lines, first, last) }
153
167
  next if ranges.empty?
154
168
 
155
- kept = ranges.reject { |first, last| source.suppressed?(rule.id, first, last) }
156
- if kept.empty?
157
- suppressed << rule
158
- else
159
- asked = []
160
- kept.each do |first, last|
161
- asked << Question.new(rule, first, last)
162
- asked << Question.new(rule, first, last, inverted: true) if rule.convention
163
- end
164
- questions[rule] = asked
169
+ asked = []
170
+ ranges.each do |first, last|
171
+ asked << Question.new(rule, first, last)
172
+ places = places(source, rule, first, last)
173
+ asked << Question.new(rule, first, last, kind: :where, places: places) unless places.empty?
174
+ asked << Question.new(rule, first, last, kind: :excuse) if source.hinted?(first, last)
165
175
  end
176
+ questions[rule] = asked
166
177
  end
167
- job = Job.new(source, questions, suppressed, prose: rules.first&.prose, expected: expected)
178
+ job = Job.new(source, questions, prose: rules.first&.prose, expected: expected)
168
179
  pack(job)
169
180
  job
170
181
  end
171
182
 
183
+ # The options for asking where a bullet is broken: the range's line ids, or
184
+ # the directory's files. None for a rule written as a question, or past
185
+ # what a choice can hold.
186
+ def places(source, rule, first, last)
187
+ return [] unless rule.convention
188
+
189
+ places = rule.directory? ? source.members.map { |_path, shown| shown } : (first..last).map { |line| "L#{line}" }
190
+ places.size > MAX_OPTIONS ? [] : places
191
+ end
192
+
172
193
  # Rules that read different context files or spec prose can't share a request.
173
194
  def by_context(rules)
174
195
  rules.group_by { |rule| [rule.context, rule.prose] }.map { |(globs, _prose), group| yield(Glob.expand(globs), group) }
@@ -236,32 +257,87 @@ module Judgelint
236
257
  def verdicts(job)
237
258
  path = job.source.path
238
259
  found = job.questions.map { |rule, questions| verdict_for(rule, path, questions, job) }
239
- found += job.suppressed.map { |rule| Verdict.new(rule: rule, path: path, probability: nil, status: :suppressed) }
240
260
  job.expected ? found.map { |verdict| verdict.as_example(job.expected) } : found
241
261
  end
242
262
 
243
- # A window's probability of a violation is the average over its questions
244
- # (a bullet is asked both ways) and the models; a rule's verdict is its
245
- # worst window.
263
+ # A window's probability of a violation is the average of its verdict
264
+ # over the models, and its place is the line or file the models' "where"
265
+ # answers agree on. A rule's verdict is its worst window. A window whose
266
+ # judgehint excuses it, by the same average and threshold, is reported as
267
+ # excused instead, unless another window breaks the rule.
246
268
  def verdict_for(rule, path, questions, job)
247
269
  return Verdict.new(rule: rule, path: path, probability: nil, status: :skipped) if job.skipped
248
270
 
249
- windows = []
271
+ open = []
272
+ excused = []
250
273
  questions.group_by { |question| [question.first, question.last] }.each do |(first, last), asked|
251
274
  values = []
275
+ excuses = []
276
+ spread = {}
252
277
  asked.each do |question|
253
278
  job.answers.each_value do |answers|
254
- yes = answers[question.key]
255
- values << question.violation(yes) unless yes.nil?
279
+ answer = answers[question.key]
280
+ next if answer.nil?
281
+
282
+ if question.excuse?
283
+ excuses << answer
284
+ elsif question.where?
285
+ answer.each { |place, probability| spread[place] = (spread[place] || 0.0) + probability }
286
+ else
287
+ values << question.violation(answer)
288
+ end
256
289
  end
257
290
  end
258
- windows << [first, last, values.sum / values.size] unless values.empty?
291
+ next if values.empty?
292
+
293
+ window = Window.new(first, last, values.sum / values.size, agreed(spread))
294
+ if !excuses.empty? && excuses.sum / excuses.size >= rule.threshold
295
+ excused << window
296
+ else
297
+ open << window
298
+ end
259
299
  end
260
- return Verdict.new(rule: rule, path: path, probability: nil, status: :refused) if windows.empty?
300
+ return Verdict.new(rule: rule, path: path, probability: nil, status: :refused) if open.empty? && excused.empty?
301
+
302
+ found = worst(rule, path, open, job.source)
303
+ return found if found && found.status != :pass
304
+
305
+ hidden = worst(rule, path, excused, job.source)
306
+ return found || hidden if hidden.nil? || hidden.status == :pass
307
+
308
+ Verdict.new(rule: rule, path: path, probability: hidden.probability, status: :excused, lines: hidden.lines,
309
+ line: hidden.line, file: hidden.file)
310
+ end
261
311
 
262
- first, last, probability = windows.max_by { |window| window[2] }
263
- lines = rule.directory? ? nil : [first, last]
264
- Verdict.classify(rule, path, probability, warn_at: @config.warn_at, lines: lines)
312
+ # The place with at least PLACE_AT of the summed "where" probabilities.
313
+ # A loop rather than max_by, which Spinel's Hash doesn't have yet.
314
+ def agreed(spread)
315
+ total = 0.0
316
+ best = nil
317
+ spread.each do |place, value|
318
+ total += value
319
+ best = place if best.nil? || value > spread[best]
320
+ end
321
+ return nil if total <= 0
322
+
323
+ spread[best] / total >= PLACE_AT ? best : nil
324
+ end
325
+
326
+ # The worst of +windows+, or nil if there are none.
327
+ def worst(rule, path, windows, source)
328
+ return nil if windows.empty?
329
+
330
+ window = windows.max_by(&:probability)
331
+ line = nil
332
+ file = nil
333
+ if rule.directory?
334
+ member = source.members.find { |_member, shown| shown == window.place }
335
+ file = member[0] if member
336
+ elsif window.place
337
+ line = window.place.delete_prefix("L").to_i
338
+ end
339
+ lines = rule.directory? ? nil : [window.first, window.last]
340
+ Verdict.classify(rule, path, window.probability, warn_at: @config.warn_at, lines: lines, line: line, file: file)
265
341
  end
266
342
 
267
343
  # Each model's answers are cached on their own, so adding a model asks
@@ -5,14 +5,11 @@ module Judgelint
5
5
  # line numbered so a question can point at a range, followed by any context
6
6
  # files the rule names, which are there to read and never judged.
7
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.
8
+ # A judgehint is a comment (#, //, --, /*, <!--, or ;) starting with
9
+ # `judgehint:` that says why the code is the way it is. The model weighs it
10
+ # for the window it falls in, or for the whole directory.
13
11
  class Source
14
- SUPPRESSION = %r{(?:#|//|--|/\*|<!--|;)\s*judgelint:(disable-file|disable)\b(.*)\z}
15
- REASONED = %r{\A\s+([A-Za-z0-9_./, -]+?)\s+--\s+\S}
12
+ HINT = %r{(?:#|//|--|/\*|<!--|;)\s*judgehint:}
16
13
 
17
14
  attr_reader :path, :shown_as, :line_count
18
15
 
@@ -28,9 +25,10 @@ module Judgelint
28
25
  @files = files.map { |file| [file, File.read(file).lines] }
29
26
  @context = context
30
27
  @line_count = @files.sum { |_file, lines| lines.size }
31
- @disabled = {}
32
- @disabled_file = []
33
- @files.each { |file, lines| find_suppressions(file, lines) }
28
+ @hints = []
29
+ @files.each do |_file, lines|
30
+ lines.each_with_index { |line, index| @hints << index + 1 if HINT.match?(line) }
31
+ end
34
32
  end
35
33
 
36
34
  def directory?
@@ -56,15 +54,13 @@ module Judgelint
56
54
  (1..line_count).step(size).map { |first| [first, [first + size - 1, line_count].min] }
57
55
  end
58
56
 
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) }
57
+ # The directory's files as [path, the path the model sees].
58
+ def members
59
+ @files.map { |file, _lines| [file, shown(file)] }
64
60
  end
65
61
 
66
- def suppressed_ids
67
- (@disabled.keys + @disabled_file).uniq
62
+ def hinted?(first, last)
63
+ directory? ? !@hints.empty? : @hints.any? { |line| line.between?(first, last) }
68
64
  end
69
65
 
70
66
  private
@@ -77,22 +73,5 @@ module Judgelint
77
73
  def shown(file)
78
74
  "#{shown_as}/#{file.delete_prefix("#{path}/")}"
79
75
  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
76
  end
98
77
  end
@@ -5,55 +5,59 @@ module Judgelint
5
5
  #
6
6
  # +status+ is :violation (at or above the rule's threshold), :uncertain
7
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.
8
+ # large to judge), :refused (the model declined the question), or :excused
9
+ # (a violation or uncertain, but a judgehint excuses it). Skipped and
10
+ # refused files are reported, never silently passed. +lines+ is the [first,
11
+ # last] range the verdict came from. +line+ is the line in it that breaks
12
+ # the rule, and +file+ the file in a directory, when the model could say.
12
13
  #
13
14
  # An example's verdict also knows the answer it should have had: +expected+
14
15
  # is :pass or :fail for files under rules/<id>/pass and rules/<id>/fail.
15
16
  # Not Data.define: Spinel can't compile a Data or Struct in a program that also uses net/https and json.
16
17
  class Verdict
17
- attr_reader :rule, :path, :probability, :status, :lines, :expected
18
+ attr_reader :rule, :path, :probability, :status, :lines, :line, :file, :expected
18
19
 
19
- def initialize(rule:, path:, probability:, status:, lines: nil, expected: nil)
20
+ def initialize(rule:, path:, probability:, status:, lines: nil, line: nil, file: nil, expected: nil)
20
21
  @rule = rule
21
22
  @path = path
22
23
  @probability = probability
23
24
  @status = status
24
25
  @lines = lines
26
+ @line = line
27
+ @file = file
25
28
  @expected = expected
26
29
  end
27
30
 
28
31
  def as_example(expected)
29
- Verdict.new(rule: rule, path: path, probability: probability, status: status, lines: lines, expected: expected)
32
+ Verdict.new(rule: rule, path: path, probability: probability, status: status, lines: lines, line: line, file: file,
33
+ expected: expected)
30
34
  end
31
35
 
32
36
  # +probability+ is the probability the code breaks the rule.
33
- def self.classify(rule, path, probability, warn_at:, lines: nil)
37
+ def self.classify(rule, path, probability, warn_at:, lines: nil, line: nil, file: nil)
34
38
  status =
35
39
  if probability >= rule.threshold then :violation
36
40
  elsif probability >= warn_at then :uncertain
37
41
  else :pass
38
42
  end
39
- new(rule: rule, path: path, probability: probability, status: status, lines: lines)
43
+ new(rule: rule, path: path, probability: probability, status: status, lines: lines, line: line, file: file)
40
44
  end
41
45
 
42
46
  def failing?
43
47
  status == :violation && rule.error?
44
48
  end
45
49
 
46
- # A refused, skipped, or suppressed example is a miss either way: the rule
47
- # didn't work on it.
50
+ # A refused or skipped example is a miss either way: the rule didn't work
51
+ # on it. An excused one isn't a violation.
48
52
  def correct?
49
- return false if %i[refused skipped suppressed].include?(status)
53
+ return false if %i[refused skipped].include?(status)
50
54
 
51
55
  (status == :violation) == (expected == :fail)
52
56
  end
53
57
 
54
58
  def to_h
55
59
  {
56
- "path" => path, "lines" => lines, "rule" => rule.id, "severity" => rule.severity, "status" => status.to_s,
60
+ "path" => path, "lines" => lines, "line" => line, "file" => file, "rule" => rule.id, "severity" => rule.severity, "status" => status.to_s,
57
61
  "probability" => probability&.round(3), "message" => rule.message
58
62
  }.merge(expected ? { "expected" => expected.to_s, "correct" => correct? } : {})
59
63
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Judgelint
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/libexec/judgelint CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: judgelint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - JP Camara
@@ -10,10 +10,10 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
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.
13
+ judgelint checks code against conventions you write as bullets in a README.
14
+ It asks a decision model, such as TypeSafe's Jev, whether each file breaks
15
+ each bullet, and reports violations with line numbers. On macOS and Linux
16
+ the gem includes a native binary.
17
17
  email:
18
18
  - johnpcamara@gmail.com
19
19
  executables:
@@ -71,6 +71,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
71
71
  requirements: []
72
72
  rubygems_version: 3.6.9
73
73
  specification_version: 4
74
- summary: Lint for conventions a linter can't check, judged by System One decision
74
+ summary: Checks code against conventions written in plain English, using decision
75
75
  models.
76
76
  test_files: []