Enforce your team's code rules deterministically. Rules live in a YAML file, violations come back with a reason and a fix so both humans and AI agents can act on them.
Supports Python, JavaScript, TypeScript, Rust, Go, HTML, shell and CSS, plus JSON, YAML and TOML data files.
cargo install --path .In a project without a config, tvl init writes an empty tvlint.yaml to fill in. Then run from the directory that contains tvlint.yaml:
tvltvl [OPTIONS] [COMMAND]
| Command | What it does |
|---|---|
check [PATH]... |
Checks the project against all rules and prints every violation. This is the default when no command is given, so tvl and tvl check are the same. Pass files, directories or glob patterns to check only those (see below). |
init [--force] |
Scaffolds an empty tvlint.yaml: no rules, only a commented example and the commands to run next. Refuses to overwrite an existing file unless --force is given. |
list |
Lists all configured rules with their language and the number of scopes that use them. |
explain <rule> |
Shows why a rule exists, the fix, what it forbids, and which paths it applies to. |
validate |
Checks the config file and explains every problem it finds, with the path to the key and how to fix it. Also tells you how many files each rule will run on. Use it after writing or changing a rule. |
schema |
Prints the JSON Schema of the config file (also kept in tvlint.schema.json). Point your editor at it for completion and inline errors, or give it to an AI agent that writes rules. |
help [command] |
Prints help for TVLint or for one command. |
tvl check takes any number of files, directories or glob patterns, relative to the directory you run it from. Without any, it checks the whole project.
tvl check src/api.ts # one file
tvl check src/routes src/hooks # directories: everything below them
tvl check "src/**/*.test.ts" # a glob: quote it so tvl expands it, not the shell
tvl check -c frontend/tvlint.yaml frontend/src/App.tsx # from the repo root, as a pre-commit hook does- A file is still only checked by the rules whose scope includes it. Passing a path narrows what runs, it never widens a scope.
- Paths must be inside the project, the directory of the config file, and must exist, otherwise TVLint exits with
2. A glob that matches nothing is a warning on stderr. Files ignored by.gitignoreare skipped, as always. - Everything is still parsed, so rules that follow imports or class hierarchies see the same project as in a full run, but only the selected files are reported on.
- With
--staged, a file must be both staged and selected.
| Flag | What it does |
|---|---|
--staged |
check only. Checks only the files staged in git, using their staged contents, so unstaged edits are ignored. Meant for pre-commit hooks. Needs a git repository (exit 2 otherwise). Only staged files under the project root are considered. Combined with PATH arguments, a file must be both staged and selected. |
--format <FORMAT> |
check only. text (default) is for people. json prints one document with violations (each with rule, file, line, column, message, rationale and fix) and a summary, for scripts and AI agents. sarif prints SARIF 2.1.0 for GitHub code scanning and editors; the fix goes in the rule's help and the result message. With json or sarif, stdout holds only the document (warnings go to stderr), and the exit code is the same: 1 when there are violations. |
-c, --config <path> |
Path to the config file. Defaults to tvlint.yaml in the current directory. The project root is the directory containing this file, and scope paths are relative to it. Works with every command. |
-h, --help |
Prints help. |
-V, --version |
Prints the version. |
tvl validate is meant for people and AI agents that add rules. It reports all problems in one pass, not just the first.
- Errors make the config invalid (exit
1): unknown or misspelled keys (with a "did you mean" suggestion), missinglanguage,rationaleorfix, wrong types, a rule with no checks, invalid globs, scopes that name a rule that doesn't exist, bad YAML syntax. - Warnings don't fail validation (exit
0) but usually mean a mistake: a rule no scope uses, a scope that matches no files for the rule's language, asubclass_ofclass that isn't defined in the project. They are only reported once there are no errors. - When valid, it lists each rule with what it forbids and how many files it runs on. A rule that runs on
0 file(s)is almost always a wrong glob.
tvlint.schema.json describes the config file: keys, types, which languages each check supports, and which keys need each other. Add this as the first line of tvlint.yaml and editors with the YAML language server (VS Code, JetBrains, Neovim) complete keys and flag mistakes as you type:
# yaml-language-server: $schema=./tvlint.schema.jsontvl schema prints the same schema, so it works from an installed binary: tvl schema > tvlint.schema.json. The schema cannot check that scopes name existing rules, that globs are valid, or that a scope matches any files; tvl validate does.
| Code | Meaning |
|---|---|
0 |
No violations. |
1 |
At least one violation was found (check), or the config is invalid (validate). |
2 |
TVLint could not run: missing or invalid config, unknown rule, bad glob. The message goes to stderr. |
Violations go to stdout, one per rule hit:
backend/src/auth/router/save_attribute.py:39:13: error[no-http-exceptions] raising `ValueError` is forbidden
why: One global handler produces every error body. Ad-hoc responses bypass the code system.
fix: Raise a ExampleException subclass. A new HTTP status needs a new subclass, not an inline response.
| Language | Files |
|---|---|
python |
.py |
javascript |
.js, .jsx, .mjs, .cjs |
typescript |
.ts, .tsx, .mts, .cts |
rust |
.rs |
go |
.go |
html |
.html, .htm |
shell |
.sh, .bash |
css |
.css |
json |
.json, .jsonc |
yaml |
.yaml, .yml |
toml |
.toml |
Rules are written once and apply to every language they name, for example language: [javascript, typescript] for a rule on a mixed codebase. Each language has an adapter that maps its syntax tree to the same few questions the rules ask (what is thrown, what is called, what is imported). Not every language can express every check: Rust and Go have no exceptions, so they have no forbid_raise or forbid_catch, and only JavaScript and TypeScript have object-literal checks. HTML is markup, so it has no calls or imports: it supports forbid_element (tag names, case-insensitive), forbid_property (attribute names) and property_value with forbid_literal (attributes that carry a value), plus require_filename. Code inside <script> and <style> is not parsed. Shell scripts have commands and source: forbid_call matches the command name as written (curl, sudo, ./deploy.sh; a name built from an expansion such as $CMD is ?), forbid_import matches the file given to source or ., and literal_argument reads a command's words. Only bare and quoted words without expansions have a value; shell has no forbid_raise. CSS supports forbid_call (functions in values, lowercased: rgb, calc, url), forbid_import (@import; a bare name is relative to the stylesheet, a URL stays as written) and forbid_property (declaration names such as float; custom properties keep their case). Selectors, !important and declaration values are not checked. tvl validate says so when a rule asks a language for something it cannot do.
JSON, YAML and TOML are data, not code: they are checked by key path (see Data files), and a rule cannot mix them with code languages. Hidden directories such as .github/ are read; .git and anything in .gitignore are not.
tvl check --format json > tvl-report.json
tvl check --format sarif > tvl.sarifTo show violations as annotations on pull requests, upload the SARIF file with GitHub's github/codeql-action/upload-sarif step.
A job that runs tvl check fails when there are violations (exit code 1), so the workflow is green or red on its own:
name: TVLint
on:
push:
branches: [main]
pull_request:
jobs:
tvl:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: tvl check # install tvl first, see InstallGitHub draws a pass/fail badge for any workflow, so there is nothing to host. Put this in your README, replacing OWNER/REPO and the file name (tvlint.yml) with your own:
[](https://github.com/OWNER/REPO/actions/workflows/tvlint.yml)It shows the state of the latest run on the default branch. Add ?branch=main to the image URL to pin a branch, or ?event=push to ignore pull request runs.
For JavaScript and TypeScript, forbid_import matches an aliased import by the project path it leads to. TVLint reads the aliases from the files your compiler already uses, without running tsc or Node:
- The nearest
tsconfig.json(orjsconfig.json) above the importing file decides, as in TypeScript. If both are in one directory,tsconfig.jsonis used. compilerOptions.pathsandbaseUrlare read, withextendsfollowed (relative files, and packages innode_modules). A child'spathsreplaces its parent's. A pattern with several targets uses the first one that exists.- A config whose
pathslive in a referenced file ("references": [{ "path": "./tsconfig.app.json" }], as in Vite projects) also gets the referenced file's aliases. - Comments and trailing commas are fine.
- A config that cannot be read, an
extendsthat does not exist or loops, or invalid JSON stopstvl checkwith exit code 2 and names the file. This is only checked when a rule hasforbid_importfor JavaScript or TypeScript. - Aliases written in
tvlint.yaml(aliases) win over the tsconfig ones. Bundler-only aliases (Vite, webpack, Jest) live in JavaScript and cannot be read, so list them there.
Python and Go imports are matched by the project path they lead to, using the files those ecosystems already keep. Nothing is run.
- Go:
go.modgives the module path. Withmodule github.com/acme/app, the importgithub.com/acme/app/internal/dbresolves to the directoryinternal/db(a nestedgo.modwins for its own directory). Packages are directories, so list bothinternal/dbandinternal/db/**. Standard library and third-party imports stay as written (fmt,github.com/lib/pq). Ago.modwithout amoduleline stopstvl checkwith exit code 2. - Python:
import a.b as cisa.b, andfrom a.b import c, disa.b.canda.b.d, as with Rust'suse a::{b, c};from a import *isa.*. List a package and its names (sqlalchemy,sqlalchemy.**). Patterns may use the dotted name (app.db.**) or the project path it resolves to (src/app/db/**). Absolute imports start at the source roots of the nearestpyproject.toml:tool.setuptools.packages.find.where,tool.setuptools.package-dir,tool.poetry.packages[].from,tool.pytest.ini_options.pythonpathand the folder oftool.hatch.build.targets.wheel.packages, plus the directory of thepyproject.tomland itssrc/folder. Relative imports (from . import x,from ..p import y) start at the importing file.a.b.calso findsa/b.pywhencis a name inside it. Anything that does not lead to a project file is a third-party package and stays as written.
Two comments switch checks off. They work the same in Python, JavaScript and TypeScript, and are read from real comments, so the same words inside a string do nothing.
| Comment | Effect |
|---|---|
tvlint-ignore-next-line |
Hides every violation reported on the line directly after the comment. |
tvlint-ignore-file |
On line 1 of the file, excludes the whole file from every rule, file-name rules included. |
# tvlint-ignore-next-line: legacy endpoint, see ISSUE-123
raise ValueError("not found"){/* tvlint-ignore-next-line */}
<button onClick={open}>Open</button>// tvlint-ignore-file: generated code- Use the comment style of the language:
#in Python and shell;//or/* */in JavaScript and TypeScript, and{/* */}inside JSX;<!-- -->in HTML;/* */in CSS. - The directive must start the comment. A reason may follow it (
: why), and is a good idea, but is not required. - Next line means the line right after the comment's last line. A violation is reported on the line where the flagged code starts, so for a statement spanning several lines, put the comment above its first line. A trailing comment (
code # tvlint-ignore-next-line) hides the next line, not its own. - File needs the comment to be the first thing on line 1. A shebang line or a blank line before it means it does not count. Every language can carry it, but a shell script's shebang already takes line 1, so use
tvlint-ignore-next-linethere. - An ignored file is still read to learn its classes, so other files'
forbid_catchresults do not change when a file opts out. - Nothing is silent: the summary line says how many violations were hidden and how many files were skipped, e.g.
tvl: 7 violation(s) in 4 file(s) [5 hidden by tvlint-ignore-next-line, 3 file(s) skipped by tvlint-ignore-file].
JSON, YAML and TOML files are read into one model of maps, lists and values, so the same rule works whichever format a file uses. Rules address a value by key path, anchored at the root of the file:
| Path | Matches |
|---|---|
scripts.postinstall |
the postinstall key inside scripts |
jobs.*.runs-on |
runs-on in every job. * is any one key or list index |
jobs.*.steps.*.uses |
uses in every step of every job (list items are addressed by index) |
**.version |
version at any depth, including the top level |
dependencies.@types/* |
a * or ? inside a segment is a glob on that key |
"a.b".c |
a key that contains a dot goes in double quotes |
forbid_propertyreports the key where it is written.require_propertysplits the path into the container and the key:jobs.*.timeout-minutesmeans "every job hastimeout-minutes". A container that does not exist (permissions.contentswhen there is nopermissions) is reported once for the file; a wildcard container that matches nothing is fine. The last segment must be a key name. In a multi-document YAML file every document is checked.property_valuelooks at the scalar values at thenamespaths, withforbid_value: [globs](none may match) and/orrequire_value: [globs](one must match). Values are plain text, sotrue,"true"and1.0are just the text of the value, and quotes and escapes are removed. Lists and mappings are skipped. Values are globs, where*matches anything: write a literal star as[*](forbid_value: ["[*]"]forbids a wildcard version).same_keys_ascompares the keys of every in-scope file with a reference file (usually the source-language translation):a.bmust exist in both. Lists count as values. Addsame_placeholders: trueto also compare the{{name}},{name}and ICU{count, plural, ...}names in each message.- Comments work as in code: in YAML and TOML,
# tvlint-ignore-next-linehides the violations of the next line, and# tvlint-ignore-fileon line 1 skips the file. JSON has no comment syntax of its own, but the parser accepts//and/* */comments, so a directive works in.jsonand.jsoncfiles alike (tools that read the file strictly will reject them). - YAML aliases (
*name) and merge keys (<<) are not resolved; an alias is a value whose text is*name.
rules:
workflow-jobs-have-timeouts:
language: yaml
require_property: ["jobs.*.timeout-minutes"]
rationale: A hung job runs for six hours.
fix: Add timeout-minutes to the job.
pinned-npm-versions:
language: json
property_value:
names: ["dependencies.*", "devDependencies.*"]
forbid_value: ["^*", "~*", latest]
rationale: Ranges make installs differ between machines.
fix: Pin the exact version.
locale-files-match:
language: json
same_keys_as: src/locales/en.json
same_placeholders: true
rationale: A missing key renders as a raw key in the UI.
fix: Add the missing keys, translated.
scopes:
- rules: [workflow-jobs-have-timeouts]
include: [".github/workflows/*.yml"]
- rules: [pinned-npm-versions]
include: ["package.json"]
- rules: [locale-files-match]
include: ["src/locales/*.json"]literal_argument ties code to a data file: string literals passed to certain calls must be entries of a registry.
rules:
error-codes-are-registered:
language: python
literal_argument:
calls: ["*Exception"] # patterns, as in forbid_call
position: 0 # which argument, counting from 0 (default 0)
registry:
file: backend/error_codes.json # relative to the config
path: "codes.*" # key path selecting the entries
use: value # what an entry is called: value (default), key or path
report_unused: true # also report entries that no call uses
rationale: The frontend translates errors by code.
fix: Add the code to backend/error_codes.json.use: valuetakes the scalar values atpath,use: keytheir keys, anduse: paththe full dotted path of each scalar, sot("menu.home")matches{"menu": {"home": "Home"}}withpath: "**".- Only plain string literals are checked: an argument that is a variable, an f-string or a template with
${}is skipped, as is a call with a spread before the argument. report_unusedreports each entry that no call in the rule's scope passes, in the registry file, at the entry. With--stagedorPATHarguments the usage is still collected from the whole scope, and the entries are reported only if the registry file is selected.- The registry file must exist and be JSON, YAML or TOML (
validatechecks, and warns whenpathselects nothing).
The registry is optional. The same block can check the shape of the literal, or that it is a literal at all:
rules:
error-codes-have-a-shape:
language: python
literal_argument:
calls: ["*Exception"]
pattern: "[a-z]*_failed.[a-z]*" # globs; the literal must match one
require_literal: true # a variable or f-string is reported instead of skipped
rationale: The code is read statically.
fix: Pass a literal "<action>_failed.<reason>".
error-prefixes-are-translated:
language: python
literal_argument:
calls: ["*Exception"]
registry: { file: frontend/src/locales/en.json, path: "*", use: key }
lookup: prefix # look up "contract_creation_failed", the part before the first dot
rationale: The toast title is the translated prefix.
fix: Translate the prefix in every locale.
event-types-are-registered:
language: python
literal_assignment: # the same checks for values assigned to names
names: [event_type]
registry: { file: event_types.json, path: "*" }
report_unused: true
rationale: Event types are stored forever, so each one is written down.
fix: Add the event type to event_types.json.patternglobs match the whole literal.lookup: prefixtakes the part before the first dot (the whole literal if it has none). Without a registry,report_unusedandlookupare errors.literal_assignmentwatchesnames, globs on the assignment target as written (event_type,self.*). Declarations count (event_type: ClassVar[str] = "...",const KIND = "...").
Some rules are about one statement or expression: what is assigned, what a string is compared with, which keywords a call passes, what is raised. Each takes within_function and except_function like forbid_call.
rules:
state-changes-only-in-apply:
language: python
forbid_assignment: ["aggregate.*", "asset.*"]
within_function: ["*"]
except_function: [apply]
rationale: State changes only through DomainEvent.apply().
fix: Emit an event and change the state in its apply().
no-in-place-updates-in-apply:
language: python
forbid_augmented_assignment: ["aggregate.*"] # += is not idempotent when an event is delivered twice
within_function: [apply]
rationale: Events are delivered at least once, so apply() must give the same result when it runs twice.
fix: Assign the final value, computed from the event.
enums-not-strings:
language: python
forbid_literal:
compared: true # if x.status == "active"
assigned_to: ["[A-Z]*"] # STATUS_ACTIVE = "active"
rationale: A typo in a string is silent.
fix: Use an Enum or a Literal type.
load-checks-the-tenant:
language: python
require_argument: { calls: ["*.from_aggregate_id"], names: [account_id] }
rationale: A command must only load aggregates of the caller's account.
fix: Pass account_id=account_id.
error-parameters-are-for-people:
language: python
argument_keys: { calls: ["*Exception"], position: 1, forbid_keys: ["*_id", "*[A-Z]*"] }
rationale: Parameters are shown to the user in a translated message.
fix: Pass names and titles in snake_case, never ids.
commands-raise-locus-exceptions:
language: python
allow_raise: { subclass_of: LocusException }
rationale: The global handler turns a LocusException into the standard error body.
fix: Raise a LocusException subclass with a code.- Targets are matched as written.
aggregate.name = xisaggregate.name,a.items[0] = xisa.items[], and a tuple target (a, b = ...) is not checked. A name that is not a plain identifier, attribute or index is?. forbid_assignmentalso reports+=;forbid_augmented_assignmentis for rules that only mind in-place updates. Annotated names without a value (x: int) assign nothing.- Comparisons look only at
==,!=,===and!==.<,inand f-strings are left alone. - Keyword arguments exist in Python only.
argument_keysreads dict literals in Python and object literals in JavaScript and TypeScript; it skips keys that are not plain strings and a dict built elsewhere. Withkeyword: parametersit looks at that keyword argument instead of a position. allow_raisefollowssubclass_ofthrough the project's own classes, likein_class.
Some rules are about what a class declares or what a function does, not about what a file imports. Python, JavaScript and TypeScript can express them; the rule reads the same in each.
rules:
events-declare-apply-and-describe:
language: python
require_method: [apply, describe] # a glob: "{apply,create}" accepts either
forbid_field: [author, account_id] # envelope data does not belong on the event
in_class: { subclass_of: DomainEvent } # DomainEvent[Agg] counts; DomainEvent itself does not
rationale: Events are replayed forever and shown in the history list.
fix: Declare apply() and describe() on the event, and keep envelope data on the Event row.
commands-emit-events:
language: python
require_call: [apply_event, "*.apply_event", create_aggregate]
within_function: ["*"] # every function in scope ...
except_function: ["_*"] # ... except the private ones
rationale: A command changes data by emitting an event.
fix: End the command with apply_event or create_aggregate.
no-clock-in-apply:
language: python
forbid_call: ["date.today", "datetime.now"]
within_function: [apply] # only hits inside apply() count
rationale: apply() replays old events and must give the same result every time.
fix: Read the clock in the command and put the value on the event.- Classes are found by name across the project, like
forbid_catch.subclass_offollows inheritance through the project's own classes, and Python and TypeScript keep separate hierarchies (JavaScript and TypeScript share one). A base written with type arguments (Base[T],Base<T>) is stillBase. - Members are what the class body declares itself. A method inherited from a base class does not satisfy
require_method, so a base class that definesapplydoes not hide a subclass that forgot to. - Fields are annotated or assigned names in the class body (
x: int,x = 1). Instance attributes set in__init__are not fields. - Functions are
defs and methods, and in JavaScript and TypeScript alsoconst f = () => .... Anonymous functions are not named, so a hit inside one belongs to the function around it. require_callcounts calls anywhere in the function body, nested blocks and nested functions included. It does not follow calls into other functions.- A class is reported on its
classline and a function on itsdefline, sotvlint-ignore-next-linegoes above that line.
Some rules are about how a file is organised: how many things it defines, and how it exposes them. Nothing here is specific to a framework. A React component is just a capitalised function that contains JSX.
rules:
one-component-per-file:
language: [javascript, typescript]
max_functions: { names: "[A-Z]*", containing_element: "*", max: 1 }
rationale: A file with one component is easy to find and to test.
fix: Move the extra component into a file of its own.
components-use-default-export:
language: [javascript, typescript]
forbid_export: { names: "[A-Z]*", declared: true } # no `export const Foo` or `export function Foo`
require_export: { default: true, names: "[A-Z]*" } # but `export default Foo;` at the end
rationale: One way of exporting makes imports predictable.
fix: Declare the component, then write `export default ComponentName;`.max_functionscounts functions at the top level of the file (not inside another function or class) whose name matchesnames, and, withcontaining_element, whose body contains a matching JSX element. The ones beyondmaxare reported, each on its own line.- Exports are read in JavaScript and TypeScript only.
export const a = 1, b = 2exports two names,export { A as B }exportsB,export { A as default }exportsAas the default, andexport * from "m"exports*.export defaultof something that is not a plain name (export default memo(Foo)) is named by the expression as written.export interfaceandexport typeare types and are left alone. declared: truelimitsforbid_exportto exports that declare what they export.export default Foo;andexport { Foo }name something declared elsewhere and stay allowed.- Index files and tests that re-export are left out with the scope's
exclude.
forbid_import and except_import may use {1}, {2}, ... for what the wildcard segments of the scope's include pattern matched, in order. This lets one rule say "a package may import itself" instead of one scope for every package:
rules:
subscriptions-use-their-own-commands:
language: python
forbid_import: ["src/*/commands/**"]
except_import: ["src/{1}/commands/**"] # {1} is the package in the include pattern
rationale: A subscription lives with the aggregate that reacts to the event.
fix: Move it to the package of the aggregate it acts on.
scopes:
- rules: [subscriptions-use-their-own-commands]
include: ["src/*/subscriptions/*.py"] # the first * fills {1}For src/assets/subscriptions/media_links.py, {1} is assets, so importing src/assets/commands/** is fine and src/contracts/commands/** is reported.
- Each segment of the
includepattern that has a*,?,[..]or{..}in it fills one placeholder, with the whole path segment it matched (*.pycaptures the file name, extension included).**captures nothing. - A scope with several
includepatterns uses the first one that matches the file. If a rule is in several scopes, the first scope that selects the file counts. - A rule that uses
{2}in a scope whoseincludehas only one wildcard segment is an error when the config loads. - Only whole numbers in braces are placeholders, so
{a,b}alternation still works.
Looking for rules to start from? See the cookbook.
tvlint.yaml has two sections: rules says what is forbidden, scopes says which files each rule runs on.
rules:
no-http-exceptions:
language: python
forbid_raise: [HTTPException, ValueError]
forbid_catch: { subclass_of: ExampleException }
rationale: One global handler produces every error body.
fix: Raise a ExampleException subclass.
scopes:
- rules: [no-http-exceptions]
include: ["backend/src/*/router/**"]
exclude: ["backend/src/tests/**"]| Key | Meaning |
|---|---|
language |
python, javascript, typescript, rust, json, yaml or toml, or a list such as [javascript, typescript]. Limits the rule to files of those languages. Required with every check except require_filename. Optional with require_filename, where omitting it applies the rule to every file type. |
forbid_raise |
Python, JavaScript and TypeScript. Exception names that may not be raised (throw in JavaScript and TypeScript, where throw new Foo() names Foo). Matches the full dotted name or its last part, so HTTPException also matches fastapi.HTTPException. Bare raise (re-raise) is allowed. |
forbid_call |
Call patterns that are forbidden. * matches anything, so *.one matches session.exec(q).one() and console.* matches console.log(...). In JavaScript and TypeScript, new Foo() counts as a call to Foo. In Rust, functions are written with :: (std::fs::read, Vec::new), methods with . (*.unwrap matches s.parse::<i32>().unwrap()), and macros with their ! (println!); calls inside a macro's arguments are not seen. Imports and aliases are not resolved. |
forbid_catch |
Python only, because JavaScript and TypeScript catch clauses have no exception type. subclass_of: Name forbids except clauses that catch Name or any class inheriting from it. Subclasses are found by class name across the whole project, not by import path. |
forbid_import |
Python, JavaScript, TypeScript, Rust, Go, shell and CSS. Module specifiers that may not be imported (import, export ... from, import(), require(); in Rust use and extern crate, where use a::{b, c} counts as a::b and a::c, and self:: and super:: are resolved to crate:: paths). Relative imports and imports through an alias are matched by their project path (src/hooks/use-mobile.ts), anything else by the specifier as written (@base-ui/react/dialog). Patterns are path globs: * stays within one segment, ** crosses segments, so list a package and its subpaths (react-aria-components, react-aria-components/**). Rust patterns are written with :: (sqlx::**, crate::db::**). |
except_import |
Imports that are fine although they match forbid_import. Together they express "this folder may only import from here": forbid_import: ["src/**"] with except_import: ["src/components/ui/**"]. Both take placeholders such as {1}. |
forbid_element |
JavaScript, TypeScript and HTML. JSX element or HTML tag names that may not be used, e.g. [button, textarea]. JSX is matched as written, so button does not match Button; HTML tag and attribute names are lowercased first. |
forbid_property |
JavaScript and TypeScript: object-literal property names that may not appear ({ onError: ... }). HTML: attribute names such as onclick or style. JSX attributes such as <img onError={...}> are not properties. JSON, YAML and TOML: key paths that may not exist, e.g. scripts.postinstall (key paths). |
property_value |
JavaScript and TypeScript: constraints on the value of object-literal properties: names (required), then forbid_literal: true (the value may not be a string literal) and/or array_starts_with: accountID (an array-literal value must start with that element; other values are not checked). in_call: toast.add limits it to objects passed directly to that call. JSON, YAML and TOML: names are key paths, with forbid_value and/or require_value (globs, see Data files). |
require_property |
JSON, YAML and TOML. Key paths that must exist, e.g. jobs.*.timeout-minutes: every place the parent path (jobs.*) matches must contain the key. |
same_keys_as |
JSON, YAML and TOML. Path to a reference file. Every file in the rule's scope must have exactly its keys: missing and extra keys are reported. The reference itself is skipped. |
same_placeholders |
JSON, YAML and TOML. true also requires each shared message to use the same {{name}} / {name} placeholders as the reference. Needs same_keys_as. |
literal_argument |
Python, JavaScript, TypeScript and Rust. String literals passed to calls must be listed in a data file and/or have a shape, see Code that must match a data file. |
literal_assignment |
Python, JavaScript and TypeScript. The same checks for string literals assigned to names (event_type = "asset.renamed"): names instead of calls. A rule has one or the other. |
forbid_assignment |
Python, JavaScript and TypeScript. Target globs that may not be assigned, matched on the target as written: aggregate.name, self.items[] ([] is any index). += counts. Declarations (const x = 1) do not. See Statements and expressions. |
forbid_augmented_assignment |
Python, JavaScript and TypeScript. Target globs that may not be changed in place with +=, -= and the like. |
forbid_literal |
Python, JavaScript and TypeScript. compared: true forbids a string literal on either side of ==, !=, === and !==; assigned_to: [globs] forbids assigning one to a matching name, e.g. [A-Z]* for constants. |
require_argument / forbid_argument |
Python. calls and names: every matching call must (or must not) pass these keyword arguments. A call with **kwargs is skipped by require_argument. |
argument_keys |
Python, JavaScript and TypeScript. calls, position (or keyword) and forbid_keys / allow_keys: the keys of a dict or object literal passed to a call. |
allow_raise |
Python, JavaScript and TypeScript. names and/or subclass_of: nothing else may be raised. A name that does not start with a capital is a variable (raise error) and is left alone; a bare re-raise is fine. |
within_property |
JavaScript and TypeScript. Narrows forbid_call, forbid_raise and forbid_element to hits that sit inside an object-literal property with one of these names, e.g. onError. |
require_call |
Python, JavaScript, TypeScript, Rust, Go and shell. Call patterns, written as in forbid_call: at least one must be called in each file in scope, or, with within_function, in each function it selects. A file without one is reported on line 1, a function on its def line. See Classes and functions. |
require_method |
Python, JavaScript and TypeScript. Method name globs that every selected class must declare in its own body. Inherited methods do not count. |
require_field |
Python, JavaScript and TypeScript. Field name globs that every selected class must declare in its own body: annotated or assigned names such as x: int or x = 1 (a TypeScript x: T or a class field x = 1). |
forbid_field |
Python, JavaScript and TypeScript. Field name globs that no selected class may declare. Reported at the field. |
in_class |
Python, JavaScript and TypeScript. Which classes require_method, require_field and forbid_field check: subclass_of: Name (inheriting from it, directly or not; the class Name itself is left out) and/or names: [globs]. Without it, every class. |
max_functions |
Python, JavaScript and TypeScript. max, and optionally names and containing_element (JavaScript and TypeScript): a file may define at most max top-level functions that match. Each one beyond the limit is reported. See Definitions and exports. |
forbid_export |
JavaScript and TypeScript. names, declared: true and default: true or false: exports that may not exist. |
require_export |
JavaScript and TypeScript. names and/or default: true: every file in scope needs such an export, or is reported on line 1. |
within_function |
Python, JavaScript and TypeScript. Function name globs. forbid_call and forbid_raise only count inside such a function (nested functions included), and require_call is checked per function instead of per file. |
except_function |
Python, JavaScript and TypeScript. Function name globs that within_function leaves out, e.g. _*. Needs within_function. |
max_lines |
Any language. The most lines a file may have (a positive whole number). A longer file is reported once, on the first line over the limit, with the file's total. tvlint-ignore-file skips it. |
require_filename |
List of file name globs, matched against the file's base name only (not its directory). Every file in the rule's scope must match at least one, otherwise it is a violation reported at line 1. Applies to all files in scope, not just parseable ones, so data.json or helpers.py in a tests/ scope are flagged too. |
rationale |
Required. Why the rule exists. Printed with every violation. |
fix |
Required. How to comply. Printed with every violation. |
A rule needs at least one check, such as forbid_raise, forbid_call, require_call, require_method or require_filename. Unknown keys are rejected.
| Key | Meaning |
|---|---|
rules |
Required. The rules, by id. An id is lowercase words joined by dashes, like no-direct-db-access: no slashes, capitals, digits or underscores. |
scopes |
Required. Which files each rule runs on. |
aliases |
Optional. Import prefixes that stand for a project path: "@/": "src/" turns @/api into src/api. You rarely need it: forbid_import rules read paths, baseUrl, extends and references from the nearest tsconfig.json or jsconfig.json (see Import aliases). aliases is for what those files cannot say, such as a bundler-only alias, and wins over them. The prefix and the path must both end with /, or neither. |
| Key | Meaning |
|---|---|
rules |
Rule ids that run on the matched files. |
include |
Glob patterns relative to the project root. * stays within one directory, ** crosses directories. |
exclude |
Optional globs to leave out. |
A rule that appears in no scope never runs. Files ignored by .gitignore are skipped.
Any directory below the project root may hold its own tvlint.yaml, for a package in a monorepo or a team that owns one corner of the code:
tvlint.yaml # rules for the whole repository
services/billing/
tvlint.yaml # rules for services/billing/ only
error_codes.json
tvl check finds them on its own; nothing needs to be listed in the root config.
- Rules add up. The root config keeps applying everywhere. A nested config adds rules that run on its own directory and below, never outside it. It cannot switch off or change a rule from above.
- Paths are relative to the config that contains them:
includeandexcludein scopes,same_keys_as,registry.file, the targets ofaliases, and project paths inforbid_importandexcept_import(a path outside the directory is written../shared/**). Violations are still reported relative to the project root. - Aliases are inherited. A nested config sees the aliases of the configs above it, and its own win on the same prefix. A rule uses the aliases of the config it is written in.
- Rule ids only need to be unique within one config. Outside the root config a rule is reported with its directory in front:
no-printinservices/billing/tvlint.yamlisservices/billing/no-printin violations,tvl listandtvl explain. Inside the file, scopes still use the short id. - A
tvlint.yamlinside a gitignored directory is not found.tvl validatechecks every config, each against its own directory, andtvl explainsays which file a rule comes from. - The root is the config you point at (
-c, default./tvlint.yaml). Runningtvlfrom a subdirectory with no config of its own does not look upward for one.
AGPL-3.0-only. See LICENSE. To contribute, see CONTRIBUTING.md; contributors sign a CLA so the maintainer can also offer TVLint under other terms.