Skip to content

Commit 47640fb

Browse files
author
merge-queue-bot
committed
Merge PR #509: feat(convention): built-in no-llm-tells convention with append-mode forbidden lists
2 parents bae98ec + 3d4eac4 commit 47640fb

16 files changed

Lines changed: 551 additions & 73 deletions

File tree

.claude/skills/docs-author/SKILL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,15 @@ a non-native reader can parse on a first read.
416416

417417
## Anti-slop pass
418418

419+
The banned-word and opener lists from
420+
`slop-patterns.md` ship as the built-in
421+
`no-llm-tells` convention. Set `convention:
422+
no-llm-tells` in `.mdsmith.yml` and MDS056 and
423+
MDS055 enforce them in CI with no model in the
424+
loop. This skill still owns the structural, tone,
425+
and formatting passes. Those need context that a
426+
substring check cannot provide.
427+
419428
Run after the global-English pass. The catalog
420429
lives in `slop-patterns.md`. Load it, then walk
421430
the draft against each category. The catalog

.claude/skills/docs-author/slop-patterns.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,18 @@ by recasting the sentence — swapping the word
2121
leaves the same shape behind, and that shape is
2222
the tell.
2323

24+
The mechanical sections — "Vocabulary tells",
25+
"Phrasal tells", and "Sentence openers" — also
26+
ship as the built-in [`no-llm-tells`
27+
convention][conv], which MDS056 and MDS055 enforce
28+
in CI and the editor without a model in the loop. A
29+
drift-checker test keeps the convention's lists a
30+
subset of this catalog. The structural, tone, and
31+
formatting sections below need contextual judgement
32+
and stay skill-only.
33+
2434
[wp-signs]: https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing
35+
[conv]: ../../../docs/reference/conventions.md
2536

2637
## Vocabulary tells
2738

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ footer: |
138138
| 210 || opus | [Single source of truth for product messaging via `mdsmith extract`](plan/210_messaging-source-of-truth.md) |
139139
| 211 | 🔳 | opus | [`<?include?>` projects any typed value of any kind via `extract`](plan/211_include-extract-value.md) |
140140
| 212 || opus | [`mdsmith extract` projects paragraph inline spans as data](plan/212_extract-inline-spans.md) |
141-
| 213 | 🔲 | opus | [Built-in `no-llm-tells` convention with append-mode forbidden lists](plan/213_anti-slop-convention.md) |
141+
| 213 | 🔳 | opus | [Built-in `no-llm-tells` convention with append-mode forbidden lists](plan/213_anti-slop-convention.md) |
142142
| 214 || sonnet | [MDS019 catalog: CUE-expression row templates](plan/214_catalog-cue-row-expressions.md) |
143143
| 214 || opus | [Obsidian plugin via hand-rolled LSP bridge](plan/214_obsidian-plugin.md) |
144144
| 215 || opus | [mdsmith public engine API and WASM bindings](plan/215_engine-api-wasm.md) |

docs/reference/conventions.md

Lines changed: 47 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,9 @@ settings that pairs a Markdown flavor with a set of
1212
style choices. Setting `convention:` at the top of
1313
your `.mdsmith.yml` selects one of the built-in
1414
bundles; the rule presets in that bundle are applied
15-
as a base layer beneath your own rule config.
16-
17-
Conventions answer "what kind of Markdown does this
18-
project write?" with one config knob instead of
19-
eight.
15+
as a base layer beneath your own rule config. It
16+
answers "what kind of Markdown does this project
17+
write?" with one config knob instead of eight.
2018

