argotdocs

Custom rules

Write your own rule — a rule.toml manifest plus a sandboxed Rhai script under .argot/rules/<name>/ — versioned with the repo, discovered fresh on every run, no recompiling argot. Same severities, suppressions, and output surfaces as every built-in.

argot’s twelve built-in rules cover the patterns every repo eventually cares about — foreign imports, reinvented functions, layering, gamed tests, migration leftovers. Some conventions are yours alone, though: “never call this deprecated internal helper,” “route handlers must not touch the ORM directly,” “this repo’s one rule about raw SQL.” For those, write a custom rule.

A custom rule is a directory under .argot/rules/<name>/, committed with the rest of the repo: a small rule.toml manifest plus a sandboxed Rhai script. argot check discovers it fresh on every run — no recompiling argot, no plugin build step, no restart. Its findings behave exactly like a built-in’s: the same rule name in every output format, the same [rules]/--rule severity knobs, the same inline-comment and [[mute]] suppression surfaces — all under one new group, custom.

When a custom rule earns its place

A custom rule is not an excuse to rebuild OXLint or ESLint in Rhai. Those tools are excellent homes for broadly useful, one-file rules: formatting, naming, import order, deprecated APIs, simple forbidden calls, framework best practices, and security patterns that mean the same thing in every repository. Keep them there: their ecosystem, language services, and shared rule packs are the advantage.

Argot is different because a rule executes against a change in a particular repository, with local context that a conventional lint rule does not carry as first-class input. It is worth authoring only when that context changes the answer:

Team policyArgot-specific evidenceWhy a generic one-file lint is the wrong owner
A route must be documentedRead the committed API description with read_repo_file()The route and its contract live in different files; hard-coding a list of routes goes stale.
Every implementation must answer a new contract memberCompare the pre-image with ts_query_old(), then enumerate siblings via repo_paths()The violation is often a file that did not change.
A public endpoint may not disappear silentlyCompare the old and new tree with file.old_text / ts_query_old()A normal lint run sees only the post-image.
A change touching a schema must include its migration or test companionInspect changeset_paths()The policy is about the absence of another path in this diff.
This repository must use its already-established HTTP client or internal gatewayAsk import_attested() / callee_attested()The fitted history is a repository-specific allowlist, not a dependency list maintained by hand.
Validation, persistence, or boundary code belongs in its team-established homeStart from a concentrated placement convention mined by argot conventionsThe architecture is inferred from this repository’s layout and history, rather than assumed from a framework.

This does not mean a custom rule is magically more powerful than every ESLint plugin a team could write. A plugin can always grow bespoke state. The useful difference is that Argot already supplies the local history, two-sided diff, changeset, repository-read and learned-voice contexts — safely and testably — without a separate cache, compiler service, or manually maintained allowlist.

Use this admission test before writing a rule:

  1. Is the convention durable, important, and expressed by canonical repository examples — not merely an aesthetic preference?
  2. Does it require one of the contextual inputs above, or a placement pattern argot conventions can evidence? If a current-file AST is enough, use the normal linter instead.
  3. Can the offending shape be detected syntactically and scoped narrowly, without pretending Argot has type or cross-file symbol resolution?
  4. Can the message name the correct local remedy in one hop, and can a silent fixture demonstrate a legitimate nearby pattern does not fire?

If any answer is no, do not ship a rule. Zero custom rules is the healthy outcome for most repositories. Setup offers this exploration only after the ordinary fit, audit, and integrations are complete; it is always opt-in.

Start from a working example

The repository ships rules you can copy, one directory each, under examples/rules/:

rulelanguageshows
route-documentedtypescriptread_repo_file — a route must appear in the committed openapi.yaml
contract-answeredpascalread_repo_file + repo_paths + ts_query_old — a member added to a shared contract must be answered by every implementation of it
cp -r examples/rules/route-documented /path/to/repo/.argot/rules/
cd /path/to/repo && argot rules test route-documented

Their fixtures run in argot’s own test suite, so they cannot rot: a change to the host API that breaks one breaks the build. Copy, then make it yours — the paths, the severity and the message belong to the repository that runs it.

Layout

.argot/rules/no-raw-sql/
  rule.toml          # identity, severity, language scope
  check.rhai         # the detection logic — sandboxed Rhai
  tests/             # fixtures for `argot rules test` (see below)
    fires-on-execute/
      input.py
      expected.json
    silent-on-builder/
      input.py
      expected.json

