Developer Guide
June 22, 2026 · View on GitHub
Prerequisites
To work on this project, it is required to have the following tools installed:
RSPEC Access
For the full RSPEC sync and lifecycle model, including the distinction between
resources/rule-data-state.json, root rspec.sha, and generated per-language rspec.sha, see
RSPEC Rule-Data Lifecycle.
The build fetches rule metadata from the private SonarSource/rspec repository through
rspec-maven-plugin. The generated RSPEC rule metadata JSON published under
sonar-plugin/*/src/main/resources/**/rules/*/ is tracked in Git. The generated HTML files and
per-language rspec.sha files remain build artifacts and are not tracked.
For local development, the Maven flow can reuse your existing GitHub CLI login. Running
gh auth login is enough on a fresh checkout or after mvn clean.
If you already have a token with read access to SonarSource/rspec, exporting GITHUB_TOKEN still
works as well.
In CI, the token is provided from Vault-managed secrets and passed explicitly to the Maven plugin.
Examples:
-
Reuse your GitHub CLI login:
gh auth login -
Or use a token with read access to
SonarSource/rspecand export it in your shell:export GITHUB_TOKEN="your-token-here" -
Restart your terminal or source the file if needed:
source ~/.zshenv
npm run generate-meta first runs npm run ensure-rule-data. This reuses prepared RSPEC outputs
when the generated local rule data directories, per-language rspec.sha files, and ignored
resources/rule-data-state.json stamp match the current checkout. On a fresh checkout, after
switching revisions, after changing the ignored root rspec.sha pin, or after mvn clean, it runs
Maven first and uses either your GitHub CLI auth or GITHUB_TOKEN to fetch from
SonarSource/rspec.
To pin all rule data generation to an exact RSPEC revision, write the commit SHA to rspec.sha at
the repository root and run npm run ensure-rule-data. When the ignored root pin is present, the
Maven wrapper passes it to rspec-maven-plugin for both JavaScript and CSS.
Prepared rule data keeps separate generated pins in
sonar-plugin/javascript-checks/src/main/resources/rspec.sha and
sonar-plugin/css/src/main/resources/rspec.sha. If there is no root pin, the Maven wrapper falls
back to these per-language generated pins when they are present. For direct Maven commands that
generate rule data, use Direct Maven pinning: pass -Drspec.sha=<commit-sha> to pin both languages,
or -Drspec.javascript.sha=<commit-sha> and -Drspec.css.sha=<commit-sha> to pin them
independently. When npm run ensure-rule-data detects stale state without a root pin, it clears the
generated per-language pins before regenerating so pins from another checkout are not reused.
You can also use Docker container defined in ./.cirrus/nodejs.Dockerfile which bundles all
required dependencies and is used for our CI pipeline.
Build and run unit tests
To build the plugin and run its unit tests, execute this command from the project's root directory:
mvn clean install
To skip tests:
mvn clean install -DskipTests
Integration Tests
First make sure the submodules are checked out:
git submodule init
git submodule update
Plugin Tests
The "Plugin Test" is an integration test which verifies plugin features such as metric calculation, coverage etc.
cd its/plugin
mvn clean install
Ruling Tests
The "Ruling Test" is an integration test which launches the analysis of a large code base of third-party projects (stored as submodules), saves the issues created by the plugin in report files, and then compares those results to the set of expected issues (stored as JSON files). This test gives you the opportunity to examine the issues created by each rule and make sure that they are what you expect.
JS/TS
npm run ruling
The generated issues are written under packages/ruling/actual/<project>/. The expected issues are
stored under its/ruling/src/test/expected/<project>/.
From the project root, run: npm run ruling-sync
You can review the Ruling difference by running sh tools/ruling-debug-script.sh.
Java ruling (old way)
cd its/ruling
mvn verify -Dtest=RulingTest -Dmaven.test.redirectTestOutputToFile=false
To review the Ruling difference in SonarQube UI, put the breakpoint on assertThat(...) in
RulingTest.java and open in the browser the orchestrated local SonarQube. Note that you can fix
the port in orchestrator.properties files, e.g. orchestrator.container.port=9100.
If everything looks good to you, you can copy the file with the actual issues located at
its/ruling/target/actual/ into the directory with the expected issues
its/ruling/src/test/expected/.
You can review the Ruling difference by running diff -rq src/test/expected target/actual from
its/ruling.
:warning: Please note that running ruling tests will remove
node_modulesfrom the root to avoid affecting the results. Runnpm cito put them back.
Debug node process during scan
You can run your own Node.js process manually and set the environment variable
SONARJS_EXISTING_NODE_PROCESS_PORT with the value of the port where your process is listening to.
When set, SonarJS will not start a new Node process and will send the analysis requests to the
specified port instead.
When using this for the ruling tests, make sure that you run them in series (and not in parallel),
by removing @Execution(ExecutionMode.CONCURRENT) from the ruling test.
Testing in VS Code SonarLint
To patch a local VS Code SonarLint or SonarQube for IDE installation with the latest SonarJS master build from Repox, see Testing VS Code SonarLint with the latest SonarJS master build.
Adding a rule
Rule Description
-
Create a PR with a rule description in the RSPEC repo as described in the RSPEC create-or-modify-a-rule guide
- Tag the RSPEC with
type-dependentif the rule relies partially or fully on type information.- In practice, this means the implementation uses TypeScript parser services, either directly or through shared helpers or wrappers.
- Direct signals are code paths using
context.sourceCode.parserServices,isRequiredParserServices(...),services.program.getTypeChecker(), or ESTree/TypeScript node maps such asservices.esTreeNodeToTSNodeMap. - Indirect signals include helpers that wrap the type checker such as
getTypeFromTreeNode(...), regex rules built withcreateRegExpRule(...), and wrapped@typescript-eslintrules whose implementation requires typed services. - Do not use
type-dependentfor rules that only inspect TypeScript syntax and never query typed parser services.
- Add a field
dependenciesif your rule should only be executed if it relies on a specific import (example: 'react' or 'jest'). - Add a field
compatibleLanguageswith an array including which languages you support (jsand/orts).
- Tag the RSPEC with
-
Link this RSPEC PR to the implementation issue in this repo
-
Make sure the implementation issue title contains the RSPEC number and name
Implementing a rule
- Generate other files required for a new rule. Just choose your options in the prompt of the
new-rulescript
npm run new-rule
This script will ask a few questions and:
- generates a
rules/SXXXXfolder - generates a
rules/SXXXX/index.tsrule index file - generates a
rules/SXXXX/rule.tsfile for the rule implementation - generates a
rules/SXXXX/cb.fixture.jscomment-based test file (empty) - generates a
rules/SXXXX/cb.test.jstest launcher
It will also update some files which are not tracked by Git as they are automatically generated:
- generates a Java check class for the rule
SXXXX.java - updates the
rules/rules.tsfile to include the new rule - updates the
rules/plugin-rules.tsfile to include the new rule - updates the
AllRules.javato include the new rule
-
Update generated files
- Make sure annotations in the Java class specify languages to cover (
@JavaScriptRuleand/or@TypeScriptRule) - If your rule has configurations, or you are using some from an ESLint rule, override the
configurations()method of the Java check class- You can use a
MyRuleCheckTest.javatest case to verify how the configurations will be serialized to JSON as shown here
- You can use a
- If writing a rule for the test files, replace
extends Checkwithextends TestFileCheckin the Java class. This will be done by thenew-rulescript, but make sure you are extending the right base class.
- Make sure annotations in the Java class specify languages to cover (
-
Implement the rule logic in
S1234/rule.ts- Prefer using
meta.messagesto specify messages throughmessageIds. Message can be part of the RSPEC description, like here. - If writing a regex rule, use createRegExpRule
- Prefer using
-
If possible, implement quick fixes for the rule:
- If the ESLint fix is at the root of the report (and not in a suggestion), add the message for
the quick fix in
rules/SXXXX/meta.ts. - Add a code fixture that should provide a quickfix in
tests/linter/fixtures/wrapper/quickfixes/<ESLint-style rulekey>.{js,ts}. The following test asserts that the quickfix is enabled.
- If the ESLint fix is at the root of the report (and not in a suggestion), add the message for
the quick fix in
Testing a rule
We support 2 kinds of rule unit-tests: ESLint's RuleTester or our comment-based tests.
Comment-based testing
These tests are located in the rule folder and they MUST be named *.fixture.* (where the
extension could be one of js, ts, jsx, tsx, vue). If options are to be passed to the
tested rule, add a JSON file to the same directory named cb.options.json. The file must contain
the array of options.
The contents of the test code have the following structure:
some.clean.code();
some.faulty.code(); // Noncompliant [[qf1,qf2,...]] {{Message to assert}}
// ^^^^^^
// fix@qf1 {{Suggestion description}}
// edit@qf1 [[sc=1;ec=5]] {{text to replace line from [sc] column to [ec] column}}
faulty.setFaultyParam(true);
// ^^^^^^^^^^^^^^< {{Optional secondary message to assert}}
The contents of the options file must be a valid JSON array:
// brace-style.json
["1tbs", { allowSingleLine: true }];
If your rule depends on a dependency declared in the package.json file, you can add the following
clause to your test:
process.chdir(__dirname); // change current working dir to avoid the package.json lookup to up in the tree
and define multiple subfolders for your different settings like:
- fixtures/setup-1/cb.test.ts
- fixtures/setup-1/cb.fixture.ts
- fixtures/setup-2/cb.test.ts
- fixtures/setup-2/cb.fixture.ts
You can find an example at the bottom of this document.
Tests syntax
Given the above test snippet, issue messages ({{...}}) and quick fixes (if the rule provides them)
are mandatory. The issue primary location (// ^^^^) and secondary location(s) (// ^^^<) are
optional.
Noncompliant lines will be associated by default to the line of code where they are written. The
syntax @line_number allows for an issue to be associated to another line:
// Noncompliant@2 [[qf1,qf2,...]] {{Optional message to assert}}
some.faulty.code();
Another option is to use relative line increments (@+line_increment) or decrements
(@-line_decrement):
// Noncompliant@+1
some.faulty.code();
another.faulty.code();
// another comment
// Noncompliant@-2
Secondary locations
Secondary locations are part of Sonar issues. They provide additional context to the raised issue. In order to use them, you must call the toEncodedMessage() function when reporting the issue message like this:
context.report({
node,
message: toEncodedMessage(...),
});
In order to indicate secondary locations, you must use either // ^^^^^< or // ^^^^>, the arrow
indicating whether the matching main location is either before or after the secondary one.
As stated before, the message is optional.
**/!** If you have used a secondary location in your test file, you must always report error messages using toEncodedMessage() in your rule, as it will be expecting it.
Quick fixes
Quick fixes refer to both ESLint
Suggestions
and Fixes. In
our comment-based framework both use the same syntax, with the difference that a quick fix ID
followed by an exclamation mark (!) will be internally treated as a fix with ESLint instead of
as a suggestion. Please note that rules providing fixes MUST be tested always with fixes,
otherwise the test will fail with the following error:
The rule fixed the code. Please add 'output' property.. On the other side, it is optional to check
against rule suggestions, meaning that even if a rule provides them, the tests can choose not to
test their contents.
The fix@ comment referring to a quick fix provides the suggestion description and is optional.
Eslint fixes do not support descriptions, meaning a quick fix ID declared with an exclamation mark
(i.e. qf1!) must NOT have a fix@ matching comment (i.e. fix@qf1).
Each quick fix can have multiple editions associated to it. There are three different kind of
operations to edit the code with quick fixes. Given a quick fix ID qf, these are the syntaxes used
for each operation:
add@qf {{code to add}}Add the string between the double brackets to a new line in the code.del@qfRemove the lineedit@qf1 [[sc=1;ec=5]] {{text to replace the range }}Edit the line from start columnscto end columnec(both 0-based) with the provided string between the double brackets. Alternatively, one can conveniently use onlyscorec. also optional, meaning this syntax can be used too:edit@qf1 {{text to replace the whole line -do not include //Noncompliant comment- }}
The line affected in each of these operations will be the line of the issue to which the quick fix
is linked to. It is possible to use the line modifier syntax (@[+|-]?line). When using line
increments/decrements, keep in mind the base number is the issue line number, not the line of the
quick fix edit comment. Example for rule brace-style:
//Noncompliant@+1 [[qf!]]
if (condition) {
doSomething();
}
// edit@qf [[sc=16]] {{}}
// add@qf@+1 {{ doSomething()}}
The expected output is:
if (condition) {
doSomething();
}
Let's go through the syntax used in this example:
- The test provides a fix (note the
!after the IDqf). - The line
//Noncompliant@+1 [[qf!]]means that in the following (@+1) line there is an issue for which we provide a quick fix. - The line
// edit@qf [[sc=16]] {{}}is providing an edit to the same line of the issue, replacing the contents after column 16 (sc=16) by an empty string ({{}}). An alternative with the same effect would be// edit@qf {{if (condition) {}}, which would replace the whole line byif (condition) {. - Lastly, the line
// add@qf@+1 {{ doSomething()}}will add a new line just after the issue line (@+1) with the contents doSomething()
Note that the length of the list of quick fixes cannot surpass the number of issues declared by N
or the number of expected messages unless their matching issue is reassigned (see below).
Quick fixes IDs can be any string, they don't have to follow the qfN convention. The order of
the list is important, as they will be assigned to the message in the matching position. If one
provides 3 messages and 2 quick fixes which are not to be matched against first and second message,
there are two options:
- A dummy quick fix can be used as placeholder:
some.faulty.code(); // Noncompliant [[qf1,qf2,qf3]] {{message1}} {{message2}} {{message3}}
// edit@qf1 {{fix for message1}}
// edit@qf3 {{fix for message3}}
// qf2 is declared but never used --> ignored by the engine
- Explicitly set the index (0-based) of the message to which the quick fix refers to with the syntax
=indexnext to the quick fix ID:
some.faulty.code(); // Noncompliant [[qf1,qf3=2]] {{message1}} {{message2}} {{message3}}
// edit@qf1 {{fix for message1}}
// edit@qf3 {{fix for message3}}
This last syntax is also needed if multiple suggestions are to be provided for the same issue:
some.faulty.code(); // Noncompliant [[qf1,qf2=0]]
// fix@qf1 {{first alternative quickfix description}}
// edit@qf1 {{some.faulty?.code();}}
// fix@qf2 {{second alternative quickfix description}}
// edit@qf2 {{some.faulty && some.faulty.code();}}
Ruling
Make sure to run Ruling ITs for the new or updated rule (don't forget to rebuild the jar before that!).
If your rule does not raise any issue, you should write your own code that triggers your rule in:
its/sources/custom/jsts/S1234.jsfor codeits/sources/custom/jsts/tests/S1234.jsfor test code
You can simply copy and paste compliant and non-compliant examples from your RSPEC HTML description.
Examples
- Security Hotspot implementation: PR
- Quality rule implemented with quickfix: PR
- Adding a rule already covered by ESLint or its plugins: PR
- Adding a quickfix for rule covered by ESLint or its plugins: PR
- Adding a rule covered by ESLint with an ESLint "fix" quick fix: PR
- Decorate a rule covered by ESLint: PR
- Merge 2 ESLint rules: PR
- Use comment-based tests with
package.jsondependencies dependent rule: PR - Use ESLint's Rule tester with
package.jsondependencies dependent rule: PR
Rule Options Architecture
This section explains how rule options (configurations) work across the SonarJS stack.
Overview
There are two parallel workflows for requesting JS/TS analysis from Node.js:
1. SonarQube workflow (HTTP bridge via WebSocket):
SonarQube UI → Java Check Class → HTTP/WebSocket → analyzeProject() → ESLint Linter
↓
configurations() returns
typed objects (int, boolean, etc.)
2. External workflow (gRPC - without SonarQube):
External Client → gRPC → transformers.ts → analyzeProject() → ESLint Linter
↓
parseParamValue() converts
string params to typed values
The key difference is that SonarQube's Java side sends already-typed values via configurations(),
while the gRPC endpoint receives string key-value pairs that need type parsing.
Each rule can have configurable options defined in several places that serve different purposes.
File Structure for a Rule
Each rule lives in packages/analysis/src/jsts/rules/SXXXX/ with these key files:
| File | Purpose |
|---|---|
rule.ts | Rule implementation (ESLint rule factory) |
meta.ts | Manual metadata: implementation, eslintId, schema, re-exports fields |
config.ts | Option definitions with fields array |
generated-meta.ts | Auto-generated: defaultOptions, sonarKey, scope, languages |
Implementation Types
The implementation field in meta.ts determines how a rule is structured:
original
Rules written from scratch for SonarJS. If the rule accepts options, it defines its own JSON Schema
in meta.ts. Rules without options don't need a schema or config.ts:
// S100/meta.ts
export const implementation = "original";
export const eslintId = "function-name";
export * from "./config.js";
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
export const schema = {
type: "array",
items: [{ type: "object", properties: { format: { type: "string" } } }],
} as const satisfies JSONSchema4;
decorated
Rules that wrap/extend an existing ESLint rule, adding SonarJS-specific behavior. They may
optionally define a schema if needed:
// S109/meta.ts - no schema, uses external rule's schema at runtime
export const implementation = "decorated";
export const eslintId = "no-magic-numbers";
export const externalRules = [
{ externalPlugin: "typescript-eslint", externalRule: "no-magic-numbers" },
];
export * from "./config.js";
// S107/meta.ts - explicit schema (when customization is needed)
export const implementation = "decorated";
export const eslintId = "max-params";
export const externalRules = [{ externalPlugin: "eslint", externalRule: "max-params" }];
export * from "./config.js";
export const schema = {
/* ... */
} as const satisfies JSONSchema4;
external
Rules that directly use an ESLint rule without modification. The schema is inherited from the
external rule at runtime. Some external rules expose user-configurable options via config.ts
(e.g., S103, S139, S1441):
// S106/meta.ts
export const implementation = "external";
export const eslintId = "no-console";
export const externalPlugin = "eslint";
export * from "./config.js";
The fields Array (config.ts)
The fields array is the source of truth for rule options. It defines:
- ESLint field names
- Default values (which determine types)
- SonarQube UI descriptions
- Key mappings when SQ and ESLint names differ
// S107/config.ts
export const fields = [
[
{
field: "max", // ESLint option name
displayName: "maximumFunctionParameters", // SonarQube UI name (optional)
description: "Maximum authorized...", // Shows in SQ UI
default: 7, // Default value & type inference
},
],
] as const satisfies ESLintConfiguration;
Field Properties
| Property | Required | Purpose |
|---|---|---|
field | Yes | ESLint/schema key name |
default | Yes | Default value; also determines type (number, string, boolean, arrays) |
description | For SQ visibility | Makes the option visible in SonarQube UI |
displayName | No | SonarQube key if different from field |
items | For arrays | { type: 'string' } or { type: 'integer' } |
fieldType | No | Override SQ field type (e.g., 'TEXT') |
Making Options Visible in SonarQube
A field is only visible in SonarQube if it has a description.
The Java code generator (tools/generate-java-rule-classes.ts) checks:
function isSonarSQProperty(property): property is ESLintConfigurationSQProperty {
return property.description !== undefined;
}
Fields without description are internal-only defaults that users cannot configure.
Example - S109 (Magic Numbers):
// S109/config.ts - NO descriptions, so not exposed in SQ
export const fields = [
[
{ field: "ignore", default: [0, 1, -1, 24, 60] }, // Internal only
{ field: "ignoreDefaultValues", default: true }, // Internal only
],
] as const satisfies ESLintConfiguration;
Example - S2068 (Hardcoded Credentials):
// S2068/config.ts - HAS description, so visible in SQ
export const fields = [
[
{
field: "passwordWords",
items: { type: "string" },
description: "Comma separated list of words identifying potential passwords.",
default: ["password", "pwd", "passwd", "passphrase"],
},
],
] as const satisfies ESLintConfiguration;
Key Mapping: SonarQube ↔ ESLint
When SonarQube and ESLint use different names for the same option:
| SonarQube Key | ESLint Key | Mapping |
|---|---|---|
maximumFunctionParameters | max | displayName: 'maximumFunctionParameters' in config.ts |
format | format | No displayName needed (same name) |
The transformation layer (packages/grpc/src/transformers.ts) handles this mapping at runtime.
JSON Schema vs fields
| Aspect | JSON Schema (meta.ts) | fields (config.ts) |
|---|---|---|
| Purpose | ESLint validation | SQ UI + defaults + key mapping |
| Used by | ESLint at runtime | Java codegen, meta generation, linter |
| Required for | original rules with options | All rules with options |
| Defines | Structure & constraints | Defaults, descriptions, SQ keys |
Important: The schema is for ESLint validation. The fields array provides default values and
metadata for SonarQube integration. For original rules with options, both schema and fields must
be kept in sync manually. For decorated/external rules, the schema is inherited from the
external rule at runtime.
defaultOptions in generated-meta.ts
The npm run generate-meta script reads fields and generates defaultOptions:
// generated-meta.ts (auto-generated)
export const meta = {
// ...
defaultOptions: [
{ format: "^[_a-z][a-zA-Z0-9]*$" }, // From fields[0][0].default
],
};
This is extracted using the defaultOptions() helper from helpers/configs.ts.
How Options Flow at Runtime
SonarQube workflow (gRPC)
- Java Side:
@RulePropertyfields are read,configurations()returns typedList<Object>(e.g.,Map.of("max", 7)) - Transport:
AnalyzeProjectMessagesconverts those Java values to protobufStruct/Valuewhile preserving their types - Linter:
linter.ts:createRulesRecord()merges defaults with user config:rules[`sonarjs/${rule.key}`] = [ "error", ...merge(defaultOptions(ruleMeta.fields), rule.configurations), ];
gRPC workflow (external clients)
- Client: Sends rule params as string key-value pairs via proto3
- Transformer:
transformers.tsmaps SQ keys → ESLint keys and parses string values to proper types - Linter: Same merging as above
Type Parsing from Strings (gRPC only)
The gRPC workflow receives all param values as strings. The transformer parses them based on the
default value type in fields:
| Default Type | Input String | Parsed Result |
|---|---|---|
number | "5" | 5 |
boolean | "true" | true |
string | "pattern" | "pattern" |
string[] | "a,b,c" | ["a", "b", "c"] |
number[] | "1,2,3" | [1, 2, 3] |
Adding Options to an Existing Rule
- Update
config.tswith the new field in thefieldsarray - Add
descriptionif it should be visible in SonarQube - Update
meta.tsschema (fororiginal/decoratedrules) to match - Run
npm run generate-metato updategenerated-meta.ts - Run
npm run generate-java-rule-classesto update Java check classes
Common Patterns
Object-style configuration (most common):
// config.ts
export const fields = [
[
{ field: "max", description: "...", default: 7 },
{ field: "ignoreIIFE", description: "...", default: false },
],
] as const satisfies ESLintConfiguration;
// ESLint receives: [{ max: 7, ignoreIIFE: false }]
Primitive configuration:
// config.ts
export const fields = [
{ default: "^[a-z]+$" }, // Single non-array element
] as const satisfies ESLintConfiguration;
// ESLint receives: ['^[a-z]+$']
Array options (comma-separated in SQ):
// config.ts
export const fields = [
[
{
field: "passwordWords",
items: { type: "string" }, // Required for Java codegen
description: "Comma separated list...",
default: ["password", "pwd"],
},
],
] as const satisfies ESLintConfiguration;
// SQ sends: "password,pwd,secret"
// ESLint receives: [{ passwordWords: ['password', 'pwd', 'secret'] }]
Misc
- Use issue number for a branch name, e.g.
issue-1234 - You can use AST explorer to explore the tree share. Use the
regexppparser when implementing a Regex rule. - ESlint's working with rules
Issue tracking
Working on a rule
You don't need to make separate Jira tickets for RSPEC and rule implementation, a single one is good enough.
Add a link to the RSPEC PR from the SonarJS PR as shown in this example.