Extending Sheath

Custom Rules

Custom Sheath rules use the same AbstractRule base class and RuleContext reporting model as the built-in rules.

#Rule Skeleton

Every rule must provide:

  • an ID
  • a description
  • a category
  • a default severity
  • a check(Document $document, RuleContext $context) implementation
<?php
namespace App\Sheath\Rules;
use Forte\Ast\Document\Document;
use Forte\Ast\Elements\ElementNode;
use Forte\Sheath\Rules\RuleCategory;
use Forte\Sheath\Rules\RuleContext;
use Forte\Sheath\Rules\AbstractRule;
use Forte\Sheath\Results\Severity;
class NoDataTestIdRule extends AbstractRule
{
public function getId(): string
{
return 'custom-no-data-testid';
}
public function getDescription(): string
{
return 'Production templates should not ship with data-testid attributes.';
}
public function getCategory(): RuleCategory
{
return RuleCategory::BEST_PRACTICES;
}
public function getDefaultSeverity(): Severity
{
return Severity::WARNING;
}
public function check(Document $document, RuleContext $context): void
{
$document
->getElements()
->each(function (ElementNode $element) use ($context): void {
if ($element->attributes()->has('data-testid')) {
$context->report($element, 'Remove data-testid attributes from production templates.');
}
});
}
}

#Querying the Document

The current document API exposes:

$document->getElements();
$document->getComponents();
$document->findElementByName('img');
$document->findElementsByName('button');
$document->walk(function ($node) {
// inspect the full AST
});

Typical element checks look like this:

$document
->findElementsByName('button')
->each(function (ElementNode $button): void {
$class = $button->attributes()->get('class')?->valueText();
});

#Reporting Violations

Use RuleContext to add violations:

$context->report($node, 'Message');
$context->report($node, 'Message', $fix);
$context->reportAt($startPosition, $endPosition, 'Message');

reportAt() expects Forte\Sheath\Results\Position instances, not raw offsets. report() uses the node's source range, except that an ElementNode is reported over its opening tag only. This keeps an element or missing-attribute finding from highlighting its children and closing tag. Use reportAt() when a rule intentionally needs a different range, including the whole element.

If the current config lists the rule under neverFix, Sheath automatically strips the fix before storing the violation.

RuleContext also exposes the current file path, resolved config, source text helpers, and Composer dependency checks:

$context->getFilePath();
$context->getConfig();
$context->getSourceForNode($node);
$context->getSourceAt($startOffset, $endOffset);
$context->hasPackage('livewire/livewire');
$context->packageSatisfies('livewire/livewire', '^3.0');

#Adding Autofix

Use the ReportsWithFix trait. It derives offsets from Forte's token stream, which is the only reliable way to locate part of a tag in Blade.

Do not compute tag offsets yourself!

Scanning for the > that ends a tag looks correct but can silently corrupt templates. In {{ $post->url }}, for example, the > belongs to the echoed expression.

#Using ReportsWithFix

use Forte\Sheath\Rules\Concerns\ReportsWithFix;
class RequireButtonTypeRule extends AbstractRule
{
use ReportsWithFix;
public function getId(): string
{
return 'custom-require-button-type';
}
public function getDescription(): string
{
return 'Buttons must declare a type attribute.';
}
public function getCategory(): RuleCategory
{
return RuleCategory::BEST_PRACTICES;
}
public function getDefaultSeverity(): Severity
{
return Severity::WARNING;
}
public function check(Document $document, RuleContext $context): void
{
$document
->findElementsByName('button')
->each(function (ElementNode $button) use ($context): void {
if (! $button->attributes()->has('type')) {
$context->report(
$button,
'Buttons must declare a type attribute.',
$this->createAddAttributeFix($button, 'type', 'button')
);
}
});
}
}

#What the Trait Provides

These helpers create common attribute edits while preserving source locations.

Method Produces
createAddAttributeFix($element, $name, $value) Adds name="value" to the opening tag
createInsertAttributeFix($element, $attrString) Inserts a pre-rendered attribute, e.g. a boolean like defer
createInsertAfterOpeningTagFix($element, $content) Inserts content just inside the element, as @csrf does
createReplaceAttributeFix($attribute, $newValue) Replaces an attribute outright
createRemoveAttributeFix($attribute, $dangerous = true) Removes an attribute and the whitespace before it
createSelfClosingFix($element) Turns <img> into <img />
createCollapseToSelfClosingFix($element) Turns <x-a></x-a> into <x-a />
openingTagEndOffset($element) Offset of the > that ends the opening tag
attributeInsertOffset($element) Offset where a new attribute belongs

