snifftest 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.
- package/LICENSE +21 -0
- package/README.md +372 -0
- package/SECURITY.md +165 -0
- package/bench/panel-direct.yaml +97 -0
- package/bench/panel.yaml +136 -0
- package/bench/prices/anthropic-2026-09-17.yaml +33 -0
- package/bench/prices/openai-2026-09-17.yaml +27 -0
- package/dist/snifftest.js +12217 -0
- package/examples/CORPUS.md +54 -0
- package/examples/corpus/cold-email.md +5 -0
- package/examples/corpus/editing-by-ear.md +5 -0
- package/examples/corpus/field-notes-app.md +5 -0
- package/examples/corpus/franklin-the-printer.md +3 -0
- package/examples/corpus/franklin-the-socratic-method.md +3 -0
- package/examples/corpus/franklin-the-spectator.md +1 -0
- package/examples/corpus/on-checklists.md +5 -0
- package/examples/corpus/release-note.md +5 -0
- package/examples/corpus/station-routes.md +5 -0
- package/examples/corpus/strunk-introduction.md +1 -0
- package/examples/replays/bench-2026-09-17.json +104 -0
- package/examples/replays/drafts/clean.md +1 -0
- package/examples/replays/drafts/faulty.md +1 -0
- package/examples/replays/example.json +79 -0
- package/examples/seeds/bank.json +196 -0
- package/package.json +46 -0
- package/rules/default.yaml +458 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Willoughby
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/nose/concepts/e-sketch.svg" alt="A pencil sketch of a nose in profile" width="160">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Sniff Test
|
|
6
|
+
|
|
7
|
+
Does the draft pass the sniff test?
|
|
8
|
+
|
|
9
|
+
Sniff Test reads Markdown and plain text and checks it against your house
|
|
10
|
+
rules. It prints one line per flag with the file, the line, the rule that
|
|
11
|
+
tripped and how sure it is.
|
|
12
|
+
|
|
13
|
+
There are two kinds of rule. **Countable rules** are regular expressions.
|
|
14
|
+
Long dashes, a paragraph with three colons, a banned word, every sentence the
|
|
15
|
+
same length. They run on your machine, cost nothing and send nothing anywhere.
|
|
16
|
+
**Judgment rules** need something read rather than counted. A closing sentence
|
|
17
|
+
that only restates the paragraph, a claim hedged three times, a line that talks
|
|
18
|
+
the reader out of the offer. For those it sends one paragraph at a time to a
|
|
19
|
+
small hosted judgment model and gets back one probability per rule. It only
|
|
20
|
+
does that after you have said yes.
|
|
21
|
+
|
|
22
|
+
Two things it is not. There is no general model in the loop; the judgment
|
|
23
|
+
model returns one probability per question and nothing else. And it will not
|
|
24
|
+
rewrite your text. A flag is a sentence for you to fix.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install --global snifftest
|
|
28
|
+
snifftest check --dry-run draft.md
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
That runs the countable rules and nothing leaves your machine. To run the
|
|
32
|
+
judgment rules too, put a TypeSafe key in `TYPESAFE_API_KEY` and drop
|
|
33
|
+
`--dry-run`. The first time, it prints exactly what it is about to send and
|
|
34
|
+
where, and waits for your answer.
|
|
35
|
+
|
|
36
|
+
It exits 0 when nothing tripped, 1 when at least one rule did, 2 when the tool
|
|
37
|
+
could not do its job, and 3 when the judgment rules needed a yes and did not
|
|
38
|
+
get one.
|
|
39
|
+
|
|
40
|
+
The nose is the mascot. It is one hand-drawn SVG with six expressions, in
|
|
41
|
+
`assets/nose/`, and `snifftest serve` opens a local page where you type and the
|
|
42
|
+
nose reacts.
|
|
43
|
+
|
|
44
|
+
## Reading a flag
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
draft.md:14 dash_present 1.00 A long dash. Give the sentence a full stop instead.
|
|
48
|
+
draft.md:31 restating_closer 0.84 The last sentence says the paragraph again. End one sentence earlier.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The number is how sure the checker is, between 0 and 1, and it says nothing
|
|
52
|
+
about how bad the problem is. A countable rule always scores 1.00. A judgment
|
|
53
|
+
rule counts as a flag at or above the threshold, which is 0.7 unless the
|
|
54
|
+
ruleset or `--threshold` says otherwise. A reading between 0.4 and 0.6 is
|
|
55
|
+
reported as no judgment and never becomes a flag or a pass. That band exists
|
|
56
|
+
because the judgment model answers about 0.5 on text it cannot read, and a
|
|
57
|
+
flat middle number is the one failure that looks like a clean draft.
|
|
58
|
+
|
|
59
|
+
`--format json` prints the same flags for a machine, with every reading
|
|
60
|
+
including the ones under the threshold. `snifftest rules` prints the ruleset in
|
|
61
|
+
force and which file each rule came from, which is the quickest answer to "why
|
|
62
|
+
did that flag".
|
|
63
|
+
|
|
64
|
+
## The rules
|
|
65
|
+
|
|
66
|
+
The default ruleset ships in `rules/default.yaml`. Fifteen rules, and the file
|
|
67
|
+
itself is the documentation. Ten apply to most prose and five are conventions
|
|
68
|
+
of copy meant to sell.
|
|
69
|
+
|
|
70
|
+
| Rule | Kind | What it catches |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `dash_present` | countable | An en dash or an em dash. A hyphen is fine, and so is an en dash between numerals. |
|
|
73
|
+
| `colon_heavy` | countable | Three or more colons in one paragraph. URLs and clock times do not count. |
|
|
74
|
+
| `sentence_rhythm` | countable | Every sentence the same length, in paragraphs of four sentences and sixty words or more. |
|
|
75
|
+
| `slop_vocab` | countable | Twelve words that turn up far more often in generated text than in prose a person wrote. |
|
|
76
|
+
| `banned_words` | countable | Your own list. Empty until you fill it. |
|
|
77
|
+
| `not_x_but_y` | judgment | A sentence whose whole job is to swap one label for another. |
|
|
78
|
+
| `tricolon` | judgment | Three items for the cadence. |
|
|
79
|
+
| `stacked_hedging` | judgment | Two or more softeners on one claim. |
|
|
80
|
+
| `rhetorical_opener` | judgment | A paragraph that opens on a question nobody asked. |
|
|
81
|
+
| `restating_closer` | judgment | A last sentence that says the paragraph again. |
|
|
82
|
+
| `self_undercutting` | judgment, marketing | Copy that talks the reader out of the thing on offer. |
|
|
83
|
+
| `first_x_that` | judgment, marketing | A claim to be the first. |
|
|
84
|
+
| `naked_cost_figure` | judgment, marketing | A cost with no customer price and no alternative beside it. |
|
|
85
|
+
| `jobs_claim` | judgment, marketing | A claim about employment. |
|
|
86
|
+
| `pullquote_fragment` | judgment, marketing | A display quote with no verb. |
|
|
87
|
+
|
|
88
|
+
The five rules tagged `marketing` sit out an ordinary run. Ask for them on a
|
|
89
|
+
landing page with `--only marketing`, which runs those five and nothing else.
|
|
90
|
+
To run everything, write a project file that extends the default and sets
|
|
91
|
+
`off_by_default: []`.
|
|
92
|
+
|
|
93
|
+
Fenced code is dropped before either kind of rule sees it. Headings, tables,
|
|
94
|
+
front matter, link definitions and HTML comments are never sent to the
|
|
95
|
+
judgment rules, and the countable rules only read the ones a rule names.
|
|
96
|
+
|
|
97
|
+
### Writing your own
|
|
98
|
+
|
|
99
|
+
A `.snifftest.yaml` in your project root is picked up on every run. It can
|
|
100
|
+
extend the default ruleset and change the rules it disagrees with, matched by
|
|
101
|
+
id, so a house that wants five colons instead of three writes one rule and
|
|
102
|
+
inherits the other fourteen.
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
version: 1
|
|
106
|
+
extends: default
|
|
107
|
+
threshold: 0.7
|
|
108
|
+
|
|
109
|
+
rules:
|
|
110
|
+
# A countable rule of your own is a pattern and a message.
|
|
111
|
+
- id: no_utilize
|
|
112
|
+
kind: regex
|
|
113
|
+
pattern: "\\butili[sz]e"
|
|
114
|
+
flags: i
|
|
115
|
+
message: "Use. The word is use."
|
|
116
|
+
|
|
117
|
+
# Override a shipped rule by reusing its id.
|
|
118
|
+
- id: colon_heavy
|
|
119
|
+
kind: regex
|
|
120
|
+
builtin: colon_count
|
|
121
|
+
min: 5
|
|
122
|
+
message: "Five colons in one paragraph."
|
|
123
|
+
|
|
124
|
+
# A judgment rule is a description, its near misses, and two criteria.
|
|
125
|
+
- id: passive_apology
|
|
126
|
+
kind: judgment
|
|
127
|
+
what: |
|
|
128
|
+
A sentence that apologises for the document itself: for its length,
|
|
129
|
+
its lateness, or the writer's lack of expertise.
|
|
130
|
+
not_for: |
|
|
131
|
+
An apology to a person for a thing that happened.
|
|
132
|
+
A stated limit of scope.
|
|
133
|
+
examples:
|
|
134
|
+
- "Sorry this is so long."
|
|
135
|
+
- "I am no expert, but here goes."
|
|
136
|
+
criteria:
|
|
137
|
+
true: "At least one sentence apologises for the document or the writer."
|
|
138
|
+
false: "No sentence apologises for the document or the writer."
|
|
139
|
+
message: "The draft is apologising for itself. Cut the line."
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A countable rule names either a `builtin` or a `pattern`, never both. The
|
|
143
|
+
built-ins are `dash_present`, `colon_count`, `sentence_rhythm`, `slop_vocab`
|
|
144
|
+
and `banned_words`, and each takes the tuning keys the default file shows. A
|
|
145
|
+
rule may carry `tags`, and a countable rule may name the kinds of block it
|
|
146
|
+
applies to with `chunks`. A judgment rule's `not_for` is what keeps it honest.
|
|
147
|
+
A rule with a `what` and no `not_for` will flag things you did not mean.
|
|
148
|
+
|
|
149
|
+
A pattern with a nested quantifier is refused when the ruleset loads, and
|
|
150
|
+
every pattern runs against a capped slice of text. Neither is a proof that a
|
|
151
|
+
pattern cannot run for a long time. See `SECURITY.md`.
|
|
152
|
+
|
|
153
|
+
To propose a rule for the shipped set, open a rule proposal issue.
|
|
154
|
+
`CONTRIBUTING.md` says what one needs.
|
|
155
|
+
|
|
156
|
+
## Consent, and what leaves the machine
|
|
157
|
+
|
|
158
|
+
Nothing is sent before the answer to one question is yes. The countable rules
|
|
159
|
+
run first, on your machine, whatever happens next. Then, if the ruleset has
|
|
160
|
+
judgment rules and you did not pass `--dry-run`, the checker looks for a key in
|
|
161
|
+
`TYPESAFE_API_KEY`. Without one it prints the countable verdict, says the
|
|
162
|
+
judgment rules could not run, and exits 2. With one, it prints this and waits:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
Sniff Test is about to use the judgment rules, which run on a model.
|
|
166
|
+
|
|
167
|
+
What leaves this machine: one paragraph of your text at a time, from 3 files,
|
|
168
|
+
together with the wording of these rules: not_x_but_y, tricolon, stacked_hedging, rhetorical_opener, restating_closer.
|
|
169
|
+
Where it goes: https://api.typesafe.ai/v1/systemone (TypeSafe), over HTTPS, with your TYPESAFE_API_KEY.
|
|
170
|
+
What comes back: one probability per rule per paragraph.
|
|
171
|
+
What is never sent: file names, file paths, anything outside the text you pointed at.
|
|
172
|
+
There is no telemetry, no analytics, and nothing is stored by this tool.
|
|
173
|
+
|
|
174
|
+
Countable rules never leave the machine. Run with --dry-run to use only those.
|
|
175
|
+
|
|
176
|
+
Send paragraphs to TypeSafe? [y/N]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
A yes is remembered under `~/.config/snifftest/consent.json`, or under
|
|
180
|
+
`$XDG_CONFIG_HOME` when that is set. It is never written into the directory
|
|
181
|
+
being checked, because a consent file committed to a repository would answer
|
|
182
|
+
for everyone who clones it. `--yes` answers and remembers without the prompt.
|
|
183
|
+
`SNIFFTEST_SEND=1` answers for one run in CI and writes nothing down. With no
|
|
184
|
+
terminal to ask in and no answer in the environment, the checker exits 3 and
|
|
185
|
+
sends nothing.
|
|
186
|
+
|
|
187
|
+
Each request carries one paragraph and every judgment rule's wording. It goes
|
|
188
|
+
over HTTPS with your key as a bearer token, and the reply is a probability per
|
|
189
|
+
rule. The wire format and the guards around it are in `src/jev.ts`, which is
|
|
190
|
+
the only file in the tool that opens a network connection for `check`.
|
|
191
|
+
|
|
192
|
+
Answers are cached on disk so that a run interrupted by an outage does not pay
|
|
193
|
+
for every paragraph again. The cache lives under `~/.cache/snifftest`, or
|
|
194
|
+
`$XDG_CACHE_HOME`, or `SNIFFTEST_CACHE_DIR`. Each entry is named by a hash of
|
|
195
|
+
the paragraph, the exact wording of the questions asked about it and the model
|
|
196
|
+
they were sent to, and it holds rule ids, probabilities, the model name and a
|
|
197
|
+
date. The paragraph itself is never
|
|
198
|
+
written to disk. Entries expire after a fortnight. `--no-cache` skips the cache
|
|
199
|
+
for a run, and `SNIFFTEST_CACHE_DIR=off` turns it off for good.
|
|
200
|
+
|
|
201
|
+
`SECURITY.md` has the full account, including the limits.
|
|
202
|
+
|
|
203
|
+
## Before a commit
|
|
204
|
+
|
|
205
|
+
One command, from the root of your repository:
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
curl -fsSL https://raw.githubusercontent.com/DanRWilloughby/snifftest/v0.1.0/hooks/pre-commit \
|
|
209
|
+
-o .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Read it before you trust it. The hook checks the Markdown and text files you
|
|
213
|
+
staged, as you staged them, with the countable rules only. It sends nothing
|
|
214
|
+
unless `SNIFFTEST_SEND=1` and `TYPESAFE_API_KEY` are both in your environment.
|
|
215
|
+
It blocks a commit on a flag. When the checker itself is missing, broken or
|
|
216
|
+
could not be fetched, it says so and steps aside, because a failed download is
|
|
217
|
+
not evidence about your prose. `SNIFFTEST_STRICT=1` reverses that.
|
|
218
|
+
|
|
219
|
+
Skip it once with `SNIFFTEST_SKIP=1 git commit` or `git commit --no-verify`.
|
|
220
|
+
|
|
221
|
+
The same script works under husky, and there are two entries for the
|
|
222
|
+
pre-commit framework, `snifftest` for the free rules and `snifftest-send` under
|
|
223
|
+
the manual stage for the rest. `docs/husky.md` has all three.
|
|
224
|
+
|
|
225
|
+
## In CI
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
name: Prose
|
|
229
|
+
on: pull_request
|
|
230
|
+
|
|
231
|
+
permissions: {}
|
|
232
|
+
|
|
233
|
+
jobs:
|
|
234
|
+
snifftest:
|
|
235
|
+
runs-on: ubuntu-latest
|
|
236
|
+
steps:
|
|
237
|
+
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.0.0
|
|
238
|
+
- uses: DanRWilloughby/snifftest@v0.1.0
|
|
239
|
+
with:
|
|
240
|
+
paths: docs
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The Action runs the countable rules and nothing else unless you set `send` and
|
|
244
|
+
pass a key in as `api-key`. A pull request from a fork always gets the
|
|
245
|
+
countable rules only, whatever the workflow says. The Action refuses to run on
|
|
246
|
+
any event whose name ends in `_target`, refuses a floating `version`, and
|
|
247
|
+
fetches the exact version named from the npm registry into a scratch directory
|
|
248
|
+
outside the checkout, so the repository being checked cannot hand it a checker
|
|
249
|
+
by committing one. It needs no token of its own. With `comment: true` and a `github-token` it posts
|
|
250
|
+
the checker's output on the pull request, and no line of the draft is copied
|
|
251
|
+
into that comment.
|
|
252
|
+
|
|
253
|
+
Pin the Action by commit SHA if you want the stronger guarantee. The inputs and
|
|
254
|
+
the exit codes are tabled in `docs/husky.md`.
|
|
255
|
+
|
|
256
|
+
## In Claude Code
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
/plugin marketplace add DanRWilloughby/snifftest
|
|
260
|
+
/plugin install snifftest@snifftest
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Then `/snifftest draft.md`, or hand Claude a draft and ask whether it passes.
|
|
264
|
+
The skill runs the countable rules by default. The judgment pass is the user's
|
|
265
|
+
call and the skill never answers the sending question on their behalf: it adds
|
|
266
|
+
no flag that skips the prompt and never touches the key. `docs/claude-code.md`
|
|
267
|
+
has the two other ways to install it.
|
|
268
|
+
|
|
269
|
+
## Measuring it
|
|
270
|
+
|
|
271
|
+
`snifftest eval` plants one known fault per rule into copies of your own clean
|
|
272
|
+
paragraphs, runs the corpus three ways, and reports what each way caught and
|
|
273
|
+
what it flagged on the clean originals. Arm A is no tool. Arm B is the
|
|
274
|
+
countable rules. Arm C is the countable rules plus the judgment rules, and it
|
|
275
|
+
is the only arm that costs anything. Each run writes a Markdown report and a
|
|
276
|
+
`scores.json`, and it writes the clean and seeded paragraphs beside them with
|
|
277
|
+
a `.gitignore`, because they carry your prose in full.
|
|
278
|
+
|
|
279
|
+
`snifftest bench` asks a panel of general models the same questions over the
|
|
280
|
+
same corpus, through OpenRouter and one direct Anthropic call, and puts cost,
|
|
281
|
+
speed and accuracy side by side. An OpenAI row can be called directly too, on
|
|
282
|
+
OpenAI's own API with `OPENAI_API_KEY`, so a model can be measured without an
|
|
283
|
+
OpenRouter account and its latency is the provider's rather than a proxy's.
|
|
284
|
+
`bench/panel-direct.yaml` is a panel of nothing but direct rows. It asks for
|
|
285
|
+
consent per destination, and a yes given for TypeSafe is never a yes for anyone
|
|
286
|
+
else.
|
|
287
|
+
|
|
288
|
+
The run committed under `bench/results/2026-09-17/` is the eval over the
|
|
289
|
+
packaged corpus with eight seeds per rule, and beside it the bench over the
|
|
290
|
+
same 166 paragraphs with `bench/panel-direct.yaml`: four general models, each
|
|
291
|
+
on its own provider's API, on the same day.
|
|
292
|
+
|
|
293
|
+
<!-- numbers:start -->
|
|
294
|
+
| Arm | Judgment faults caught, of 80 | Clean paragraphs flagged, of 54 | Cost per 100 paragraphs | Median per paragraph |
|
|
295
|
+
| --- | --- | --- | --- | --- |
|
|
296
|
+
| Sniff Test, judgment model (Jev, measured) | 63 | 1 | $0.0129 | 182 ms |
|
|
297
|
+
| Claude Haiku 4.5 (measured) | 66 | 37 | $0.43 | 1,971 ms |
|
|
298
|
+
| Claude Sonnet 5 (measured) | 72 | 2 | $1.34 | 6,083 ms |
|
|
299
|
+
| Claude Opus 5 (measured) | 77 | 0 | $3.08 | 6,532 ms |
|
|
300
|
+
| OpenAI gpt-5.6-sol (measured) | 73 | 0 | $1.64 | 4,428 ms |
|
|
301
|
+
<!-- numbers:end -->
|
|
302
|
+
|
|
303
|
+
Every row is one paragraph per request. The Sniff Test row is the eval's arm
|
|
304
|
+
C, flags counted at 0.7, cost and latency from provider-reported usage. The
|
|
305
|
+
model rows are `snifftest bench` on 2026-09-17, one repeat, cache off, and
|
|
306
|
+
they count each model's own yes, which is the decision the model made; their
|
|
307
|
+
cost is the returned token usage at the list prices recorded under
|
|
308
|
+
`bench/prices/` with no prompt caching, and their latency is the wall clock at
|
|
309
|
+
this end. Haiku, Sonnet and Opus were given 2,000 completion tokens, because
|
|
310
|
+
at the default 900 Sonnet 5 ran out of room on 20 of its 166 replies on an
|
|
311
|
+
earlier pass of the same run and a cut reply counts as unanswered; gpt-5.6-sol
|
|
312
|
+
was given 4,000 at low reasoning effort. `bench-tables.md` in the results
|
|
313
|
+
folder carries p95, the per-rule detail, every failure and the budget each row
|
|
314
|
+
was sent. Jev ran in the same rotation as the models, where it read 59 of 80,
|
|
315
|
+
2 of 54 and 198 ms, with one rule missing from its reply on 13 paragraphs;
|
|
316
|
+
across the runs of this corpus on 2026-09-17 the judgment model read between
|
|
317
|
+
59 and 64 of 80, and that swing is the size to read every row against.
|
|
318
|
+
|
|
319
|
+
What that supports. On this corpus the judgment model catches about as many
|
|
320
|
+
faults as the cheapest general model and fewer than the mid and top tiers,
|
|
321
|
+
raises far fewer false alarms than the cheapest one, and does it at about a
|
|
322
|
+
thirtieth of that model's cost and a tenth of its latency, with no general
|
|
323
|
+
model in the loop. The top models catch more, at twenty to thirty-five times
|
|
324
|
+
the latency and a hundred to two hundred and forty times the cost.
|
|
325
|
+
|
|
326
|
+
Three things to read beside the table. Eight seeds per rule is a small sample,
|
|
327
|
+
so the per-rule figures are direction rather than measurement. The eval
|
|
328
|
+
measures every rule in the file, including the five marketing rules an
|
|
329
|
+
ordinary check sits out. And two of the ten judgment rules were reworded after
|
|
330
|
+
a first run on these seeds and measured again on the same seeds;
|
|
331
|
+
`docs/eval-notes.md` carries both numbers and the reasoning.
|
|
332
|
+
|
|
333
|
+
`snifftest eval --twins examples/adversarial` measures what a sentence written
|
|
334
|
+
to the checker does to the readings around it. `examples/CORPUS.md` describes
|
|
335
|
+
the corpus and where every paragraph came from.
|
|
336
|
+
|
|
337
|
+
## Configuration
|
|
338
|
+
|
|
339
|
+
| Option | What it does |
|
|
340
|
+
| --- | --- |
|
|
341
|
+
| `--rules <path>` | Use this ruleset instead of `.snifftest.yaml` or the built-in one. |
|
|
342
|
+
| `--root <dir>` | The tree being checked, when it is not the directory you are in. |
|
|
343
|
+
| `--threshold <0-1>` | The probability at or above which a judgment counts as a flag. |
|
|
344
|
+
| `--format text\|json` | How to print the flags. |
|
|
345
|
+
| `--only <tags>` | Run only the rules carrying one of these tags. |
|
|
346
|
+
| `--skip <tags>` | Never run a rule carrying one of these tags. |
|
|
347
|
+
| `--dry-run` | Make no network request of any kind. For `check`, the countable rules and nothing else. |
|
|
348
|
+
| `--no-cache` | Ask about every paragraph again instead of reusing an answer from the last fortnight. |
|
|
349
|
+
| `--yes`, `-y` | Answer the send question for this run and remember the answer. |
|
|
350
|
+
|
|
351
|
+
| Variable | What it does |
|
|
352
|
+
| --- | --- |
|
|
353
|
+
| `TYPESAFE_API_KEY` | The key the judgment rules are sent with. Read from the environment and nowhere else. |
|
|
354
|
+
| `SNIFFTEST_SEND` | Answer the send question in CI without remembering it. `1` means TypeSafe, which is where `check` sends and nowhere else. |
|
|
355
|
+
| `SNIFFTEST_CACHE_DIR` | Where cached answers live. `off` for none. |
|
|
356
|
+
| `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` | Used by `bench` only. Each one is the key its own rows are called with, and a panel row whose key is missing says so rather than running. |
|
|
357
|
+
|
|
358
|
+
`snifftest --help` lists the options for `eval` and `bench` as well.
|
|
359
|
+
|
|
360
|
+
The package has no runtime dependencies and needs Node 20 or newer. Installing
|
|
361
|
+
from the npm registry needs nothing else. Installing from a git URL needs Bun
|
|
362
|
+
on the machine, because the executable is built on install.
|
|
363
|
+
|
|
364
|
+
## Contributing
|
|
365
|
+
|
|
366
|
+
`CONTRIBUTING.md` has the ground rules, the test setup and what a rule proposal
|
|
367
|
+
needs. Security problems go to a private advisory, never to a public issue.
|
|
368
|
+
`SECURITY.md` says how.
|
|
369
|
+
|
|
370
|
+
## Licence
|
|
371
|
+
|
|
372
|
+
MIT. See `LICENSE`.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
What leaves your machine, when, and what to do if you find a problem.
|
|
4
|
+
|
|
5
|
+
## Supported versions
|
|
6
|
+
|
|
7
|
+
| Version | Supported |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| 0.1.x | Yes |
|
|
10
|
+
|
|
11
|
+
## What leaves the machine
|
|
12
|
+
|
|
13
|
+
The countable rules are regular expressions. They run on your machine and
|
|
14
|
+
never open a network connection.
|
|
15
|
+
|
|
16
|
+
The judgment rules send text. For each paragraph, one request goes to
|
|
17
|
+
`https://api.typesafe.ai/v1/systemone` over HTTPS with your `TYPESAFE_API_KEY`
|
|
18
|
+
as a bearer token. The request carries that one paragraph and the wording of
|
|
19
|
+
every judgment rule in the ruleset in force. The reply is one probability per
|
|
20
|
+
rule. That endpoint is the only place `check` sends anything, and the address
|
|
21
|
+
cannot be changed by a ruleset, a flag or an environment variable.
|
|
22
|
+
|
|
23
|
+
What is never sent: file names, file paths, and anything outside the text you
|
|
24
|
+
pointed the tool at. Fenced code is dropped before either kind of rule reads
|
|
25
|
+
it. Headings, tables, front matter, link definitions and HTML comments are
|
|
26
|
+
never sent. A file that is not UTF-8 text is skipped and reported as skipped.
|
|
27
|
+
Text that carries a data URI or a long unbroken run of encoded characters is
|
|
28
|
+
refused before a request is built, because the judgment model answers HTTP 200
|
|
29
|
+
with meaningless probabilities on such input. A paragraph over 24,000
|
|
30
|
+
characters is split before it is sent.
|
|
31
|
+
|
|
32
|
+
There is no telemetry and no analytics.
|
|
33
|
+
|
|
34
|
+
`bench` is different. It sends the same paragraphs to general models at other
|
|
35
|
+
companies. Each destination is its own key and its own endpoint, and a row runs
|
|
36
|
+
only when its key is set:
|
|
37
|
+
|
|
38
|
+
- OpenRouter, which routes most of the panel: the paragraphs and the rule
|
|
39
|
+
wording go to `https://openrouter.ai/api/v1/chat/completions` with
|
|
40
|
+
`OPENROUTER_API_KEY`.
|
|
41
|
+
- Anthropic, called directly: the same text to
|
|
42
|
+
`https://api.anthropic.com/v1/messages` with `ANTHROPIC_API_KEY`.
|
|
43
|
+
- OpenAI, called directly: the same text to
|
|
44
|
+
`https://api.openai.com/v1/chat/completions` with `OPENAI_API_KEY`.
|
|
45
|
+
|
|
46
|
+
It asks for consent naming each destination, and a yes given for TypeSafe never
|
|
47
|
+
covers any of them. Before the first model call each provider's model list is
|
|
48
|
+
read, which is an authenticated request with that key, so it sits behind the
|
|
49
|
+
same question.
|
|
50
|
+
|
|
51
|
+
## Consent
|
|
52
|
+
|
|
53
|
+
Nothing is sent before the answer to the send question is yes.
|
|
54
|
+
|
|
55
|
+
- The key is checked first. Without `TYPESAFE_API_KEY` the judgment rules
|
|
56
|
+
cannot run, the countable verdict is printed, and the run exits 2. The
|
|
57
|
+
question is never asked for a request the tool could not make.
|
|
58
|
+
- With a key, the first run prints what will be sent, where, with which key,
|
|
59
|
+
and what comes back, then waits for `y`. Any other answer sends nothing.
|
|
60
|
+
- A yes is stored in `$XDG_CONFIG_HOME/snifftest/consent.json`, or
|
|
61
|
+
`~/.config/snifftest/consent.json`, with mode 0600 in a 0700 directory. It is
|
|
62
|
+
never stored in the directory being checked. The stored answer names the
|
|
63
|
+
destinations it covers, and a new destination asks again.
|
|
64
|
+
- `--yes` answers without the prompt and stores the answer.
|
|
65
|
+
- `SNIFFTEST_SEND` answers for one run and stores nothing. Its value names the
|
|
66
|
+
destinations it answers for, comma separated. `1` is shorthand for TypeSafe,
|
|
67
|
+
the only place `check` sends, so a CI job that sets it for `check` has not
|
|
68
|
+
answered for `bench`.
|
|
69
|
+
- With no terminal to ask in and no answer in the environment, the run exits 3
|
|
70
|
+
and sends nothing. Exit 3 is the question, not an error to work around.
|
|
71
|
+
- `--dry-run` makes no network request of any kind, on every command.
|
|
72
|
+
|
|
73
|
+
The key is read from `TYPESAFE_API_KEY` and from nowhere else. There is no
|
|
74
|
+
config file for it and no flag. Every error message that could quote a
|
|
75
|
+
provider's reply is passed through a scrubber that removes the key, whole or
|
|
76
|
+
cut at either end, before it is printed.
|
|
77
|
+
|
|
78
|
+
## The hook and the Action
|
|
79
|
+
|
|
80
|
+
The pre-commit hook in `hooks/pre-commit` runs `check --dry-run` on the staged
|
|
81
|
+
Markdown and text files. It sends nothing unless `SNIFFTEST_SEND` is set and
|
|
82
|
+
`TYPESAFE_API_KEY` is present, both at once. It refuses a `snifftest` on `PATH`
|
|
83
|
+
that resolves inside the repository being committed to, and it fetches the
|
|
84
|
+
pinned version from a scratch directory rather than from inside the checkout,
|
|
85
|
+
so the repository cannot supply the checker by committing one. A tool failure
|
|
86
|
+
never blocks a commit unless `SNIFFTEST_STRICT=1`, but a countable flag that
|
|
87
|
+
was printed before the failure still does.
|
|
88
|
+
|
|
89
|
+
The two entries in `.pre-commit-hooks.yaml` behave the same way. `snifftest`
|
|
90
|
+
never sends. `snifftest-send` sits under the manual stage and needs both the
|
|
91
|
+
key and `SNIFFTEST_SEND=1`.
|
|
92
|
+
|
|
93
|
+
The GitHub Action in `action.yml`:
|
|
94
|
+
|
|
95
|
+
- Runs the countable rules only, unless `send` is `true` and a key is passed in
|
|
96
|
+
as `api-key`. It reads no secret of its own.
|
|
97
|
+
- Forces the countable rules on a pull request from a fork, whatever the
|
|
98
|
+
workflow asked for, and on any event it has no fork guard for.
|
|
99
|
+
- Refuses to run on any event whose name ends in `_target`.
|
|
100
|
+
- Refuses a floating `version`. It fetches the exact version named from the npm
|
|
101
|
+
registry, from a scratch directory, on every run.
|
|
102
|
+
- Puts the key in the environment of the step that runs the checker only when
|
|
103
|
+
sending is on, and never in the step that decides.
|
|
104
|
+
- Posts a pull request comment only when `comment` is `true` and a token is
|
|
105
|
+
passed in as `github-token`. The comment carries the checker's output, which
|
|
106
|
+
is file, line, rule, probability and the rule's message. No line of the draft
|
|
107
|
+
is copied into it.
|
|
108
|
+
|
|
109
|
+
The Claude Code skill runs the countable rules by default, adds no flag that
|
|
110
|
+
skips the consent prompt, and never reads or prints the key.
|
|
111
|
+
|
|
112
|
+
## The answer cache
|
|
113
|
+
|
|
114
|
+
Judgment answers are cached on disk so a run interrupted by an outage does not
|
|
115
|
+
pay for every paragraph twice. The cache is under `SNIFFTEST_CACHE_DIR`, else
|
|
116
|
+
`$XDG_CACHE_HOME/snifftest`, else `~/.cache/snifftest`. Each file is named by a
|
|
117
|
+
SHA-256 of the paragraph, the exact wording of the questions and the model
|
|
118
|
+
alias, and it holds rule ids, probabilities, the served model name and a date.
|
|
119
|
+
The paragraph is never written to disk. Files are written 0600 inside a 0700
|
|
120
|
+
directory, and a relative `SNIFFTEST_CACHE_DIR` is resolved against the home
|
|
121
|
+
directory so the cache can never land inside the tree being checked. Entries
|
|
122
|
+
expire after fourteen days.
|
|
123
|
+
`--no-cache` skips the cache for a run and `SNIFFTEST_CACHE_DIR=off` disables
|
|
124
|
+
it. The cache is opened only after the key check and the consent gate, and
|
|
125
|
+
never on `--dry-run`.
|
|
126
|
+
|
|
127
|
+
## Known limits
|
|
128
|
+
|
|
129
|
+
- A hash is not the text, but anyone who can read the cache and already has a
|
|
130
|
+
paragraph can confirm from it that the paragraph was checked.
|
|
131
|
+
- Cache entries are keyed on the model alias `jev-latest`. A run's first live
|
|
132
|
+
answer names the model actually served, and entries from an older model are
|
|
133
|
+
dropped from then on. A run answered entirely from the cache has no live
|
|
134
|
+
answer to compare against, so for up to fourteen days after the service
|
|
135
|
+
moves the alias such a run can report the previous version's answers.
|
|
136
|
+
- A cache directory that other users or other jobs can write to is a cache they
|
|
137
|
+
can poison. Keep it private. A poisoned entry can change a probability and
|
|
138
|
+
nothing else, because the reader drops any entry that is not a well-formed
|
|
139
|
+
set of numbers between 0 and 1.
|
|
140
|
+
- A pattern rule is refused when it applies a quantifier to a group that
|
|
141
|
+
already contains one, or when two branches of an alternation can start on
|
|
142
|
+
the same character. Behind those shape rules sits a timing probe with a
|
|
143
|
+
small budget. The probe is a measurement, not a proof: a shape nobody has
|
|
144
|
+
written down yet can pass it on a fast machine and run long on a slow one.
|
|
145
|
+
- The hook and the Action fetch the checker from the npm registry at the
|
|
146
|
+
pinned version. What arrives is whatever the registry serves for that name
|
|
147
|
+
and version, so a tag protection rule on this repository and a commit pin on
|
|
148
|
+
the Action are the readers' controls, and owning the name on the registry is
|
|
149
|
+
the maintainer's.
|
|
150
|
+
- The scrubber removes the key's value, and prefixes and suffixes of it. It
|
|
151
|
+
cannot remove a re-encoding of the value, such as base64 or a hash, and does
|
|
152
|
+
not try.
|
|
153
|
+
- A `.snifftest.yaml` found in the working directory may extend only files
|
|
154
|
+
under its own directory tree, or the shipped default, and is refused when it
|
|
155
|
+
is a symbolic link. A file named on `--rules` is followed wherever it points,
|
|
156
|
+
because naming it is the decision.
|
|
157
|
+
|
|
158
|
+
## Reporting a vulnerability
|
|
159
|
+
|
|
160
|
+
Open a private security advisory on the repository:
|
|
161
|
+
|
|
162
|
+
https://github.com/DanRWilloughby/snifftest/security/advisories/new
|
|
163
|
+
|
|
164
|
+
Do not open a public issue. Say what you found, how to reproduce it, and which
|
|
165
|
+
version. You will get a reply on the advisory.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# The direct panel: every row on its provider's own API.
|
|
2
|
+
#
|
|
3
|
+
# `bench/panel.yaml` routes most of its rows through OpenRouter, which buys one
|
|
4
|
+
# key for the whole bench at the cost of a proxy hop in every latency number.
|
|
5
|
+
# This file is the other trade. Each row here is called on the API of the
|
|
6
|
+
# company that serves it, with that company's own key, so the latencies are the
|
|
7
|
+
# providers' own rather than a proxy's, and a row's cost is the published price
|
|
8
|
+
# of the model rather than a reseller's.
|
|
9
|
+
#
|
|
10
|
+
# What it costs to read this way: more keys, and rows that cannot be compared
|
|
11
|
+
# with the ones in the routed panel on latency, because they did not make the
|
|
12
|
+
# same journey. Rows within this file are comparable with each other.
|
|
13
|
+
#
|
|
14
|
+
# Everything else is the same contract. Nothing here claims a model exists: each
|
|
15
|
+
# row carries a pattern and a preference, every row is matched against the
|
|
16
|
+
# provider's own model list on the day of the run, and a model the provider does
|
|
17
|
+
# not list becomes a row that says "not available on <date>" rather than being
|
|
18
|
+
# swapped for a near neighbour.
|
|
19
|
+
|
|
20
|
+
version: 1
|
|
21
|
+
|
|
22
|
+
prices:
|
|
23
|
+
# Neither the Messages API nor the OpenAI models endpoint publishes a price,
|
|
24
|
+
# so both direct providers are priced from a dated file and every table that
|
|
25
|
+
# uses those numbers cites the file, its source and the date it was checked.
|
|
26
|
+
anthropic: prices/anthropic-2026-09-17.yaml
|
|
27
|
+
openai: prices/openai-2026-09-17.yaml
|
|
28
|
+
# No dated published price has been recorded for the judgment service, so no
|
|
29
|
+
# file is named here and its row prints its cost as unknown. A constant in
|
|
30
|
+
# this repo's source is not a source.
|
|
31
|
+
|
|
32
|
+
models:
|
|
33
|
+
# The three Claude rows share one completion budget, larger than the default.
|
|
34
|
+
# On the first direct run, at the default 900 tokens, Sonnet 5 ran out of room
|
|
35
|
+
# on 20 of its 152 replies and Opus 5 on 2: the JSON was cut mid-object and
|
|
36
|
+
# the paragraph counted as unanswered. A cut reply is a budget failure, not a
|
|
37
|
+
# model verdict, so every Claude row is given room to finish, and the same
|
|
38
|
+
# room, so their cost columns stay a fair comparison.
|
|
39
|
+
- id: haiku
|
|
40
|
+
label: Claude Haiku 4.5
|
|
41
|
+
tier: fast
|
|
42
|
+
provider: anthropic
|
|
43
|
+
match: "^claude-haiku-4-5"
|
|
44
|
+
prefer:
|
|
45
|
+
- claude-haiku-4-5-20251001
|
|
46
|
+
max_tokens: 2000
|
|
47
|
+
|
|
48
|
+
- id: sonnet
|
|
49
|
+
label: Claude Sonnet 5
|
|
50
|
+
tier: mid
|
|
51
|
+
provider: anthropic
|
|
52
|
+
match: "^claude-sonnet-5"
|
|
53
|
+
prefer:
|
|
54
|
+
- claude-sonnet-5
|
|
55
|
+
max_tokens: 2000
|
|
56
|
+
|
|
57
|
+
- id: opus
|
|
58
|
+
label: Claude Opus 5
|
|
59
|
+
tier: deep
|
|
60
|
+
provider: anthropic
|
|
61
|
+
match: "^claude-opus-5"
|
|
62
|
+
prefer:
|
|
63
|
+
- claude-opus-5
|
|
64
|
+
max_tokens: 2000
|
|
65
|
+
|
|
66
|
+
- id: sol
|
|
67
|
+
label: gpt-5.6-sol
|
|
68
|
+
tier: mid
|
|
69
|
+
provider: openai
|
|
70
|
+
match: "^gpt-5\\.6-sol$"
|
|
71
|
+
prefer:
|
|
72
|
+
- gpt-5.6-sol
|
|
73
|
+
# This row reasons, and on this API the reasoning tokens come out of the
|
|
74
|
+
# same completion budget as the answer. On the default budget it can spend
|
|
75
|
+
# the whole of it thinking and stop before it writes the JSON object, which
|
|
76
|
+
# lands in the table as a parse failure and reads as a verdict on the model
|
|
77
|
+
# rather than on the request. So the budget is raised and the effort is
|
|
78
|
+
# declared: low keeps the row affordable, and the footnote prints both, so
|
|
79
|
+
# nobody reads this row as the model's best.
|
|
80
|
+
max_tokens: 4000
|
|
81
|
+
reasoning:
|
|
82
|
+
effort: low
|
|
83
|
+
|
|
84
|
+
- id: jev
|
|
85
|
+
label: Jev, the judgment arm
|
|
86
|
+
tier: judgment
|
|
87
|
+
provider: jev
|
|
88
|
+
match: "^jev-"
|
|
89
|
+
prefer:
|
|
90
|
+
- jev-latest
|
|
91
|
+
note: >
|
|
92
|
+
Asked its own way, one request per paragraph carrying every rule as a
|
|
93
|
+
separate question, which is the shape the service documents and the shape
|
|
94
|
+
arm C uses. The request count matches the other rows; the request shape
|
|
95
|
+
does not. It returns a probability and no boolean, so its flag column is
|
|
96
|
+
that probability against the shipped threshold rather than a decision the
|
|
97
|
+
service made.
|