One rule per directory. argot check and argot rules scan every directory under .argot/rules/; a repo with no such directory — the common case — is unaffected.

rule.toml reference

[rule]
schema = 1                 # required — manifest schema generation (currently 1)
name = "no-print"          # required — must equal the directory name
label = "no print calls"   # optional — shown next to a finding; defaults to `name`
description = "this repo logs, never prints"   # optional — shown by `argot rules`
severity = "error"         # optional — error | warn | off; defaults to warn
languages = ["python"]     # optional — restrict to these scored languages (does NOT reach
                           #   unscored files like .env — use `include` for that)
include = []               # optional — path globs; runs the rule on ANY matching file,
                           #   even extensions argot doesn't score (.env, .yml, lockfiles…)
exclude = []               # optional — path globs subtracted from the scope (e.g. tests)

[engine]
script = "check.rhai"      # optional — script path, relative to the rule dir; defaults to check.rhai
FieldRequiredDefaultNotes
rule.schemayesThe manifest schema generation. A schema newer than this argot understands is skipped with a clear message.
rule.nameyesMust equal the directory name — a rule is addressable by its path.
rule.labelnonameShort label shown next to a finding.
rule.descriptionno""One-line description, shown by argot rules.
rule.severitynowarnerror / warn / off. Note the default differs from built-ins, which default to error — a rule just dropped into a repo reports before it gates.
rule.languagesnoevery languageRestrict to these scored languages — python, typescript, javascript, go, rust, java, csharp, php, cpp, ruby, c, pascal (see Languages). This gate is over supported source files only; it can’t reach a .env — that’s what include is for.
rule.includeno(none)Repo-relative path globs (dialect: */** cross /, ?, [abc]). When set, the rule runs on any matching path — including extensions argot doesn’t score. See Which files a rule runs on.
rule.excludeno(none)Path globs subtracted from the scope — even from the default language scope, so an include-less rule can still skip **/*.test.ts.
engine.scriptnocheck.rhaiScript file, relative to the rule directory.

A manifest that fails to parse, names a schema this argot doesn’t understand, or whose name doesn’t match its directory is skipped with a warning on stderr — discovery degrades per rule, never for the whole run.

Which files a rule runs on

By default a rule sees the same files check scores — the source files of the languages argot supports (.py, .ts, .go, …), minus anything excluded by [exclude] or the argot:recommended set. No configuration needed, which is why the built-in rules carry none.

A custom rule’s manifest narrows or widens that scope with three fields:

include and languages intersect (include = ["src/api/**"] + languages = ["typescript"] = changed TypeScript under src/api/); exclude always wins. The glob dialect is exactly [[mute]].path’s (see Configure): * and ** cross /, ? is one character, [abc] / [a-z] are character classes.

Scoping any rule, not just custom ones. The include/exclude above are a custom rule’s own scope, declared by its author (and the only way to reach unscored files). A repo owner can additionally restrict any rule — built-in or custom — to paths from argot.toml’s [rules], e.g. layering = { include = ["src/**"] } or convention = { exclude = ["legacy/**"] }. That’s a config-side filter on findings, covered in Configure → path-scoping a rule.

Host API

The script’s top-level statements run once per in-scope changed file (see Which files a rule runs on above). Two read-only bindings are in scope:

And the host functions:

FunctionReturnsDoes
ts_query(query)array of #{capture, text, line, end_line}Runs a tree-sitter query against the file; one map per capture, lines 1-indexed. An invalid query or unsupported language returns an empty array.
ts_query_old(query)sameThe same query against the pre-image (file.old_text) — for rules about what a change removed. Empty when there is no old side.
import_attested(module)boolDid the fitted voice model see this module imported anywhere in this language, at fit time?
callee_attested(name)boolSame, for a called name.
changeset_paths()array of stringsEvery path in the current changeset — for rules that need cross-file context (e.g. “flag X unless a sibling test file also changed”).
read_repo_file(path)string or ()API 2. The text of another file in the repository, repo-relative. () when it is missing, escapes the root, is not UTF-8, or exceeds 1 MiB.
repo_paths(glob)array of stringsAPI 2. Repo-relative paths the repository contains (git’s index when the root is a repo, else a bounded walk) matching glob — the same dialect as [[mute]].path. Sorted.
report(line, message)Records one finding on a single line.
report_span(start, end, message, opts)Records one finding over a line range. opts is a map: optional evidence (array of strings, shown as the finding’s evidence lines) and optional symbol (string).

import_attested/callee_attested reflect the fitted voice model — in argot rules test there is no fitted model, so both always return false; test the unattested path there and the attested path live.

Reading the rest of the repository (API 2)

ts_query and hunks see the changed file. A whole family of conventions is about two files, though — a contract and the implementations that must answer it, a migration and the schema it belongs to, a route and its entry in the API description. Those need read_repo_file and repo_paths.

// Every backend under kernel/<platform>/ must answer every member of the contract.
let contract = read_repo_file("lib/common/kernel/mseguiintf.inc");
if contract != () {
    let missing = [];
    for line in contract.split("\n") {
        // … collect the members the contract declares …
    }
}
for backend in repo_paths("lib/common/kernel/*/mseguiintf.pas") {
    // … and compare each sibling against them.
}

The sandbox stays closed where it matters: reads are read-only, refused outside the repository root (.., absolute paths, and symlinks that leave it), capped at 1 MiB per file, and metered per checked file — 64 reads, 4 MiB, 16 listings. Past the budget the calls return () / [] and the rule keeps running, exactly like an unresolvable ts_query. A rule can read nothing its author’s own clone does not already hold.

Unlike the model facts, these work in argot rules test: repository access is rooted at the fixture case directory, so a case can ship the sibling files its rule reads next to input.<ext> — the cross-file analogue of old.<ext>.

Worked example: domain-imports-stay-inward

The convention: the pure domain layer (src/domain/) must not reach outward into infrastructure (src/infra/) — dependencies point inward, ports/adapters keep it testable. Everyone agrees in review; nothing enforces it. In ESLint this is an afternoon with a boundaries plugin and a config file nobody wants to own. Here it’s ten lines that live next to the convention’s own README:

# .argot/rules/domain-imports-stay-inward/rule.toml
[rule]
schema = 1
name = "domain-imports-stay-inward"
description = "src/domain must not import src/infra — dependencies point inward"
languages = ["typescript"]
severity = "error"
include = ["src/domain/**"]     # scope lives in the manifest, not the script
// .argot/rules/domain-imports-stay-inward/check.rhai
for m in ts_query("(import_statement source: (string (string_fragment) @from))") {
    if m.capture == "from" && m.text.contains("/infra/") {
        report_span(m.line, m.end_line, "domain imports infrastructure — invert the dependency", #{
            evidence: ["depend on a port defined in the domain (see src/domain/README.md)"],
        });
    }
}