The last two are the building blocks if you need something the helpers do not cover. Each returns null when the element is synthetic or its tag is unterminated, so a rule can decline to offer a fix rather than guess.

#Safe vs Dangerous Fixes

new Fix($start, $end, $replacement);
Fix::dangerous($start, $end, '');
Fix::fromNode($node, $replacement);
Fix::dangerousFromNode($node, '');

A dangerous fix is withheld unless the user passes --dangerous. Mark a fix dangerous when it can change what the page does, such as removing an attribute or deleting markup. Adding a missing attribute, or rewriting a value to an equivalent one, is safe.

#Rule Options

Rules can consume options through getOption().

class RequireDataIdRule extends AbstractRule
{
protected array $options = [
'prefix' => '',
'elements' => ['button'],
];
public function getId(): string
{
return 'custom-require-data-id';
}
public function getDescription(): string
{
return 'Interactive elements must declare a data-id attribute.';
}
public function getCategory(): RuleCategory
{
return RuleCategory::BEST_PRACTICES;
}
public function getDefaultSeverity(): Severity
{
return Severity::WARNING;
}
public function check(Document $document, RuleContext $context): void
{
$prefix = (string) $this->getOption('prefix', '');
$elements = $this->getOption('elements', ['button']);
foreach ((array) $elements as $tag) {
$document
->findElementsByName((string) $tag)
->each(function (ElementNode $element) use ($context, $prefix): void {
$value = $element->attributes()->get('data-id')?->valueText();
if ($value === null) {
$context->report($element, 'Element must declare data-id.');
return;
}
if ($prefix !== '' && ! str_starts_with($value, $prefix)) {
$context->report($element, "data-id must start with '{$prefix}'.");
}
});
}
}
}

Configure them in config/sheath.php:

'rules' => [
'custom-require-data-id' => ['error', [
'prefix' => 'btn-',
'elements' => ['button', 'a'],
]],
],

Configured options override the defaults on $options. Unspecified options keep their defaults, so getOption() does not need to repeat them as fallback values. Declaring defaults also opts a custom rule into option-name and type validation. Array defaults are treated as lists of strings. Use an empty list to make a string-list option configurable without enabling values by default.

The exclude option is reserved for path patterns. Sheath applies it before the rule runs, so custom rules must not use that name. See the universal exclude option.

#Registering Rules

The simplest approach is the facade in a service provider:

<?php
namespace App\Providers;
use App\Sheath\Rules\NoDataTestIdRule;
use Forte\Sheath\Facades\Sheath;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Sheath::registerRule(NoDataTestIdRule::class);
}
}

You can also register multiple rules or auto-discover a directory:

Sheath::registerRules([
NoDataTestIdRule::class,
RequireDataIdRule::class,
]);
Sheath::discoverRules(app_path('Sheath/Rules'), 'App\\Sheath\\Rules');

Registration makes a rule available but does not enable it. Add the rule to your configuration or a preset to run it.

For standalone programmatic linting with Sheath's built-ins, use RuleRegistry::withBuiltInRules(). Concrete built-in rule classes are internal: configure and reference rules by their documented IDs, which are the stable integration surface.

Rule IDs must be non-empty and globally unique. Registering the same class again is harmless, but registering a different class with an ID already in the registry throws an exception so package boot order cannot silently choose which implementation runs.

RuleRegistry::register() also accepts a rule object for integrations that need constructor-injected state. The object is an execution prototype: Sheath clones it for each lint run so configured severity and options cannot leak into later runs. Instance and class registrations must therefore be cloneable. A rule with object- or resource-valued properties must implement __clone() to copy mutable state. Rules whose dependencies are intentionally safe to share may implement the SharesRuleState marker instead. Non-cloneable rules and implicit shared object/resource state are rejected during registration with an InvalidArgumentException.

#Categories

RuleCategory is a backed enum. Its value is the prefix every rule ID in that category uses:

RuleCategory::ACCESSIBILITY; // 'a11y'
RuleCategory::BEST_PRACTICES; // 'best-practices'
RuleCategory::BLADE; // 'blade'
RuleCategory::PERFORMANCE; // 'perf'
RuleCategory::SECURITY; // 'security'
RuleCategory::SEO; // 'seo'

A rule may also return a plain string to declare a category of its own:

public function getCategory(): RuleCategory|string
{
return 'project-conventions';
}

