flakeforge
Reusable AST-based Python lint rules with a standalone CLI, a thin Flake8 adapter, explicit pytest helpers, and first-class custom-rule support.
What it is
flakeforge packages the copied flake8_project_rules behavior as a reusable distribution:
- standalone
flakeforge check - shared Python API
- built-in X001–X015 rules, all enabled by default
- project-local and installed custom rules
- opt-in pytest helper
- thin Flake8 integration over the same core engine
Installation
pip install flakeforge
For local development:
pip install -e .[dev]
Basic CLI usage
flakeforge --version
flakeforge check
flakeforge check .
flakeforge check src tests
flakeforge check path/to/file.py
flakeforge check --select X002 --ignore X011 src
flakeforge check --output-format json .
flakeforge check --rule-module my_project.lint_rules .
flakeforge check --no-rule-plugins .
Exit codes:
0= completed, no violations1= completed, violations found2= invalid config, invalid invocation, or tool failure
Inspecting the effective config
flakeforge config show prints the fully resolved, validated configuration and
where each value came from, without linting anything. It accepts the same
config-selection, path-anchor and override flags as check (--config /
--no-config, positional paths, --select, --ignore, and so on), so it reports
exactly what check would use:
flakeforge config show # discovered config, anchored on cwd
flakeforge config show path/to/proj # anchored on the target, run from anywhere
flakeforge config show --no-config . # what the built-in defaults are
flakeforge config show --select X002 src # see a CLI override take effect
flakeforge config show --output-format json . # stable, sort_keys JSON document
The report names the source file and section (or defaults), the discovery
anchor, and base_dir, then lists every config key with its effective value and
its origin — default, file, or cli. Legacy-section and shadowed-file
warnings, and an invalid config (exit 2), surface exactly as they do for
check. Only text (default) and json render the report; github / sarif
are annotation formats for findings and are rejected here (exit 2). Note that
output_format is itself a reported key: a file's output_format = "github"
shows up as data with origin file while the report still renders as text.
Both
config showandrulesresolve the registry the waycheckdoes, so they import the project'srule_modules. Use--no-configon untrusted checkouts; see the trust boundary.
Listing the resolved rules
flakeforge rules lists every rule in the resolved registry — its code, short
description, provider origin, and whether it is enabled under the effective
select / ignore — without linting anything. It accepts the same
config-selection, path-anchor and override flags as check, and resolves the
registry the same way (project-local rule_modules and installed
flakeforge.rules providers included), so what it lists is what check would
run:
flakeforge rules # every registered rule, enabled state included
flakeforge rules --select X002 # see which rules a selector leaves enabled
flakeforge rules --no-rule-plugins . # hide installed entry-point providers
flakeforge rules --no-config . # built-ins only, ignoring project rule modules
flakeforge rules --output-format json # stable, sort_keys JSON document
Each rule's origin is one of builtin, rule_module:<name> for a project-local
module, or entry_point:<dist> for an installed provider. Rows are ordered by
code. Only text (default) and json render the report; the github / sarif
annotation formats are rejected (exit 2), and an invalid config exits 2,
exactly as for check.
Writing a starter config
flakeforge init writes a fully commented starter config with every key at its
default, so you can delete what you do not need and edit the rest:
flakeforge init # write ./flakeforge.toml
flakeforge init path/to/proj # write path/to/proj/flakeforge.toml
flakeforge init --pyproject # append a [tool.flakeforge] table to ./pyproject.toml
Unlike check, config show, and rules, init does not take the
config-selection or path-anchor flags: it writes a fresh config rather than
resolving an existing one, so --config, --no-config, and the override flags
would have nothing to act on.
It never overwrites or duplicates (there is no --force): it exits 2 with a
message when the target flakeforge.toml already exists, when pyproject.toml
already defines [tool.flakeforge] or the legacy [tool.flake8_lint], or when a
same-directory file of the other surface would shadow the one being written (a
flakeforge.toml outranks a pyproject.toml section, C3). --pyproject creates
pyproject.toml if it is absent and otherwise preserves the existing content
byte-for-byte, appending the table after a separating blank line; a pyproject.toml
that is not valid TOML is refused rather than appended to. On success it prints the
written path and exits 0.
Standalone CLI
flakeforge runs as a fully standalone linter: point it at any target and, optionally,
pass a single TOML file that carries the same parameters as the command line. It does not
need to share the target project's virtualenv, and it does not need to be run from the
project root.
Config discovery is anchored on the target
Config discovery walks upward from the discovery anchor — the lint target — not from the
current working directory, so flakeforge check path/to/proj honours
path/to/proj/flakeforge.toml even when it is invoked from elsewhere:
- C3 — config-file selection.
--config PATHwins; otherwise--no-config(built-in defaults only); otherwise discovery. Within each directory, nearest first:flakeforge.toml>pyproject.toml [tool.flakeforge]>pyproject.toml [tool.flake8_lint](deprecated). The first directory with a match wins. - C4 — discovery anchor. No path arguments → the current directory. One path → that directory (a file counts as its parent directory). Several paths → their deepest common ancestor directory. Linting several unrelated projects in one run is out of scope.
A path argument that does not exist is an invalid invocation (exit code 2).
A config file that carries the CLI parameters
Every check flag has a TOML key and every TOML key has a flag, so one config file can
stand in for a long command line. For example, this CI profile:
# ci.toml
output_format = "json"
rule_plugins = false
select = ["X001", "X005"]
is equivalent to the flags:
flakeforge check --output-format json --no-rule-plugins --select X001,X005 src
Pass the file explicitly with --config, which wins over discovery:
flakeforge check --config ci.toml src
The full key/flag reference lives in Configuration.
Configuration
flakeforge reads its settings from a single config file. Both surfaces —
pyproject.toml [tool.flakeforge] and a standalone flakeforge.toml — share
one schema (contract C5): the same keys, parsed by the same code, so a
snippet can be copied verbatim between them. A flakeforge.toml may use either
flat top-level keys or a single [tool.flakeforge] wrapper table (not both).
# pyproject.toml
[tool.flakeforge]
include = []
exclude = []
select = []
ignore = []
allow_noqa = true
noqa_allowed = []
noqa_forbidden = []
per_file_ignores = {}
rule_modules = []
rule_plugins = true
output_format = "text"
# flakeforge.toml (same keys, no [tool.flakeforge] header needed)
include = []
exclude = []
select = []
ignore = []
allow_noqa = true
noqa_allowed = []
noqa_forbidden = []
per_file_ignores = {}
rule_modules = []
rule_plugins = true
output_format = "text"
include is a whitelist. exclude is a blacklist and wins. select is a
whitelist. ignore is a blacklist and wins. noqa_allowed and noqa_forbidden
are path filters for files allowed to use # noqa; noqa_forbidden wins.
Unknown keys are a config error (exit code 2) with a "did you mean" hint;
the deprecated [tool.flake8_lint] section is the one exception, where an
unknown key is only a warning.
Key reference
| CLI | TOML key | Type / default |
|---|---|---|
--select |
select |
list[str] / [] |
--ignore |
ignore |
list[str] / [] |
--include |
include |
list[str] / [] |
--exclude |
exclude |
list[str] / [] |
--noqa / --no-noqa |
allow_noqa |
bool / true |
--rule-module |
rule_modules |
list[str] / [] |
--rule-plugins / --no-rule-plugins |
rule_plugins |
bool / true |
--output-format |
output_format |
"text" / "json" / "github" / "sarif", default "text" |
--statistics / --no-statistics |
statistics |
bool / false |
--per-file-ignores GLOB:CODE[,CODE] |
per_file_ignores |
table {glob = [codes]} / {} |
--baseline PATH |
baseline |
str path / "" (none) |
--write-baseline PATH |
(CLI-only, no TOML key) | write current fingerprints, exit 0 |
--no-config |
(CLI-only, no TOML key) | flag; skip discovery, defaults + CLI |
| (file-only, no CLI flag) | noqa_allowed, noqa_forbidden |
list[str] / [] |
per_file_ignores maps a path glob (resolved against the config file's
directory like include/exclude, C6; absolute globs are a config error) to
rule-code prefixes skipped for matching files — e.g.
per_file_ignores = { "tests/**" = ["X002"], "scripts/*.py" = ["X0"] }. The
codes are validated like ignore (an unknown selector exits 2) and applied by
the engine after rule execution and before # noqa. The repeatable
--per-file-ignores "GLOB:CODE[,CODE]" flag replaces the file's whole table
(C2); a malformed value exits 2.
Baseline (incremental adoption)
A baseline lets an existing codebase adopt flakeforge without fixing every
violation on day one: it records the violations you accept today so later runs
fail only on new ones. The workflow is write once, commit, shrink over time:
# 1. Record every current violation and commit the file.
flakeforge check --write-baseline flakeforge-baseline.json src
git add flakeforge-baseline.json
# 2. From then on, gate on new violations only (exit 1 only for new ones).
flakeforge check --baseline flakeforge-baseline.json src
Each recorded entry is a fingerprint —
sha256(code + relative path + normalized source line text) plus an occurrence
index for identical lines. Because it hashes the line text, not the line
number, a baselined violation stays suppressed when unrelated lines are added
above it, but re-surfaces the moment you edit the flagged line — so the
baseline can only shrink, never silently absorb a regression. --write-baseline
always exits 0 (it records, it does not gate) and writes the full current set,
ignoring any --baseline in effect.
Set it in config with baseline = "flakeforge-baseline.json" (a path relative to
the config file, C6; absolute paths are rejected). When a recorded entry no
longer matches anything it is reported as fixed — a count in text output and
a list under the additive baseline key in JSON — so you know what to prune. A
missing or malformed baseline file exits 2.
noqa_allowed and noqa_forbidden are file-only: they have no matching CLI
flag and can be set only in a config file. Boolean flags use
argparse.BooleanOptionalAction, so --noqa/--no-noqa and
--rule-plugins/--no-rule-plugins can override a file's value in either
direction. An unknown output_format is a config error (exit code 2).
Value precedence (C2)
For every setting: an explicit CLI flag > the selected config file > the
built-in default. The list flags --select, --ignore, --include, and
--exclude replace the file's value; --rule-module appends to it.
--include/--exclude are repeatable and comma-splittable (like --select).
Config-file selection (C3)
--config PATH > --no-config (defaults only) > discovery. Discovery walks
upward from the discovery anchor — the lint target, not the process cwd
(C4) — and in each directory picks, nearest first: flakeforge.toml >
pyproject.toml [tool.flakeforge] > pyproject.toml [tool.flake8_lint]
(deprecated). The first directory with a match wins, and a pyproject.toml
without any recognised section does not stop the upward search.
If flakeforge.toml and a pyproject.toml section sit in the same
directory, flakeforge.toml wins and a single warning names the shadowed
section. That shadow warning is discovery-only: pointing --config straight at
flakeforge.toml selects it silently. Passing both --config and --no-config
is rejected with exit code 2.
--no-config skips discovery entirely: only built-in defaults and CLI flags
apply, and no project-local rule_modules load (installed entry-point providers
still follow --rule-plugins; --rule-module stays an explicit opt-in).
Legacy [tool.flake8_lint] is still accepted as a migration fallback when the
canonical section is absent — the same way [tool.flake8_lint_tests] was
accepted as a fallback for [tool.flake8_lint] before this rename (that older
fallback is now retired). The CLI warns:
[tool.flake8_lint] is deprecated; rename it to [tool.flakeforge].
Path patterns (C6)
The path-pattern keys — include, exclude, noqa_allowed, noqa_forbidden,
and the per_file_ignores globs — resolve against the directory of the config
file they appear in, not the
current working directory. With --no-config they resolve against the discovery
anchor. Absolute patterns are rejected with exit code 2; relative
patterns, including ones that climb out with .. (e.g. ../src), are accepted.
Display names in output are shown relative to that base directory when a file
sits under it, and otherwise shown as given.
When to pick which file
pyproject.toml [tool.flakeforge]— flakeforge is one of several tools the project already configures inpyproject.toml. This is the default choice for a tool-mode setup.flakeforge.toml— a dedicated, standalone config, or when you want it to win over an inheritedpyproject.tomlsection in the same tree.--config ci.toml— a throwaway or CI-specific profile that should apply regardless of discovery; it wins over any discovered file.--no-config— a hermetic run driven entirely by CLI flags, ignoring every on-disk config and every project-local rule module.
Built-in rules
X001bareexcept:X002except Exception:X003circular imports between project modulesX004silently swallowed exceptionsX005missing or malformed docstringsX006local imports inside functions or classesX007value-returning functions without return annotationsX008explicit-> NoneX009old%string formattingX010suppressedImportError/ModuleNotFoundErrorX011Type | Noneinstead ofOptional[Type]X012non-NonePEP 604 unions likeType1 | Type2instead ofUnion[...]X013subprocess.Popen/socket.socketnot used as a context managerX014malformed or untrackedTODO/FIXMEcommentsX015unused# noqadirectives (a# noqathat suppressed nothing, or one naming an unknown code)
X003 circular imports
X003 resolves each module's runtime imports against its import root - the first directory above the module that is not a package - and reports any import that takes part in a cycle leading back to the module being checked. Every module in a cycle is flagged, each at its own offending import line, and the message names the full chain:
src/pkg/alpha.py:3:0: X003 Circular import detected: pkg.alpha -> pkg.beta -> pkg.alpha; ...
These are deliberately not treated as cycle edges, because Python does not run them while first importing the module:
- imports inside a function body, which is the standard way to break a cycle
- imports guarded by
if TYPE_CHECKING: - targets that do not resolve to a file under the import root, so the standard library and third-party packages are never traversed
# noqa semantics
Supported forms:
# noqa
# noqa: X002
# noqa: X001, X002
# noqa: ACME001
- bare
# noqasuppresses package violations on that line - code-qualified markers suppress only matching codes or prefixes
allow_noqa = falsedisables suppression globallynoqa_allowedrestricts suppression to matching file paths when non-emptynoqa_forbiddenalways blocks suppression for matching file paths
If source text is unavailable, the engine still reports violations but cannot apply source-based # noqa suppression.
Custom rules
Minimal project-local registration:
# my_project/lint_rules.py
import ast
from flakeforge import RuleContext, RuleRegistry, RuleViolation
class NoPrintRule:
code = "ACME001"
description = "Do not call print() in production code."
def check(self, context: RuleContext):
for node in ast.walk(context.tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "print":
yield RuleViolation(
context.filename,
node.lineno,
node.col_offset,
self.code,
self.description,
)
def register_rules(registry: RuleRegistry) -> None:
registry.register(NoPrintRule(), provider="my_project.lint_rules")
[tool.flakeforge]
rule_modules = ["my_project.lint_rules"]
Reusable provider packages can also register through the flakeforge.rules Python entry-point group. See docs/custom-rules.md.
Flake8 integration
flakeforge registers a Flake8 AST plugin entry point. The adapter is intentionally thin:
- built-in diagnostics come from the same shared engine used by the CLI/API
- Flake8 selection and
--disable-noqamap into the shared engine - installed
flakeforge.rulesproviders remain authoritative for the standalone CLI/API - third-party packages that need native Flake8 discovery should also expose their own Flake8 entry points
pytest integration
Pytest integration is explicit:
from flakeforge.testing import assert_lint_clean
def test_project_lint() -> None:
assert_lint_clean("src", "tests")
Installing flakeforge alone does not auto-run repository lint during ordinary pytest.
CI recommendation
Prefer separate steps or jobs:
flakeforge check .
pytest
That keeps unit and integration tests running even when lint finds violations.
CI output formats
--output-format accepts text (default), json, github, and sarif. The
last two are meant for CI systems. Exit codes are unchanged (0 clean, 1
violations, 2 invalid config or invocation), so a failing lint still fails the
job whatever the format.
GitHub Actions annotations (github)
The github format prints one ::error workflow command per violation, so
findings show up as inline annotations on the pull request:
# .github/workflows/lint.yml
name: lint
on: [push, pull_request]
jobs:
flakeforge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install flakeforge
- run: flakeforge check . --output-format github
A clean run prints nothing and exits 0; a run with violations annotates each
line and exits 1, failing the job.
Code scanning with SARIF (sarif)
The sarif format emits a SARIF 2.1.0 document that GitHub code scanning ingests
via github/codeql-action/upload-sarif. Redirect it to a file and upload it even
when the lint step fails, so annotations still appear:
# .github/workflows/code-scanning.yml
name: code-scanning
on: [push, pull_request]
jobs:
flakeforge-sarif:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install flakeforge
- name: Run flakeforge
run: flakeforge check . --output-format sarif > flakeforge.sarif
continue-on-error: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: flakeforge.sarif
The document lists every registered rule under tool.driver.rules and reports
each violation with a base_dir-relative artifactLocation.uri and a 1-based
region.
Per-code statistics (--statistics)
--statistics (TOML key statistics, default false) appends a per-code count
summary. In text it follows the violation lines as aligned
code count description rows (sorted by code); in json it adds a
statistics object mapping each violated code to {"count", "description"}
without changing any existing key or the schema_version. A clean run has
nothing to summarise, so text omits the block and json emits an empty
object. The github and sarif formats ignore --statistics and print a
warning on stderr. The flag never changes the exit code.
flakeforge check . --statistics # text summary after findings
flakeforge check . --output-format json --statistics # adds a "statistics" object
Renamed from flake8-lint
This project was initially released as flake8-lint and was later renamed to flakeforge to
avoid it reading as "flake8 itself" rather than an extension of it.
If you depend on an older release or a pre-rename commit:
- the import package is
flakeforge(wasflake8_lint) - the console script is
flakeforge(wasflake8-lint) - the canonical config section is
[tool.flakeforge]/flakeforge.toml(was[tool.flake8_lint]/flake8_lint.toml) —[tool.flake8_lint]is still accepted as a deprecated fallback, same as any other config migration (see the precedence list above) - the custom-rule entry-point group is
flakeforge.rules(wasflake8_lint.rules)
No rule code, rule behaviour, or public API shape changed - only names.
Migration from the copied implementation
Before:
src/flake8_project_rules/
tests/flake8_lint/config.py
tests/flake8_lint/test_plugin_direct.py
tests/flake8_lint/test_project_lint.py
tests/flake8_lint/samples/
Add dependency:
flakeforge==1.0.0
Then:
- remove
src/flake8_project_rules/ - remove copied package-implementation tests now owned by
flakeforge - remove
test_project_lint.pyunless pytest should stay an explicit lint gate - rename the legacy config section to
[tool.flakeforge] - change CI from implicit pytest lint gating to:
flakeforge check .
pytest
- update direct imports from
flake8_project_rulesto publicflakeforgeAPIs where needed
Canonical migrated configuration example
pyproject.toml:
[tool.flakeforge]
include = []
exclude = [
"tests/flake8_lint/samples/",
"src/aegis_swr/openclaude/grpc/client.py",
"src/aegis_swr/openclaude/tool.py",
"src/aegis_swr/openclaude/grpc/proto/",
"src/aegis_swr/openhands/client.py",
"src/aegis_swr/openhands/tool.py",
"src/aegis_swr/claude_code/tool.py",
]
select = [
"X001",
"X002",
"X003",
"X004",
"X005",
"X006",
"X007",
"X008",
"X009",
"X010",
"X011",
"X012",
"X013",
"X014",
"X015",
]
ignore = []
allow_noqa = true
noqa_allowed = [
"src/aegis_swr/core/events.py",
]
noqa_forbidden = []
rule_modules = []
Equivalent flakeforge.toml:
include = []
exclude = [
"tests/flake8_lint/samples/",
"src/aegis_swr/openclaude/grpc/client.py",
"src/aegis_swr/openclaude/tool.py",
"src/aegis_swr/openclaude/grpc/proto/",
"src/aegis_swr/openhands/client.py",
"src/aegis_swr/openhands/tool.py",
"src/aegis_swr/claude_code/tool.py",
]
select = [
"X001",
"X002",
"X003",
"X004",
"X005",
"X006",
"X007",
"X008",
"X009",
"X010",
"X011",
"X012",
"X013",
"X014",
"X015",
]
ignore = []
allow_noqa = true
noqa_allowed = ["src/aegis_swr/core/events.py"]
noqa_forbidden = []
rule_modules = []
Development / build
pytest --cov=flakeforge --cov-report=term-missing
ruff check .
python -m build
Additional documentation:
Metadata
Release files for flakeforge 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| flakeforge-1.1.0.tar.gz | 109.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| flakeforge-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 178.0 kB
Release files / flakeforge-1.1.0.tar.gz
| Download URL | flakeforge-1.1.0.tar.gz |
|---|---|
| Size | 109.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9d52f2236551fd745b1422b3f3e284943e7d2bf2c1c61bcae21b2509af0f6b3d
|
|
BLAKE2b-256 checksum How to use checksums |
4f69b1798c0b69892ef5e88788ca9e5053011ce661f2dba76855dd5789c2fe1e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Release files / flakeforge-1.1.0-py3-none-any.whl
| Download URL | flakeforge-1.1.0-py3-none-any.whl |
|---|---|
| Size | 68.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f1044cac4a3d23c7e7d400ce7f2ee0f40a029a1f852c06d7d7b487a2b33d4dd7
|
|
BLAKE2b-256 checksum How to use checksums |
38ef0e4a7efabed6c8d50b688869d5310ce6edc975652bb29aa4d4f73f67c6a6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Security for flakeforge 1.1.0
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.