Write both fixtures before polishing the script — the silent case is what protects the team from a noisy rule:

// .argot/rules/domain-imports-stay-inward/tests/fires-on-infra-import/input.ts
import { PgClient } from '../../infra/postgres';
export const load = (c: PgClient) => c.query('...');
// .argot/rules/domain-imports-stay-inward/tests/fires-on-infra-import/expected.json
[{"line": 1, "message": "domain imports infrastructure — invert the dependency"}]
// .argot/rules/domain-imports-stay-inward/tests/silent-on-port/input.ts
import type { Store } from './ports';
export const load = (s: Store) => s.get();
// .argot/rules/domain-imports-stay-inward/tests/silent-on-port/expected.json
[]
argot rules test domain-imports-stay-inward
# ok    domain-imports-stay-inward :: fires-on-infra-import
# ok    domain-imports-stay-inward :: silent-on-port
#
# 2 case(s), 0 failed

Once it’s green, it’s live — the very next argot check runs it over real changes:

argot check
# ! src/domain/orders.ts:1   domain-imports-stay-inward — domain imports infrastructure — invert the dependency
#                            ↳ depend on a port defined in the domain (see src/domain/README.md)

Shapes you’ll actually write

Composite call shapes — the pattern is two calls nested, which is exactly where flat lint rules give up and a tree query doesn’t. “Files are parsed through lib/config — its loader validates and applies defaults; a raw JSON.parse over a file read skips both”:

# rule.toml — the loader itself is allowed; scope it out in the manifest
exclude = ["lib/config/**"]
// parse-through-loader — JSON.parse directly over a file read
for m in ts_query("(call_expression function: (member_expression) @f
                    arguments: (arguments (call_expression function: (identifier) @inner)))") {
    if m.capture == "inner" && m.text.contains("readFile") {
        report(m.line, "parse through lib/config — a raw JSON.parse skips schema validation and defaults");
    }
}

