slopvac 0.1.0__tar.gz

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.
Files changed (115) hide show
  1. slopvac-0.1.0/.gitignore +31 -0
  2. slopvac-0.1.0/CHANGELOG.md +12 -0
  3. slopvac-0.1.0/PKG-INFO +572 -0
  4. slopvac-0.1.0/README.md +541 -0
  5. slopvac-0.1.0/docs/counter-signals.md +32 -0
  6. slopvac-0.1.0/docs/coverage.md +216 -0
  7. slopvac-0.1.0/docs/domain-categories.md +109 -0
  8. slopvac-0.1.0/docs/exclusions.md +71 -0
  9. slopvac-0.1.0/docs/metrics.md +291 -0
  10. slopvac-0.1.0/docs/migration.md +175 -0
  11. slopvac-0.1.0/docs/orwell-derivation.md +1610 -0
  12. slopvac-0.1.0/docs/rules.md +3935 -0
  13. slopvac-0.1.0/docs/ste-principles.md +17 -0
  14. slopvac-0.1.0/docs/triage.md +90 -0
  15. slopvac-0.1.0/docs/vale-traps.md +286 -0
  16. slopvac-0.1.0/examples/blocklist.toml +106 -0
  17. slopvac-0.1.0/pyproject.toml +105 -0
  18. slopvac-0.1.0/src/slopvac/__init__.py +30 -0
  19. slopvac-0.1.0/src/slopvac/analyze.py +771 -0
  20. slopvac-0.1.0/src/slopvac/cli.py +1160 -0
  21. slopvac-0.1.0/src/slopvac/compile_vale.py +1277 -0
  22. slopvac-0.1.0/src/slopvac/config.py +671 -0
  23. slopvac-0.1.0/src/slopvac/engine.py +1137 -0
  24. slopvac-0.1.0/src/slopvac/html.py +272 -0
  25. slopvac-0.1.0/src/slopvac/locale.py +328 -0
  26. slopvac-0.1.0/src/slopvac/model.py +334 -0
  27. slopvac-0.1.0/src/slopvac/profiles.py +178 -0
  28. slopvac-0.1.0/src/slopvac/reference.py +285 -0
  29. slopvac-0.1.0/src/slopvac/report.py +444 -0
  30. slopvac-0.1.0/src/slopvac/rules/ai-residue.yml +39 -0
  31. slopvac-0.1.0/src/slopvac/rules/ai-tells-agentic.yml +2122 -0
  32. slopvac-0.1.0/src/slopvac/rules/ai-tells-figurative.yml +453 -0
  33. slopvac-0.1.0/src/slopvac/rules/docs-discipline.yml +94 -0
  34. slopvac-0.1.0/src/slopvac/rules/orwell.yml +151 -0
  35. slopvac-0.1.0/src/slopvac/rules/prose-agency.yml +156 -0
  36. slopvac-0.1.0/src/slopvac/rules/prose-craft.yml +1018 -0
  37. slopvac-0.1.0/src/slopvac/rules/prose-discipline.yml +672 -0
  38. slopvac-0.1.0/src/slopvac/rules/prose-format.yml +95 -0
  39. slopvac-0.1.0/src/slopvac/rules/prose-inclusive.yml +177 -0
  40. slopvac-0.1.0/src/slopvac/rules/prose-inflation.yml +359 -0
  41. slopvac-0.1.0/src/slopvac/rules/prose-scope.yml +142 -0
  42. slopvac-0.1.0/src/slopvac/rules/ste-descriptive.yml +222 -0
  43. slopvac-0.1.0/src/slopvac/rules/ste-nouns.yml +78 -0
  44. slopvac-0.1.0/src/slopvac/rules/ste-practices.yml +499 -0
  45. slopvac-0.1.0/src/slopvac/rules/ste-procedural.yml +189 -0
  46. slopvac-0.1.0/src/slopvac/rules/ste-punctuation.yml +277 -0
  47. slopvac-0.1.0/src/slopvac/rules/ste-safety.yml +106 -0
  48. slopvac-0.1.0/src/slopvac/rules/ste-sentences.yml +265 -0
  49. slopvac-0.1.0/src/slopvac/rules/ste-verbs.yml +307 -0
  50. slopvac-0.1.0/src/slopvac/rules/ste-words.yml +564 -0
  51. slopvac-0.1.0/src/slopvac/rules.py +223 -0
  52. slopvac-0.1.0/src/slopvac/score.py +345 -0
  53. slopvac-0.1.0/src/slopvac/templates.py +138 -0
  54. slopvac-0.1.0/src/slopvac/vale.py +205 -0
  55. slopvac-0.1.0/src/slopvac/vocabulary.py +274 -0
  56. slopvac-0.1.0/tests/conftest.py +30 -0
  57. slopvac-0.1.0/tests/fixtures/vale/hard.md +12 -0
  58. slopvac-0.1.0/tests/fixtures/vale/mos-clean.md +16 -0
  59. slopvac-0.1.0/tests/fixtures/vale/mos-fire.md +13 -0
  60. slopvac-0.1.0/tests/fixtures/vale/must-fire.md +13 -0
  61. slopvac-0.1.0/tests/fixtures/vale/must-not-fire.md +17 -0
  62. slopvac-0.1.0/tests/fixtures/vale/weasel-clean.md +12 -0
  63. slopvac-0.1.0/tests/fixtures/vale/weasel-fire.md +13 -0
  64. slopvac-0.1.0/tests/test_cli.py +776 -0
  65. slopvac-0.1.0/tests/test_compile_vale.py +892 -0
  66. slopvac-0.1.0/tests/test_config.py +213 -0
  67. slopvac-0.1.0/tests/test_engine.py +1103 -0
  68. slopvac-0.1.0/tests/test_html.py +229 -0
  69. slopvac-0.1.0/tests/test_report.py +253 -0
  70. slopvac-0.1.0/tests/test_ruleset.py +243 -0
  71. slopvac-0.1.0/tests/test_vale_rules.py +327 -0
  72. slopvac-0.1.0/tests/test_vocabulary.py +318 -0
  73. slopvac-0.1.0/uv.lock +616 -0
  74. slopvac-0.1.0/vale/.vale.ini +97 -0
  75. slopvac-0.1.0/vale/styles-verified/hedge/AdditiveHedge.yml +7 -0
  76. slopvac-0.1.0/vale/styles-verified/hedge/BaselinelessComparative.yml +17 -0
  77. slopvac-0.1.0/vale/styles-verified/hedge/BidirectionalHedge.yml +7 -0
  78. slopvac-0.1.0/vale/styles-verified/hedge/HedgeStack.yml +7 -0
  79. slopvac-0.1.0/vale/styles-verified/hedge/HedgedHedge.yml +14 -0
  80. slopvac-0.1.0/vale/styles-verified/hedge/UnsupportedAttribution.yml +17 -0
  81. slopvac-0.1.0/vale/styles-verified/hedge/VagueQuantifier.yml +44 -0
  82. slopvac-0.1.0/vale/styles-verified/mos/Editorializing.yml +27 -0
  83. slopvac-0.1.0/vale/styles-verified/mos/ExpressionOfDoubt.yml +14 -0
  84. slopvac-0.1.0/vale/styles-verified/mos/PresumptuousLanguage.yml +14 -0
  85. slopvac-0.1.0/vale/styles-verified/mos/Puffery.yml +29 -0
  86. slopvac-0.1.0/vale/styles-verified/mos/RedundantSubsetTerm.yml +10 -0
  87. slopvac-0.1.0/vale/styles-verified/mos/RelativeTime.yml +10 -0
  88. slopvac-0.1.0/vale/styles-verified/mos/RhetoricalQuestionHeading.yml +9 -0
  89. slopvac-0.1.0/vale-styles/README.md +137 -0
  90. slopvac-0.1.0/vale-styles/ai-residue/ChatLeakage.yml +21 -0
  91. slopvac-0.1.0/vale-styles/ai-residue/meta.json +4 -0
  92. slopvac-0.1.0/vale-styles/build.sh +42 -0
  93. slopvac-0.1.0/vale-styles/docs-discipline/HistoryNarration.yml +11 -0
  94. slopvac-0.1.0/vale-styles/docs-discipline/InternalRefs.yml +18 -0
  95. slopvac-0.1.0/vale-styles/docs-discipline/StatusLanguage.yml +15 -0
  96. slopvac-0.1.0/vale-styles/docs-discipline/meta.json +4 -0
  97. slopvac-0.1.0/vale-styles/prose-agency/AgentlessPassive.yml +23 -0
  98. slopvac-0.1.0/vale-styles/prose-agency/FalseAgency.yml +28 -0
  99. slopvac-0.1.0/vale-styles/prose-agency/NarratorDistance.yml +16 -0
  100. slopvac-0.1.0/vale-styles/prose-agency/meta.json +4 -0
  101. slopvac-0.1.0/vale-styles/prose-format/EmojiHeading.yml +9 -0
  102. slopvac-0.1.0/vale-styles/prose-format/NoUnicodeDash.yml +24 -0
  103. slopvac-0.1.0/vale-styles/prose-format/ProseBlock.yml +11 -0
  104. slopvac-0.1.0/vale-styles/prose-format/meta.json +4 -0
  105. slopvac-0.1.0/vale-styles/prose-inflation/AdditiveHedge.yml +9 -0
  106. slopvac-0.1.0/vale-styles/prose-inflation/BorderlineHype.yml +10 -0
  107. slopvac-0.1.0/vale-styles/prose-inflation/BusinessJargon.yml +14 -0
  108. slopvac-0.1.0/vale-styles/prose-inflation/SlopLexicon.yml +14 -0
  109. slopvac-0.1.0/vale-styles/prose-inflation/VagueDeclarative.yml +17 -0
  110. slopvac-0.1.0/vale-styles/prose-inflation/meta.json +4 -0
  111. slopvac-0.1.0/vale-styles/prose-scope/Epigram.yml +35 -0
  112. slopvac-0.1.0/vale-styles/prose-scope/ImplementationLeak.yml +40 -0
  113. slopvac-0.1.0/vale-styles/prose-scope/RejectedAlternative.yml +36 -0
  114. slopvac-0.1.0/vale-styles/prose-scope/UnrequestedReassurance.yml +38 -0
  115. slopvac-0.1.0/vale-styles/prose-scope/meta.json +4 -0