2119
A convention is distinct from a flavor. Flavor is a
2220
property of the *renderer* (CommonMark, GFM,
@@ -36,12 +34,11 @@ convention: portable
3634
That single line pins a flavor and a curated set of
3735
style-rule settings. `convention:` is a top-level
3836
config key, sibling to `rules:`, `kinds:`, and
39-
`overrides:`. Setting an unknown name is a config
40-
error at load time.
37+
`overrides:`. An unknown name is a config error.
4138

4239
Built-in values: `portable`, `github`, `obsidian`,
43-
`parity`, `plain`. The key is optional; omit it for no
44-
convention.
40+
`parity`, `plain`, `no-llm-tells`. The key is
41+
optional; omit it for no convention.
4542

4643
You may also set `flavor:` inside `markdown-flavor`
4744
alongside `convention:`. If both are set, they must
@@ -130,16 +127,32 @@ markdownlint-compatible tools (mado, rumdl) check
130127
it: `flavor: gfm` plus every mdsmith-only rule
131128
turned off, leaving the structural style class
132129
those tools also run. Use it for a like-for-like
133-
comparison, or as a fast check-only markdownlint
134-
gate before you adopt the cross-file and
135-
generated-section layer. The disabled-rule list is
136-
generated from the convention so it never drifts —
137-
see the [benchmark doc][parity-list]. Unlike the
138-
other built-ins, `parity` leaves `markdown-flavor`
130+
comparison, or as a fast markdownlint gate before
131+
you adopt the cross-file and generated-section
132+
layer. The disabled-rule list is generated from the
133+
convention, so it never drifts — see the
134+
[benchmark doc][parity-list]. Unlike the other
135+
built-ins, `parity` leaves `markdown-flavor`
139136
(MDS034) opt-in.
140137

141138
[parity-list]: ../research/benchmarks/README.md
142139

140+
### `no-llm-tells`
141+
142+
Flags mechanical LLM-prose tells in CI. MDS056
143+
blocks vocabulary and phrasal tells; MDS055 blocks
144+
banned sentence openers; MDS023 and MDS024 tighten
145+
readability budgets. Lists are sourced from
146+
[`slop-patterns.md`][slop]; a drift-checker test
147+
keeps the two in sync.
148+
149+
Pins no flavor and does not enable `markdown-flavor`
150+
(MDS034). The `contains` and `starts` lists merge by
151+
**append**: a project's own entries join the
152+
convention's list instead of replacing it.
153+
154+
[slop]: ../../.claude/skills/docs-author/slop-patterns.md
155+
143156
## How presets layer with user config
144157

145158
Convention presets sit between built-in defaults
@@ -166,9 +179,8 @@ The `default` and `user` layers come from the same
166179
convention so a convention can enable a rule that
167180
is opt-in by default (e.g. `convention: portable`
168181
turns on MDS034). Without the split, the default's
169-
`Enabled: false` would land on top of the
170-
convention's `Enabled: true` and silently disable
171-
the rule.
182+
`Enabled: false` would override the convention's
183+
`Enabled: true`.
172184

173185
For example, the `github` convention sets
174186
`no-inline-html.allow: [details, summary]`. To
@@ -199,24 +211,15 @@ rules:
199211
markdown-flavor: false
200212
```
201213

202-
The `convention:` selector lives at the top level.
203-
So the user can disable MDS034 cleanly with a
204-
bool-only `markdown-flavor: false` entry in the
205-
rules block. The convention preset has already
206-
populated the merged config at load time. A
207-
bool-only later layer toggles `enabled` without
208-
erasing the preset's settings. The rule stays
209-
configured but its `Check()` is gated off. The
210-
other rules in the preset are untouched.
211-
212-
This split keeps MDS034 focused on "what does this
213-
renderer interpret as a feature." Conventions
214-
orchestrate style separately.
214+
A bool-only `markdown-flavor: false` entry toggles
215+
`enabled` without erasing the preset's settings. The
216+
rule stays configured but its `Check()` is gated
217+
off. The other rules in the preset are untouched.
215218

216219
## User-defined conventions
217220

218-
The three built-in conventions cover common cases.
219-
Teams that need something custom define it inline in
221+
The built-in conventions cover common cases. Teams
222+
that need something custom define it inline in
220223
`.mdsmith.yml`. The top-level `conventions:` key holds
221224
the map:
222225

@@ -260,10 +263,10 @@ convention "our-team" rule "no-inline-html": no-inline-html: unknown setting "al
260263
### Reserved names
261264

262265
The built-in names `portable`, `github`,
263-
`obsidian`, `parity`, and `plain` are reserved.
264-
Defining a `conventions.portable` entry is a config
265-
error. This keeps the built-in names stable across
266-
docs and tutorials.
266+
`obsidian`, `parity`, `plain`, and `no-llm-tells`
267+
are reserved. Defining a `conventions.portable`
268+
entry is a config error. This keeps the built-in
269+
names stable across docs and tutorials.
267270

268271
### Resolution order
269272

@@ -274,25 +277,17 @@ is impossible. When neither table matches, the error
274277
lists both sets:
275278

276279
```text
277-
unknown convention "bogus" (valid: github, obsidian, our-team, parity, plain, portable)
280+
unknown convention "bogus" (valid: github, no-llm-tells, obsidian, our-team, parity, plain, portable)
278281
```
279282

280283
### Interaction with top-level rules
281284

282-
User-defined conventions apply as a base layer, exactly
283-
like the built-in conventions. A top-level `rules:`
284-
entry overrides the convention preset for that rule.
285-
The rest of the preset remains.
286-
287-
### Inspecting user conventions
288-
289-
`mdsmith kinds resolve <file>` labels user convention
290-
layers with a `(user)` suffix. Built-in conventions
291-
carry no suffix. Example merge-chain output:
292-
293-
```text
294-
convention.our-team (user) set {flavor: gfm}
295-
```
285+
User-defined conventions apply as a base layer, like
286+
the built-in conventions. A top-level `rules:` entry
287+
overrides the convention preset for that rule; the
288+
rest of the preset remains. `mdsmith kinds resolve
289+
<file>` labels user-convention layers with a
290+
`(user)` suffix.
296291

297292
## Inspecting an effective convention
298293

internal/config/config_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import (
2727
_ "github.com/jeduden/mdsmith/internal/rules/fencedcodelanguage"
2828
_ "github.com/jeduden/mdsmith/internal/rules/fencedcodestyle"
2929
_ "github.com/jeduden/mdsmith/internal/rules/firstlineheading"
30+
_ "github.com/jeduden/mdsmith/internal/rules/forbiddenparagraphstarts"
31+
_ "github.com/jeduden/mdsmith/internal/rules/forbiddentext"
3032
_ "github.com/jeduden/mdsmith/internal/rules/headingincrement"
3133
_ "github.com/jeduden/mdsmith/internal/rules/headingstyle"
3234
_ "github.com/jeduden/mdsmith/internal/rules/horizontalrulestyle"

internal/config/convention.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ func applyConvention(cfg *Config) error {
4646
if err != nil {
4747
return fmt.Errorf("convention: %w", err)
4848
}
49-
if rc, ok := cfg.Rules["markdown-flavor"]; ok {
49+
if rc, ok := cfg.Rules["markdown-flavor"]; ok && conv.Flavor != convention.FlavorAny {
50+
// A convention with FlavorAny is renderer-agnostic (e.g.
51+
// no-llm-tells); it imposes no flavor and never conflicts with a
52+
// user's markdown-flavor selection, so the guard is skipped.
5053
userFlavor, err := stringSetting(
5154
rc.Settings, "flavor", "rules.markdown-flavor.flavor",
5255
)

internal/config/convention_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,51 @@ func TestApplyConvention_FlavorMismatchErrors(t *testing.T) {
9696
assert.Contains(t, err.Error(), "gfm")
9797
}
9898

99+
func TestApplyConvention_FlavorUnsetConventionAllowsAnyUserFlavor(t *testing.T) {
100+
// no-llm-tells leaves its flavor unset (FlavorAny). A project that
101+
// also pins markdown-flavor to gfm must not be rejected: the
102+
// convention is renderer-agnostic and does not enable markdown-flavor.
103+
cfg := &Config{
104+
Convention: "no-llm-tells",
105+
Rules: map[string]RuleCfg{
106+
"markdown-flavor": {Enabled: true, Settings: map[string]any{"flavor": "gfm"}},
107+
},
108+
}
109+
require.NoError(t, applyConvention(cfg))
110+
require.NotNil(t, cfg.ConventionPreset)
111+
}
112+
113+
func TestApplyConvention_NoLLMTells_EnablesRulesWithSettings(t *testing.T) {
114+
cfg := &Config{Convention: "no-llm-tells"}
115+
require.NoError(t, applyConvention(cfg))
116+
117+
ft, ok := cfg.ConventionPreset["forbidden-text"]
118+
require.True(t, ok, "preset must contain forbidden-text")
119+
assert.True(t, ft.Enabled)
120+
contains, ok := ft.Settings["contains"].([]any)
121+
require.True(t, ok, "contains must be []any")
122+
assert.Contains(t, contains, "delve")
123+
assert.Contains(t, contains, "it's important to note that")
124+
125+
fps, ok := cfg.ConventionPreset["forbidden-paragraph-starts"]
126+
require.True(t, ok, "preset must contain forbidden-paragraph-starts")
127+
starts, ok := fps.Settings["starts"].([]any)
128+
require.True(t, ok)
129+
assert.Contains(t, starts, "Moreover,")
130+
131+
ps, ok := cfg.ConventionPreset["paragraph-structure"]
132+
require.True(t, ok)
133+
assert.Equal(t, 25, ps.Settings["max-words-per-sentence"])
134+
135+
pr, ok := cfg.ConventionPreset["paragraph-readability"]
136+
require.True(t, ok)
137+
assert.Equal(t, 12.0, pr.Settings["max-index"])
138+
139+
dlt, ok := cfg.ConventionPreset["descriptive-link-text"]
140+
require.True(t, ok)
141+
assert.True(t, dlt.Enabled)
142+
}
143+
99144
func TestApplyConvention_FlavorAgreeAccepted(t *testing.T) {
100145
cfg := &Config{
101146
Convention: "github",
@@ -166,6 +211,49 @@ func TestEffectiveRules_UserSettingDeepMergesOverConvention(t *testing.T) {
166211
assert.Equal(t, true, rc.Settings["require-blank-lines"], "preset sibling preserved")
167212
}
168213

214+
func TestEffectiveRules_NoLLMTells_UserForbiddenTextUnionsWithConvention(t *testing.T) {
215+
// A project pins no-llm-tells and adds its own forbidden phrase.
216+
// MDS056 opts contains: into MergeAppend, so the user's list unions
217+
// with the convention's instead of replacing it.
218+
cfg := &Config{
219+
Convention: "no-llm-tells",
220+
Rules: map[string]RuleCfg{
221+
"forbidden-text": {
222+
Enabled: true,
223+
Settings: map[string]any{"contains": []any{"synergy"}},
224+
},
225+
},
226+
ExplicitRules: map[string]bool{"forbidden-text": true},
227+
}
228+
require.NoError(t, applyConvention(cfg))
229+
230+
got := Effective(cfg, "doc.md", nil, nil)
231+
contains, ok := got["forbidden-text"].Settings["contains"].([]any)
232+
require.True(t, ok)
233+
assert.Contains(t, contains, "delve", "convention entry survives")
234+
assert.Contains(t, contains, "synergy", "user entry is added")
235+
}
236+
237+
func TestEffectiveRules_NoLLMTells_UserOpenersUnionWithConvention(t *testing.T) {
238+
cfg := &Config{
239+
Convention: "no-llm-tells",
240+
Rules: map[string]RuleCfg{
241+
"forbidden-paragraph-starts": {
242+
Enabled: true,
243+
Settings: map[string]any{"starts": []any{"We "}},
244+
},
245+
},
246+
ExplicitRules: map[string]bool{"forbidden-paragraph-starts": true},
247+
}
248+
require.NoError(t, applyConvention(cfg))
249+
250+
got := Effective(cfg, "doc.md", nil, nil)
251+
starts, ok := got["forbidden-paragraph-starts"].Settings["starts"].([]any)
252+
require.True(t, ok)
253+
assert.Contains(t, starts, "Moreover,", "convention entry survives")
254+
assert.Contains(t, starts, "We ", "user entry is added")
255+
}
256+
169257
func TestProvenance_ConventionLayerVisible(t *testing.T) {
170258
cfg := &Config{Convention: "portable"}
171259
require.NoError(t, applyConvention(cfg))

internal/convention/convention.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,52 @@ var conventions = map[string]Convention{
179179
},
180180
},
181181
},
182+
// no-llm-tells ships the mechanical layer of the docs-author
183+
// anti-slop catalog as a one-key convention. It enables MDS056
184+
// (forbidden-text) with a curated list of LLM vocabulary and phrase
185+
// tells, MDS055 (forbidden-paragraph-starts) with the banned
186+
// sentence openers, and tightens MDS023 (max-words-per-sentence) and
187+
// MDS024 (paragraph-readability max-index) for non-native readers.
188+
// MDS027 (descriptive-link-text) rounds out the bundle.
189+
//
190+
// Flavor is left unset (FlavorAny): anti-slop is renderer-agnostic,
191+
// so the convention must not force a GFM or Obsidian project off its
192+
// flavor. The convention does not enable markdown-flavor (MDS034), so
193+
// the flavor field never reports; the loader skips the flavor-conflict
194+
// guard for a convention whose flavor is FlavorAny.
195+
//
196+
// The curated lists live in nollmtells.go; their source of truth is
197+
// .claude/skills/docs-author/slop-patterns.md, kept in sync by the
198+
// drift-checker integration test.
199+
"no-llm-tells": {
200+
Name: "no-llm-tells",
201+
Flavor: FlavorAny,
202+
Rules: map[string]RulePreset{
203+
"forbidden-text": {
204+
Enabled: true,
205+
Settings: map[string]any{
206+
"contains": toAnySlice(llmVocabularyAndPhrases()),
207+
},
208+
},
209+
"forbidden-paragraph-starts": {
210+
Enabled: true,
211+
Settings: map[string]any{
212+
"starts": toAnySlice(llmParagraphOpeners()),
213+
},
214+
},
215+
"paragraph-structure": {
216+
Enabled: true,
217+
Settings: map[string]any{
218+
"max-words-per-sentence": 25,
219+
},
220+
},
221+
"paragraph-readability": {
222+
Enabled: true,
223+
Settings: map[string]any{"max-index": 12.0},
224+
},
225+
"descriptive-link-text": {Enabled: true},
226+
},
227+
},
182228
// parity restricts mdsmith to the markdownlint-compatible rule
183229
// class — the structural style rules the Rust markdownlint ports
184230
// (mado, rumdl) also run — by turning off every mdsmith-only rule:

internal/convention/convention_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ func TestNamesSorted(t *testing.T) {
199199
names := Names()
200200
assert.True(t, sort.StringsAreSorted(names),
201201
"Names should return a sorted slice; got %v", names)
202-
assert.ElementsMatch(t, []string{"github", "obsidian", "parity", "plain", "portable"}, names)
202+
assert.ElementsMatch(t, []string{"github", "no-llm-tells", "obsidian", "parity", "plain", "portable"}, names)
203203
}
204204

205205
// parityDisabledRules is the canonical set of rules the built-in

0 commit comments

Comments
 (0)