linnl 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.
- linnl-0.1.0/LICENSE +21 -0
- linnl-0.1.0/PKG-INFO +305 -0
- linnl-0.1.0/README.md +275 -0
- linnl-0.1.0/linnl/__init__.py +1 -0
- linnl-0.1.0/linnl/agents.py +60 -0
- linnl-0.1.0/linnl/cli.py +215 -0
- linnl-0.1.0/linnl/config.py +85 -0
- linnl-0.1.0/linnl/constants.py +5 -0
- linnl-0.1.0/linnl/document.py +138 -0
- linnl-0.1.0/linnl/judge.py +228 -0
- linnl-0.1.0/linnl/linter.py +299 -0
- linnl-0.1.0/linnl/logconfig.py +63 -0
- linnl-0.1.0/linnl/plugins.py +45 -0
- linnl-0.1.0/linnl/resources/config.toml +110 -0
- linnl-0.1.0/linnl/resources/templates/prompt.md.j2 +20 -0
- linnl-0.1.0/linnl/rules/__init__.py +24 -0
- linnl-0.1.0/linnl/rules/cliche_rules.py +485 -0
- linnl-0.1.0/linnl/rules/code_rules.py +101 -0
- linnl-0.1.0/linnl/rules/pattern_rules.py +46 -0
- linnl-0.1.0/linnl/rules/rules.py +245 -0
- linnl-0.1.0/linnl/rules/wikipedia_rules.py +159 -0
- linnl-0.1.0/linnl/violations.py +83 -0
- linnl-0.1.0/pyproject.toml +95 -0
- linnl-0.1.0/pyproject.toml.orig +81 -0
linnl-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CrossNox
|
|
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.
|
linnl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linnl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lint prose against a configurable set of style rules, judged by a LLM.
|
|
5
|
+
Keywords: lint,prose,writing,style,claude,codex
|
|
6
|
+
Author: CrossNox
|
|
7
|
+
Author-email: CrossNox <ijmermet@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
17
|
+
Requires-Dist: claude-agent-sdk>=0.2.148
|
|
18
|
+
Requires-Dist: docutils>=0.23
|
|
19
|
+
Requires-Dist: jinja2>=3
|
|
20
|
+
Requires-Dist: markdown-it-py>=4
|
|
21
|
+
Requires-Dist: openai-codex>=0.147.0
|
|
22
|
+
Requires-Dist: pydantic>=2
|
|
23
|
+
Requires-Dist: python-json-logger>=3
|
|
24
|
+
Requires-Dist: rich>=13
|
|
25
|
+
Requires-Dist: typer>=0.15
|
|
26
|
+
Requires-Python: >=3.12
|
|
27
|
+
Project-URL: Repository, https://github.com/CrossNox/linnl
|
|
28
|
+
Project-URL: Issues, https://github.com/CrossNox/linnl/issues
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# linnl
|
|
32
|
+
`linnl` is a text linter aimed to catch LLM tells and reduce the complexity of the text produced by them, so that the cognitive load on the reader is reduced and ideas are clearer.
|
|
33
|
+
|
|
34
|
+
Some built-in rules are checked in code, others (most) are judged by an LLM. You can also install `linnl` plugins to provide your own set of rules.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv tool install linnl
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Claude as a judge
|
|
43
|
+
You will either need a working `claude` login or an `ANTHROPIC_API_KEY` set in your environment.
|
|
44
|
+
|
|
45
|
+
### Codex as a judge
|
|
46
|
+
Codex reuses the local Codex session. You can also set `OPENAI_API_KEY`.
|
|
47
|
+
|
|
48
|
+
## Use
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
linnl lint notes.md
|
|
52
|
+
linnl lint docs/
|
|
53
|
+
echo "text" | linnl lint
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Example output:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
notes.md
|
|
60
|
+
=========
|
|
61
|
+
line 3:
|
|
62
|
+
> No config. No cron. No surprises.
|
|
63
|
+
SCH003 Three successive fragments open with the same word for emphasis, which is anaphora.
|
|
64
|
+
|
|
65
|
+
line 5:
|
|
66
|
+
> Set it up once; forget about it.
|
|
67
|
+
CHR004 Semicolon.
|
|
68
|
+
|
|
69
|
+
Found 2 violations across 1 file.
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Configure
|
|
73
|
+
### CLI
|
|
74
|
+
|
|
75
|
+
Check
|
|
76
|
+
```sh
|
|
77
|
+
linnl lint --help
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
To see available quick configuration options.
|
|
81
|
+
|
|
82
|
+
### File configuration
|
|
83
|
+
The priority is:
|
|
84
|
+
- The closest `pyproject.toml` with a tool.linnl section
|
|
85
|
+
- A `linnl.toml` file next to the closest pyproject.toml
|
|
86
|
+
- `~/.config/linnl/config.toml`
|
|
87
|
+
- The config file shipped with the tool
|
|
88
|
+
|
|
89
|
+
You can easily create a `linnl.toml` with:
|
|
90
|
+
```sh
|
|
91
|
+
linnl config > linnl.toml
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
#### Adding your own rules
|
|
95
|
+
|
|
96
|
+
Add groups under `[rules.<PREFIX>]` in your config file. `description` is the only reserved key. Every other key is a rule:
|
|
97
|
+
|
|
98
|
+
```toml
|
|
99
|
+
extend-select = ["SEC"]
|
|
100
|
+
|
|
101
|
+
[rules.SEC]
|
|
102
|
+
description = "Wording that must not leak infrastructure details"
|
|
103
|
+
001 = "Names an internal host, IP or path"
|
|
104
|
+
abc = "Shows a credential or token in an example"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That defines `SEC001` and `SECabc`, judged by the model. `linnl` shows the group description to the model above its rules, so make it say what the group is for.
|
|
108
|
+
|
|
109
|
+
The reserved `RGX` group defines regular expression rules. Every value under
|
|
110
|
+
`[rules.RGX]` is compiled as a Python regular expression and is enabled by
|
|
111
|
+
default:
|
|
112
|
+
|
|
113
|
+
```toml
|
|
114
|
+
[rules.RGX]
|
|
115
|
+
description = "Patterns to avoid"
|
|
116
|
+
001 = "X is not (.*), it's (.*)"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Each non-overlapping match is reported separately, with the full match as the
|
|
120
|
+
quoted text. Invalid regular expressions fail configuration.
|
|
121
|
+
|
|
122
|
+
If the rule requires configurable parameters:
|
|
123
|
+
|
|
124
|
+
```toml
|
|
125
|
+
extend-select = ["SEC"]
|
|
126
|
+
|
|
127
|
+
[rules.SEC]
|
|
128
|
+
description = "Wording that must not leak infrastructure details"
|
|
129
|
+
001 = "Names an internal host, IP or path"
|
|
130
|
+
abc = "Shows a credential or token in an example"
|
|
131
|
+
|
|
132
|
+
[rules.SEC.xyz]
|
|
133
|
+
description = "Do now show more {n_files} files in the current directory"
|
|
134
|
+
n-files = 3
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A description can name the rule's options in braces. Write an option name with underscores, so `max-sentences` becomes `{max_sentences}` and `"More than {max_sentences} sentences."` renders with the configured value. Options work on Python and model rules. linnl renders their values before sending model rules to the judge.
|
|
138
|
+
|
|
139
|
+
#### Installing rules plugins
|
|
140
|
+
|
|
141
|
+
Install them alongside `linnl`:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
uv tool install linnl --with linnl-acme-rules
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Then enable their `linnl.plugins` entry-point names.
|
|
148
|
+
|
|
149
|
+
```toml
|
|
150
|
+
plugins = ["acme-rules"]
|
|
151
|
+
extend-select = ["ACM"]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The package contributes default sections under `[rules]`. Your configuration can select, ignore, and override those rules in the same way as built-in rules.
|
|
155
|
+
|
|
156
|
+
`linnl plugins` lists the rule packages enabled by the active configuration.
|
|
157
|
+
|
|
158
|
+
To author a package, declare an entry point that names a `linnl.plugins.Plugin` subclass:
|
|
159
|
+
|
|
160
|
+
```toml
|
|
161
|
+
[project.entry-points."linnl.plugins"]
|
|
162
|
+
acme-rules = "linnl_acme_rules:AcmePlugin"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from typing import ClassVar
|
|
167
|
+
|
|
168
|
+
from linnl.document import Document
|
|
169
|
+
from linnl.plugins import Plugin
|
|
170
|
+
from linnl.rules import CodeRule
|
|
171
|
+
from linnl.rules.pattern_rules import TextPatternRule
|
|
172
|
+
from linnl.violations import Violation, Violations
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Acme001(TextPatternRule, identifier="ACM001"):
|
|
176
|
+
"""Flag obviously.
|
|
177
|
+
|
|
178
|
+
Example: "This is obviously correct."
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
pattern = r"\bobviously\b"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Acme002(CodeRule, identifier="ACM002"):
|
|
185
|
+
"""Flag lines longer than 80 characters.
|
|
186
|
+
|
|
187
|
+
Example: "This line has more than eighty characters and should be shortened before it is committed."
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __call__(self, document: Document) -> Violations:
|
|
191
|
+
violations = []
|
|
192
|
+
|
|
193
|
+
for line_number, line in enumerate(document.lines, start=1):
|
|
194
|
+
if len(line) > 80:
|
|
195
|
+
violations.append(
|
|
196
|
+
Violation(
|
|
197
|
+
rule=self,
|
|
198
|
+
path=document.path,
|
|
199
|
+
line=line_number,
|
|
200
|
+
offset=81,
|
|
201
|
+
quote=line,
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return Violations(violations)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class AcmePlugin(Plugin):
|
|
209
|
+
"""Define Acme's writing rules."""
|
|
210
|
+
|
|
211
|
+
name = "acme-rules"
|
|
212
|
+
rules: ClassVar = {
|
|
213
|
+
"ACM": {
|
|
214
|
+
"description": "Acme writing rules",
|
|
215
|
+
"001": "Avoid obviously.",
|
|
216
|
+
"002": "Keep lines at 80 characters or fewer.",
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The class's `name` must match the entry point name. `Acme001` registers itself when the package imports it. Each code rule needs a definition in `rules`.
|
|
222
|
+
|
|
223
|
+
### Check configuration
|
|
224
|
+
`linnl rules` prints every rule with its resolved on/off state and whether Python or the model checks it.
|
|
225
|
+
|
|
226
|
+
## Built-in rules
|
|
227
|
+
|
|
228
|
+
| Code | Default | Rule |
|
|
229
|
+
| --- | --- | --- |
|
|
230
|
+
| SCH001 | on | Tricolon. Three parallel words, phrases or clauses arranged as a series for rhythm or emphasis. Example: 'It is fast, small and simple.' A list of three things that happen to be three is fine, the arrangement for effect is not. |
|
|
231
|
+
| SCH002 | on | Isocolon. Two or more clauses of matching length and grammatical structure, set side by side for balance. Example: 'Simple to learn, hard to master.' |
|
|
232
|
+
| SCH003 | on | Anaphora. The same word or phrase opening successive clauses or sentences. Example: 'No config. No setup. No surprises.' |
|
|
233
|
+
| SCH004 | on | Antithesis. Contrasting ideas placed in parallel structure. Example: 'Not because it is easy, but because it is hard.' |
|
|
234
|
+
| SCH005 | on | Chiasmus. Words or structure repeated in reverse order across two clauses. Example: 'Ask not what your country can do for you, ask what you can do for your country.' |
|
|
235
|
+
| SCH006 | on | Asyndeton. Conjunctions dropped from a series of clauses or phrases to quicken the pace. Example: 'I came, I saw, I conquered.' |
|
|
236
|
+
| SCH007 | on | Alliterative pairing. Two or more nearby words chosen for a shared initial sound. Example: 'fast and fluid', 'bold and brave'. |
|
|
237
|
+
| SCH008 | on | Epigrammatic closer. A short, pithy, quotable sentence used to close a paragraph or the whole text. Example: ending a paragraph with 'Simple tools, simple problems.' |
|
|
238
|
+
| SCH000 | on | Other scheme. Any scheme not covered above: epistrophe, polysyndeton, climax, symploce, and the like. |
|
|
239
|
+
| SLO001 | on | Slogan. A sentence written to be quoted rather than to inform. Example: 'Ship less, sleep more.' |
|
|
240
|
+
| ZIN001 | on | Simplicity. The sentence is more complex than the idea it carries, through long-winded construction or jargon where a plain word exists. |
|
|
241
|
+
| ZIN002 | on | Brevity. Words that do no work: padding, redundant pairs, throat-clearing openers and restating what was already said. |
|
|
242
|
+
| ZIN003 | on | Clarity. The reader cannot tell what is meant: ambiguous pronouns, vague references, undefined terms, sentences that need a second reading. |
|
|
243
|
+
| ZIN004 | on | Humanity. The writing does not sound like one person talking to another: stiff, bureaucratic, impersonal, over-hedged, or passive voice hiding who did what. |
|
|
244
|
+
| CHR001 | on | Em dash (U+2014). |
|
|
245
|
+
| CHR002 | on | En dash (U+2013). |
|
|
246
|
+
| CHR003 | on | Middle dot (U+00B7). |
|
|
247
|
+
| CHR004 | on | Semicolon. |
|
|
248
|
+
| CHR000 | off | Any non-ASCII character not covered by another CHR rule. |
|
|
249
|
+
| LEN001 | off | The text has more than 3 sentences. |
|
|
250
|
+
| LEN002 | on | Not concise. The text includes material the reader did not ask for: justification, background, alternatives or caveats. |
|
|
251
|
+
| CLH001 | on | No X, no Y chains. |
|
|
252
|
+
| CLH002 | on | That's the whole point, game, or thing. |
|
|
253
|
+
| CLH003 | on | Did not X, did not Y chains. |
|
|
254
|
+
| CLH004 | on | Don't VERB it, VERB it. |
|
|
255
|
+
| CLH005 | on | Sit with that. |
|
|
256
|
+
| CLH006 | on | You already know. |
|
|
257
|
+
| CLH007 | on | Is the entire point, game, or business model. |
|
|
258
|
+
| CLH008 | on | The entire point, game, or business model is. |
|
|
259
|
+
| CLH009 | on | Is real and or not. |
|
|
260
|
+
| CLH010 | on | The punchline is. |
|
|
261
|
+
| CLH011 | on | Worth naming. |
|
|
262
|
+
| CLH012 | on | That's not nothing. |
|
|
263
|
+
| CLH013 | on | Is the whole point, trick, pitch, or idea. |
|
|
264
|
+
| CLH014 | on | Echoing sentence runs. |
|
|
265
|
+
| CLH015 | on | Performative honesty. |
|
|
266
|
+
| CLH016 | on | That's the part. |
|
|
267
|
+
| CLH017 | on | The only X I trust. |
|
|
268
|
+
| CLH018 | on | Don't take my word for it. |
|
|
269
|
+
| CLH019 | on | Turns out. |
|
|
270
|
+
| CLH020 | on | Fits in your head. |
|
|
271
|
+
| CLH021 | on | Stacked rhetorical questions. |
|
|
272
|
+
| CLH022 | on | Repeated sentence openers. |
|
|
273
|
+
| CLH023 | on | Colon into a triple. |
|
|
274
|
+
| CLH024 | on | Here's the twist. |
|
|
275
|
+
| CLH025 | on | X is dead. |
|
|
276
|
+
| CLH026 | on | That's why X mattered. |
|
|
277
|
+
| CLH027 | on | Stranded auxiliary contrast. |
|
|
278
|
+
| WIK001 | on | AI vocabulary words. |
|
|
279
|
+
| WIK002 | on | Not just X, but Y. |
|
|
280
|
+
| WIK003 | on | It's important to note. |
|
|
281
|
+
| WIK004 | on | Stands as a testament. |
|
|
282
|
+
| WIK005 | on | Plays a crucial role. |
|
|
283
|
+
| WIK006 | on | Ever-evolving landscape. |
|
|
284
|
+
| WIK007 | on | Experts argue. |
|
|
285
|
+
| WIK008 | on | Despite these challenges. |
|
|
286
|
+
| WIK009 | on | Participle sentence tails. |
|
|
287
|
+
| WIK010 | on | Promotional boilerplate. |
|
|
288
|
+
| WIK011 | on | Chatbot leftovers. |
|
|
289
|
+
|
|
290
|
+
## Develop
|
|
291
|
+
|
|
292
|
+
```sh
|
|
293
|
+
uv sync --group dev
|
|
294
|
+
uv run pytest
|
|
295
|
+
uv run ty check linnl
|
|
296
|
+
uv run ruff check
|
|
297
|
+
uv run ruff format
|
|
298
|
+
uv tool install --editable .
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Acknowledgments
|
|
302
|
+
|
|
303
|
+
The CLH and WIK rules are adapted from [Simon Willison's LLM cliche highlighter](https://github.com/simonw/tools/blob/main/llm-cliche-highlighter.html).
|
|
304
|
+
|
|
305
|
+
For a broader collection of deterministic prose checks, see [Proselint](https://github.com/amperser/proselint).
|
linnl-0.1.0/README.md
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# linnl
|
|
2
|
+
`linnl` is a text linter aimed to catch LLM tells and reduce the complexity of the text produced by them, so that the cognitive load on the reader is reduced and ideas are clearer.
|
|
3
|
+
|
|
4
|
+
Some built-in rules are checked in code, others (most) are judged by an LLM. You can also install `linnl` plugins to provide your own set of rules.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
uv tool install linnl
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
### Claude as a judge
|
|
13
|
+
You will either need a working `claude` login or an `ANTHROPIC_API_KEY` set in your environment.
|
|
14
|
+
|
|
15
|
+
### Codex as a judge
|
|
16
|
+
Codex reuses the local Codex session. You can also set `OPENAI_API_KEY`.
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
linnl lint notes.md
|
|
22
|
+
linnl lint docs/
|
|
23
|
+
echo "text" | linnl lint
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Example output:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
notes.md
|
|
30
|
+
=========
|
|
31
|
+
line 3:
|
|
32
|
+
> No config. No cron. No surprises.
|
|
33
|
+
SCH003 Three successive fragments open with the same word for emphasis, which is anaphora.
|
|
34
|
+
|
|
35
|
+
line 5:
|
|
36
|
+
> Set it up once; forget about it.
|
|
37
|
+
CHR004 Semicolon.
|
|
38
|
+
|
|
39
|
+
Found 2 violations across 1 file.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configure
|
|
43
|
+
### CLI
|
|
44
|
+
|
|
45
|
+
Check
|
|
46
|
+
```sh
|
|
47
|
+
linnl lint --help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
To see available quick configuration options.
|
|
51
|
+
|
|
52
|
+
### File configuration
|
|
53
|
+
The priority is:
|
|
54
|
+
- The closest `pyproject.toml` with a tool.linnl section
|
|
55
|
+
- A `linnl.toml` file next to the closest pyproject.toml
|
|
56
|
+
- `~/.config/linnl/config.toml`
|
|
57
|
+
- The config file shipped with the tool
|
|
58
|
+
|
|
59
|
+
You can easily create a `linnl.toml` with:
|
|
60
|
+
```sh
|
|
61
|
+
linnl config > linnl.toml
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
#### Adding your own rules
|
|
65
|
+
|
|
66
|
+
Add groups under `[rules.<PREFIX>]` in your config file. `description` is the only reserved key. Every other key is a rule:
|
|
67
|
+
|
|
68
|
+
```toml
|
|
69
|
+
extend-select = ["SEC"]
|
|
70
|
+
|
|
71
|
+
[rules.SEC]
|
|
72
|
+
description = "Wording that must not leak infrastructure details"
|
|
73
|
+
001 = "Names an internal host, IP or path"
|
|
74
|
+
abc = "Shows a credential or token in an example"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That defines `SEC001` and `SECabc`, judged by the model. `linnl` shows the group description to the model above its rules, so make it say what the group is for.
|
|
78
|
+
|
|
79
|
+
The reserved `RGX` group defines regular expression rules. Every value under
|
|
80
|
+
`[rules.RGX]` is compiled as a Python regular expression and is enabled by
|
|
81
|
+
default:
|
|
82
|
+
|
|
83
|
+
```toml
|
|
84
|
+
[rules.RGX]
|
|
85
|
+
description = "Patterns to avoid"
|
|
86
|
+
001 = "X is not (.*), it's (.*)"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Each non-overlapping match is reported separately, with the full match as the
|
|
90
|
+
quoted text. Invalid regular expressions fail configuration.
|
|
91
|
+
|
|
92
|
+
If the rule requires configurable parameters:
|
|
93
|
+
|
|
94
|
+
```toml
|
|
95
|
+
extend-select = ["SEC"]
|
|
96
|
+
|
|
97
|
+
[rules.SEC]
|
|
98
|
+
description = "Wording that must not leak infrastructure details"
|
|
99
|
+
001 = "Names an internal host, IP or path"
|
|
100
|
+
abc = "Shows a credential or token in an example"
|
|
101
|
+
|
|
102
|
+
[rules.SEC.xyz]
|
|
103
|
+
description = "Do now show more {n_files} files in the current directory"
|
|
104
|
+
n-files = 3
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A description can name the rule's options in braces. Write an option name with underscores, so `max-sentences` becomes `{max_sentences}` and `"More than {max_sentences} sentences."` renders with the configured value. Options work on Python and model rules. linnl renders their values before sending model rules to the judge.
|
|
108
|
+
|
|
109
|
+
#### Installing rules plugins
|
|
110
|
+
|
|
111
|
+
Install them alongside `linnl`:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
uv tool install linnl --with linnl-acme-rules
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Then enable their `linnl.plugins` entry-point names.
|
|
118
|
+
|
|
119
|
+
```toml
|
|
120
|
+
plugins = ["acme-rules"]
|
|
121
|
+
extend-select = ["ACM"]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The package contributes default sections under `[rules]`. Your configuration can select, ignore, and override those rules in the same way as built-in rules.
|
|
125
|
+
|
|
126
|
+
`linnl plugins` lists the rule packages enabled by the active configuration.
|
|
127
|
+
|
|
128
|
+
To author a package, declare an entry point that names a `linnl.plugins.Plugin` subclass:
|
|
129
|
+
|
|
130
|
+
```toml
|
|
131
|
+
[project.entry-points."linnl.plugins"]
|
|
132
|
+
acme-rules = "linnl_acme_rules:AcmePlugin"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from typing import ClassVar
|
|
137
|
+
|
|
138
|
+
from linnl.document import Document
|
|
139
|
+
from linnl.plugins import Plugin
|
|
140
|
+
from linnl.rules import CodeRule
|
|
141
|
+
from linnl.rules.pattern_rules import TextPatternRule
|
|
142
|
+
from linnl.violations import Violation, Violations
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Acme001(TextPatternRule, identifier="ACM001"):
|
|
146
|
+
"""Flag obviously.
|
|
147
|
+
|
|
148
|
+
Example: "This is obviously correct."
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
pattern = r"\bobviously\b"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Acme002(CodeRule, identifier="ACM002"):
|
|
155
|
+
"""Flag lines longer than 80 characters.
|
|
156
|
+
|
|
157
|
+
Example: "This line has more than eighty characters and should be shortened before it is committed."
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __call__(self, document: Document) -> Violations:
|
|
161
|
+
violations = []
|
|
162
|
+
|
|
163
|
+
for line_number, line in enumerate(document.lines, start=1):
|
|
164
|
+
if len(line) > 80:
|
|
165
|
+
violations.append(
|
|
166
|
+
Violation(
|
|
167
|
+
rule=self,
|
|
168
|
+
path=document.path,
|
|
169
|
+
line=line_number,
|
|
170
|
+
offset=81,
|
|
171
|
+
quote=line,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return Violations(violations)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class AcmePlugin(Plugin):
|
|
179
|
+
"""Define Acme's writing rules."""
|
|
180
|
+
|
|
181
|
+
name = "acme-rules"
|
|
182
|
+
rules: ClassVar = {
|
|
183
|
+
"ACM": {
|
|
184
|
+
"description": "Acme writing rules",
|
|
185
|
+
"001": "Avoid obviously.",
|
|
186
|
+
"002": "Keep lines at 80 characters or fewer.",
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The class's `name` must match the entry point name. `Acme001` registers itself when the package imports it. Each code rule needs a definition in `rules`.
|
|
192
|
+
|
|
193
|
+
### Check configuration
|
|
194
|
+
`linnl rules` prints every rule with its resolved on/off state and whether Python or the model checks it.
|
|
195
|
+
|
|
196
|
+
## Built-in rules
|
|
197
|
+
|
|
198
|
+
| Code | Default | Rule |
|
|
199
|
+
| --- | --- | --- |
|
|
200
|
+
| SCH001 | on | Tricolon. Three parallel words, phrases or clauses arranged as a series for rhythm or emphasis. Example: 'It is fast, small and simple.' A list of three things that happen to be three is fine, the arrangement for effect is not. |
|
|
201
|
+
| SCH002 | on | Isocolon. Two or more clauses of matching length and grammatical structure, set side by side for balance. Example: 'Simple to learn, hard to master.' |
|
|
202
|
+
| SCH003 | on | Anaphora. The same word or phrase opening successive clauses or sentences. Example: 'No config. No setup. No surprises.' |
|
|
203
|
+
| SCH004 | on | Antithesis. Contrasting ideas placed in parallel structure. Example: 'Not because it is easy, but because it is hard.' |
|
|
204
|
+
| SCH005 | on | Chiasmus. Words or structure repeated in reverse order across two clauses. Example: 'Ask not what your country can do for you, ask what you can do for your country.' |
|
|
205
|
+
| SCH006 | on | Asyndeton. Conjunctions dropped from a series of clauses or phrases to quicken the pace. Example: 'I came, I saw, I conquered.' |
|
|
206
|
+
| SCH007 | on | Alliterative pairing. Two or more nearby words chosen for a shared initial sound. Example: 'fast and fluid', 'bold and brave'. |
|
|
207
|
+
| SCH008 | on | Epigrammatic closer. A short, pithy, quotable sentence used to close a paragraph or the whole text. Example: ending a paragraph with 'Simple tools, simple problems.' |
|
|
208
|
+
| SCH000 | on | Other scheme. Any scheme not covered above: epistrophe, polysyndeton, climax, symploce, and the like. |
|
|
209
|
+
| SLO001 | on | Slogan. A sentence written to be quoted rather than to inform. Example: 'Ship less, sleep more.' |
|
|
210
|
+
| ZIN001 | on | Simplicity. The sentence is more complex than the idea it carries, through long-winded construction or jargon where a plain word exists. |
|
|
211
|
+
| ZIN002 | on | Brevity. Words that do no work: padding, redundant pairs, throat-clearing openers and restating what was already said. |
|
|
212
|
+
| ZIN003 | on | Clarity. The reader cannot tell what is meant: ambiguous pronouns, vague references, undefined terms, sentences that need a second reading. |
|
|
213
|
+
| ZIN004 | on | Humanity. The writing does not sound like one person talking to another: stiff, bureaucratic, impersonal, over-hedged, or passive voice hiding who did what. |
|
|
214
|
+
| CHR001 | on | Em dash (U+2014). |
|
|
215
|
+
| CHR002 | on | En dash (U+2013). |
|
|
216
|
+
| CHR003 | on | Middle dot (U+00B7). |
|
|
217
|
+
| CHR004 | on | Semicolon. |
|
|
218
|
+
| CHR000 | off | Any non-ASCII character not covered by another CHR rule. |
|
|
219
|
+
| LEN001 | off | The text has more than 3 sentences. |
|
|
220
|
+
| LEN002 | on | Not concise. The text includes material the reader did not ask for: justification, background, alternatives or caveats. |
|
|
221
|
+
| CLH001 | on | No X, no Y chains. |
|
|
222
|
+
| CLH002 | on | That's the whole point, game, or thing. |
|
|
223
|
+
| CLH003 | on | Did not X, did not Y chains. |
|
|
224
|
+
| CLH004 | on | Don't VERB it, VERB it. |
|
|
225
|
+
| CLH005 | on | Sit with that. |
|
|
226
|
+
| CLH006 | on | You already know. |
|
|
227
|
+
| CLH007 | on | Is the entire point, game, or business model. |
|
|
228
|
+
| CLH008 | on | The entire point, game, or business model is. |
|
|
229
|
+
| CLH009 | on | Is real and or not. |
|
|
230
|
+
| CLH010 | on | The punchline is. |
|
|
231
|
+
| CLH011 | on | Worth naming. |
|
|
232
|
+
| CLH012 | on | That's not nothing. |
|
|
233
|
+
| CLH013 | on | Is the whole point, trick, pitch, or idea. |
|
|
234
|
+
| CLH014 | on | Echoing sentence runs. |
|
|
235
|
+
| CLH015 | on | Performative honesty. |
|
|
236
|
+
| CLH016 | on | That's the part. |
|
|
237
|
+
| CLH017 | on | The only X I trust. |
|
|
238
|
+
| CLH018 | on | Don't take my word for it. |
|
|
239
|
+
| CLH019 | on | Turns out. |
|
|
240
|
+
| CLH020 | on | Fits in your head. |
|
|
241
|
+
| CLH021 | on | Stacked rhetorical questions. |
|
|
242
|
+
| CLH022 | on | Repeated sentence openers. |
|
|
243
|
+
| CLH023 | on | Colon into a triple. |
|
|
244
|
+
| CLH024 | on | Here's the twist. |
|
|
245
|
+
| CLH025 | on | X is dead. |
|
|
246
|
+
| CLH026 | on | That's why X mattered. |
|
|
247
|
+
| CLH027 | on | Stranded auxiliary contrast. |
|
|
248
|
+
| WIK001 | on | AI vocabulary words. |
|
|
249
|
+
| WIK002 | on | Not just X, but Y. |
|
|
250
|
+
| WIK003 | on | It's important to note. |
|
|
251
|
+
| WIK004 | on | Stands as a testament. |
|
|
252
|
+
| WIK005 | on | Plays a crucial role. |
|
|
253
|
+
| WIK006 | on | Ever-evolving landscape. |
|
|
254
|
+
| WIK007 | on | Experts argue. |
|
|
255
|
+
| WIK008 | on | Despite these challenges. |
|
|
256
|
+
| WIK009 | on | Participle sentence tails. |
|
|
257
|
+
| WIK010 | on | Promotional boilerplate. |
|
|
258
|
+
| WIK011 | on | Chatbot leftovers. |
|
|
259
|
+
|
|
260
|
+
## Develop
|
|
261
|
+
|
|
262
|
+
```sh
|
|
263
|
+
uv sync --group dev
|
|
264
|
+
uv run pytest
|
|
265
|
+
uv run ty check linnl
|
|
266
|
+
uv run ruff check
|
|
267
|
+
uv run ruff format
|
|
268
|
+
uv tool install --editable .
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Acknowledgments
|
|
272
|
+
|
|
273
|
+
The CLH and WIK rules are adapted from [Simon Willison's LLM cliche highlighter](https://github.com/simonw/tools/blob/main/llm-cliche-highlighter.html).
|
|
274
|
+
|
|
275
|
+
For a broader collection of deterministic prose checks, see [Proselint](https://github.com/amperser/proselint).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lint prose against style rules, with an LLM judging."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Install linnl commands for supported coding agents."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Agent(StrEnum):
|
|
11
|
+
"""Select the model backend for model-judged rules."""
|
|
12
|
+
|
|
13
|
+
CLAUDE = "claude"
|
|
14
|
+
CODEX = "codex"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_AGENT_MODELS: dict[Agent, str] = {
|
|
18
|
+
Agent.CLAUDE: "claude-opus-5",
|
|
19
|
+
Agent.CODEX: "gpt-5.6-luna",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
LINNL_COMMAND = """---
|
|
24
|
+
description: Check your most recent response with linnl.
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
Check your most recent assistant response with linnl.
|
|
28
|
+
|
|
29
|
+
Extract only the user-facing text from your immediately preceding response.
|
|
30
|
+
Do not include tool calls, this command, or your current reasoning. Pipe that
|
|
31
|
+
text to `linnl lint` through standard input and report the result.
|
|
32
|
+
|
|
33
|
+
If linnl reports violations, explain them and provide a corrected version. If it
|
|
34
|
+
reports no violations, say that the response is clean.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
AGENT_COMMAND_DIRECTORIES: dict[Agent, tuple[str, str]] = {
|
|
38
|
+
Agent.CLAUDE: (".claude", "commands"),
|
|
39
|
+
Agent.CODEX: (".codex", "prompts"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def install_hook(agent: Agent, *, local: bool) -> Path:
|
|
44
|
+
"""Install the linnl command for an agent and return its path."""
|
|
45
|
+
agent_directory, command_directory = AGENT_COMMAND_DIRECTORIES[agent]
|
|
46
|
+
base_directory = Path.cwd() if local else Path.home()
|
|
47
|
+
command_path = base_directory / agent_directory / command_directory / "linnl.md"
|
|
48
|
+
command_path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
if command_path.exists():
|
|
51
|
+
existing_command = command_path.read_text(encoding="utf-8")
|
|
52
|
+
if existing_command != LINNL_COMMAND:
|
|
53
|
+
logger.info("linnl command out of date, will update at %s", command_path)
|
|
54
|
+
else:
|
|
55
|
+
logger.info("linnl command already installed at %s", command_path)
|
|
56
|
+
return command_path
|
|
57
|
+
|
|
58
|
+
command_path.write_text(LINNL_COMMAND, encoding="utf-8")
|
|
59
|
+
logger.info("Installed linnl command at %s", command_path)
|
|
60
|
+
return command_path
|