Skip to content

Latest commit

History

142 Commits

Folders and files

NameName
Last commit message
Last commit date

Coding Standards

Latest Stable Versionnpm VersionBuild StatusQuality GatesMaintainabilityCode CoverageTotal Downloads

Centralized coding standards, static analysis configurations, and code quality tooling for all Sine Macula repositories.

This package ships config files only - no runtime dependencies. Consuming projects install the tools themselves.

Installation

Composer (PHP-side: PHP CS Fixer, PHPStan, PHPCS)

composer require --dev sinemacula/coding-standards

npm (JS-side: Biome, Knip)

npm install --save-dev @sinemacula/coding-standards

The npm package ships only the static configs (js/, markdown/, yaml/, shell/, security/). The PHP autoloaded code lives in the Composer package.

Usage

Each consuming project creates thin wrapper files at its root that reference the shared configs.

PHP CS Fixer

Create a .php-cs-fixer.dist.php at your project root:

<?phpuseSineMacula\CodingStandards\PhpCsFixerConfig;
return PhpCsFixerConfig::make([
__DIR__ . '/src',
__DIR__ . '/tests',
]);

You can pass rule overrides as a second argument:

return PhpCsFixerConfig::make(
[__DIR__ . '/src', __DIR__ . '/tests'],
['strict_comparison' => false],
);

PHPCS

The SineMacula coding standard is auto-discovered via the phpcodesniffer-standard composer type. Create a phpcs.xml at your project root:

<?xml version="1.0"?>
<rulesetname="Project">
<ruleref="SineMacula"/>
<file>src</file>
<file>tests</file>
</ruleset>

PHPStan

The shared PHPStan configs are auto-included via the extra.phpstan.includes section in composer.json. Your project's phpstan.neon only needs its paths:

parameters:paths:- src- tests

Do not set level. Analysis runs through qlty, whose phpstan driver passes --level=9 on the command line, and a command-line level overrides the config file outright - so a level set here does nothing except mislead whoever reads it next.

The base config enables PHPStan's checked-exception analysis: every exception a method can throw must appear in its @throws tag, except a configured set of programming-error and infrastructure exceptions that stay unchecked - the LogicException, RuntimeException and Error families among them (see php/phpstan-base.neon for the full list). Suppress a deliberate case with @phpstan-ignore missingType.checkedException.

Laravel projects

For Laravel projects, also install sinemacula/coding-standards-laravel and reference its SineMaculaLaravel PHPCS standard (which includes this one) in place of SineMacula. It adds the Laravel-specific sniffs and PHPStan rules; see that package's README for setup.

Biome (JavaScript / TypeScript)

After installing the npm package, extend the shared Biome config from your project's biome.json (or .qlty/configs/biome.json when wired through Qlty):

