@pyreon/lint is a framework-specific linter that catches Pyreon anti-patterns at the AST level — bare signal reads in JSX, props destructuring that breaks reactivity, browser globals in SSR code, bundler-coupled dev gates, and dozens more. It is powered by oxc-parser for fast ESTree/TS-ESTree parsing, ships a CLI (pyreon-lint), a programmatic API (lint / lintFile), watch mode, an AST cache, and an LSP server for editor integration.
It is complementary to oxlint/eslint, not a replacement: those catch general JS/TS issues; @pyreon/lint catches mistakes specific to Pyreon's signal model, JSX semantics, SSR contract, and package architecture. Every rule message is prescriptive — it states the fix — so the output is directly actionable by humans and AI assistants alike.
Installation
npm install -D @pyreon/lintbun add -d @pyreon/lintpnpm add -D @pyreon/lintyarn add -D @pyreon/lintQuick Start
# Lint the current directory with the recommended preset
pyreon-lint
# Strict mode for CI (warnings become errors), errors-only output
pyreon-lint --preset strict --quiet
# Auto-fix fixable issues
pyreon-lint --fix
# List every rule
pyreon-lint --listAdd it to your package.json scripts:
{
"scripts": {
"lint:pyreon": "pyreon-lint --preset recommended",
"lint:pyreon:ci": "pyreon-lint --preset strict --quiet"
}
}pyreon-lint exits with code 1 when there are any errors (warnings and infos do not fail the process; use --preset strict to promote warnings to errors for CI gating).
CLI Usage
# Lint specific paths
pyreon-lint src/ app/
# Watch mode — re-lint on file changes
pyreon-lint --watch src/
# JSON output for tooling integration
pyreon-lint --format json
# Compact one-line-per-diagnostic output
pyreon-lint --format compact
# Override a specific rule's severity
pyreon-lint --rule pyreon/no-classname=off
# Override a rule's options (JSON object)
pyreon-lint --rule-options 'pyreon/no-window-in-ssr={"exemptPaths":["src/foundation/"]}' src/
# Use a custom config / ignore file
pyreon-lint --config ./custom-lint.json --ignore ./.customignore
# Start the LSP server (stdin/stdout JSON-RPC) for editor integration
pyreon-lint --lspFlags
| Flag | Description |
|---|---|
[path...] | Files or directories to lint (default: .). Directories are walked recursively. |
--preset <name> | Preset: recommended (default), strict, app, lib, best-practices |
--fix | Apply auto-fixes for fixable diagnostics, writing changes back to disk |
--format <fmt> | Output format: text (default), json, compact |
--quiet | Only show errors (hide warnings and infos) |
--watch | Watch mode — re-lint on file changes (fs.watch recursive + 100ms debounce + AST cache) |
--list | List all rules (id, category, severity, description, [fixable]) and the total count |
--rule <id>=<sev> | Override one rule's severity, e.g. --rule pyreon/no-window-in-ssr=off |
--rule-options <id>=<json> | Override one rule's options on this run; <json> must be a JSON object |
--config <path> | Path to a config file (skips the auto-discovery walk) |
--ignore <path> | Path to a custom ignore file |
--lsp | Start the LSP server over stdin/stdout JSON-RPC |
--init | Write a .pyreonlintrc.json for this project (lib or app preset); refuses to overwrite an existing config |
--why-off <rule-id> | Explain why a rule is off (or on) for this config, with the edit that changes it, then exit |
--version, -v | Print the version |
--help, -h | Print usage |
Configuration
Config file
Create .pyreonlintrc.json in your project root:
{
"$schema": "./node_modules/@pyreon/lint/schema/pyreonlintrc.schema.json",
"preset": "recommended",
"rules": {
"pyreon/no-classname": "off",
"pyreon/no-eager-import": "warn",
"pyreon/no-window-in-ssr": [
"error",
{ "exemptPaths": ["src/foundation/"] }
]
},
"include": ["src/"],
"exclude": ["generated", ".test."]
}The $schema reference enables IDE autocomplete + validation while editing the config in VSCode, IntelliJ, Zed, or any JSON-aware editor.
LintConfigFile shape
| Field | Type | Description |
|---|---|---|
preset | 'recommended' | 'strict' | 'app' | 'lib' | 'best-practices' | Base preset; rule entries below override it. |
rules | Record<string, RuleEntry> | Per-rule severity / options. Overrides the preset. |
include | string[] | Substring patterns — a file is linted only if its path matches one of them. |
exclude | string[] | Substring patterns — a matching file is skipped (takes precedence over include). |
Rule entries
Each entry in rules is either a bare severity or a [severity, options] tuple (ESLint-style):
{
"rules": {
"pyreon/no-classname": "off",
"pyreon/no-window-in-ssr": ["error", { "exemptPaths": ["packages/core/runtime-dom/"] }],
"pyreon/require-browser-smoke-test": ["error", { "additionalPackages": ["@my-org/widgets"] }]
}
}Severities are "error", "warn", "info", or "off".
package.json field
Instead of a standalone file, you can put the config under a "pyreonlint" field in package.json:
{
"pyreonlint": {
"preset": "strict",
"rules": {
"pyreon/no-inline-style-object": "off"
}
}
}Discovery order
loadConfig(cwd) walks up from the working directory toward the filesystem root, returning the first config it finds. Within each directory the search order is:
.pyreonlintrc.json.pyreonlintrcpyreonlint.config.jsonpackage.json"pyreonlint"field
--config <path> bypasses the walk and loads that file directly.
Resolution precedence
For a single run, rule severity/options are resolved in this order (later wins):
The base preset (
--presetflag → config-filepreset→recommended)Config-file
rulesentriesCLI
--rule <id>=<sev>severity overrides / programmaticruleOverridesCLI
--rule-options <id>=<json>options overrides / programmaticruleOptionsOverrides
Per-rule options & exemptPaths
Several rules read an exemptPaths: string[] option — each entry is a substring matched against the file path, letting you opt specific files/directories out of a rule without hardcoding paths in the rule source (which would ship to every consuming project). Rules that honor exemptPaths today:
no-window-in-ssr, prefer-isserver, no-bare-signal-in-jsx, no-unbatched-updates, no-props-destructure, no-imperative-effect-on-create, no-heavy-import-only-in-handler, dev-guard-warnings, no-error-without-prefix, no-process-dev-gate, no-module-signal-in-server-package, no-query-selector-cast-in-test, require-browser-smoke-test, vitest-config-uses-shared, no-raw-addeventlistener, no-raw-setinterval, and every opt-in best-practice rule (frontend/*, query-options-as-function, rx-prefer-pipe, i18n-prefer-trans-for-rich-jsx, prefer-typed-search-params, no-storage-write-as-call, no-signal-in-form-initial-values).
require-browser-smoke-test additionally accepts additionalPackages: string[] to opt new browser packages into its coverage check.
{
"rules": {
"pyreon/no-window-in-ssr": ["error", { "exemptPaths": ["src/client-only/"] }]
}
}Option validation
Each rule that takes options declares an option shape in meta.schema (Record<string, 'string' | 'string[]' | 'number' | 'boolean'>). The runner validates user-supplied options once per (rule, options) pair:
Unknown option key → a
warn-severity config diagnostic; the rule still runs.Wrong-typed value → an
error-severity config diagnostic; the rule is disabled for the run.
These config-level diagnostics surface on LintResult.configDiagnostics (and on stderr / the JSON reporter) so CI, the LSP, and JSON consumers all see them alongside file diagnostics.
Ignore file
Create .pyreonlintignore (same format as .gitignore):
# Ignore generated code
src/generated/
**/*.gen.ts
# Ignore test fixtures
src/tests/fixtures/.gitignore patterns are respected automatically in addition to .pyreonlintignore. Pass --ignore <path> to load a custom ignore file.
Inline suppression
Suppress a diagnostic on the immediately following line with a comment. Two equivalent syntaxes are accepted — pyreon-lint-ignore (short) and pyreon-lint-disable-next-line (long-form, the convention several rule docstrings use):
// pyreon-lint-disable-next-line pyreon/no-peek-in-tracked
if (next === editor.value.peek()) return // intentional loop-prevention read
// pyreon-lint-ignore pyreon/no-window-in-ssr
const w = window.innerWidth // guarded elsewhere
// pyreon-lint-ignore
// ^ omitting the rule id suppresses EVERY rule on the next line
const x = someBroadlyFlaggedExpression()Omitting the rule id suppresses all rules on the next line; including it suppresses only that rule. The comment must match exactly — word-boundary matching means a typo like // pyreon-lint-ignored is not treated as a suppression (so a misspelled directive fails loudly rather than silently disabling the rule).
Presets
@pyreon/lint ships five presets. The first four cover the common lifecycle (dev → CI → app/library); best-practices opts you into the full opinionated rule surface.
| Preset | Built from | Use for |
|---|---|---|
recommended | Every non-opt-in rule at its default severity | Day-to-day development |
strict | recommended with all warn → error | CI and pre-commit gating |
app | recommended minus library-only rules | Pyreon applications |
lib | strict with all architecture rules forced to error | Pyreon packages / libraries |
best-practices | recommended plus every opt-in rule enabled | Projects that want the full a11y/CLS + library-usage best-practice surface |
recommended
All rules at their declared default severity, except opt-in best-practice rules (meta.optIn), which are forced off so they never add unexpected noise.
strict
recommended with every warn promoted to error. info rules stay info; off (opt-in) rules stay off. This is the CI/pre-commit preset — any warning now fails the run.
app
recommended with the library-only architecture rules turned off, because applications don't ship as packages with those obligations:
pyreon/dev-guard-warnings→offpyreon/no-error-without-prefix→offpyreon/no-circular-import→offpyreon/no-cross-layer-import→offpyreon/require-browser-smoke-test→off
lib
strict, plus every architecture rule forced to error:
pyreon/no-circular-importpyreon/no-cross-layer-importpyreon/dev-guard-warningspyreon/no-error-without-prefixpyreon/no-process-dev-gatepyreon/require-browser-smoke-test
For Pyreon packages and libraries — the strongest preset.
best-practices
recommended plus every opt-in best-practice rule enabled (meta.optIn) at its declared severity — off in all other presets by design, so you opt in explicitly. The mechanism is per-rule, not per-category: query, rx, i18n, storage, http, and portable are wholesale opt-in (every rule in the category), frontend is half opt-in (6 of its 12 rules ship on by default — see Accessibility is on by default), and a handful of individually opt-in rules sit inside otherwise-mandatory categories (reactivity, jsx, performance, ssr, form, styling, router, security, web-perf, js — marked ᵒ in Categories at a glance). Library-scoped opt-in rules still self-gate on your package.json dependencies even under best-practices, so you only see rules for libraries you actually use.
Opt-in best-practice rules
Rules tagged opt-in (meta.optIn) are off in recommended / strict / app / lib. Enable them either wholesale via --preset best-practices / "preset": "best-practices", or per-rule:
{
"preset": "recommended",
"rules": {
"pyreon/require-img-alt": "error",
"pyreon/query-options-as-function": "error",
"pyreon/rx-prefer-pipe": "off"
}
}Per-rule config in your config file always wins over the preset — enable one, disable another, change severity, or scope with exemptPaths.
Dependency auto-detection
Library-scoped opt-in rules — every rule in query, rx, i18n, storage, and http; form's no-signal-in-form-initial-values; router's prefer-typed-search-params; ssr's no-private-env-in-client; and frontend's prefer-zero-image / no-discarded-optimize-fields — only activate when the linted project actually declares the relevant @pyreon/* package in its package.json (dependencies / devDependencies / peerDependencies / optionalDependencies). A project that doesn't use @pyreon/query never sees query-options-as-function — zero config, zero noise. The check (isProjectDependency) walks up to the nearest package.json and is cached per manifest. Per-rule config still overrides if you want to force a rule on or off regardless of deps.
Rules
Performance
Linting is embarrassingly parallel — a file's diagnostics depend only on that file and the resolved config — so runs above a few hundred files are split across a worker pool instead of walking one core. The config is resolved once on the main thread and handed to workers as data, so no worker re-reads .pyreonlintrc.json or can disagree with its siblings about what is enabled.
Three properties are deliberate:
Results are re-sorted by path. Workers finish in whatever order they finish; output that shifted between runs would make CI diffs useless.
Small runs stay sequential. Below the threshold, spawning workers costs more than the work — a tool that is slower on
lint one-file.tsto be faster on the whole repo is a bad trade.Whether the pool is used is DECIDED, never discovered by failing.
planRun()reports the reason a run is sequential —below-threshold,source-entry(a.tsentry can't be loaded by a worker, which is the workspace/dev layout), orentry-missing. Attempting a spawn that is known to fail and catching the result would make a genuine worker crash indistinguishable from routine unavailability.A worker that genuinely fails is NOT silent. On a read-only run it prints why and completes sequentially. Under
--fixit stops with guidance instead: workers that already succeeded have written their files, and re-running over a half-modified tree is only safe if every fixer is idempotent — an assumption the code cannot enforce.
The programmatic lint() stays synchronous for the LSP and watch mode; lintAsync() is the parallel driver, and a test locks that the two produce identical diagnostics over the same corpus.
Autofix
--fix applies fixes that are unambiguous. A fix may carry several edits — prefer-isserver rewrites typeof window !== 'undefined' to isClient and adds the import, as one fix — and a multi-edit fix is applied whole or not at all, because half of that pair is broken code.
Overlapping fixes from different rules are deferred, not applied blind: the first in source order wins, the other stays reported for a later pass. Fixes are deliberately conservative about intent:
no-signal-call-writefixescount(5)→count.set(5), but leavescount(prev => prev + 1)alone — that reads as update intent, and.set(fn)would store the function as the value.prefer-isserverrefuses to fix through a namespace or type-only import, where adding a specifier would produce code that doesn't compile.no-peek-in-trackedhas no fixer at all:.peek()inside a tracked scope is frequently intentional loop-prevention, so rewriting it would turn "skip writes during a write" into an infinite loop.
Reading fixes programmatically? Diagnostic.fix is Fix | readonly Fix[] — normalize with fixEdits(d.fix).
Getting started
pyreon-lint --init # writes .pyreonlintrc.json
pyreon-lint .--init picks the preset from your project rather than always writing recommended — a package with an entry point gets lib (which enables the library-author rules), anything else gets app. It points $schema at the installed schema so your editor completes rule ids and rejects typos, and it refuses to overwrite an existing config.
The file it writes is deliberately minimal — a schema reference and a preset, nothing else. Scaffolding every rule at its current severity would freeze today's defaults into your file, so a later improvement to recommended would never reach you.
The linter also works with no config at all, defaulting to recommended.
Security
A security group, on by default in every standard preset:
| rule | what it catches |
|---|---|
no-script-url | javascript: / vbscript: in href, src, action, formaction, poster, ping — code execution in the page origin wearing a link's clothing |
no-target-blank-without-rel | target="_blank" without rel="noreferrer" — the destination receives the full referring URL |
Both are deliberately conservative: they read static values only. A dynamic href={u} or rel={r} might well be correct, and a rule that can't prove otherwise stays quiet — a security rule that cries wolf is one people switch off.
no-script-url normalizes the way a browser does before matching the scheme, so it catches the bypasses a naive prefix check misses: leading control characters, and embedded ones like java\tscript:. no-target-blank-without-rel still reports rel="noopener" alone — modern browsers imply noopener, but nothing implies noreferrer, and the referring URL can identify a user or tenant. Its autofix only adds a missing rel; a partially-correct one is reported and left alone rather than silently rewritten.
Accessibility is on by default
Six a11y rules ship on in every standard preset — require-img-alt, anchor-is-valid, no-autofocus, no-redundant-role, no-positive-tabindex and primitive-media-needs-label. Each is an unambiguous WCAG failure with a counterpart in oxlint's correctness tier, so a fresh Pyreon app gets the same a11y floor an ESLint user expects. primitive-media-needs-label is dependency-gated, so it stays silent unless you use @pyreon/primitives.
What remains opt-in is deliberately a different class: layout shift (img-requires-dimensions, content-visibility-needs-intrinsic-size), heuristic detection that can't see across components (heading-order, color-contrast), and @pyreon/zero preferences (prefer-zero-image, no-discarded-optimize-fields). Enable those with the best-practices preset or per rule.
Rule groups
Every rule belongs to one of ten groups — the axis the 25 categories don't capture: what knowledge does this rule require, where does the file run, and which platforms must it survive?
| group | rules | what it is |
|---|---|---|
pyreon | 52 | Framework semantics — reactivity, JSX, lifecycle, SSR/SSG. Nothing outside Pyreon can know these. |
isomorphic | 6 | Runs on BOTH sides — the hydration contract for a file that renders on server and client. |
backend | 7 | Server-role files — handlers, loaders, adapters. |
web-perf | 7 | Client-role files — main thread, teardown, network. |
portable | 6 | Must survive iOS + Android through PMTC. |
security | 3 | Exploitable shapes — script URLs, referrer leakage. |
a11y | 15 | Accessibility — standard markup plus Pyreon's own surfaces (toast, dialog, overlay, primitives). |
pkg | 27 | Per-library. Each self-activates on a declared dependency, so you only see rules for libraries you use. |
js | 3 | Language-level shapes oxlint cannot express on its own. |
internal | 6 | Encodes the Pyreon repository itself. Never on in a shipped preset. |
Categories live underneath, so a query rule is group: 'pkg', category: 'query'. Set a whole group in one line — applied after the preset and before per-rule entries, so an explicit rule always wins:
{
"preset": "best-practices",
"groups": { "a11y": "off" },
"rules": { "pyreon/require-img-alt": "error" }
}Options shared by more than one rule
Some options are a property of the project, not of one rule. portablePaths names the directories whose source has to survive three targets — and five rules in the portable group need that same answer. Repeating it per rule turns your config into a hand-maintained copy of the rule registry: add a sixth portable rule and it is silently inert in every project that listed the other five.
settings says it once:
{
"preset": "recommended",
"groups": { "portable": "warn" },
"settings": { "portablePaths": ["src/"] }
}A key is seeded into a rule's options only when that rule declares it in its own schema, so a shared key can never reach a rule that would reject it as unknown. Per-rule options still win, so you can widen the shared answer and narrow it for one rule:
{
"settings": { "portablePaths": ["src/"] },
"rules": {
"pyreon/no-out-of-subset-construct": ["warn", { "portablePaths": ["src/shared/"] }]
}
}A settings key that no rule declares is reported as a config error — a typo here would otherwise be as silent as a typo'd rule id.
pyreon-lint --list groups its output the same way. There is deliberately no js or ts group: those are for general JS/TS correctness rules, which this package does not have yet, and an empty group would advertise coverage that doesn't exist.
Why isn't a rule firing?
A rule can be silently inert for four independent reasons, and three of them are invisible in your config: its severity is off; it is an opt-in best-practice rule; it is monorepo-scoped; or it is dependency-gated and your project doesn't declare the library it covers. They compose, so a rule is often off for more than one reason and fixing one changes nothing.
--why-off reports every reason that applies, each with the edit that lifts it:
pyreon-lint --why-off pyreon/rx-prefer-pipe pyreon/rx-prefer-pipe
✗ WILL NOT RUN — severity: off
[severity-off]
The resolved config sets this rule to `off`.
fix: Set `"pyreon/rx-prefer-pipe": "info"` in .pyreonlintrc.json.
[opt-in]
This is an opt-in best-practice rule, so every standard preset forces it off.
fix: Select the `best-practices` preset, or enable this rule by id.
[dependency-missing]
This rule only applies to projects using `@pyreon/rx`, which is not a declared dependency here.
fix: Nothing to fix — the rule is correctly silent. Add `@pyreon/rx` if you meant to use it.The dependency gate is checked relative to a file, so pass a path (pyreon-lint --why-off <id> src/) when you want that reason evaluated. An unknown id exits non-zero and suggests the near miss. Programmatic equivalent: explainRuleState(id, { config, filePath }).
There are 133 rules across 25 categories. Six of them are monorepo-scoped (meta.scope: 'monorepo') — no-circular-import, no-cross-layer-import, no-error-without-prefix, no-query-selector-cast-in-test, require-browser-smoke-test, vitest-config-uses-shared. They encode the Pyreon repository's own conventions (its layer order, its private internal packages, its [Pyreon] error prefix) rather than anything about Pyreon-the-framework, so every preset a consumer selects forces them off, best-practices included. The Pyreon repo re-enables them by id in its own .pyreonlintrc.json, which keeps that dependency visible in config instead of hidden inside a shared preset. query, rx, i18n, storage, http, and portable are wholesale opt-in best-practice categories — off in the standard presets. frontend is mixed (6 of 12 on by default); the rest of the opt-in surface is individual rules scattered across otherwise-mandatory categories, marked ᵒ below. Run pyreon-lint --list for the authoritative list with live severities.
Categories at a glance
| Category | Rules | Opt-in? | Purpose |
|---|---|---|---|
reactivity | 17 | 2 of 17 | Signal/effect/computed correctness — tracking, batching, leaks |
jsx | 12 | 2 of 12 | Pyreon JSX semantics — class/for, <For>/by, props, <Show> |
lifecycle | 6 | onMount/effect setup-vs-mount, cleanup, idempotent init* | |
performance | 5 | 1 of 5 | Keyed lists, lazy imports, leak-prone timers |
ssr | 5 | 1 of 5 | Browser globals, hydration mismatch, per-request state |
architecture | 11 | Import layering, dev gates, error prefixes, test/config contracts | |
store | 3 | defineStore ids, mutation discipline, provider scope | |
form | 4 | 1 of 4 | useField/register, validation, field arrays |
styling | 5 | 1 of 5 | Inline style objects, dynamic styled(), theme/cx, rocketstyle .attrs() |
hooks | 3 | Prefer auto-cleanup hooks over raw listeners/timers/storage | |
accessibility | 3 | Dialog / overlay / toast ARIA | |
router | 5 | 1 of 5 | <Link> vs <a>, navigate-in-render, fallback, active state |
ssg | 3 | @pyreon/zero route exports (getStaticPaths, revalidate, loader) | |
security | 3 | 1 of 3 | Exploitable shapes — reverse tabnabbing, javascript: URLs, unsanitized innerHTML |
frontend | 12 | 6 of 12 | Accessibility + layout-shift (CLS) + asset optimization |
query | 2 | ✅ all | @pyreon/query options-as-function, queryFn signal forwarding |
rx | 1 | ✅ | @pyreon/rx pipe composition |
i18n | 1 | ✅ | @pyreon/i18n <Trans> for rich JSX |
storage | 1 | ✅ | @pyreon/storage .set() vs call-write |
http | 2 | ✅ all | @pyreon/http path encoding, fetch without a timeout signal |
isomorphic | 6 | Server/client render must agree — locale, timezone, ids, env branches, iteration order | |
backend | 7 | Server-role request handlers — blocking I/O, unvalidated input, shared mutable state, leaked secrets | |
web-perf | 7 | 1 of 7 | Client-role runtime perf — reflow, LCP hints, third-party scripts, passive listeners, rAF leaks |
portable | 6 | ✅ all | Must survive iOS + Android — PMTC subset, canonical primitives, nativeCompat() |
js | 3 | 1 of 3 | Language shapes oxlint cannot express — swallowed errors, ESM require(), dropped error cause |
Opt-in (ᵒ) rules below are off in recommended/strict/app/lib — enable via best-practices or per-rule config; library-scoped ones additionally auto-gate on package.json deps.
Reactivity (17)
| Rule | Severity | Fixable | Description |
|---|---|---|---|
pyreon/no-bare-signal-in-jsx ᵒ | info | {count()} won't be reactive — wrap in () => count() | |
pyreon/no-signal-in-loop | error | signal() inside loops creates signals on every iteration | |
pyreon/no-signal-in-props | warn | <C x={sig()} /> captures the value once unless the compiler wraps it | |
pyreon/no-async-effect | error | async in effect/renderEffect/computed — reads after await aren't tracked | |
pyreon/no-context-destructure | warn | Destructuring useContext() breaks reactivity when the provider uses getters | |
pyreon/no-signal-call-write | error | yes | sig(value) does NOT write — use sig.set(value) / sig.update(fn) |
pyreon/no-nested-effect | warn | effect() inside effect() — use computed() | |
pyreon/no-peek-in-tracked | error | .peek() inside effect/computed bypasses tracking | |
pyreon/no-unbatched-updates | warn | 3+ .set() calls without batch() | |
pyreon/prefer-computed | warn | effect() that only sets a signal — use computed() | |
pyreon/no-effect-assignment | warn | effect(() => signal.update(...)) — use computed() | |
pyreon/no-signal-leak | warn | Signal created but never read | |
pyreon/storage-signal-v-forwarding | error | Signal-like wrapper callable missing _v forwarding — breaks the _bindText fast path | |
pyreon/no-iterate-children-without-resolve | error | Iterating props.children at the VNode level (cloneVNode, .map/.filter, .props) must first unwrap a possible compiler accessor via resolveChildren(…) | |
pyreon/no-guard-only-signal-reads-in-effect | info | Every reactive read in an effect() sits behind a non-reactive guard (if (ref.current) { … }) — the first run can short-circuit before any read, so the effect subscribes to nothing and never re-runs | |
pyreon/plain-mode-footgun | error | Every Plain Mode compile-time warning as an error — mutating SHALLOW state, assigning to derived, an unlowerable signalOf, … Set projectWide: true when the app uses pyreon({ plain: true }) | |
pyreon/no-unguarded-async-signal-write ᵒ | warn | An async function writes captured signal state after an await with no staleness guard — a slow earlier response resolves last and clobbers newer data (leak class F) |
JSX (12)
| Rule | Severity | Fixable | Description |
|---|---|---|---|
pyreon/no-classname | error | yes | Use class not className |
pyreon/no-htmlfor | error | yes | Use for not htmlFor |
pyreon/use-by-not-key | error | yes | Use by not key on <For> |
pyreon/no-props-destructure | error | Destructuring props breaks reactivity | |
pyreon/no-map-in-jsx | warn | Use <For> instead of .map() | |
pyreon/no-onchange | warn | yes | Use onInput not onChange on inputs |
pyreon/no-ternary-conditional ᵒ | warn | Use <Show> instead of ternary | |
pyreon/no-and-conditional ᵒ | warn | Use <Show> instead of && | |
pyreon/no-index-as-by | warn | Index keys cause reconciliation bugs | |
pyreon/no-missing-for-by | error | <For> without by defeats keyed reconciliation | |
pyreon/no-children-access | info | Raw props.children in renderer files | |
pyreon/no-line-comment-in-jsx | error | A // line in JSX child position is not a comment — JSX has no line comments, so it renders as literal text on web and, through PMTC, on iOS and Android. Use {/* … */} |
Lifecycle (6)
| Rule | Severity | Description |
|---|---|---|
pyreon/no-missing-cleanup | warn | onMount with timer/listener but no cleanup return |
pyreon/no-mount-in-effect | warn | onMount() inside effect() runs every re-execution |
pyreon/no-effect-in-mount | info | effect() inside onMount() is redundant |
pyreon/no-dom-in-setup | warn | DOM access in component setup — use onMount |
pyreon/no-imperative-effect-on-create | warn | effect() doing DOM/IO/timer work at component setup — move it into onMount() |
pyreon/init-fn-needs-idempotency | warn | Exported init* fn called without a refcount/boolean guard registers effects N times |
Performance (5)
| Rule | Severity | Description |
|---|---|---|
pyreon/no-effect-in-for | warn | effect() inside <For> creates N effects |
pyreon/no-heavy-import-only-in-handler | warn | Heavy module imported statically but used only in a deferred scope — use a dynamic import() |
pyreon/promise-race-needs-cleartimeout | warn | Promise.race([work, setTimeout-reject]) without clearTimeout in finally leaks the timer |
pyreon/no-eager-import | info | Static import of heavy packages — use lazy() |
pyreon/prefer-show-over-display ᵒ | info | Conditional display style — use <Show> |
SSR (5)
| Rule | Severity | Fixable | Description |
|---|---|---|---|
pyreon/no-window-in-ssr | error | Browser globals outside onMount/typeof guard | |
pyreon/no-mismatch-risk | warn | Date.now()/Math.random() in render — hydration mismatch | |
pyreon/prefer-request-context | warn | Module-level state in SSR handlers | |
pyreon/prefer-isserver | warn | yes | Prefer isServer/isClient from @pyreon/reactivity over hand-rolled typeof window/typeof document |
pyreon/no-private-env-in-client ᵒ | warn | Raw process.env.X / import.meta.env.X reads in client-reachable @pyreon/zero code — use publicEnv() from @pyreon/zero/env with a ZERO_PUBLIC_-prefixed var (auto-gated on @pyreon/zero) |
Architecture (11)
| Rule | Severity | Fixable | Description |
|---|---|---|---|
pyreon/no-circular-import | error | Violates package dependency layer order | |
pyreon/no-cross-layer-import | error | Core importing from UI-system | |
pyreon/dev-guard-warnings | error | console.warn without a dev guard | |
pyreon/no-process-dev-gate | error | yes | Bundler-coupled dev gate — use bundler-agnostic process.env.NODE_ENV |
pyreon/require-browser-smoke-test | error | Browser-categorized package with no *.browser.test.{ts,tsx} under src/ | |
pyreon/no-module-signal-in-server-package | error | Module-scoped signals in server packages race between requests — use per-request state | |
pyreon/no-query-selector-cast-in-test | error | In tests, el.querySelector(x) as T should use query() from @pyreon/test-utils | |
pyreon/vitest-config-uses-shared | error | Per-package vitest config must use defineNodeConfig/defineBrowserConfig | |
pyreon/no-deep-import | warn | Deep import into @pyreon/*/src/ internals | |
pyreon/no-error-without-prefix | warn | yes | Error message without a [Pyreon] prefix |
pyreon/island-import-from-client | warn | Import island from @pyreon/server/client (or @pyreon/zero), never the @pyreon/server barrel — the barrel drags node:* and the server singleton into client bundles |
Store (3)
| Rule | Severity | Description |
|---|---|---|
pyreon/no-duplicate-store-id | error | Duplicate defineStore() IDs |
pyreon/no-mutate-store-state | warn | Direct .set() on store signals outside actions |
pyreon/no-store-outside-provider | warn | Store hook in SSR without provider |
Form (4)
| Rule | Severity | Description |
|---|---|---|
pyreon/no-unregistered-field | warn | useField() without register() |
pyreon/no-submit-without-validation | warn | useForm({ onSubmit }) without validators |
pyreon/prefer-field-array | info | signal([]) in form files — use useFieldArray() |
pyreon/no-signal-in-form-initial-values ᵒ | warn | useForm({ initialValues: { x: sig() } }) snapshots the signal once — pass the plain value / use a reactive field |
Styling (5)
| Rule | Severity | Description |
|---|---|---|
pyreon/no-inline-style-object | warn | Inline style object creates a new object each render |
pyreon/no-dynamic-styled | warn | styled() inside component body |
pyreon/no-theme-outside-provider ᵒ | warn | useTheme() without provider |
pyreon/prefer-cx | info | String concatenation for class names — use cx() |
pyreon/no-signal-read-in-attrs-callback | warn | Signal/computed read inside a rocketstyle .attrs() callback — the callback runs once at setup, so the read captures a dead value (auto-gated on @pyreon/rocketstyle) |
Hooks (3)
| Rule | Severity | Description |
|---|---|---|
pyreon/no-raw-addeventlistener | info | Use useEventListener() — auto-cleanup |
pyreon/no-raw-setinterval | info | Use onMount with cleanup return |
pyreon/no-raw-localstorage | info | Use useStorage() — reactive, SSR-safe |
Accessibility (3)
| Rule | Severity | Description |
|---|---|---|
pyreon/dialog-a11y | warn | <dialog> without aria-label/aria-labelledby |
pyreon/overlay-a11y | warn | <Overlay> without role/aria attributes |
pyreon/toast-a11y | warn | Toast component without role="alert" |
Router (5)
| Rule | Severity | Description |
|---|---|---|
pyreon/no-href-navigation | warn | <a href> instead of <Link> in router apps |
pyreon/no-imperative-navigate-in-render | error | navigate() in component body causes infinite loops |
pyreon/no-missing-fallback | warn | Route config without a catch-all / 404 route |
pyreon/prefer-use-is-active | info | location.pathname === — use useIsActive() |
pyreon/prefer-typed-search-params ᵒ | info | Manual new URLSearchParams(...) — use useTypedSearchParams() (auto-gated on @pyreon/router) |
SSG (3)
Route-scoped (src/routes/) rules for @pyreon/zero static-site generation.
| Rule | Severity | Description |
|---|---|---|
pyreon/revalidate-not-pure-literal | error | export const revalidate must be a numeric literal or false — non-literals are silently dropped from the build-time ISR manifest |
pyreon/missing-get-static-paths | warn | Dynamic route ([id].tsx / [...slug].tsx) without export const getStaticPaths — silently skipped by SSG auto-detect. App-mode-aware via the appMode option: ["warn", { "appMode": "ssr" }] stays quiet for undeclared routes (they render per-request) and fires only on explicit renderMode = 'ssg' declarations |
pyreon/invalid-loader-export | error | export const loader is not callable — crashes the SSR runtime with loader is not a function |
Security (3)
Exploitable shapes — a different question from "is this idiomatic Pyreon?", so this is its own group and teams gate on it separately.
| Rule | Severity | Fixable | Description |
|---|---|---|---|
pyreon/no-target-blank-without-rel | warn | yes | target="_blank" without rel="noopener noreferrer" — the destination receives the full referring URL, and in older browsers the opened page can navigate its opener |
pyreon/no-script-url | error | javascript:/vbscript: URLs execute in the page origin — use a <button onClick> for an action | |
pyreon/no-unsanitized-inner-html ᵒ | warn | dangerouslySetInnerHTML={{ __html: X }} where X is not a literal or the result of a sanitizer — Pyreon assigns __html raw, so an interpolated value is a direct XSS vector |
Frontend (12)
Accessibility, layout-shift (CLS) and asset-optimization checks. 6 of the 12 rules are on by default — unambiguous WCAG/CLS failures with an oxlint correctness-tier counterpart. The other 6 (marked ᵒ) are opt-in — enable via the best-practices preset or per-rule config; the @pyreon/zero/@pyreon/primitives-gated ones additionally auto-activate only in a project that depends on that package.
| Rule | Severity | Fixable | Description |
|---|---|---|---|
pyreon/require-img-alt | error | <img> without an alt attribute — required for a11y (alt="" for decorative is fine) | |
pyreon/img-requires-dimensions ᵒ | warn | <img> without both width & height — causes layout shift (CLS); set intrinsic dimensions | |
pyreon/no-positive-tabindex | warn | yes | tabIndex={n} where n > 0 breaks natural tab order — use 0 (autofix) or -1 |
pyreon/no-discarded-optimize-fields ᵒ | warn | A raw <img src={x.src}> that discards the rest of a ?optimize descriptor (CLS + no responsive images; auto-gated on @pyreon/zero) | |
pyreon/no-autofocus | warn | yes | autoFocus disorients screen-reader/keyboard users by moving focus on load (autofix removes it) |
pyreon/no-redundant-role | warn | yes | An ARIA role that duplicates the element's implicit role (autofix removes it) |
pyreon/anchor-is-valid | warn | <a> that isn't a valid link — missing href, or href is ""/#/javascript: | |
pyreon/heading-order ᵒ | warn | A skipped heading level (e.g. <h1> → <h3>) breaks the screen-reader outline | |
pyreon/color-contrast ᵒ | warn | A low-contrast color/background literal-hex pair in a style object (below the WCAG AA 4.5:1 ratio) | |
pyreon/content-visibility-needs-intrinsic-size ᵒ | warn | content-visibility: auto with no contain-intrinsic-size — the browser estimates the box then corrects it on render, shifting everything below (CLS) | |
pyreon/prefer-zero-image ᵒ | info | Raw <img> in a @pyreon/zero project — prefer <Image> for lazy-load + srcset + blur (auto-gated on @pyreon/zero) | |
pyreon/primitive-media-needs-label | error | In @pyreon/primitives projects, every <Image>/<Icon> needs an accessibilityLabel (or alt/aria-label), or accessibilityHidden if decorative (auto-gated on @pyreon/primitives) |
Query (2) ᵒ
Auto-gated on a @pyreon/query dependency.
| Rule | Severity | Description |
|---|---|---|
pyreon/query-options-as-function | error (fixable) | useQuery/useInfiniteQuery/useQueries/useSuspenseQuery with an options object literal — --fix wraps it in () => ({ ... }) so queryKey tracks signals and refetches reactively (useMutation excluded). Also a proactive MCP validate detector (flagged before commit). |
pyreon/query-fn-must-forward-signal | warn | A queryFn that performs a request must forward the AbortSignal it is given, or TanStack cancellation silently does nothing |
Rx (1) ᵒ
Auto-gated on a @pyreon/rx dependency.
| Rule | Severity | Description |
|---|---|---|
pyreon/rx-prefer-pipe | info | Nested rx transforms (map(filter(src, …), …)) — compose in one pipe(src, (xs) => …, (xs) => …) (plain-function steps; rx transforms are not curried) for a single computed instead of N |
I18n (1) ᵒ
Auto-gated on a @pyreon/i18n dependency.
| Rule | Severity | Description |
|---|---|---|
pyreon/i18n-prefer-trans-for-rich-jsx | info | {t('…')} interleaved with JSX element siblings (rich content) — use <Trans> for safe JSX interpolation. Plain-text {t('title')} never fires (zero-FP) |
Storage (1) ᵒ
Auto-gated on a @pyreon/storage dependency.
| Rule | Severity | Fixable | Description |
|---|---|---|---|
pyreon/no-storage-write-as-call | error | yes | Writing to a storage signal by calling it (s(x)) reads-and-discards — use s.set(x)/s.update(fn) (autofix to .set) |
HTTP (2) ᵒ
Auto-gated on a @pyreon/http dependency.
| Rule | Severity | Description |
|---|---|---|
pyreon/no-unencoded-path-interpolation | warn | Interpolating a value into an HTTP path skips URL encoding — a value containing / escapes its segment. Use the params option |
pyreon/no-untimed-raw-fetch | info | A raw fetch() with no signal has no deadline — a server that never responds hangs the promise forever |
Isomorphic (6)
A file that renders on both the server and the client needs its output to be identical on first render, or hydration mismatches.
| Rule | Severity | Description |
|---|---|---|
pyreon/no-locale-dependent-format | warn | Locale-dependent formatting with no explicit locale differs between the server and the browser — the SSR HTML and the first client render disagree on ordinary-looking text |
pyreon/no-timezone-dependent-date | warn | Local-timezone date reads render differently on a UTC server and in the visitor's browser — the same expression produces different text on each side |
pyreon/no-unstable-render-id | error | A random value in an id / label-association attribute differs between the server and client render, so every pairing built from it breaks at hydration — use createUniqueId() |
pyreon/no-node-builtin-in-component | error | A static node: import in a file that renders JSX drags a Node builtin into the client bundle — the documented break is a failed resolve or a dead externalized chunk |
pyreon/no-env-branch-in-render | warn | An environment branch that decides rendered OUTPUT (rather than behaviour) makes the server tree and the first client tree disagree |
pyreon/require-stable-iteration-order | warn | Rendering from Object.keys() / a Set relies on insertion order, which is stable within one process and not between two — the server and client can order the same data differently |
Backend (7)
Server-role request-handler correctness — files that run once per request, on the server only.
| Rule | Severity | Description |
|---|---|---|
pyreon/no-sync-fs-in-request-path | warn | A synchronous fs/process call inside a server-side function blocks the event loop for every concurrent request. Module-scope boot-time reads are not flagged |
pyreon/no-floating-promise-in-handler | warn | A dropped promise in a server function is a dropped failure — the handler returns success while the rejection lands in a log as an unhandled rejection |
pyreon/no-unvalidated-request-body | warn | A request body read into a binding with nothing validating it — the type annotation asserts nothing at runtime, and the usual failure is a bad row rather than an exception |
pyreon/no-await-in-loop-over-io | warn | An awaited I/O call inside a loop runs N sequential round-trips where Promise.all would run one batch — on a request path that scales linearly with data nobody controls |
pyreon/require-request-signal-forwarding | warn | An outbound fetch that ignores the inbound request's abort signal keeps working after the client hangs up — a connection spent on a response nobody will read |
pyreon/no-module-mutable-in-handler | error | Request state written to a module-level binding is shared by every concurrent request — one request overwrites another between its own write and read, so a handler finishes using someone else's data |
pyreon/no-secret-in-shared-module | error | A non-public env var read from a file that also runs on the client — the bundler inlines the value, so the secret ships in the browser bundle without failing the build |
Web Perf (7)
Client-role runtime performance — files that run in the browser, after hydration.
| Rule | Severity | Description |
|---|---|---|
pyreon/no-close-before-handler-teardown | warn | close() before detaching a socket's handlers leaves a window where a buffered frame still fires into a disposed scope — null the handlers first, then close |
pyreon/no-layout-thrash | warn | A layout read after a style write inside a loop forces one synchronous reflow per iteration — invisible at ten items, a frozen tab at a thousand |
pyreon/require-abort-on-unmount | warn | A fetch started in onMount with no AbortController keeps running after unmount — its .then writes into a disposed scope and retains the component until the response settles |
pyreon/require-img-loading-hint ᵒ | info | An <img> with no loading hint competes for bandwidth with on-screen content — the usual way LCP is delayed by images nobody has scrolled to |
pyreon/no-blocking-third-party-script | warn | A <script src> without defer or async blocks the HTML parser while a third-party domain you do not control is fetched and executed |
pyreon/prefer-passive-listener | warn | A scroll / wheel / touch listener without { passive: true } forces the browser to wait for JS before scrolling — every frame of every scroll blocks on the main thread |
pyreon/no-unbounded-raf-loop | warn | A requestAnimationFrame whose id is discarded can never be cancelled — a self-scheduling callback then outlives its component, holding its whole closure |
Portable (6) ᵒ
Opt-in — must survive iOS + Android through the Pyreon Multi-Target Compiler (PMTC). All off in the standard presets.
| Rule | Severity | Description |
|---|---|---|
pyreon/no-web-only-import-in-portable | error | A web-only package imported from shared source that must reach iOS and Android — PMTC refuses it at build time; this refuses it at authoring time |
pyreon/prefer-canonical-primitive | warn | A raw DOM element in portable source — PMTC lowers only the canonical primitives, and a <div> has no SwiftUI or Compose counterpart to lower to |
pyreon/require-native-compat-marker | warn | A component using provide() / onMount() without nativeCompat() breaks under the compat JSX runtimes — its setup runs in the wrapper, not Pyreon's setup frame, and unit tests cannot see it |
pyreon/no-css-in-js-in-portable | error | CSS-in-JS in portable source — the styler stack emits real CSS into a real stylesheet, and neither native target has one |
pyreon/no-out-of-subset-construct | warn | A construct outside the PMTC subset (enum, class, try/throw, regex literal, JSON.*, computed key) in a file meant to reach iOS and Android — reported at authoring time rather than at the next native build |
pyreon/no-platform-branch-without-fallback | warn | A <Web> / <NativeIOS> / <NativeAndroid> branch with no sibling for the other targets renders NOTHING there — a blank region on a device rather than an error in source |
JS (3)
Language-level shapes oxlint cannot express — each needs project context (a package's type field) rather than AST-only reasoning.
| Rule | Severity | Description |
|---|---|---|
pyreon/no-catch-without-rethrow-or-report ᵒ | warn | A catch block that neither rethrows nor reports turns a failure into a mystery — the crash is a bug report, the swallow is a support ticket six months later |
pyreon/no-require-in-esm | error | require() in a "type": "module" package throws at runtime — and Bun defines require in ESM, so a bun-run test suite cannot catch it |
pyreon/require-error-cause | warn | Re-throwing inside catch without { cause } discards the original error — the surviving stack points at where you noticed, not where it broke |
Notable Rules
pyreon/no-process-dev-gate (auto-fixable)
Pyreon ships libraries to npm; consumers compile with whatever bundler they use — Vite, Webpack (Next.js), Rolldown, esbuild, Rollup, Parcel, or Bun. Two dev-gate patterns are broken in library code and this rule flags both:
typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'— dead code in real Vite browser bundles, because Vite does not polyfillprocess. The whole expression statically folds tofalseand the dev warning never fires.import.meta.env.DEV(and(import.meta as ViteMeta).env?.DEV === true) — Vite/Rolldown-only. Under Webpack/Next.js, esbuild, Rollup, Parcel, or Bun,import.meta.envisundefinedand the warning never fires, even in development.
// ❌ flagged — dead in real Vite browser bundles
if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') warn()
// ❌ flagged — Vite/Rolldown-only, undefined elsewhere
if (import.meta.env.DEV) warn()
// ✅ auto-fixed to the bundler-agnostic convention
if (process.env.NODE_ENV !== 'production') warn()Every modern bundler auto-replaces process.env.NODE_ENV at consumer build time and tree-shakes the dev branch to zero bytes in production — no consumer config needed. The companion rule pyreon/dev-guard-warnings recognises if (process.env.NODE_ENV === 'production') return as a valid early-return guard so warnings in the function body don't fire spuriously. Server-only packages are exempt because they always run in Node where process is real. Stays on in app — the bug hits user-facing browser code regardless of project type.
pyreon/require-browser-smoke-test
Every browser-categorized package must ship at least one *.browser.test.{ts,tsx} file under src/. The rule fires once per package on its src/index.ts, walks the package directory for browser smoke tests, and reports if none exist. This locks in the browser smoke harness: without it, new browser-running packages can silently ship without real-Chromium coverage, and happy-dom masks environment-divergence bugs (mock-vnode metadata drops, typeof process dead code, event-delegation bugs that only surface in a real browser). The default browser-package list mirrors .agents/rules/browser-packages.json; extend via additionalPackages, opt out via exemptPaths. Off in app, forced to error in lib.
{
"rules": {
"pyreon/require-browser-smoke-test": [
"error",
{
"additionalPackages": ["@my-org/my-browser-pkg"],
"exemptPaths": ["packages/experimental/"]
}
]
}
}pyreon/no-imperative-effect-on-create
effect() is for pure reactive subscriptions (signal reads driving signal writes). When an effect() callback at component-body scope does imperative work — fetch(), setTimeout/setInterval, requestAnimationFrame/requestIdleCallback, queueMicrotask, or document.* / window.* / localStorage.* / sessionStorage.* access — that work allocates per instance and runs synchronously during component setup, which compounds badly under many instances. Move it into onMount(() => { ... }):
// ❌ flagged — imperative work in a per-instance effect
effect(() => {
document.addEventListener('keydown', onKey)
})
// ✅ imperative work belongs in onMount
onMount(() => {
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
})Pure effects (effect(() => sum.set(a() + b())), effect(() => console.log(count()))) are not flagged. Foundation hooks (@pyreon/hooks, @pyreon/rx) are exempt via exemptPaths — they're the abstraction layer that wraps timers/listeners for users.
pyreon/no-async-effect
async functions passed to effect(), renderEffect(), or computed() only track signal reads up to the first await — reads after an await happen in a detached microtask with no active tracking context, so the effect never re-runs when those signals change. Read every tracked signal synchronously before the first await, or move the async work into an event handler / onMount.
pyreon/no-signal-call-write & pyreon/no-signal-in-props
no-signal-call-write flags sig(value) where sig was declared const sig = signal(...) / computed(...) — the callable form is read-only; the argument is ignored. Use sig.set(value) or sig.update(fn). no-signal-in-props flags <Comp x={sig()} /> shapes where the signal value is captured once unless the compiler wraps it — prefer the props.x access pattern (or pass the accessor) so the prop stays reactive.
pyreon/no-context-destructure
Destructuring useContext() (const { mode } = useContext(Ctx)) captures the value once at setup time. If the provider exposes values via getters (get mode()), destructuring evaluates the getter immediately and the value becomes static. Keep the context object reference and read properties lazily inside reactive scopes: const ctx = useContext(Ctx) then ctx.mode.
pyreon/no-iterate-children-without-resolve
Library code that iterates props.children at the VNode level — cloneVNode(children, …), (Array.isArray(children) ? children : [children]).map/filter(…), or reading children.props — must first unwrap a possible compiler-emitted accessor function. The compiler's prop-inlining wrap can deliver props.children as a function; iterating it directly silently produces {type: undefined} → <undefined> DOM tags. Resolve at body entry with resolveChildren(…) or typeof X === 'function' ? X() : X.
pyreon/query-options-as-function (auto-fixable, dep-gated)
@pyreon/query hooks take options as a function so queryKey can read signals and refetch reactively. An options object literal captures the key once. --fix wraps it:
// ❌ flagged — captures id() once
useQuery({ queryKey: ['user', id()], queryFn: fetchUser })
// ✅ auto-fixed — refetches when id() changes
useQuery(() => ({ queryKey: ['user', id()], queryFn: fetchUser }))useMutation is excluded (its options are imperative — no tracking). This rule is also a proactive MCP validate detector, so an AI assistant flags it before commit, not just after running lint.
Programmatic API
import { lint, listRules } from '@pyreon/lint'
// Lint a directory tree
const result = lint({ paths: ['src/'], preset: 'recommended' })
console.log(result.totalErrors, result.totalWarnings, result.totalInfos)
// Surface config-level diagnostics (malformed rule options, etc.)
for (const d of result.configDiagnostics) console.log(d.severity, d.ruleId, d.message)
// Enumerate every rule's metadata
for (const rule of listRules()) {
console.log(`${rule.id} (${rule.category}, ${rule.severity})${rule.fixable ? ' [fixable]' : ''}`)
}lint(options) — the high-level entry
LintOptions:
| Field | Type | Description |
|---|---|---|
paths | string[] (required) | Files / directories to lint (directories walked recursively) |
preset | PresetName | Base preset; defaults to the config-file preset or recommended |
fix | boolean | Apply auto-fixes and write changed files back to disk |
quiet | boolean | Drop warnings + infos from each file's diagnostics |
ruleOverrides | Record<string, Severity> | Per-rule severity overrides (highest priority for severity) |
ruleOptionsOverrides | Record<string, RuleOptions> | Per-rule options merged on top of config-file options |
config | string | Path to a specific config file (skips auto-discovery) |
ignore | string | Path to a custom ignore file |
Returns LintResult: { files, totalErrors, totalWarnings, totalInfos, configDiagnostics }. Each files[i] is a LintFileResult ({ filePath, diagnostics, fixedSource? }).
// Severity + options overrides on a single run
lint({
paths: ['.'],
ruleOverrides: { 'pyreon/no-classname': 'off' },
ruleOptionsOverrides: {
'pyreon/no-window-in-ssr': { exemptPaths: ['src/foundation/'] },
},
})lintFile(...) — the low-level single-file entry
For tooling that owns the file-gathering loop (watchers, editors, custom CI). Takes a parsed-or-to-be-parsed source string, the rule set, a config, and an optional AST cache:
import { lintFile, allRules, getPreset, AstCache } from '@pyreon/lint'
import type { ConfigDiagnostic } from '@pyreon/lint'
const cache = new AstCache()
const config = getPreset('recommended')
const configSink: ConfigDiagnostic[] = []
// First call parses + caches the AST (FNV-1a hash keyed by source text)
const r1 = lintFile('app.tsx', source, allRules, config, cache, configSink)
// Same source → cache hit, no re-parse
const r2 = lintFile('app.tsx', source, allRules, config, cache, configSink)lintFile(filePath, sourceText, rules, config, cache?, configDiagnosticsSink?) returns LintFileResult. Pass a configDiagnosticsSink array to collect malformed-option diagnostics; without it they print to stderr. Inline suppression comments and option validation are applied inside lintFile, so every entry point (CLI, lint, watcher, LSP) behaves identically.
applyFixes(source, diagnostics)
Apply every fixable diagnostic to a source string and return the result. Fixes are sorted by position descending and applied end-to-start so earlier offsets stay valid:
import { applyFixes } from '@pyreon/lint'
const fixed = applyFixes(source, fileResult.diagnostics)(lint({ fix: true }) and pyreon-lint --fix use this internally and write the result back to disk.)
getPreset(name), allRules, listRules()
import { getPreset, allRules, listRules } from '@pyreon/lint'
const config = getPreset('strict') // resolve a preset to a LintConfig
allRules // Rule[] — the full rule set (used by lintFile)
listRules() // RuleMeta[] — id, category, severity, fixable, descriptionloadConfig / loadConfigFromPath
import { loadConfig, loadConfigFromPath } from '@pyreon/lint'
const fromCwd = loadConfig(process.cwd()) // walks up for .pyreonlintrc.json / package.json field
const fromFile = loadConfigFromPath('./lint.json') // load a specific fileloadConfig returns LintConfigFile | null; loadConfigFromPath returns the parsed JSON or null if missing/invalid.
createIgnoreFilter
import { createIgnoreFilter } from '@pyreon/lint'
const isIgnored = createIgnoreFilter(process.cwd(), './.customignore')
isIgnored('/abs/path/to/file.ts') // boolean — honors .pyreonlintignore + .gitignore + the custom fileAstCache
import { AstCache } from '@pyreon/lint'
const cache = new AstCache() // FNV-1a hash keyed by source text — share across lintFile calls in watch modeThe cache keys parsed ASTs by a hash of the source text, so re-linting an unchanged file (the common case in watch mode and the LSP) skips re-parsing entirely.
Reporters
import { formatText, formatJSON, formatCompact } from '@pyreon/lint'
console.log(formatText(result)) // human-readable, colorized when stdout is a TTY
console.log(formatJSON(result)) // machine-readable
console.log(formatCompact(result)) // one line per diagnosticisProjectDependency / file-role utilities
import { isProjectDependency, isTestFile, isPathExempt } from '@pyreon/lint'
isProjectDependency('@pyreon/query', '/abs/path/to/file.ts') // dep-gating used by opt-in rules
isTestFile('/abs/src/foo.test.ts') // true
isPathExempt('/abs/src/foundation/x.ts', ['src/foundation/']) // true — the exemptPaths helper@pyreon/lint also re-exports import-analysis helpers used by the rules themselves — extractImportInfo, getLocalName, importsName, isPyreonImport, isPyreonPackage, and the LineIndex offset→line/column mapper — for authors building their own AST tooling on top of the same primitives.
Watch mode
import { watchAndLint } from '@pyreon/lint'
watchAndLint({
paths: ['src/'],
preset: 'recommended',
format: 'text', // 'text' | 'json' | 'compact'
})watchAndLint uses fs.watch (recursive) with a 100ms debounce and the shared AstCache, clearing the screen and re-printing on each change. It accepts the same LintOptions fields as lint plus a required format.
LSP server
@pyreon/lint ships a Language Server Protocol server for in-editor diagnostics and inlay hints that surface the compiler's reactivity analysis at the cursor:
pyreon-lint --lspimport { startLspServer } from '@pyreon/lint'
startLspServer() // speaks JSON-RPC over stdin/stdoutThe server advertises textDocumentSync: full and inlayHintProvider: true. On textDocument/didOpen / didChange it publishes diagnostics; on textDocument/inlayHint it renders structural reactivity facts as end-of-span ghost text. When the PYREON_LPIH_CACHE environment variable points at a live-program-inlay-hints cache file, the server also overlays live runtime data — signal fire counts, effect re-runs — at the source line that created each primitive (🔥 signal fired N×). PYREON_LPIH_PATH_MAP (format from1=to1;from2=to2) remaps host paths to container paths for dev-container / remote setups.
CI integration
# .github/workflows/lint.yml
- name: Pyreon lint
run: bunx pyreon-lint --preset strict --quiet--preset strict promotes every warning to an error so the job fails on any deviation; --quiet keeps the output to errors only. The --format json output ({ files, totalErrors, totalWarnings, totalInfos, configDiagnostics }) feeds dashboards and custom gates. For a ratcheted burn-down (counts may only decrease), pair the JSON output with a baseline file.
API Reference
Functions
| Export | Signature | Description |
|---|---|---|
lint | lint(options: LintOptions): LintResult | High-level entry: gather files, apply preset/overrides, lint, count |
lintFile | lintFile(filePath, sourceText, rules, config, cache?, configDiagnosticsSink?): LintFileResult | Low-level single-file API with optional AST cache + config-diag sink |
listRules | listRules(): RuleMeta[] | Metadata for every rule |
applyFixes | applyFixes(sourceText: string, diagnostics: Diagnostic[]): string | Apply all fixable diagnostics to a source string |
getPreset | getPreset(name: PresetName): LintConfig | Resolve a preset name to a concrete config |
loadConfig | loadConfig(cwd: string): LintConfigFile | null | Walk up from cwd for a config file / package.json field |
loadConfigFromPath | loadConfigFromPath(filePath: string): LintConfigFile | null | Load a specific config file |
createIgnoreFilter | createIgnoreFilter(cwd: string, ignorePath?: string): (filePath: string) => boolean | Build an ignore predicate from .pyreonlintignore + .gitignore |
watchAndLint | watchAndLint(options: LintOptions & { format: string }): void | Watch directories and re-lint on change (debounced, cached) |
startLspServer | startLspServer(): void | Start the LSP server over stdin/stdout JSON-RPC |
formatText / formatJSON / formatCompact | (result: LintResult): string | Render results in text / JSON / compact form |
isProjectDependency | isProjectDependency(pkg: string, filePath: string): boolean | Dep-gating used by opt-in rules |
isPathExempt | isPathExempt(filePath: string, exemptPaths: string[]): boolean | The exemptPaths substring-match helper |
isTestFile | isTestFile(filePath: string): boolean | Classify a path as a test file |
extractImportInfo / getLocalName / importsName / isPyreonImport / isPyreonPackage | (import-analysis helpers) | Building blocks for AST tooling over imports |
Values & classes
| Export | Type | Description |
|---|---|---|
allRules | Rule[] | The full rule set, in registration order |
AstCache | class | FNV-1a hash–keyed parsed-AST cache for repeat runs |
LineIndex | class | Maps byte offsets to { line, column } |
Types
| Export | Description |
|---|---|
LintOptions | Input to lint (paths required; preset/fix/quiet/overrides/…) |
LintResult | { files, totalErrors, totalWarnings, totalInfos, configDiagnostics } |
LintFileResult | { filePath, diagnostics, fixedSource? } |
Diagnostic | { ruleId, severity, message, span, loc, fix? } |
ConfigDiagnostic | Config-level diagnostic (malformed rule options) |
Fix | { span, replacement } |
Severity | 'error' | 'warn' | 'info' | 'off' |
PresetName | 'recommended' | 'strict' | 'app' | 'lib' | 'best-practices' |
RuleCategory | The 18 category literals |
Rule / RuleMeta / RuleContext | Rule-authoring surface |
RuleEntry / RuleOptions / RuleOptionsSchema / OptionType | Config rule-entry + option shapes |
LintConfig / LintConfigFile | Resolved config / on-disk config-file shape |
SourceLocation / Span | Position primitives |
ImportInfo / VisitorCallbacks | Import metadata / AST visitor map |
RuleMeta shape
interface RuleMeta {
id: string // e.g. 'pyreon/no-bare-signal-in-jsx'
category: RuleCategory
description: string
severity: Severity // default severity
fixable: boolean
schema?: RuleOptionsSchema // declared option shape, validated against user config
optIn?: boolean // opt-in best-practice rule (off in standard presets)
}Diagnostic shape
interface Diagnostic {
ruleId: string
severity: Severity
message: string
span: { start: number; end: number }
loc: { line: number; column: number }
fix?: { span: Span; replacement: string }
}