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 forms0/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 aruleId => severitymap. - Options that are not an array, and an
excludeoption 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.