History-parameterized rules — the allowlist is the repo’s own git log, so the same rule file is correct in every repo that adopts it, with zero configuration. “One HTTP client per repo — whichever one history already knows”:

// one-http-client — flag any HTTP client this repo has never used
for m in ts_query("(import_statement source: (string (string_fragment) @mod))") {
    if m.capture == "mod"
        && ["axios", "got", "ky", "undici", "superagent"].contains(m.text)
        && !import_attested(m.text) {
        report(m.line, m.text + " — this repo already has an HTTP client; history knows which one");
    }
}

Drop that rule into a got shop and it fires on axios; drop it into an axios shop and it fires on got. Nobody edits the rule — each repo’s fitted history is the configuration. That’s what makes custom rules shareable: a rule pack can encode the category and let every repo’s own voice supply the allowlist.

What only argot can express

Two host calls have no equivalent in any classic linter:

The pre-image. A linter sees one version of one file; argot hands your rule both sides of the diff. ts_query_old runs the same tree-sitter query against the file as it was before the change — so you can write rules about what a diff removed:

// no-dropped-endpoints — a route that existed before this change, gone now
const ROUTES = "(call_expression function: (member_expression property: (property_identifier) @verb)
                arguments: (arguments (string (string_fragment) @path)))";
let now = [];
for m in ts_query(ROUTES) { if m.capture == "path" { now.push(m.text); } }
for m in ts_query_old(ROUTES) {
    if m.capture == "path" && !now.contains(m.text) {
        report(m.line, "endpoint '" + m.text + "' removed without a deprecation cycle — see docs/api-lifecycle.md");
    }
}

Pair it with an old.ts fixture in the harness (see below) and severity error: silently dropping a public route now fails the check with the route’s name.

The learned model. import_attested(module) / callee_attested(name) consult the fitted voice model — your own git history as the allowlist. “Flag any date library this repo has never used” needs no hardcoded list and never goes stale: the repo’s history is the list.

Severity, suppression, output — identical to a built-in

A custom finding’s internal reason is custom:<name>, but every user-facing surface treats it exactly like a built-in rule:

See Configure for the full severity and suppression reference — nothing here is a new mechanism.

The optional pre-write hook consults the same effective [rules] policy, but it does not execute custom rules and has no custom-rule-specific configuration.

One addition worth knowing: a custom rule can be locked"domain-imports-stay-inward" = { severity = "error", locked = true } in the committed argot.toml. A locked rule’s findings refuse every suppression surface, and a diff that edits the rule’s own script or manifest fires rule-tampered (error, unsuppressable) — so an agent can’t “fix” a failing check by rewriting the rule that caught it. See Locked rules.

Sandbox guarantees

The script runs in a stripped-down Rhai engine, not a general-purpose scripting environment:

Two things that will bite you

trim(), replace() and friends mutate in place and return (). This is Rhai, not Rust:

let t = line.to_lower().trim();   // t is (), and every later call on it fails
let t = line.to_lower(); t.trim(); // what you meant

A cross-file rule costs real operations. The sandbox budget grows with the changed file but remains finite, and a per-character scan can still hit it. Work line by line, and reach for contains / index_of (one native op) over interpreted loops. When a rule trips a cap, Argot says so on stderr; do not read the absence of a finding as a clean result until that diagnostic is understood.

The argot rules test harness

Fixtures live inside the rule directory, one subdirectory per case:

.argot/rules/domain-imports-stay-inward/tests/
  fires-on-infra-import/
    input.ts           # the file the rule runs over — the whole file is one hunk
    expected.json      # [{"line": 1, "message": "domain imports infrastructure — …"}]
  silent-on-port/
    input.ts
    expected.json      # []

input.<ext> picks the case’s language from its extension; expected.json is the exact list of {line, message} pairs the script should (or, for a silent case, shouldn’t) report — compared order-independently.

argot rules test              # every discovered rule, every case
argot rules test domain-imports-stay-inward     # one rule

Exit codes: 0 every case passed, 1 at least one failure, 2 a setup problem (an unknown rule name, a script that fails to compile, or no tests/ directory at all — add one case before shipping the rule).

An optional old.<ext> sibling in a case directory supplies the pre-image for file.old_text / ts_query_old (absent = the rule sees an added file).

Because the harness has no fitted model, import_attested/callee_attested return false in every fixture — write a case for the unattested branch here, and rely on a real argot check run to exercise the attested one live.