Category is metadata: it groups rules for people reading them and never affects whether a rule runs. Use a built-in category when your rule fits one, and a string when it genuinely does not.

When you handle a category that could be either, these normalize it:

RuleCategory::nameFor($rule->getCategory()); // 'a11y' or 'project-conventions'
RuleCategory::labelFor($rule->getCategory()); // 'Accessibility' or 'project-conventions'
RuleCategory::BLADE->label(); // 'Blade'
RuleCategory::fromRuleId('a11y-alt-text'); // RuleCategory::ACCESSIBILITY

#Testing Rules

Use RuleTester for focused rule tests.

<?php
use App\Sheath\Rules\NoDataTestIdRule;
use Forte\Sheath\Testing\RuleTester;
it('reports data-testid attributes', function (): void {
(new RuleTester)->run(new NoDataTestIdRule, [
'valid' => [
'<button>Submit</button>',
],
'invalid' => [
[
'code' => '<button data-testid="submit-btn">Submit</button>',
'errors' => 1,
],
],
]);
});

Use an integer errors value when the violation count is the behavior under test. Pass an error array only when a location, fix contract, or exact message is itself significant. If your rule emits fixes, RuleTester also supports hasFix, hasDangerousFix, and output expectations.

#Package-Aware Rules

Rules can be scoped to installed Composer packages with #[RequiresPackage].

use Forte\Sheath\Attributes\RequiresPackage;
#[RequiresPackage('livewire/livewire', '^3.0')]
class LivewireV3OnlyRule extends AbstractRule
{
// ...
}

For runtime branching, AbstractRule exposes helpers:

$this->hasPackage($context, 'livewire/livewire');
$this->packageVersionAtLeast($context, 'livewire/livewire', '3.0.0');
$this->packageSatisfies($context, 'livewire/livewire', '^3.0');
$this->getPackageVersion($context, 'livewire/livewire');

#Sharing file analysis across rules

Rules in the same package often need the same element inventory, component model, or token index. Build that work once per document with analysis():

final readonly class ComponentAnalysis
{
public function __construct(public array $components) {}
}
$analysis = $context->analysis(
ComponentAnalysis::class,
fn (): ComponentAnalysis => new ComponentAnalysis(
$document->findElementsByName('x-card')->all(),
),
);

The first rule builds the object and later rules receive the same instance. Storage lasts for one linted file and is separated by parsed document, so it cannot leak into another file or mix original and semantic component documents. Factories that throw are not cached.

#Providing a Rule-Specific Document

A package rule can implement Forte\Sheath\Contracts\ProvidesRuleDocument when it needs a normalized parser view instead of the authored document. This is useful for an embedded template language whose delimiters must be masked or rewritten before a group of related rules inspects it.

ruleDocumentKey() must return a stable, non-empty key. Rules that receive the same input document and return the same key share one transformed document for that lint run. ruleDocument() receives the current document and Sheath's resolved ParserOptions, then returns the document the rule should inspect.

The returned source must have exactly the same byte length as its input. Every replacement therefore needs to preserve newlines and byte offsets so reported locations still point into the template being linted. Sheath rejects a different source length, and parser errors in the returned document fail the lint result as parse-error diagnostics.

#Declaring External Inputs for the Result Cache

Result caching tracks the template, resolved configuration, rule source, parser settings, and installed package versions. A rule that reads another input, such as an icon manifest or design-token file, must add that input to the cache context.

Such a rule declares its external inputs by implementing Forte\Sheath\Contracts\ProvidesCacheContext:

use Forte\Sheath\Contracts\ProvidesCacheContext;
use Forte\Sheath\Rules\AbstractRule;
class RequireKnownIconRule extends AbstractRule implements ProvidesCacheContext
{
/**
* @param array<string, mixed> $options The rule's configured options
* @return array<string, mixed>|string
*/
public function cacheContext(array $options): array|string
{
$manifest = (string) ($options['manifest'] ?? resource_path('icons/manifest.json'));
return is_file($manifest) ? (string) md5_file($manifest) : 'missing';
}
// ...
}

Return a deterministic snapshot, such as a content hash, version string, or array of values. When it changes, affected files are linted again. This method runs once per lint invocation, so keep it inexpensive and never return changing values such as the current time.

When several rules use the exact same external snapshot, implement Forte\Sheath\Contracts\SharesCacheContext and return the same non-empty group key from cacheContextGroup(). Sheath evaluates that group once per command cache build and assigns the result to every participating rule. Include every option that can change the snapshot in the group key; rules with different inputs must not share a group.

