stilyagi df12 · deterministic prose analysis
§ 02 · The mechanism

How Stilyagi works.

Five stages from filesystem to diagnostic. Rust extracts structure. Python analyses it. A capability planner decides which linguistic providers to load. The formatter renders verdicts. CI reads them.

A
§ Architecture · pipeline

Five stages, one contract.

01

Discover

Config loading, path walking, owner attribution. A normalised set of FileInputs arrives at the extractor.

Python
02

Extract

Rust reads source bytes and emits a region-oriented Document IR with segments, a line_index, and a content_hash.

Rust
03

Plan

The capability planner inspects enabled rules and materialises only the annotators that are actually required.

Bridge
04

Analyse

Rules run against typed region wrappers. Diagnostics, suppressions, and fix payloads are accumulated deterministically.

Python
05

Report

The formatter renders plain text, JSON, or SARIF. Exit codes are disciplined. CI is happy.

Python
§ Rust layer

Extraction only.

  • Parses Markdown, MDX, reStructuredText, Python, and Rust source.
  • Emits a region-oriented Document IR keyed by byte offsets.
  • Computes the line_index and content_hash.
  • Owns no rules. Owns no diagnostics. Owns no policy.
  • Narrow FFI boundary: one data structure crosses the line.
§ Python layer

Analysis, fixes, and output.

  • Owns the rule engine, capability planner, and diagnostic model.
  • Provides typed wrappers over regions: Heading, Paragraph, Code, Docstring.
  • Plans spaCy and spellchecker providers on demand.
  • Applies safe fixes; flags unsafe fixes; refuses to touch synthetic spans.
  • Emits text, JSON, and SARIF with stable ordering.
The Iron — Rust extraction, gears, structural machinery
☞ The Iron · Rust · extraction only
The Jazz — Python analysis, instruments, colourful expression
☞ The Jazz · Python · analysis & policy
B
§ Live rule · author → output

A rule is a Python class.

rules/heading_depth.pyauthor view
# rules/heading_depth.py
from stilyagi.rule import Rule, Level
from stilyagi.regions import Heading


class HeadingDepthRule(Rule):
    """Reject Markdown headings deeper than level 3."""

    code = "MD201"
    name = "heading-depth"
    level = Level.WARNING
    pack = "default"
    capabilities = ()   # no spaCy, no spellchecker

    def check_heading(self, h: Heading) -> None:
        if h.depth > 3:
            self.report(
                span=h.marker_span,
                message=f"Heading depth {h.depth} exceeds maximum of 3.",
                suggestion="Promote to a new section, or use bold text.",
            )
$ stilyagi check docs/diagnostic view
$ stilyagi check docs/
stilyagi 0.4.0 · loaded 1 pack · planned 0 annotators
scanning 14 files · cache hit 11 · analysed 3

docs/guide/advanced.md:72:1
warning[MD201] Heading depth 4 exceeds maximum of 3.

70 │ ### Advanced topics
71 │
72 │ #### Caching strategy
^^^^ heading-depth (MD201)
73 │

= help: Promote to a new section, or use bold text.
= suppress: <!-- stilyagi: disable-line MD201 -->

1 warning · 0 errors · 3 files analysed in 142 ms

The IR is the contract.

Rules never see raw source bytes directly, and never drive a parser. They receive a typed region — a Heading, a Paragraph, a Docstring — backed by a single shared Document IR. The region knows its span, its depth, its content, its markers. It knows whether it was synthesised from structural inference or extracted verbatim.

This is what separates Stilyagi from a regex harness. A regex doesn't know whether it is inside a code block, a docstring, a comment, or a heading. A Stilyagi rule subscribes to region classes and is only ever called for the ones it asked for — which is also what lets the capability planner skip spaCy loading when no enabled rule needs it.

Determinism is designed, not incidental.

Every diagnostic carries a canonical sort key. Files are ordered by normalised path; diagnostics within a file by byte offset, rule code, and stable message hash. Two rules cannot emit fixes to overlapping, non-identical ranges without one losing explicitly. Stilyagi wants CI to be bored, and agents to have something they can trust.

C
§ CLI contract

Six verbs. No surprises.

The Ruff-inspired UX — compact command surface, legible configuration, fix safety
$ stilyagi check docs/ --format text
stilyagi 0.4.0 · loaded 2 packs · planned 1 annotator
scanning 38 files · cache hit 31 · analysed 7

docs/guide/install.md:12:1
warning[MD201] Heading depth 4 exceeds maximum of 3.

10 │ ### Installing extras
11 │
12 │ #### With pipx
^^^^ heading-depth (MD201)
13 │

= help: Promote to a new section, or use bold text.
= suppress: <!-- stilyagi: disable-line MD201 -->

docs/guide/advanced.md:72:1
warning[MD201] Heading depth 4 exceeds maximum of 3.

70 │ ### Advanced topics
71 │
72 │ #### Caching strategy
^^^^ heading-depth (MD201)

docs/reference/cli.md:41:23
error[PUN201] Straight apostrophe in prose; use a typographic apostrophe.

40 │
41 │ The planner doesn't load providers no rule asked for.
^ apostrophe-typography (PUN201)

= help: Replace ' with ’. A safe fix is available.

= fixable: 1 diagnostic may be fixed with `stilyagi fix --safety safe`

2 warnings · 1 error · 7 files analysed in 214 ms

$
stilyagi check
Analyse paths or stdin. The primary verb. Emits diagnostics and the only verb CI runs.
--format text|json|sarif
--fix=safe|unsafe|none
--select CODE,CODE
--ignore PATTERN
--stdin-filename PATH
--config PATH
stilyagi fix
Apply autofixes within the declared safety class. Refuses overlapping non-identical edits.
--safety safe|unsafe
--dry-run
--diff
--preview
stilyagi rule
Inspect rules. list, show <code>, describe <code>, explain <code>. For humans and for agents.
list
show MD201
describe MD201
explain MD201
stilyagi dump-ir
Emit the extractor's view of a file. Essential for debugging rule authors and maintainers alike.
--file PATH
--format json|pretty
--include annotations
--mask-hashes
stilyagi config
Print, validate, or discover configuration. Respects precedence: CLI → file → pyproject → defaults.
print
validate
explain KEY
locate
stilyagi cache
Inspect and clear the analysis cache. Per-file keys derive from content_hash, ruleset, capabilities.
info
prune
clear
path

The capability planner.

Linguistic providers are expensive. A small lint run that only checks heading depth should not pay for spaCy model loading, a symspell dictionary, or a terminology index. The planner exists to prevent this tax.

Each rule declares a capabilities tuple. At startup, the planner intersects the enabled ruleset with the capability graph and materialises only the providers required. Rules never import providers directly — they ask the region for an annotation, and the planner guarantees it will be there or the rule will be skipped with a single loud diagnostic.

The same mechanism lets Stilyagi stay offline: grammar and spelling providers ship as optional extras, stilyagi[grammar] and stilyagi[spell], and the planner refuses to enable a rule whose capability is absent. The base install does nothing clever, reaches no network, and stays useful.

"The IR is the contract. The rule is the policy. Everything else is scaffolding."— Maintainer's rubric, § 4
☞ Open the IR inspector☞ Read the roadmap☞ Rules & config