Skip to content

Configuration

Braden Seaborn edited this page Aug 26, 2026 · 1 revision

Configuration

The linter needs no configuration to run. This page is the task-oriented tour: how config resolves, what the shipped presets and profiles are for, how severity is decided, and how to adopt the linter on an existing codebase. For the exhaustive key-by-key reference, see docs/configuration.md in the repository.

Config resolution order

First match wins, verified against src/ste100/paths.py:resolve_config():

  1. --config path/to/file.json -- an explicit file wins outright.
  2. --preset NAME -- a preset shipped inside the package (src/ste100/presets/).
  3. A project-local ste100.json or .ste100.json, found by walking up from the target directory (or --root, if given) toward the filesystem root. Drop one of these next to your docs and plain ste100 picks it up with no flags.
  4. The shipped default preset, if nothing above matched.
ste100 --preset default docs/                 # explicit preset
ste100 --config ./my-ste100.json docs/         # explicit file, highest priority
ste100 docs/                                   # falls back through 3, then 4

Verified: running ste100 with no --config/--preset from a subdirectory that has an ste100.json two levels up in its ancestry picked up that file without any flag.

The two shipped presets

  • default -- the generic preset for any project. Use this one first.
  • veistra -- a byte-for-byte copy of the config this linter was extracted from (Firelight's internal document-control monorepo). Its profiles (core, csv, spec, design, vision, prose) and its csv_integrity checks match that project's specific registry schema (truths.csv, decisions-*.csv, terminology.csv with columns such as superseded_by and review_by). Use it if you are that project, or as a complete worked example with every feature turned on, including CSV integrity checking.
ste100 --preset default docs/
ste100 --preset veistra docs/

Profiles

Every linted file is assigned a profile, which controls which of the six tests run against it and what its word/ARI budgets are. A profile is an editorial policy, not a property of a file extension. The same .md file resolves to a stricter or looser profile depending on where it lives or an override.

How a profile is chosen

Verified against src/ste100/discovery.py:detect_profile(), in this exact order:

  1. --profile NAME on the command line -- overrides everything, for every targeted file in the run.
  2. A first-line comment, <!-- lint-profile: NAME -->, honored only if NAME names a profile that exists in the resolved config. An unknown name is ignored, and detection falls through to the next step.
  3. profile_order glob matching. The config's profile_order list is walked in order; for each profile, each of its path_globs entries is tested against the file's path (measured from the resolution root, with / separators) using Python's fnmatch.fnmatch(). First match wins.
  4. prose, unconditionally, if nothing above matched.
<!-- lint-profile: spec -->

The shipped default preset's profiles

Five profiles exist in default's profile_order: spec, reference, csv, docs, prose.

  • prose -- the unconditional fallback for anything that matches nothing else. T1/T3/T6 (replaceable words, optionality, zero-information) stay at error; every other test is capped to review by a profile-wide cap (see Severity resolution below). This is the safest profile to run against a whole existing repo the first time -- it does not fail CI on prose-quality nitpicks, only on the three checks with the lowest false-positive rate.
  • docs (README.md, CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md, CHANGELOG.md, docs/**, examples/**) -- ordinary project documentation. This profile is newer than the rest (its own note in default.json dates it to this project's 2026-08 out-of-box-experience retune) and is not yet described in docs/configuration.md. This section is the current source of truth for docs, not that file. It exists because dogfooding the linter on this project's own README and CONTRIBUTING guide found the prose profile's non-T1/T3/T6 checks firing on unremarkable English (should, may, e.g.), not on genuine defects -- T1 stays at error (its suggestions are still good advice on ordinary prose), and a long list of individual severity_overrides entries pin the rest to review, reproducing the prose cap's behavior by hand under a profile name the cap does not special-case.
  • spec (*spec*/**, *requirements*/**, SPEC.md, REQUIREMENTS.md, ...) -- formal requirements and specifications: shall-statements, EARS templates, atomicity enforcement. The name is conventional, not load-bearing: the EARS, indefinite-article, and zero-shall checks fire for whichever profiles list ears (or ears_review) in tests. A profile named requirements gets them under a custom config, the same as spec does.
  • reference (*reference*/**, *api*/**, REFERENCE.md, API.md, ...) -- reference/API documentation: parameter tables, enumerations, code samples. Runs the same test set as spec minus EARS.
  • csv (*.csv) -- generic CSV cell linting: T1/T3/T6 word-level checks plus per-field word budgets, run against every cell of every row. csv_integrity is left out of this profile's tests on purpose (see below).

Why the generic csv profile does not run csv_integrity

csv_integrity (STE-CSV-0001..STE-CSV-0010) validates a bespoke registry schema -- specific filenames and columns from Firelight's own document-control process; other projects do not have them. Running it against an arbitrary CSV either finds nothing, or produces a confusing false positive on an unrelated CSV that shares a column name such as review_by. Neither is useful to a new user.

Worth knowing, verified by running the tool: today, turning csv_integrity off in a profile's tests list is cosmetic, not an enforced gate. main() in src/ste100/cli.py calls check_csv_integrity() unconditionally for every discovered .csv file, without consulting any profile's tests list. A dirty registry-shaped CSV fixture still produced STE-CSV-* findings against default's csv profile, whose tests list excludes csv_integrity.

Severity resolution

Three tiers -- error (shown, fails the run), warning (shown, does not fail the run), review (hidden unless --stats, never fails the run). Severity for a given finding is resolved once, by Engine.severity() in src/ste100/engine.py. Verified against that code and against docs/rules.md, the precedence, in this exact order:

  1. The prose profile-wide review-cap, applied first, unconditionally. If the file's profile is prose and the check is not T1, T3, or T6, the finding is forced to review before any rule-specific override is even consulted. This cap is hardcoded to the profile name prose -- the newer docs profile has a different name and reproduces the same behavior through explicit severity_overrides entries instead (see above), not through this cap.
  2. A rule-specific severity_overrides entry for the exact profile. The first entry whose rule matches and whose profile equals (or lists) the current profile wins outright, regardless of where it sits in the list compared to a wildcard entry.
  3. A severity_overrides entry with profile: "*" (applies to every profile) -- but a later specific-profile match for the same rule anywhere in the list still wins; a wildcard match only sets a tentative answer and keeps scanning.
  4. severity_defaults, keyed by rule name.
  5. The hardcoded literal passed as a default at the call site in src/ste100/checks_*.py, used only when none of the four rules above apply.

One point worth carrying over from docs/rules.md: a "*" override beats severity_defaults in every case. Retuning a rule's severity_defaults entry that already has a wildcard override has no visible behavior change; change the override instead.

Baselines: adopting the linter on an existing codebase

Running a new linter over years of existing documentation produces an unusable wall of findings on day one. A baseline snapshot lets you record what exists today and enforce only on what changes afterward.

ste100 --format json docs/ > .ste100-baseline.json   # snapshot today's findings
ste100 --baseline .ste100-baseline.json docs/         # only new findings surface

Two semantics matter here, both verified by execution:

  • Suppression is by occurrence count, not by mere presence. The baseline is keyed on (file, rule, message), and each key is allowed that number of matches per run. Matching on presence alone means a file with one baselined utilize swallows every utilize added to it later, hiding new regressions -- counting occurrences means a new instance of an already-baselined problem still surfaces. Verified: a file baselined with two findings on one sentence stayed clean after baselining, but adding a second sentence with the same two rule violations produced two new findings and a nonzero exit code.
  • Line numbers are excluded from the match key, on purpose. They shift every time anyone edits above a finding, and a baseline that invalidates itself on every unrelated edit is worse than no baseline at all.

Parsed-but-unread config keys

The following config keys are parsed but do not drive any behavior yet, verified by grepping the source for each key name, not by inspection of the schema alone:

  • thresholds (smell_density_max, passive_ratio_max, paragraph_sentences_max) -- the run summary reports smell_density and passive_ratio as informational metrics, but nothing compares them against these configured thresholds -- no pass/fail gate ties to them. paragraph_sentences_max is doubly dead: the enforced paragraph budget comes from src/ste100/data/budgets.json instead, a separate value this key does not drive.
  • Per-profile ari_target -- ari_grade() computes one whole-corpus score for the run summary; no per-profile comparison against a target exists anywhere in the codebase. The key is parsed into the config dict and never read again.
  • severity_defaults is read (see Severity resolution above) -- not part of this dead-keys list. Do not confuse it with thresholds or ari_target.

Also unread: profile_override_comment (the regex is hardcoded in discovery.py), budgets_file (budgets load from src/ste100/data/budgets.json directly), rule_id_taxonomy (purely descriptive), t5_oblique_slash_exceptions_regex and s7_tbd_pattern (each has a hardcoded regex instead), and top-level schema_version (report.py uses its own fixed constant). These are kept in the shipped presets to keep the schema stable, and to give a future implementation sane defaults to inherit instead of a missing key.

Writing your own config

Copy src/ste100/presets/default.json as a base -- the code requires the following keys at load time (it raises on load if any is missing): t4_pronouns, t4_comparative_irregulars, t4_comparative_exclusions, t4_comparative_min_stem_length, t5_combinators, t5_punctuation_density_max, t5_punctuation_chars, s7_units, universal_quantifiers, nasa_arm_directives, abbreviation_allowlist, profiles, profile_order, never_lint. Verify a new config loads and produces sane output before trusting it:

ste100 --config your_config.json --stats path/to/one/file.md

--stats is worth keeping on while developing a config -- it surfaces review-tier findings that are otherwise silent, including any place a profile-wide cap demotes a check you expected at a higher tier.

For the complete key-by-key table -- every config key, whether the code reads it, its meaning, and its default -- see docs/configuration.md in the repository. That page also covers fnmatch glob gotchas (* matches /, **/ requires a literal separator before it, case sensitivity depends on the host OS) and how never_lint path-segment matching works, in more depth than this page repeats.

Related pages

Clone this wiki locally