#Ignoring Package-Owned Template Regions

A package that embeds another template language inside Blade can keep that foreign source out of Blade and HTML rules by implementing Forte\Sheath\Contracts\IgnoredRegionProvider. Providers return zero-based, half-open byte ranges in the original template:

use Forte\Sheath\Contracts\IgnoredRegionProvider;
use Forte\Sheath\Facades\Sheath;
use Forte\Sheath\Parsing\IgnoredRegion;
final class ForeignTemplateRegions implements IgnoredRegionProvider
{
public function id(): string
{
return 'acme-foreign-template';
}
public function regions(string $source, string $filePath): iterable
{
// The package owns delimiter recognition. Return every complete or
// conservatively ignored region, including its delimiters.
yield new IgnoredRegion($startOffset, $endOffset);
}
public function cacheContext(): array|string
{
return ['syntaxVersion' => 1];
}
}
Sheath::registerIgnoredRegionProvider(ForeignTemplateRegions::class);

Register providers from a service provider so parallel Artisan workers boot the same integration. Sheath validates, sorts, and merges their ranges, then masks non-newline bytes before parsing. Original byte offsets and line numbers are preserved, suppression comments inside a masked region have no effect, and fixes cannot modify a masked region.

Ordinary rules receive the masked Document. A package rule that validates the foreign delimiters themselves can explicitly read the unmasked source with RuleContext::getOriginalSource() or a half-open slice with RuleContext::getOriginalSourceAt($start, $end). cacheContext() must return deterministic JSON-serializable data covering any configuration or external state that changes the returned ranges.

#Package Presets

A rule package can register a named preset so consumers compose it with the built-ins instead of hand-copying a rules block:

Sheath::registerPreset('acme', [
'acme-no-tracking-pixels' => 'error',
'acme-require-consent' => ['warning', ['regions' => ['eu']]],
]);

Projects enable package presets alongside built-ins:

'preset' => ['recommended', 'acme'],

The CLI equivalent is --preset=recommended,acme. Package presets cannot use a built-in name. Unregistered rules are ignored, and project rules entries override preset severities.

#Entries are validated when you register

registerPreset() validates entries immediately, usually during your service provider's boot() method. Invalid entries throw during registration and name the preset, rule ID, and offending value.

What throws:

  • A name that is empty or shadows a built-in preset.
  • A severity that is not one Sheath knows (error, warning/warn, info, off, or the numeric forms 0/1/2), whether given as a bare string or inside a [severity, options] entry. The list is parsed by the same enum the linter uses, so it cannot drift.
  • An entry value that is neither a string nor an array (42, null, true), and entries keyed by position instead of rule ID. A common mistake is passing a list of rule IDs instead of a ruleId => severity map.
  • Options that are not an array, and an exclude option that is not a list of pattern strings.

Unknown rule IDs do not throw. The available rules depend on the consuming project, and a preset may include rules from an optional companion package. When Sheath resolves the preset, it keeps only rules available in that project. If none of the preset's rules are available, the lint command warns instead of letting an empty run look successful.

#Order matters when you override other packages' rules

Presets apply from left to right, and later entries override earlier ones. If your preset changes rules provided by another package, put it after that package's preset so your changes take effect.

Suppose nativephp ships overrides tuning three recommended rules for native-app views, where <button> semantics do not apply:

// Right: the package's overrides are applied after recommended.
'preset' => ['recommended', 'nativephp'],
// Wrong: recommended is applied second and replaces the package overrides.
'preset' => ['nativephp', 'recommended'],

Reversing the order can restore false positives and enable unwanted fixes. Document the intended composition order alongside your preset's name.

#Prefer exclude over turning other packages' rules off

An override of 'off' disables the rule everywhere. Use the universal exclude option to disable it only in the views where it does not apply:

Sheath::registerPreset('nativephp', [
// Native views live under views/native/; the web views keep coverage.
'best-practices-button-type' => ['warning', ['exclude' => ['views/native/']]],
'a11y-button-accessible-name' => ['error', ['exclude' => ['views/native/']]],
'a11y-form-label' => ['error', ['exclude' => ['views/native/']]],
// ... the package's own rules ...
]);

The patterns use the same glob dialect as the global ignore list and match the file's path at any segment boundary. Consumers can still override the entry. A later configuration replaces the entire earlier entry, including exclude, so a project whose native views live elsewhere can set its own.

#See Also