@@ -0,0 +1,31 @@
1
+ # Built by vale-styles/build.sh and published as release assets.
2
+ packages/slopvac-lint/vale-styles/dist/
3
+
4
+ # Fetched by `vale sync`, never committed.
5
+ .vale-styles/
6
+ packages/slopvac/.apm/skills/review-docs/vale/styles/*/
7
+
8
+ __pycache__/
9
+ *.pyc
10
+
11
+ .pytest_cache/
12
+
13
+ # Beads / Dolt files (added by bd init)
14
+ .dolt/
15
+ *.db
16
+ .beads-credential-key
17
+ .beads/proxieddb/
18
+ .venv/
19
+ *.egg-info/
20
+ __pycache__/
21
+
22
+ # Build artifacts. A wheel in git goes stale the moment the source changes,
23
+ # and a stale wheel that installs is worse than no wheel.
24
+ dist/
25
+
26
+ # Derived from the shipped ruleset by `slopvac rules --judgement --format json`,
27
+ # dumped so the clean-room judges could read the rules without reading the source
28
+ # tree. Regenerable, 528K, and it would go stale against the YAML on the first
29
+ # rule edit while looking authoritative.
30
+ .dogfood-cleanroom/**/judgement-rules.json
31
+ .dogfood-cleanroom/**/rules-*.json
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## [1.0.0](https://github.com/srobroek/slopvac/compare/slopvac--v0.1.0...slopvac--v1.0.0) (2026-08-18)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * `slopvac-lint` no longer installs or imports. The command is `slopvac`, the module is `slopvac`, and `pip install slopvac-lint` finds nothing.
9
+
10
+ ### Features
11
+
12
+ * split the linter into its own package and publish it to PyPI ([#10](https://github.com/srobroek/slopvac/issues/10)) ([29d6a80](https://github.com/srobroek/slopvac/commit/29d6a802562e6454bc2131e8ac7eb24eab72c1bf))
slopvac-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,572 @@
1
+ Metadata-Version: 2.5
2
+ Name: slopvac
3
+ Version: 0.1.0
4
+ Summary: Score prose against AI-slop, Simplified Technical English, and Orwell rulesets. Deterministic, configurable, CI-ready.
5
+ Project-URL: Homepage, https://github.com/srobroek/slopvac
6
+ Project-URL: Repository, https://github.com/srobroek/slopvac
7
+ Project-URL: Issues, https://github.com/srobroek/slopvac/issues
8
+ Author: Sjors Robroek
9
+ License-Expression: Apache-2.0
10
+ Keywords: ai-slop,documentation,linter,prose,ste,vale
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Classifier: Topic :: Text Processing :: Linguistic
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: click>=8.1
21
+ Requires-Dist: markdown-it-py>=3.0
22
+ Requires-Dist: pathspec>=0.12
23
+ Requires-Dist: pydantic>=2.7
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: regex>=2024.5.15
26
+ Requires-Dist: rich>=13.7
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.2; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # slopvac
33
+
34
+ Score prose against three rulesets: AI-slop patterns, Simplified Technical
35
+ English, and Orwell's rules restated as objective tests.
36
+
37
+ Reports a finding density per 100 words and a 0-100 score, per category and
38
+ overall, with warn and error levels you set per category.
39
+
40
+ ```sh
41
+ uvx slopvac README.md
42
+ uvx slopvac --profile strict docs/
43
+ uvx slopvac --format json docs/ | jq .summary
44
+ ```
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ uv tool install slopvac # persistent, no per-call resolution
50
+ uvx slopvac --help # or run it without installing
51
+ pipx install slopvac
52
+ ```
53
+
54
+ [Vale](https://vale.sh) 3.15 or later executes most of the ruleset and belongs on
55
+ your PATH. `slopvac` compiles its own YAML rules into a Vale style directory
56
+ and generates the `.vale.ini` it passes to Vale.
57
+
58
+ | Engine | Rules | Covers |
59
+ | --- | --- | --- |
60
+ | Vale | 132 | token, regex, and substitution matching; sentence and paragraph word counts; part-of-speech checks against your word blocklist; whole-document ratios |
61
+ | built-in | 18 | patterns Go's regex engine rejects, metrics with no Vale form, and block-shape comparisons |
62
+ | neither | 67 | rules stating a question a reviewer answers; `slopvac rules --judgement` lists them |
63
+
64
+ Without the binary the run still scores the 18 built-in rules and reports the rest
65
+ as `UNCHECKED`, so a partial check never reads as a pass. `--no-vale` reports the
66
+ same way.
67
+
68
+ `slopvac compile --format json` prints the current split, and
69
+ [`docs/rules.md`](docs/rules.md) lists every rule.
70
+
71
+ Inspect the routing, or run Vale by hand against the generated config:
72
+
73
+ ```sh
74
+ slopvac compile --outdir build/vale
75
+ vale --config=build/vale/.vale.ini docs/
76
+ ```
77
+
78
+ ## Profiles
79
+
80
+ A profile is the strictness dial. It sets which rules run, how loud each one is,
81
+ and what gates the document must clear.
82
+
83
+ | Profile | For | Sentence cap | Approved-word check |
84
+ | --- | --- | --- | --- |
85
+ | `strict` | reference, specs, API docs, runbooks | 20 procedural / 25 descriptive | on |
86
+ | `normal` | README, guides, decision records | 25 advisory | off |
87
+ | `relaxed` | notes, comments, drafts | advisory | off |
88
+
89
+ `normal` is the default. `strict` on an existing repository produces a wall of
90
+ findings, which teaches people to ignore the tool.
91
+
92
+ ### What strictness changes
93
+
94
+ Each rule declares a *tier* per profile, and the tier decides how the rule
95
+ reports:
96
+
97
+ | Tier | Effect |
98
+ | --- | --- |
99
+ | `enforced` | keeps its shipped severity, so it can reach `error` |
100
+ | `advisory` | caps at `suggestion`, so it lowers the score but never fails a run |
101
+ | `off` | does not run |
102
+
103
+ Beyond the tiers, a profile sets the gates the whole document must clear:
104
+
105
+ | Profile | Total density budget | Max errors | `min_score` |
106
+ | --- | --- | --- | --- |
107
+ | `strict` | 1.5 / 100 words | 0 | 85 |
108
+ | `normal` | 3.0 / 100 words | 0 | 70 |
109
+ | `relaxed` | 8.0 / 100 words | unlimited | none |
110
+
111
+ At `relaxed` the run reports the score for information and gates nothing.
112
+
113
+ Two rules invert the tier ordering on purpose. Passive voice is advisory at
114
+ `strict` and enforced at `normal`, because the agentless passive is correct in a
115
+ specification and wrong in a guide.
116
+
117
+ A profile never overrides its own tiers. Naming a category in `slopvac.toml` and
118
+ asking for `error` beats the advisory cap, because a human wrote it. The value
119
+ the profile itself supplied does not, which is what stops a profile from
120
+ contradicting its own tiers.
121
+
122
+ ### Genres
123
+
124
+ Categories declare the genres they suit, in a `recommended_for` field that
125
+ [`docs/rules.md`](docs/rules.md) tabulates:
126
+
127
+ `adr`, `api-docs`, `change-comms`, `consumer-docs`, `essay`, `guide`,
128
+ `internal-docs`, `pr-description`, `readme`, `reference`, `runbook`,
129
+ `source-comments`
130
+
131
+ Genre and profile are separate. The genre says what the document is, and the
132
+ profile says how hard to press. `genre_recommendation()` maps one to the other so
133
+ that a caller recommends rather than asks:
134
+
135
+ | Genre | Profile |
136
+ | --- | --- |
137
+ | `reference`, `api-docs`, `runbook`, `spec`, `procedure`, `safety` | `strict` |
138
+ | `issue`, `comment`, `note`, `draft`, `chat`, `scratch` | `relaxed` |
139
+ | anything else | `normal` |
140
+
141
+ The `review-docs` skill reads both fields. It picks the profile from the genre,
142
+ and enables the categories whose `recommended_for` names that genre.
143
+
144
+ ## Configuration
145
+
146
+ `slopvac init` writes a `slopvac.toml`. Three layers patch each other per
147
+ field:
148
+
149
+ 1. the profile
150
+ 2. the `[categories]` and `[rules]` tables
151
+ 3. every `[[overrides]]` block whose glob matches, in file order
152
+
153
+ ```toml
154
+ profile = "normal"
155
+
156
+ [thresholds]
157
+ max_errors = 0
158
+ min_score = 70
159
+
160
+ [categories]
161
+ ste-vocabulary = "off"
162
+
163
+ [rules]
164
+ "prose-format.no-unicode-dash" = "off" # house style uses real em dashes
165
+
166
+ [[overrides]]
167
+ files = ["docs/reference/**/*.md", "runbooks/**/*.md"]
168
+ profile = "strict"
169
+ ```
170
+
171
+ Severity is the only per-rule setting, so a bare string stands in for the table
172
+ form: `"prose-format.no-unicode-dash" = "off"` and `[rules."prose-format.no-unicode-dash"]`
173
+ with `severity = "off"` are the same thing. The same shorthand works for a
174
+ category.
175
+
176
+ Severity is a **set at every layer, not a cap**. A category's `severity` promotes
177
+ as well as demotes, and so does a rule's, so `severity = "error"` on a category
178
+ does turn its suggestions into gate failures. Narrowest wins: a rule override
179
+ beats its category, which beats the profile's disposition, which beats the
180
+ severity the rule ships with.
181
+
182
+ A misspelled rule id or category name is an **error**, not a silent no-op,
183
+ including inside an `[[overrides]]` block. `slopvac` refuses to lint and gives
184
+ the closest real name, because the alternative failure is "I disabled it and the
185
+ gate still fails."
186
+
187
+ ### How overlapping globs resolve
188
+
189
+ Every matching `[[overrides]]` block applies, in **file order**, and the last
190
+ block to set a field owns that field. It is not strictest-wins and not
191
+ most-specific-wins:
192
+
193
+ ```toml
194
+ [[overrides]]
195
+ files = ["x.md"]
196
+ profile = "strict"
197
+
198
+ [[overrides]]
199
+ files = ["x.*"] # broader, but LATER, so this one wins for x.md
200
+ profile = "relaxed"
201
+ ```
202
+
203
+ Two alternatives lost. Specificity ranking loses because no ordering on globs a
204
+ reader can predict exists: `docs/**` against `**/*.md` is differently specific,
205
+ not more or less, so any winner a rule picks there is a rule you memorise.
206
+ Strictest-wins loses because under it nothing relaxes, and a vendored subtree or a
207
+ generated `docs/api/` then has no way down, which is the main reason overrides
208
+ exist.
209
+
210
+ `slopvac` refuses two blocks with the *same* scope, since that reads as two
211
+ independent decisions and resolves as one. Overlap between different globs is
212
+ legitimate and stays legal.
213
+
214
+ `slopvac lint --explain-config <file>` prints what applies **and which block set
215
+ each setting**:
216
+
217
+ ```
218
+ x.md
219
+ profile: relaxed
220
+ overrides: x.md, x.*
221
+ set by:
222
+ profile: overrides[1] (x.*)
223
+ rules.prose-format.no-unicode-dash: overrides[0] (x.md)
224
+ ```
225
+
226
+ The report lists only the settings some layer actually touched. The untouched
227
+ profile defaults would bury them.
228
+
229
+ ## Word blocklist
230
+
231
+ Off by default. Nothing checks your words until you name a file:
232
+
233
+ ```toml
234
+ [vocabulary]
235
+ path = "docs/blocklist.toml" # relative to this config file
236
+ ```
237
+
238
+ Each entry names a word, the part of speech to refuse it as, and why:
239
+
240
+ ```toml
241
+ [[entries]]
242
+ word = "deploy"
243
+ pos = "noun"
244
+ replacement = "deployment"
245
+ reason = "The verb is fine. The noun form is a verb used as a noun."
246
+
247
+ [[entries]]
248
+ word = "simple"
249
+ pos = "adjective"
250
+ reason = "Judges the reader's experience rather than the work."
251
+ ```
252
+
253
+ `examples/blocklist.toml` is a working starter. `.yml` and `.json` load too.
254
+
255
+ **The part of speech is the point.** `deploy` is a good verb and a bad noun, and
256
+ one entry per sense is what lets you say so: `slopvac` reports "the deploy failed"
257
+ and passes "deploy the worker". Vale's tagger decides which is which.
258
+
259
+ **`reason` is required.** `slopvac` refuses a file without one, because nobody but
260
+ the author can review or remove an undocumented refusal. `replacement` is optional:
261
+ omit it when the fix depends on the sentence, because a reader applies a suggestion
262
+ without thinking.
263
+
264
+ A word absent from the file is fine by definition. Nothing expresses "only these
265
+ words are allowed", and that gap is deliberate: this package once shipped an
266
+ ASD-STE100 word list enforced that way, and on ordinary software prose it produced
267
+ 828 findings for words that merely had no entry, half of everything it reported.
268
+ That list is gone rather than switched off. A blocklist you wrote is the only word
269
+ list that knows your domain.
270
+
271
+ ## Suppressing a finding
272
+
273
+ A suppression must name an exception from the rule's own list:
274
+
275
+ ```markdown
276
+ <!-- slopvac-allow: rule=orwell.stale-figure reason=quotation -->
277
+ ```
278
+
279
+ `slopvac explain orwell.stale-figure` lists the valid reasons. When an annotation
280
+ names a reason off that list, `slopvac` reports it rather than honors it, and
281
+ tracks the suppression rate as a metric.
282
+
283
+ ## Output formats
284
+
285
+ ```sh
286
+ slopvac docs/ # a terminal report
287
+ slopvac --format json docs/ | jq . # every finding, every score
288
+ slopvac --format github docs/ # Action annotations on the diff
289
+ slopvac --format sarif docs/ > out.sarif # code scanning
290
+ slopvac --open docs/ # an HTML report, in your browser
291
+ ```
292
+
293
+ `--open` writes a self-contained page and opens it. `--out report.html` names the
294
+ file instead of a temporary one, and implies HTML. The page needs no network and
295
+ no assets, so it survives being attached to a CI run or mailed to a reviewer.
296
+
297
+ The report leads with what did **not** run, before the score, and flags each
298
+ affected document in the table. A score from an engine that failed to start is an
299
+ upper bound, and a reader who misses that has been misled by their own report.
300
+
301
+ Below that: the verdict, then the documents worst first, then the categories that
302
+ fired, then the findings, grouped per document. Anything that failed starts open.
303
+
304
+ ## The compiled-rule cache
305
+
306
+ `slopvac` compiles its YAML rules into a Vale style directory once and reuses it.
307
+ The cache key is a hash of the rules, the resolved config, the severities, and
308
+ your blocklist, so **nothing is ever served stale**: any edit mints a new key.
309
+
310
+ ```sh
311
+ slopvac cache # where it is, how many trees, how much disk
312
+ slopvac cache --prune # keep the 16 most recently used
313
+ slopvac cache --all # delete every tree
314
+ ```
315
+
316
+ A lint prunes on its own, keeping the 16 trees used most recently. A cache hit
317
+ counts as use. A tree that a project keeps hitting therefore survives, however
318
+ old it is.
319
+ Pruning is only ever about disk: it cannot cause a wrong result. Set
320
+ `SLOPVAC_CACHE_DIR` to move it; it defaults under `XDG_CACHE_HOME`.
321
+
322
+ ## Exit codes
323
+
324
+ | Code | Means |
325
+ | --- | --- |
326
+ | 0 | clean, or every threshold met |
327
+ | 1 | a threshold failed |
328
+ | 2 | the run could not be trusted: bad config, unloadable rules, missing tool |
329
+
330
+ The 1/2 split is what makes a clean result meaningful. Exit 2 means the run checked
331
+ nothing, which is not the same as passing.
332
+
333
+ ## Scoring
334
+
335
+ Three numbers, because they answer different questions and none replaces another.
336
+
337
+ | Number | Counts | Answers |
338
+ | --- | --- | --- |
339
+ | `per_100_words` | every finding | how dense is this document |
340
+ | `gating_per_100_words` | errors and warnings | what the budget checks |
341
+ | `score` | 0-100 | what a badge shows and `min_score` gates |
342
+
343
+ Suggestions appear in the first number and not the second, because **a suggestion
344
+ may lower a score but must not fail a run**. That one rule is why there are three
345
+ numbers instead of one.
346
+
347
+ ### Density: the n-per-100-words figure
348
+
349
+ A raw count cannot compare a 40-word error message against a 4,000-word guide.
350
+ One finding is 2.5 per 100 words in the first and 0.025 in the second. The
351
+ measurement is therefore always a density:
352
+
353
+ ```
354
+ density = findings / words * 100
355
+ ```
356
+
357
+ Below 60 words density means nothing, so the scorer switches to absolute counts.
358
+ Every finding counts there, suggestions included, and the count path is harsher on
359
+ purpose: one suggestion costs 5 points, one error costs 20, and four errors reach
360
+ 0. A 40-word message has room for no defects.
361
+
362
+ ### Per-category score
363
+
364
+ Every category gets its own density and its own 0-100 score against its own
365
+ budget. Weighted density drives it, so severity matters:
366
+
367
+ | Severity | Weight |
368
+ | --- | --- |
369
+ | `error` | 4.0 |
370
+ | `warning` | 2.0 |
371
+ | `suggestion` | 1.0 |
372
+ | `off` | 0.0 |
373
+
374
+ An error weighs 4 suggestions, so a document with one error is not out-voted by
375
+ cosmetic findings.
376
+
377
+ The curve has two halves and no sudden drop:
378
+
379
+ ```
380
+ at or below budget: 100 down to 70, linearly
381
+ above budget: 70 down to 0, reaching 0 at 4x budget
382
+ ```
383
+
384
+ A document exactly at budget scores 70, which makes "just inside" visibly
385
+ different from "clean". Above budget the score decays linearly rather than
386
+ instantly, so slightly over reads differently from far over.
387
+
388
+ The scorer subtracts suggestions afterwards, as a **bounded** penalty rather than
389
+ folding them into the density: at most 15 points, reaching that maximum at a
390
+ suggestion density of 6.0 per 100 words. Unbounded, they consumed the whole scale. Measured
391
+ on one document, suggestions were 76 of 152 findings and an advisory rule the
392
+ profile explicitly does not stand behind failed the run anyway.
393
+
394
+ A category with `weight = 0` is informational. It reports its findings and
395
+ contributes to neither side of the mean below.
396
+
397
+ ### Document score
398
+
399
+ The document score is the **lower** of two figures:
400
+
401
+ 1. the weight-weighted mean of the per-category scores
402
+ 2. the same calculation run over the whole document's findings at once
403
+
404
+ Both directions matter. The mean alone is too kind: 23 categories that found
405
+ nothing score 100 each and drown the two that found errors, so a document with
406
+ five errors read as 92.7. While the rest are clean, the whole-document figure
407
+ alone loses the signal that one category sits far over its budget. Taking the
408
+ lower of the two keeps both.
409
+
410
+ ### What fails a run
411
+
412
+ A run fails when any gate breaks: the error count exceeds `max_errors`, the
413
+ gating density exceeds `max_total_per_100_words`, the score falls below
414
+ `min_score`, or any single category exceeds its own `max_per_100_words`. The
415
+ report names each broken gate with the number that broke it.
416
+
417
+ ## Rules
418
+
419
+ Rules are data. Each lives in a YAML file under `rules/<category>.yml`, so adding
420
+ a lexical, substitution, or threshold rule needs no code.
421
+
422
+ ```sh
423
+ slopvac rules --profile strict
424
+ slopvac rules --judgement # what a linter cannot check
425
+ slopvac explain ste-sentences.max-words
426
+ slopvac lint --rules-dir ./my-rules docs/
427
+ ```
428
+
429
+ `slopvac` validates every rule at load: it compiles each regex, and requires each
430
+ example's `bad` text to match while its `good` text must not. A rule whose pattern
431
+ stopped firing passes every document, which is indistinguishable from clean prose.
432
+
433
+ Rules marked `kind: judgement` never produce a finding. They carry the checks no
434
+ pattern reaches, as decidable questions, so an agentic reviewer reads one source
435
+ of truth instead of a parallel prose catalog.
436
+
437
+ [`docs/rules.md`](docs/rules.md) is the full reference, generated from the same
438
+ ruleset the linter loads and split into checked and judgement rules. CI
439
+ regenerates it and fails on a diff, so it cannot drift from the code.
440
+
441
+ ## Philosophy
442
+
443
+ Four positions, and each one rules something out. They are worth stating because
444
+ the obvious alternative is what most prose linters do.
445
+
446
+ ### Density, not zero tolerance
447
+
448
+ Nearly every prose linter reports a count. A count makes a long document worse
449
+ than a short one for writing at the same quality, so the incentive it creates is
450
+ to write less rather than to write better, and any threshold set against a count
451
+ either passes a 3,000-word document with forty problems or fails a 200-word one
452
+ with three.
453
+
454
+ So the gate is **findings per 100 words**, and the score follows from that density
455
+ against the profile's budget. A long document earns proportionally more
456
+ findings. Under 60 words, scoring switches to absolute counts, because one finding
457
+ in a 20-word error message is 5.0 per 100 words and would fail every budget ever
458
+ set.
459
+
460
+ ### Rules that fire deterministically, separated from rules that do not
461
+
462
+ A rule either has a checker or it does not, and the two make different promises.
463
+ Pretending otherwise produces the two failures this tool exists to prevent: a
464
+ reader who believes a judgement rule gates their build, and an agent that treats a
465
+ mechanical rule as a matter of opinion.
466
+
467
+ So the same ruleset carries `kind: judgement` rules, and they **never produce a
468
+ finding**. They ship for two reasons. A reviewing agent needs one source of truth
469
+ rather than a second, drifting prose catalog. And a rule no tool can automate is not
470
+ thereby less true. Deleting it would quietly redefine the standard as
471
+ "whatever a regex can reach", which is how a style guide becomes a list of
472
+ typography preferences.
473
+
474
+ ### Silence is a finding
475
+
476
+ The failure a linter is worst at reporting is its own. A rule that stopped
477
+ matching, an absent Vale binary, a metric with no implementation: each produces a
478
+ document with no findings, which is indistinguishable from clean prose.
479
+
480
+ So:
481
+
482
+ - **Exit 2 is not exit 0.** A bad config, an unloadable ruleset, or a missing tool
483
+ exits 2, and every caller treats that as "nothing was checked" rather than as a
484
+ pass.
485
+ - **Skipped rules are reported as `UNCHECKED`**, per run. Without the Vale binary
486
+ the built-in rules still score and the rest are named as not run.
487
+ - **Every rule is validated at load.** Each regex compiles, and each example's
488
+ `bad` text must match while its `good` text must not, so a rule that stopped
489
+ firing fails the build instead of passing every document.
490
+ - **A misspelled rule id is an error**, not a silent no-op: the failure it
491
+ otherwise produces is "I disabled it and the gate still fails".
492
+ - **A configured blocklist that cannot be loaded is an error.** The project asked
493
+ for that gate by name; linting on with an empty wordlist would report every
494
+ document clean.
495
+
496
+ ### A finding must be actionable, and a refusal must be reviewable
497
+
498
+ A finding a reader cannot act on trains them to disable the rule. So each one
499
+ carries the replacement or the operation, `slopvac explain <rule>` gives the
500
+ reason behind the rule's wording, and every rule cites a source.
501
+
502
+ The same standard applies to the word blocklist, and it is why **no word list
503
+ ships**. An earlier version treated ASD-STE100's 859 approved words as the
504
+ permitted set. Measured on an 8-document corpus:
505
+
506
+ - that one rule produced 51% of all findings
507
+ - it drove every document to a score of 0.0, including documents with zero errors
508
+ - 1,275 of its 1,282 refusals carried neither a reason nor a replacement
509
+
510
+ Absence from a deliberately incomplete dictionary is not disapproval. It is a
511
+ **blocklist** now, empty until you write one, and every entry requires a reason.
512
+ Nobody but its author can argue with, or later remove, an entry that gives no
513
+ reason.
514
+
515
+ Suppression follows from the same position: `<!-- slopvac-disable-next-line rule:
516
+ reason -->` requires a reason from the rule's own closed list. `slopvac` reports
517
+ any other reason rather than honors it, so a blanket suppression shows up in a
518
+ diff.
519
+
520
+ ### What this deliberately does not do
521
+
522
+ A clean run means the checked patterns are absent, and nothing more. It is not a
523
+ review. The linter does not read for truth: a sentence can pass every rule and
524
+ name a function that does not exist, describe a flag that never shipped, or
525
+ contradict the paragraph above it.
526
+
527
+ The lexical rules also perish. A memorized word list tracks one model generation,
528
+ which is why the structural and register categories carry more weight than the
529
+ token ones.
530
+
531
+ ## Word counting
532
+
533
+ Sentence-length limits use ASD-STE100's own definition of a word (rules 8.4
534
+ through 8.7), not a whitespace split:
535
+
536
+ - a number counts as one word, with its unit if it has one
537
+ - an abbreviation counts as one word
538
+ - a quoted span counts as one word
539
+ - parenthesized text counts as one word
540
+ - a hyphenated word counts as one word
541
+ - numbers identifying a step or paragraph are not counted
542
+
543
+ `Do steps 13 thru 16 a minimum of three times.` is 10 words.
544
+
545
+ ## Limits
546
+
547
+ A clean run means the checked patterns are absent, and nothing more. The linter
548
+ does not read for truth. A sentence can pass every rule and name a function that
549
+ does not exist, describe a flag that never shipped, or contradict the paragraph
550
+ above it.
551
+
552
+ No word list ships, and the word check stays inert until you write one. See
553
+ [Word blocklist](#word-blocklist).
554
+
555
+ ## Sources
556
+
557
+ The Simplified Technical English rules are an independent restatement, cited by
558
+ rule number. ASD-STE100 is copyright
559
+ [ASD](https://www.asd-ste100.org) and is an EU registered trademark; this package
560
+ reproduces none of its rule text, definitions, or examples.
561
+
562
+ This package ships and reads no dictionary content. An earlier version carried the
563
+ Issue 9 word list. That version is gone, and the word check now reads a blocklist
564
+ you write.
565
+
566
+ The AI-slop rules take their calibration from a corpus of software documentation. The
567
+ lexical ones perish: a memorized word list tracks one model generation, which is
568
+ why the structural and register rules carry more weight.
569
+
570
+ ## License
571
+
572
+ Apache-2.0.