Skip to content

rs lint -c <path>: JS plugin rules load the config from cwd in Rslint workers #591

Closed

Description

@LucasIcarus

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 main at 5fe9881 (Node 22.23.3 and 24.21.0)
  • Using a build of current Rslint main in 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 root

Actual 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

  1. setupCommands removes -c (via parseCliArgs) and stores the resolved path in globalThis.__rstackCliState (commands.ts#L216-L225, config.ts#L133-L141).
  2. runRslintCLI passes the internal dist/rslintConfig.js to Rslint as --config, not the user's file (commands.ts#L149-L160). rs check goes through the same function (#L185-L189).
  3. rslintConfig.ts calls loadRstackConfig() with no arguments at module top level and calls process.exit(1) when no lint config is found (rslintConfig.ts#L10-L19). loadRstackConfig() uses configFilePath ?? state.configPath and otherwise searches the cwd for rstack.config.* (config.ts#L264-L287).
  4. For object-form plugins, Rslint sends the --config path to each plugin worker in workerData, 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). A worker_threads worker has its own globalThis, so __rstackCliState is empty there, and the module falls back to the cwd. The main thread still loads the -c file, 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 root

Output 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions