Skip to main content

nouls

A semantic linter and language server for the problems deterministic tools cannot see.

nouls splits every file into functions with tree-sitter and asks TypeSafe a batch of yes/no questions about each one. A rule fires when the probability of yes reaches its threshold. Every question is scored independently, so one call per function answers every rule, and unchanged functions are never asked again.

The default rules target intent, not syntax:

Rule Severity
name_behaviour_mismatch warning
docstring_drift warning
query_with_side_effect warning
partial_failure warning
check_then_act warning
missing_authorisation warning
non_idempotent_retry error
unit_mismatch error
boundary_error error
misleading_error info
mixed_abstraction info

Anything ruff, a type checker or a security scanner already catches is deliberately out of scope.

Test files also get rules drawn from Kent Beck's Test Desiderata. Each asks whether a test violates one property.

Rule Desideratum Severity
test_not_isolated Isolated warning
test_not_composable Composable info
test_nondeterministic Deterministic error
test_slow Fast warning
test_hard_to_write Writable info
test_unreadable Readable info
test_not_behavioural Behavioural error
test_structure_sensitive Structure insensitive warning
test_not_automated Automated error
test_not_specific Specific warning
test_not_predictive Predictive warning
test_not_inspiring Inspiring warning

Two more rules cover fixtures, setup and teardown hooks and shared test helpers.

Rule Severity
fixture_leaks_state warning
fixture_hides_behaviour warning

The test rules only run on files matching test_files, such as test_*.py, conftest.py, *_test.go, *.spec.ts, *Test.java, spec/*.rb and */tests/*.rs. Rust unit tests inside mod tests in a source file are not matched.

Every test rule asks about a test case and every fixture rule about a fixture or hook, so each kind of function is judged only by its own rules. Decorators and attributes such as @pytest.fixture, #[fixture] and @BeforeEach are sent along with the function so the model can tell them apart. In test files, calls such as Jest's it and beforeEach, RSpec's it, let and before, and busted's it and before_each are also sent as functions.

Installation

uv tool install nouls --index typesafe=https://pypi.typesafe.ai/
export TYPESAFE_API_KEY="your-api-key"

Usage

nouls check src/
nouls rules
nouls serve
nouls label src/billing.py 42 unit_mismatch false
nouls review unit_mismatch
nouls stats rules
nouls stats hotspots
nouls stats cost
nouls stats thresholds unit_mismatch --ask

check prints path:line:column: severity [rule] message (probability) and exits 1 when an error level rule fires. Set show_probability: false to drop the probability from both the command line and editor diagnostics.

Store

Every answer lives in one SQLite file, ~/.cache/nouls/nouls.db by default, shared by the language server, the command line and every repo on the machine. It runs in WAL mode, so several processes can use it at once.

  • answers caches one probability per model, question and function source. Rewording a rule only re-asks that rule. Editing a function only re-asks that function.
  • observations holds the latest answer for every rule on every function in every file checked.
  • labels holds your verdicts. A finding labelled false is no longer reported for that function.
  • runs records questions asked, cache hits and tokens for every file checked.

Labels and thresholds

Label findings from the editor with the not a problem and confirm finding code actions, from the command line with nouls label, or in bulk with nouls review. review shows unlabelled functions for one rule, sampled evenly across probability bands, so the labels cover misses as well as hits.

nouls stats thresholds compares your labels with the current wording of each question and prints precision and recall at a range of thresholds. Labels belong to the rule, not the wording, so you can rewrite a question, run nouls stats thresholds --ask to re-ask it for every labelled function, and compare.

nouls stats rules shows how often each rule fires, how many answers sit in the ambiguous 0.35 to 0.65 band, and a histogram of probabilities. A well posed question piles up at both ends.

For anything else, attach the store read only from DuckDB:

ATTACH '~/.cache/nouls/nouls.db' AS nouls (TYPE sqlite, READ_ONLY);
SELECT rule, quantile_cont(probability, [0.1, 0.5, 0.9]) FROM nouls.observations GROUP BY rule;

Configuration

nouls merges its built in defaults with the first nouls.yaml, nouls.yml, .nouls.yaml or .nouls.yml found walking up from the target, or the file passed with --config. Maps merge key by key, so you only write what changes.

model: jev-latest
threshold: 0.8
concurrency: 8
debounce_ms: 1000
show_probability: true
lint_on: change
store: ~/.cache/nouls/nouls.db
exclude: [".*", node_modules, __pycache__, target, dist, build, venv]

languages:
  kotlin:
    grammar: kotlin
    extensions: [.kt, .kts]
    units: [function_declaration]
    attached: [annotation]
    calls:
      node: call_expression
      callee: function
      names: [test, beforeTest]

rules:
  mixed_abstraction:
    enabled: false
  unit_mismatch:
    threshold: 0.9
  ledger_sign:
    question: Does the function add a debit where the domain requires subtracting it, or the reverse?
    message: Debit and credit signs look inverted
    severity: error
    languages: [python, go]
  test_not_isolated:
    files: ["*_test.py", "*/integration/*.py"]

lint_on: save stops the language server checking while you type. store moves the SQLite file.

files limits a rule to file names or paths matching any of its globs. Setting it replaces the default list.

Languages are pure configuration. grammar is any name from tree-sitter-language-pack, and units lists the node types to send as individual questions. The diagnostic sits on the node's name field, or its first line when it has none. attached lists wrapper or preceding sibling node types, such as decorators, that belong to a unit. calls makes calls with a matching callee name into units, but only in files matching test_files.

Every question is answered against this state:

{"language": "python", "function": "<source of the unit>"}

Write questions as a single yes/no judgement about that function.

Neovim

vim.lsp.config("nouls", {
  cmd = { "nouls", "serve" },
  filetypes = { "python", "javascript", "typescript", "typescriptreact", "go", "rust", "java", "c", "cpp", "lua", "ruby" },
  root_markers = { "nouls.yaml", ".nouls.yaml", ".git" },
})
vim.lsp.enable("nouls")

Development

uv sync --all-extras
uv run prek install
uv run prek run --all-files
uv run pytest

AI Integration

An MCP server for the docs lives in src/nouls/mcp_server.py:

claude mcp add nouls --transport stdio uv run --with mcp python src/nouls/mcp_server.py

A Claude Code Agent Skill lives in .skills/nouls:

claude skill add .skills/nouls

Release files for nouls 0.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nouls 0.1.2
File Size Uploaded
nouls-0.1.2.tar.gz 186.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for nouls 0.1.2
File Interpreter ABI Platform
nouls-0.1.2-py3-none-any.whl Python 3 none any Details

Total release size: 212.8 kB

Release files / nouls-0.1.2.tar.gz

Download URL nouls-0.1.2.tar.gz
Size 186.6 kB
Tags Source
SHA-256 checksum
How to use checksums
d89288dd7209e96b9dd8aa6cc0f6d3b8e2a204947e1cef13bd81504fef53a9e0
BLAKE2b-256 checksum
How to use checksums
dc27dc402595128c558fdf4dadec0381f168893572ecb7b287b9cce678e3caa0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / nouls-0.1.2-py3-none-any.whl

Download URL nouls-0.1.2-py3-none-any.whl
Size 26.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e08db4ace5493cfcde6ba29d6054b94ea2b13380ca966dd83fc5aace48910724
BLAKE2b-256 checksum
How to use checksums
b334949a9feba2f19bb60e935f789698733722081f3838f4bd2c846fc8cf8a1a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.2 This release

2 release files

0.1.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page