stilyagi df12 · deterministic prose analysis
§ Documentation · operating surface

The rules, the config, the seal.

Every Stilyagi rule has an ID, a namespace, a severity, and a fix class — knowable at a glance. Configuration is a single TOML file. Suppressions live in the source. This page lays the whole operating surface out in one place.

v0.4

The rules catalogue

34 rules · 6 namespaces
Markdown 12  PyDoc 7  RsDoc 4  Punctuation 5  Grammar 3  Spell 3  
Filter
Rule Title & description Severity Autofix
MD
MD201
Heading depth
Headings must not skip levels (h2 → h4 is rejected).
Warning autofix safe
MD
MD202
ATX heading style
Prefer # over === / --- setext headings.
Warning autofix safe
MD
MD203
Trailing heading punctuation
No trailing punctuation on headings except question marks.
Info autofix safe
MD
MD210
Unordered list marker
Use - consistently across a document.
Info autofix safe
MD
MD211
Ordered list prefix
Order list items sequentially from 1, not all as 1.
Warning autofix safe
MD
MD220
Hard line break
No trailing double-space line breaks; use \ or a blank line.
Info autofix safe
MD
MD230
Fenced code language
Fenced code blocks must declare a language.
Warning autofix unsafe
MD
MD231
Indented code
Prefer fenced code over indented code.
Info autofix safe
MD
MD240
Autolink bare URL
Wrap bare URLs in <…> or a link.
Warning autofix safe
MD
MD241
Link text empty
Links must have non-empty text.
Error autofix
MD
MD250
Image alt text
Images must carry alt text.
Error autofix
MD
MD260
Inline HTML
Inline HTML in Markdown flagged; can be allowlisted.
Info autofix
PUN
PUN101
Double space after period
Collapse double spaces after terminal punctuation.
Info autofix safe
PUN
PUN201
Smart quotes
Curly quotes preferred over ASCII quotes in prose regions.
Warning autofix safe
PUN
PUN202
Em-dash spacing
Em-dashes must not be surrounded by spaces (house style).
Info autofix safe
PUN
PUN203
Oxford comma
Final comma before the conjunction in a list of three or more.
Warning autofix safe
PUN
PUN204
Ellipsis
Use the ellipsis character, not three periods.
Info autofix safe
PYDOC
PYDOC101
Missing docstring
Public modules, classes, and functions require a docstring.
Warning autofix
PYDOC
PYDOC102
Summary line
First docstring line must be a complete imperative sentence.
Warning autofix unsafe
PYDOC
PYDOC103
Summary length
Summary line must be 80 characters or fewer.
Info autofix
PYDOC
PYDOC110
Imperative mood
Summary should read "Compute…" not "Computes…".
Info autofix unsafe
PYDOC
PYDOC120
Args alignment
Every declared parameter must be documented; no phantoms.
Error autofix
PYDOC
PYDOC121
Returns documented
Functions with a return value require a Returns section.
Warning autofix
PYDOC
PYDOC130
Raises cited
Documented exceptions must match actual raise statements.
Warning autofix
RSDOC
RSDOC101
Crate root docstring
Every crate root must carry a //! documentation comment.
Warning autofix
RSDOC
RSDOC110
Public item doc
Public items require /// documentation.
Warning autofix
RSDOC
RSDOC120
Code example
Public API examples should compile under rustdoc.
Info autofix
RSDOC
RSDOC130
Intra-doc links
Prefer intra-doc links over bare paths.
Info autofix safe
GRAM
GRAM201
Passive voice
Flag passive-voice sentences in prose regions.
Info autofix
GRAM
GRAM210
Sentence length
Flag sentences beyond the configured word ceiling.
Info autofix
GRAM
GRAM220
Weasel words
"Very", "quite", "rather" — flagged, not forbidden.
Info autofix
SPELL
SPELL101
Unknown word
Words absent from dictionary and allowlist.
Info autofix
SPELL
SPELL110
Commonly misspelled
High-confidence corrections for common misspellings.
Warning autofix safe
SPELL
SPELL120
Terminology consistency
Enforce canonical spellings for project terms.
Warning autofix safe

Configuration · one file, no ceremony

stilyagi.toml
pyproject.toml [tool.stilyagi]

The select / ignore model

Stilyagi borrows its configuration vocabulary from Ruff. You select rule codes or namespaces to enable; you ignore specific codes to silence. Namespaces are the uppercase prefix — MD, PUN, PYDOC — and match by prefix, not by regex.

Per-rule options live under [rule.XXX]. A rule that accepts options (like PUN201, smart quotes) documents its schema in its own description; invalid keys fail the run rather than being silently ignored.

# stilyagi.toml
line-length = 100
select = ["MD", "PUN", "PYDOC"]
ignore = ["MD260", "PUN204"]

# Per-rule configuration
[rule.PUN201]
style = "british"
allow-straight = ["code", "inline_code"]

[rule.GRAM210]
max-words = 32

# Per-file overrides — glob-matched
[per-file]
"docs/reference/**" = { extend-select = ["GRAM"] }
"**/CHANGELOG.md" = { ignore = ["MD201"] }

