| id | 2606251522 |
|---|---|
| title | user-extensible named word-lists |
| status | ✅ |
| summary | Add a named word-list resource under `.mdsmith/wordlists/`. Every list-consuming rule gains a `lists:` setting that unions named lists into its inline list. No lists ship compiled in: the no-llm-tells convention keeps its curated words inline, and a project declares and extends its own lists with no rebuild. |
| model | opus |
| depends-on |
Turn the curated anti-slop vocabulary into a named word-list resource. Any rule can reference it. Any project can extend it, with no rebuild of the mdsmith binary.
internal/convention/nollmtells.go stores the LLM tell
words, phrases, and sentence openers as Go slices. The
no-llm-tells convention is the only way to apply them. A
rebuild is the only way to change them. A project can append
raw words at forbidden-text.contains, but it cannot name,
reuse, or extend the curated set.
Sixteen built-in rules already read a user-supplied list of strings:
forbidden-text.containsforbidden-paragraph-starts.startsproper-names.namesno-inline-html.allowdescriptive-link-text.bannedcallout-type.allowno-unused-link-definitions.ignored-labelsrequired-mentions.mentions- the
placeholdersvocabulary, shared by eight rules
Each keeps its list alone. None can point at a shared, named list.
Scope for this plan: wire all fifteen rules, name the
resource wordlists, and fold the work into the open PR on
this branch.
A word-list is a named, ordered set of literal strings with
an optional extends: parent. It is the fourth
user-extensible .mdsmith/ resource, after kinds, schemas,
and conventions. It reuses their loader pattern.
- No lists ship compiled in. The
no-llm-tellsconvention keeps its curatedai-speak/ai-openerswords inline ininternal/convention/nollmtells.go, as the rules'contains:/starts:presets. - A new
internal/wordlistpackage parses, looks up, and resolves user lists (theextends:chain, with cycle and missing-parent detection). It ships no embedded data. - User lists live at
.mdsmith/wordlists/<name>.yaml, where the basename is the list name. A list mayextends:another user list. - Rules name lists through a new generic
lists:setting. The resolved entries union into the rule's own inline list.lists:appends across config layers, so a convention'slists:and a project'slists:combine. - A rule decides how it reads its list:
forbidden-textbans each entry, whilerequired-mentionsrequires each entry in every section.required-text-patterns(MDS057) stays out — itspatterns:are regular expressions, not plain words. mdsmith init --wordlistsscaffolds the curatedai-speak/ai-openersset into.mdsmith/wordlists/as editable files, rendered from the convention's built-in data viawordlist.RenderFile. It is the only way mdsmith writes a curated list to disk; the resolver still has no built-ins, and an existing file is left untouched.
A .mdsmith/wordlists/<name>.yaml file carries an optional
extends: parent. It also carries a required entries:
list of literal strings. Strict YAML decoding rejects any
other key. Anchors and aliases are refused, as the other
.mdsmith/ loaders do.
YAML keeps these files out of the **/*.md lint walk. So a
project's own denylist file is never flagged by the rule it
feeds.
(This replaces the Markdown body sketched earlier. YAML matches the other loaders and avoids self-linting.)
extends: house-base
entries:
- synergy
- circle backThe lists: key is resolved in the config layer, never per
check, so rule allocation budgets stay flat:
- The merge layer treats
listsas append for every rule, in one place. - A final pass over the merged rules expands each
lists:against the registry. It unions the entries into the rule's target list, then drops thelists:key so the rule never sees it.
The target list per rule comes from a small interface,
rule.WordlistConsumer, whose one method returns the
setting key (contains, starts, placeholders, and so
on). Config reads it through rule.ByName, the same path
the merge layer already uses for ListMerger and
SettingsTranslator. A rule's match logic does not change.
- Add package
internal/wordlist: theWordlisttype, a body parser,Lookup(user lists only), andResolvefor theextends:chain with cycle and missing-parent detection. No embedded data. - Add the
WordlistConsumerinterface tointernal/rule/rule.go. - Keep the
no-llm-tellsentry ininternal/convention/convention.gopointed at its inlinecontains:/starts:presets, with the curated words ininternal/convention/nollmtells.go. - Add the user-file loader
internal/config/wordlist_files.go, modeled oninternal/config/convention_files.go. AddConfig.Wordlistsand its deep-copy ininternal/config/merge.go. - Wire resolution. Make
listsappend ininternal/config/deepmerge.go. Add the expand-and-strip pass at the end ofeffectiveRulesininternal/config/merge.go. - Add
validateWordlistsand call it frominternal/config/load.go. Reject an unknown list name, alists:on a rule that is not a consumer, and anextends:cycle. - Give each of the sixteen rules its one-line
WordlistTarget()method and interface assertion.required-mentionstargetsmentions; its entries are required, not forbidden, but the list mechanism is the same. - Keep
internal/integration/nollmtells_drift_test.gocomparing the convention's inlinecontains:/starts:lists against the catalog in.claude/skills/docs-author/slop-patterns.md. - Add
docs/reference/wordlist-files.md. Updatedocs/reference/conventions.md,docs/reference/convention-files.md, anddocs/background/concepts/placeholder-grammar.md. Add an integration test (not a fixture test) driven by alists:setting. Fixture tests callApplySettingsdirectly and bypass theeffectiveRulespipeline wherelists:is resolved and stripped, so they cannot exercise this feature; a contract-level integration test ininternal/integration/wordlist_file_contract_test.gois the correct level.
- A
.mdsmith/wordlists/team.yamlthatextends:another file resolves, and a doc using a team word failsmdsmith check. - A doc using the convention's curated words fails under
convention: no-llm-tells. - An unknown list name, a
lists:on a non-consumer rule, and anextends:cycle each fail at config load with a clear message. -
internal/convention/nollmtells.gocarries the curated words and the drift test passes against the convention's inline lists. -
mdsmith init --wordlistswrites editable.mdsmith/wordlists/ai-speak.yamlandai-openers.yaml, and skips a file that already exists. -
mdsmith check .stays green (the repo pinsconvention: no-llm-tells). - All tests pass:
go test ./... -
go tool -modfile=tools/go.mod golangci-lint runreports no issues.