Repository navigation
rs lint -c <path>: JS plugin rules load the config from cwd in Rslint workers #591
Description
When rs lint -c <path> points to a file other than the rstack.config.* that would be found in the cwd (a config in another directory, or one with another name such as -c lint.config.mjs), rules from object-form JS plugins (plugins: { kw: { rules: { ... } } }) are not loaded from the -c file. Rslint's plugin worker threads re-import Rstack's internal Rslint config module, and that module does not know the -c path inside a worker, so it looks for rstack.config.* in the current working directory instead. Depending on what the cwd contains, the run fails during worker init, silently runs a rule implementation from another config, or reports rule not found in any loaded plugin. Configs that only use built-in rules are not affected.
Environment
- rstack 0.8.5 with @rslint/core 0.9.5, Node 22.23.3 and 24.21.0, Linux x64
- Same output on rstack 0.8.3 (Node 22.23.3) and on a build of
mainat 5fe9881 (Node 22.23.3 and 24.21.0) - Using a build of current Rslint
mainin place of @rslint/core 0.9.5 does not change the result
Reproduction
.
├── package.json
└── proj
├── rstack.config.mjs
└── src
└── index.js
package.json
{
"private": true,
"type": "module",
"devDependencies": {
"rstack": "0.8.5"
}
}proj/rstack.config.mjs
import { define } from 'rstack';
const noFoo = {
meta: { type: 'problem', schema: [], messages: { foo: 'foo is not allowed' } },
create(context) {
return {
Identifier(node) {
if (node.name === 'foo') context.report({ node, messageId: 'foo' });
},
};
},
};
define.lint([
{
files: ['**/*.js'],
plugins: { kw: { rules: { 'no-foo': noFoo } } },
rules: { 'kw/no-foo': 'error' },
},
]);proj/src/index.js
export const foo = 1;npm install
(cd proj && npx rs lint) # kw/no-foo at src/index.js:1:14, exit 1 (correct)
npx rs lint -c proj/rstack.config.mjs proj/src # run from the rootActual output of the last command (exit 1):
start Linting...
[rslint:plugin] error No lint configuration found. Add define.lint(...) to your Rstack config file.
[rslint:plugin] error No lint configuration found. Add define.lint(...) to your Rstack config file.
error Linting failed in 108ms: worker exited during init (code=1) before sending ready
Expected (exit 1):
start Linting...
kw/no-foo — [error] foo is not allowed
╭─┴──────────( proj/src/index.js:1:14 )─────
│ 1 │ export const foo = 1;
│ 2 │
╰────────────────────────────────
error Lint failed with 1 error in 195ms (1 file, 1 rule, 32 threads)
The same failure occurs without changing directory when the config has another name: rename proj/rstack.config.mjs to proj/lint.config.mjs and run npx rs lint -c lint.config.mjs src in proj/.
--type-check and --type-check-only fail the same way. So does the lint phase of rs check -c proj/rstack.config.mjs proj/src; its format check then passes, and the command exits 1. The error is printed twice because Rslint starts up to two warm-up workers (worker-pool.ts#L268-L269).
Plain rslint -c proj/rslint.config.mjs proj/src, using the same plugin in an rslint.config.mjs, reports the expected finding from the root, even when the root has a conflicting rslint.config.mjs of its own.
When the cwd has a different rstack.config.mjs
Add rstack.config.mjs in the root:
import { define } from 'rstack';
const other = {
meta: { type: 'problem', schema: [], messages: { other: 'reported by ./rstack.config.mjs' } },
create(context) {
return {
Program(node) {
context.report({ node, messageId: 'other' });
},
};
},
};
define.lint([
{
files: ['**/*.js'],
plugins: { kw: { rules: { 'no-foo': other } } },
rules: { 'kw/no-foo': 'warn' },
},
]);npx rs lint -c proj/rstack.config.mjs proj/src now gets past worker init but runs the root config's implementation (exit 1):
start Linting...
kw/no-foo — [error] reported by ./rstack.config.mjs
╭─┴──────────( proj/src/index.js:1:1 )─────
│ 1 │ export const foo = 1;
│ 2 │
╰────────────────────────────────
error Lint failed with 1 error in 107ms (1 file, 1 rule, 32 threads)
The severity (error) comes from the -c file, while the rule implementation comes from the cwd config (which sets warn). If the cwd config does not define kw/no-foo, for example define.lint([{ rules: { 'no-debugger': 'error' } }]), each file reports kw/no-foo — [error] rule error: rule not found in any loaded plugin instead.
Cause
setupCommandsremoves-c(viaparseCliArgs) and stores the resolved path inglobalThis.__rstackCliState(commands.ts#L216-L225, config.ts#L133-L141).runRslintCLIpasses the internaldist/rslintConfig.jsto Rslint as--config, not the user's file (commands.ts#L149-L160).rs checkgoes through the same function (#L185-L189).rslintConfig.tscallsloadRstackConfig()with no arguments at module top level and callsprocess.exit(1)when no lint config is found (rslintConfig.ts#L10-L19).loadRstackConfig()usesconfigFilePath ?? state.configPathand otherwise searches the cwd forrstack.config.*(config.ts#L264-L287).- For object-form plugins, Rslint sends the
--configpath to each plugin worker inworkerData, and the worker re-imports that file to get the live plugin objects (worker-pool.ts#L717-L738, plugin-loader.ts#L335, define-config.ts#L247-L251). Aworker_threadsworker has its ownglobalThis, so__rstackCliStateis empty there, and the module falls back to the cwd. The main thread still loads the-cfile, which is why the rule list and severities are correct while the implementations are not.
In a worker, this does not match the documented behavior of loadRstackConfig(): "If omitted, the loader uses the path specified by the CLI's --config option" (api-reference.mdx#L123).
Fix
I will open a PR for this part only. Before starting Rslint, rs lint and rs check write the resolved -c path to a RSTACK_CLI_CONFIG_PATH environment variable, and the internal lint config passes it to loadRstackConfig(). Rslint creates its workers without an env option (worker-pool.ts#L717-L738), so they inherit the variable. Without -c, the variable is removed, so an inherited value is ignored.
Related: paths in a -c config resolve from the cwd
This is a separate point, and the current behavior may be intended, so it is a question rather than part of the bug above. The PR does not change it.
rslintConfig.ts uses process.cwd() as the default basePath of every entry. The code comment there says this preserves "the historical behavior of resolving user-authored paths from the invocation directory" (#431, rslintConfig.ts#L21-L30). The implicit tsconfig lookup uses the same base (#L39-L50). When -c points into another directory, files, ignores and the tsconfig lookup therefore resolve from the cwd. Plain rslint -c resolves relative paths from the config file's directory when an entry has no basePath (base-path.md#L33-L36), and rs fmt -c uses the config file's directory as its root (fmt/config.ts#L161).
With type checking, this gives a silent pass. Fixture in the same root (built-in rules only, so the worker bug is not involved):
typed/rstack.config.mjs
import { define } from 'rstack';
define.lint([{ rules: { 'no-debugger': 'error' } }]);typed/tsconfig.json
{
"compilerOptions": { "strict": true, "noEmit": true },
"include": ["src"]
}typed/src/index.ts
export const n: number = 'a';(cd typed && npx rs lint --type-check-only) # TS2322 at src/index.ts:1:14, exit 1
npx rs lint -c typed/rstack.config.mjs --type-check-only typed/src # run from the rootOutput of the last command (exit 0):
start Type checking...
success No files to type check in 37ms
files and ignores behave the same way. With app/rstack.config.mjs set to define.lint([{ files: ['src/**/*.js'], rules: { 'no-debugger': 'error' } }]) and debugger; in app/src/a.js, npx rs lint -c app/rstack.config.mjs app/src from the root prints Lint passed ... (1 file, 0 rules, ...) and exits 0. With { ignores: ['src/ignored/**'] } instead, files in app/src/ignored/ are still linted.
Workaround: set basePath: import.meta.dirname on each entry. That fixes files and ignores, but not the type-check case, because the implicit tsconfig lookup always uses the cwd. For type checking, also set languageOptions: { parserOptions: { project: ['./tsconfig.json'] } } on an entry that has that basePath.
Should these paths resolve from the -c file's directory instead? When the config is discovered rather than passed with -c, that directory is the cwd, so only -c runs would change. On the other hand, resolving from the cwd fits a config kept in ./configs/ and run from the project root, as in the -c docs example. If the cwd is the intended base, a note in those docs would help.
Activity