Suppressions · three scopes, no magic

line · next-line · block
<!-- stilyagi: disable-next-line PUN201 -->
She said "no" — a single word, straight-quoted on purpose. This paragraph
opts out of the sentence-length check for dramatic effect.

<!-- stilyagi: disable-block GRAM210 -->
It was the best of times, it was the worst of times, it was the age of
wisdom, it was the age of foolishness, it was the epoch of belief, it was
the epoch of incredulity, it was the season of light, it was the season of
darkness, it was the spring of hope, it was the winter of despair.
<!-- stilyagi: enable-block GRAM210 -->

Ordinary prose resumes here. <!-- stilyagi: disable-line PUN101 -->
                           # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                           # disables only the current line

Suppressions live in the source, not in the config. They travel with the prose they protect. A suppression must name its rule — wildcards are refused — so a reader can always answer the question: why is this line exempt?

The testing sandbox

pytest fixture
hermetic by default

PlannedSpecified in RFC 0004; the current tree carries an internal golden-IR and fix round-trip scaffold while the public fixture is built.

Real runs, isolated state

Rule authoring is a first-class feature, so rule testing is one too. The sandbox is a pytest fixture — stilyagi_path — that wraps tmp_path into a temporary project root you write ordinary files into, then runs the real stilyagi against them and hands back typed diagnostics.

Runs go through a subprocess by default, because that is what exercises the installed command surface, config discovery, entry-point loading, exit codes, JSON rendering, and the Rust extension import. An in-process fast path would test a different program from the one users run.

Hermetic is the default, not a flag: --isolated, --no-cache, a working directory rooted in the temporary project, and UTF-8 forced. No user config leaks in, no cache survives, and output paths are normalized to ./like/this.md so assertions read the same on every platform.

# tests/test_serial_comma.py
from stilyagi.testing import assert_has_diagnostic


def test_serial_comma(stilyagi_path):
    stilyagi_path.write_config(
        '[tool.stilyagi.lint]\n'
        'select = ["PUN201"]\n'
    )
    (stilyagi_path / "doc.md").write_text(
        "Apples, bananas and pears.\n"
    )

    result = stilyagi_path.run_check("doc.md")

    diag = assert_has_diagnostic(
        result.diagnostics, code="PUN201"
    )
    assert diag.fix.applicability == "safe"

Packs and extraction, both testable

Third-party rules arrive through entry points, so the sandbox has to be able to manufacture something that looks installed. install_rule_pack() writes a package tree and a .dist-info directory with real entry_points.txt metadata, then puts it on the subprocess path — the same discovery contract production uses, without a virtual environment or a pip install per test.

run_dump_ir() returns parsed IR documents, so a test can assert on region kinds, owner metadata, synthetic segments, and suppression state. That distinction matters: a false positive is either a bad rule or a bad extractor, and the harness should make it cheap to tell which.

The assertion helpers stay deliberately dumb — assert_has_diagnostic, assert_no_diagnostics, assert_only_codes, assert_has_fix, assert_ir_region. They exist to delete repetitive test noise, not to become a second assertion language. And the name is a promise about isolation, not about safety: rule packs are trusted Python here exactly as they are everywhere else.

def test_custom_pack(stilyagi_path):
    stilyagi_path.install_rule_pack(
        name="demo-pack",
        modules={"demo_pack/rules.py": RULE_SOURCE},
        rule_entry_points={
            "demo": "demo_pack.rules"
        },
    )
    stilyagi_path.write_config(
        '[tool.stilyagi]\n'
        'plugins = ["demo-pack"]\n'
    )
    (stilyagi_path / "doc.md").write_text(
        "foo appears here\n"
    )

    result = stilyagi_path.run_check("doc.md")

    assert_has_diagnostic(
        result.diagnostics, code="DEMO001"
    )
An agitprop object

A colophon,
of sorts.

Stilyagi is, first, a working prose linter. It is also, deliberately, an agitprop object — a statement about what documentation tooling could be if we stopped treating prose as a second-class artefact of software.

Rust does the boring, bytes-level work: opening files, finding the regions that contain prose, emitting an intermediate representation with stable line indices and content hashes. Python does the interesting work: writing down the rules of a house style, one rule at a time, in a language the people who care about prose can read.

The capability planner is the hinge. It keeps the fast path fast and permits the slow path to exist. Without it, grammar and spellcheck could not join the catalogue; with it, they are merely entries in a toml file, loaded on demand, uninvolved when unwanted.

The name is deliberate. Stilyagi, like the people it takes its name from, believes that style is a political act — and that doing style well, in the grain of a rigorous system, is the point.

KindProse linter, dual-runtime
ExtractorRust · PyO3 · markdown-rs · tree-sitter
RuntimePython 3.14+ · cyclopts · entry-point plugins
OptionalspaCy (planned)
Outputtext · json (sarif planned)
LicenseISC
StatusDraft · v0.4.0 in flight
Set inBig Shoulders · EB Garamond · IBM Plex Sans · JetBrains Mono
SpellingOxford
Made inEdinburgh, under a red pencil