stilyagi df12 · deterministic prose analysis
§ 03 · Under the bonnet

Design notes.

Two working models — the IR as a contract, and the capability planner as a lazy loader — plus the architecture decisions that pin them in place. Hover below. Toggle. Watch the machine.

D
§ IR inspector · stilyagi dump-ir

Every region remembers its bytes.

The IR is what the Rust extractor hands to the Python rule engine, and it is the only thing rules ever see. It is not a copy of the Markdown tree. It is a flat list of prose regions, each carrying its kind, its structural owner, and the exact byte range it occupies in the file on disk.

That byte range is the whole contract. Diagnostics and fixes are expressed in original source offsets, so an edit can only ever land on text that was really there. Where markup was elided — emphasis, link syntax, a soft line break — a segment map records which slice of the region's text came from which source bytes, and marks the remainder synthetic so no fix can touch it.

Two more fields are first-class rather than conveniences. content_hash keys the extraction cache, so an unchanged file is never parsed twice. line_index turns a byte offset into a line and column without reparsing the source, which is what keeps diagnostic rendering cheap.

stilyagi dump-ir prints all of it as canonical JSON, and that is the debugging surface the whole architecture leans on: when a rule misfires, the IR tells you whether the extractor got the region wrong or the rule got the logic wrong.

Hover either side of the panel below — the source span and the IR node that owns it light up together, and the footer reports the byte range.

docs/configuring.mdsource view
1# Configuring Stilyagi
2 
3Stilyagi is configured by `stilyagi.toml` or by the `[tool.stilyagi]`
4section of your `pyproject.toml`. See the [config reference](docs.html#config).
5 
6## Rule selection
7 
8Use `select` and `ignore` to scope the active ruleset:
9 
10- `select = ["MD", "PUN201"]` — enable all Markdown rules plus PUN201.
11- `ignore = ["MD401"]` — skip MD401 globally.
12 
13### Capability extras
14 
15Install `stilyagi[grammar]` to enable the spaCy-backed rule pack.
16 
Linkrange [136,172]source-faithful · no rewrites
Document IR · read-onlydump-ir --pretty
Document {
content_hash: "sha256:7a3f…e041"
line_index: [0, 23, 24, 94, …, 461]
regions: [
heading · range=[0,22] depth=1
paragraph · range=[24,173]
code · range=[50,65] lang="toml"
code · range=[76,93] lang="toml"
link · range=[136,172] scheme="relative"
heading · range=[175,192] depth=2
paragraph · range=[194,248]
list-item · range=[250,322] depth=0 marker="-"
list-item · range=[323,370] depth=0 marker="-"
heading · range=[372,393] depth=3
paragraph · range=[395,460]
code · range=[403,422] lang="python"
]
}
regions 12content_hash sha256:7a3f…e041deterministic
HeadingParagraphCodeLinkList-item
E
§ Capability planner

Toggle rules. See which providers load.

Enabled ruleset

MD201Reject Markdown headings deeper than level 3.
MD401Reject bare URLs and empty link text.
PUN201Require the serial comma in three-item lists.
PYDOC101Docstrings must start with a single-line summary.
GRAM301Flag passive-voice constructions in prose regions.+grammar
GRAM402Warn when a sentence exceeds 40 words.+grammar
SPELL101Check common words against the shipped dictionary.+spell
TERM201Enforce the canonical form of a term (e.g. API, not api).+terminology

Planner verdict

Core extractor
always loaded
Loaded
Grammar · spaCy
stilyagi[grammar]
Skipped
Spellchecker
stilyagi[spell]
Skipped
Terminology
stilyagi[term]
Skipped
4 rules enabled · 0 linguistic providers required · estimated cold start 40 ms

From tokens to clauses

Part-of-speech tags are where a prose linter usually leaks its backend. Stilyagi normalizes them into UPos — the seventeen coarse categories Universal Dependencies defines once for every language it covers.

  • ADJ
  • ADP
  • ADV
  • AUX
  • CCONJ
  • DET
  • INTJ
  • NOUN
  • NUM
  • PART
  • PRON
  • PROPN
  • PUNCT
  • SCONJ
  • SYM
  • VERB
  • X

Coarse is the point. UD keeps the inventory small and deliberately cross-linguistic, pushing the finer detail into morphology — which Stilyagi carries separately as MorphFeatures. The provider's own fine-grained tag stays reachable as fine_pos for debugging, but it never becomes the cross-provider contract.

Four derived syntactic nodes — TokenNode, SentenceNode, NounPhraseNode, and CoordinationNode — are exposed to rule authors on top of that. The Embassy Rule: Python rules talk only to stable Stilyagi grammar nodes, not raw, unstable spaCy classes.

Grammar layer pyramid — tokens, POS/morphology, clauses/coordinations
F
§ Architecture Decision Records

Nine decisions, on the record.

ADR-001

Python package, Rust extractor

Accepted

Ship one wheel per platform. PyO3 bindings. Pre-built wheels for Linux, macOS, Windows; sdist only if requested.

ADR-002

No bundled spellchecker in base

Accepted

Spelling is an optional extra. Base install stays small and offline. Network-free by default is a hard contract.

ADR-003

Rules are trusted code

Accepted

Plugins run as ordinary Python. No sandbox. Documented loudly in the README, CLI help, and the plugin guide.

ADR-004

Region IR, not raw AST

Accepted

Rules subscribe to typed region classes. Raw AST is an implementation detail of the extractor, not a public surface.

ADR-005

Capability planner

Accepted

Providers load only when at least one enabled rule declares them. Runtime cost is proportional to the enabled rule set.

ADR-006

Markdown first; MDX provisional

Provisional

MDX support ships behind a feature flag. Tree-sitter grammar stability is the pacing risk.

ADR-007

SARIF as primary machine format

Accepted

JSON is the CLI-friendly fallback; SARIF is the CI integration format. One schema for both versions, stable.

ADR-008

Fix safety is explicit

Accepted

safe / unsafe / manual are first-class. The --fix flag accepts a safety class; unsafe fixes never run without an opt-in.

ADR-009

Deterministic ordering

Accepted

Normalised path, byte offset, rule code, message hash. The same input yields the same output bytes, always.

"Runtime cost is proportional to the enabled rule set — and nothing else."— ADR-005
☞ See the roadmap☞ Open the docs