{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"root": true,
"extends": [
"@sinemacula/coding-standards/js/biome.json"
],
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!**/node_modules/**",
"!**/vendor/**"
]
}
}

extends paths are resolved through normal Node module lookup, so the package only needs to be installed (no path math against node_modules/ required). Project-specific files.includes and files.excludes stay in the consumer config.

ESLint (JavaScript / TypeScript)

ESLint runs alongside Biome, not in place of it. Biome keeps owning formatting and the fast syntactic lint; ESLint adds only the two things Biome structurally cannot express: this package's custom structural rules and the opt-in type-aware rules (the curated typescript-eslint set plus the type-driven custom rules). Add the linter, the typescript-eslint tooling, and this package to your dev dependencies:

npm install --save-dev eslint typescript typescript-eslint eslint-plugin-jsdoc yaml-eslint-parser \
@sinemacula/coding-standards

yaml-eslint-parser is imported by the base layer for the YAML comment-width block, so it has to resolve even in a repository with no YAML worth linting; without it the flat config fails to load at all.

The package exposes three flat-config entry points:

  • @sinemacula/coding-standards/js/eslint - the base layer of syntax-only custom rules; needs no tsconfig, so it stays cheap and runs anywhere Biome runs. Covers .ts/.js and, for the comment-width rule alone, .yml/.yaml.
  • @sinemacula/coding-standards/js/eslint/type-checked - the opt-in type-aware layer. It includes the base layer and adds the cross-file / type-driven rules, so it needs a consumer tsconfig; use it in place of the base layer where one exists.
  • @sinemacula/coding-standards/js/eslint/vue - the opt-in Vue layer for single-file components. Unlike the type-aware layer it carries no base rules of its own, so spread it alongside whichever layer the repository already uses rather than in place of one.

Create an eslint.config.js (or .qlty/configs/eslint.config.js when wired through Qlty) that spreads the layer you want. Without a tsconfig, use the base layer:

importsmfrom'@sinemacula/coding-standards/js/eslint';exportdefault[...sm];

Where a tsconfig exists, use the type-aware layer instead (it already carries the base rules):

importtypeCheckedfrom'@sinemacula/coding-standards/js/eslint/type-checked';exportdefault[...typeChecked];

Vue repositories add the Vue toolchain and spread the Vue layer after the layer they already use:

npm install --save-dev eslint-plugin-vue vue-eslint-parser eslint-plugin-check-file
importtypeCheckedfrom'@sinemacula/coding-standards/js/eslint/type-checked';importvuefrom'@sinemacula/coding-standards/js/eslint/vue';exportdefault[...typeChecked, ...vue];

The Vue layer registers the single-file-component parser (without it .vue files are not linted at all), resolves <script lang="ts"> blocks through the TypeScript parser, and holds component filenames to kebab-case. It also carries the template layout rules, which is the one place ESLint takes on formatting: Biome does not understand single-file components, so .vue markup would otherwise go unformatted entirely. Those rules are aligned to the shared four-space indent.

When wiring ESLint through Qlty, the shared eslint plugin sandbox installs only eslint, jest, and prettier by default, so the flat config's imports of this package and typescript-eslint fail to resolve. Widen the install filter in your .qlty/qlty.toml so the sandbox carries them (this repository's source.toml exports the same override, but source-exported plugin definitions do not reliably propagate, so mirror it consumer-side):

[plugins.definitions.eslint]
package_filters = [
"@sinemacula/coding-standards", "typescript-eslint", "@typescript-eslint", "eslint-plugin-jsdoc",
"yaml-eslint-parser",
]

Repositories enabling the Vue layer widen the same filter further, since its plugins have to resolve inside that sandbox too:

[plugins.definitions.eslint]
package_filters = [
"@sinemacula/coding-standards", "typescript-eslint", "@typescript-eslint", "eslint-plugin-jsdoc",
"yaml-eslint-parser", "eslint-plugin-vue", "vue-eslint-parser", "eslint-plugin-check-file",
]

TypeScript (tsconfig)

The package ships a shared tsconfig base so every TypeScript repository checks its code to the same bar. Install the package (as above) and extend the base from your tsconfig.json:

{
"extends": "@sinemacula/coding-standards/js/tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2023",
"DOM",
"DOM.Iterable"
],
"types": [
"node"
]
},
"include": [
"src"
]
}

The base carries only the environment-independent options: the full strictness set and the module/resolution discipline. Everything environment-specific stays in the consuming repo and layers on top - lib (DOM for the browser, none for a Node service), types, Vue's jsx/jsxImportSource, paths, noEmit, and the include/exclude globs. Thetarget and module defaults suit bundler-built apps and libraries; a non-bundler project overrides them.

The base sets noPropertyAccessFromIndexSignature, so a property that comes from an index signature is accessed with brackets (config['key']), not a dot. Biome cannot see types and so cannot tell that access apart from a normal one, which is why its useLiteralKeys rule is off and the type-aware @typescript-eslint/dot-notation rule enforces dot access for real properties instead. Load the type-checked ESLint layer to get it.

Knip (JavaScript / TypeScript)

{
"$schema": "https://unpkg.com/knip@6/schema.json",
"extends": [
"@sinemacula/coding-standards/js/knip.json"
]
}

Qlty

Reference this repository as a source in your project's .qlty/qlty.toml, pinning tag to the latest release:

[[source]]
name = "sinemacula"repository = "https://github.com/sinemacula/coding-standards"tag = "<version>"

What's Included

PathToolDescription
src/PhpCsFixerConfig.phpPHP CS FixerFactory class for building PHP CS Fixer configurations
php/.php-cs-fixer.rules.phpPHP CS FixerShared rules array (PSR-12 base + org conventions)
SineMacula/ruleset.xmlPHPCSAuto-discovered coding standard (PSR-12 + exclusions)
php/phpstan-base.neonPHPStanBase config (org-wide ignored errors + settings)
js/biome.jsonBiomeJavaScript / TypeScript formatter + linter rules
js/knip.jsonKnipUnused-export detection rules
js/eslint/ESLintStructural, type-aware + Vue rules; runs with Biome
js/eslint/ (YAML block)ESLintComment width in .yml / .yaml; yamllint owns rest
markdown/.markdownlint.jsonmarkdownlintMarkdown linting rules
yaml/.yamllint.yamlyamllintYAML linting rules
shell/.shellcheckrcShellCheckShell script linting rules
security/.gitleaks.tomlGitleaksSecret-detection ruleset
editorconfig/.editorconfig-checker.jsoneditorconfig-checkerDisables only the max-line-length check

Rules

These are the custom rules this package enforces on top of PSR-12. A deliberate exception can be bypassed with the native directive - // phpcs:ignore <code> for a sniff, @phpstan-ignore <identifier> for a rule, // eslint-disable-next-line <rule> for an ESLint rule.

PHPCS sniffs

SniffEnforces
SineMacula.Attributes.DisallowToolingAttributeNo IDE/tooling attributes (e.g. JetBrains\PhpStorm).
SineMacula.Classes.RequireFinalClassConcrete classes must be final or abstract (@inheritable opts out).
SineMacula.Classes.RequireReadonlyPublicPropertyPublic properties (declared or promoted) must be readonly.
SineMacula.Commenting.CommentLineLengthStandalone comment prose wrapped to 80 chars; premature wraps also fixed.
SineMacula.Commenting.ConsistentEnumCaseCommentsEnum case docs are all-or-nothing within an enum.
SineMacula.Commenting.MultilineMethodCommentA method's doc comment must span multiple lines.
SineMacula.Commenting.RequireConstantCommentEvery class/interface/enum/trait constant needs a doc comment.
SineMacula.Commenting.RequireCopyrightTagClass/interface/enum/trait docblocks must carry an @copyright tag.
SineMacula.Commenting.RequireNonPromotedParameterCommentPlain params mixed with promoted ones need a comment.
SineMacula.Commenting.RequirePromotedPropertyCommentEvery constructor-promoted property needs a doc comment.
SineMacula.Commenting.SingleLineMemberCommentA property, constant or enum-case doc comment sits on one line.
SineMacula.Exceptions.DisallowBaseExceptionNo throwing the base \Exception; throw a domain exception.
SineMacula.Exceptions.RequireEmptyCatchCommentAn empty catch block must comment its intentional swallow.
SineMacula.Functions.RequireSensitiveParameterSecret-named params need #[\SensitiveParameter]; object types exempt.
SineMacula.Metrics.MaxMethodCountA class/interface/trait/enum may declare at most 20 methods (tests exempt).
SineMacula.Metrics.MethodLengthA method body may have at most 50 significant lines (tests exempt).
SineMacula.Namespaces.RequireConcernsNamespaceTraits must live under a Concerns namespace segment.
SineMacula.Namespaces.RequireContractsNamespaceInterfaces must live under a Contracts namespace segment.
SineMacula.Namespaces.RequireEnumsNamespaceEnums must live under an Enums namespace segment.
SineMacula.NamingConventions.BooleanMethodNamebool methods are predicates; command verbs/@imperative exempt.
SineMacula.NamingConventions.DisallowInterfacePrefixInterface names must not use the Hungarian I prefix.
SineMacula.NamingConventions.ValidEnumCaseNameEnum cases must be SCREAMING_SNAKE_CASE.
SineMacula.NamingConventions.ValidGlobalFunctionNameGlobal functions must be declared in snake_case.
SineMacula.TypeHints.RequireConstantTypeClass/interface/enum/trait constants must declare a native type.
SineMacula.WhiteSpace.PromotedConstructorSpacingBlank line above each promoted-constructor parameter.

PHPStan rules

IdentifierEnforces
sineMacula.mutableStaticPropertyStatic properties written at runtime; @managed-static opts out.
sineMacula.readonlyClassA final class with only readonly properties must be readonly.
sineMacula.redundantStaticReferenceIn a final class, new static, static:: and instanceof static are self.
sineMacula.redundantStaticReturnTypeIn a final class, a static return type or @return must be self.

ESLint rules

All rules run in the base layer except boolean-method-name, which resolves return types and so requires the opt-in type-checked layer. Every rule is scoped to .ts/.js; comment-line-wrap alone also runs over .yml/.yaml.

RuleEnforces
@sinemacula/no-interface-prefixInterface and type-alias names must not use the Hungarian I prefix.
@sinemacula/require-readonly-public-propertyPublic class properties (declared or promoted) must be readonly.
@sinemacula/valid-enum-member-nameEnum members must be declared in SCREAMING_SNAKE_CASE.
@sinemacula/boolean-method-nameBoolean-returning methods need an is/has/can prefix; @imperative exempt.
@sinemacula/no-mutable-staticNo mutable exported bindings or mutable static class fields; test code exempt.
@sinemacula/max-methods-per-classA single class may declare at most 20 methods; test code exempt.
@sinemacula/no-base-errorThrow a domain-specific Error subclass, never the base Error; test code exempt.
@sinemacula/require-copyrightEvery file must carry a documentation comment with @copyright and @author.
@sinemacula/align-doc-tags@author and @copyright values line up at a single column; autofixable.
@sinemacula/single-line-property-docA data member's documentation comment sits on one line; autofixable.
@sinemacula/multiline-function-docA method's documentation comment spans multiple lines; autofixable.
@sinemacula/comment-line-wrapStandalone comment prose wrapped to 80 chars, YAML included; premature wraps too.

boolean-method-name takes additionalPrefixes, additionalPredicates and additionalCommandVerbs (string arrays) to widen the accepted vocabulary from a consumer config. max-methods-per-class takes max, no-base-error takes allow, and require-copyright takes tags to adjust their defaults. align-doc-tags takes tags and column, the column counting from the @, so the default of 14 gives @author six spaces and @copyright three. Together single-line-property-doc and multiline-function-doc set a member's comment shape by its kind: data members (interface property signatures, enum members and data class fields) take one line, while methods, interface method signatures and class fields holding a function take several. A data comment is never required, only held to one line where present; a free function keeps the freedom of either shape.

comment-line-wrap takes maxLength (default 80) and is the syntax-only counterpart of the PHP SineMacula.Commenting.CommentLineLength sniff. It fills standalone // and # runs and multi-line docblock prose greedily, reporting an overflowing line and a prematurely wrapped line on their own footings and autofixing both. Markdown headings, docblock tag lines, machine-parsed tool directives (eslint, biome-ignore, @ts-, Stryker, c8/v8/istanbul ignore, @vite-ignore, yamllint, yaml-language-server, renovate: and the like), fenced code, an indented code or command block, a doc-tag whose value opens a multi-line bracketed type (an array{...} shape, a <...> generic or a \Closure(...) signature), tables, separators, a line whose overflow is a single unbreakable token such as a long name or URL, trailing comments after code and compact single-line docblocks are left untouched. Each comment token reclaims its own width from the line, so a # comment fills one column further than a // one.

This is the one rule the base layer also carries over .yml and .yaml, which nothing else in the standards bounds for comment width: yamllint's line-length cannot tell a comment from a value, so it would fault run: commands and action refs nobody can shorten, and it has no autofix. The YAML block registers yaml-eslint-parser for its # comments and enables this rule alone - none of eslint-plugin-yml's own rules are switched on, so YAML quoting, key order and indentation stay yamllint's business. Only standalone comments are reached: a block scalar's body is content rather than comment, so a shell comment inside a run: | step is never seen, and a comment trailing a value is not standalone.

The base layer also switches on a set of built-in rules: @typescript-eslint/no-explicit-any, curly (a brace on every control statement, as PSR-12 already requires on the PHP side), max-lines-per-function (50 lines, test code exempt) and max-depth (4), plus eslint-plugin-jsdoc rules that require a documentation comment on every declared function, method, class, interface member and class field, forbid types in @param/@returns (the tags themselves are welcome, types belong in the signature) and keep a blank line above every documentation block, single-line blocks included. The type-checked layer adds @typescript-eslint/explicit-module-boundary-types and @typescript-eslint/only-throw-error.

Methods that only throw

A method that exists solely to refuse - a __serialize() that throws so a value holding a secret cannot reach a queue payload or a cache entry - returns nothing on any path, and never is how to say so:

/** * @throws \LogicException * * @return never */publicfunction__serialize(): never
{
thrownewLogicException('A token must not be serialised.');
}

never is a subtype of every return type, so narrowing to it always satisfies an inherited signature, a magic method's expected return included. The one place it does not fit is a method a subclass is meant to return from, because a child cannot widen never back. Such a method keeps the type it declares and throws anyway, which needs no directive:

/** * @throws \LogicException * * @return array<int, string> */publicfunctionbuild(): array
{
thrownewLogicException('Not implemented.');
}

Whether a documented return is ever produced is a question of control flow, not of tokens, so no sniff here asks it - Squiz.Commenting.FunctionComment.InvalidNoReturn decides by looking for a return token and so faults exactly the guard above. PHPStan's return.missing answers it properly: it reports a method that can reach its end without returning the type it documents, and stays quiet where every path throws.

The one thing still worth knowing is that spelling out the contained type of a documented traversable can drag in mixed, which the mixed ban faults on its own footing and which has its own directive.

Requirements

  • PHP ^8.3 (Composer package)
  • Node.js (npm package)

Testing

composer test# PHPUnit suite for the custom sniffs and PHPStan rule
composer test:coverage # suite with Clover coverage output
composer test:mutation # Infection mutation gate (min MSI 90)
composer test:mutation:full # full mutation suite without thresholds
composer analyse # PHPStan static analysis
composer check # static analysis and lint via qlty
composer format # format via qlty
composer smells # duplication / complexity smells via qlty

Changelog

See CHANGELOG.md for a list of notable changes.

Contributing

Contributions are welcome. Please read CONTRIBUTING.md for guidelines on branching, commits, code quality, and pull requests.

Security

If you discover a security vulnerability, please report it responsibly. See SECURITY.md for the disclosure policy and contact details.

License

Licensed under the Apache License, Version 2.0.

About

Centralized coding standards, static analysis configurations, and code quality tooling for all Sine Macula repositories

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages