Skip to content

Comments

feat(linter): implement prefer-const#15707

Closed
connorshea wants to merge 36 commits intooxc-project:mainfrom
connorshea:implement-prefer-const
Closed

feat(linter): implement prefer-const#15707
connorshea wants to merge 36 commits intooxc-project:mainfrom
connorshea:implement-prefer-const

Conversation

@connorshea
Copy link
Member

@connorshea connorshea commented Nov 15, 2025

Taking a stab at implementing prefer-const, but I could probably use someone else's help to finish this up.

This was primarily implemented on a whim a few weeks ago, using GitHub Copilot and Claude Sonnet 4.5. I've gotten all the tests passing, so I think this is ready for review. Unfortunately as a Rust noob I have little ability to tell whether this code is following best practices (it almost certainly isn't, it feels a bit janky even as someone unfamiliar with Rust). I've done some refactoring based on code smells I've noticed, but it's still pretty smelly.

I did test it on the mastodon repo as well to ensure it detects problematic code correctly, and it gets identical results to what the eslint rule reports: #15707 (comment)

Fixes #12645.

@connorshea connorshea changed the title WIP: Implement prefer-const WIP: feat(linter): implement prefer-const Nov 15, 2025
@graphite-app
Copy link
Contributor

graphite-app bot commented Nov 15, 2025

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent hot fixes, skip the queue and merge this PR next

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

@github-actions github-actions bot added the A-linter Area - Linter label Nov 15, 2025
@codspeed-hq
Copy link

codspeed-hq bot commented Nov 15, 2025

CodSpeed Performance Report

Merging #15707 will not alter performance

Comparing connorshea:implement-prefer-const (59719d0) with main (5815c08)

Summary

✅ 4 untouched
⏩ 33 skipped1

Footnotes

  1. 33 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@connorshea connorshea force-pushed the implement-prefer-const branch from 320c6ca to 1d22e29 Compare November 15, 2025 20:10
@connorshea connorshea changed the title WIP: feat(linter): implement prefer-const feat(linter): implement prefer-const Nov 15, 2025
@github-actions github-actions bot added the C-enhancement Category - New feature or request label Nov 15, 2025
@connorshea connorshea marked this pull request as ready for review November 15, 2025 21:21
Copilot AI review requested due to automatic review settings November 15, 2025 21:21
@connorshea
Copy link
Member Author

I'm marking this as ready for review since I've gotten the tests all passing with cam's fix for the ast.

@connorshea
Copy link
Member Author

Tested enabling the rule on mastodon, which currently does not have it enabled and so has some violations. And I got identical results as far as I can tell :D 48 errors for each.

eslint:

/Users/connorshea/code/mastodon/app/javascript/mastodon/actions/compose.js
  334:9  error  'total' is never reassigned. Use 'const' instead  prefer-const
  469:9  error  'media' is never reassigned. Use 'const' instead  prefer-const

/Users/connorshea/code/mastodon/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
  43:9  error  'uniqueDefaults' is never reassigned. Use 'const' instead  prefer-const

/Users/connorshea/code/mastodon/app/javascript/mastodon/features/emoji/emoji_compressed.mjs
  92:22  error  'search' is never reassigned. Use 'const' instead   prefer-const
  92:30  error  'unified' is never reassigned. Use 'const' instead  prefer-const

/Users/connorshea/code/mastodon/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
    7:5   error  'originalPool' is never reassigned. Use 'const' instead      prefer-const
    9:5   error  'emojisList' is never reassigned. Use 'const' instead        prefer-const
   10:5   error  'emoticonsList' is never reassigned. Use 'const' instead     prefer-const
   13:10  error  'emoji' is never reassigned. Use 'const' instead             prefer-const
   14:7   error  'emojiData' is never reassigned. Use 'const' instead         prefer-const
   15:9   error  'short_names' is never reassigned. Use 'const' instead       prefer-const
   15:22  error  'emoticons' is never reassigned. Use 'const' instead         prefer-const
   16:7   error  'id' is never reassigned. Use 'const' instead                prefer-const
   34:9   error  'emojiId' is never reassigned. Use 'const' instead           prefer-const
   45:9   error  'emojiId' is never reassigned. Use 'const' instead           prefer-const
   88:13  error  'isIncluded' is never reassigned. Use 'const' instead        prefer-const
   89:13  error  'isExcluded' is never reassigned. Use 'const' instead        prefer-const
   98:13  error  'customIsIncluded' is never reassigned. Use 'const' instead  prefer-const
   99:13  error  'customIsExcluded' is never reassigned. Use 'const' instead  prefer-const
  119:15  error  'scores' is never reassigned. Use 'const' instead            prefer-const
  124:20  error  'id' is never reassigned. Use 'const' instead                prefer-const
  125:17  error  'emoji' is never reassigned. Use 'const' instead             prefer-const
  126:17  error  'search' is never reassigned. Use 'const' instead            prefer-const
  127:15  error  'sub' is never reassigned. Use 'const' instead               prefer-const
  128:15  error  'subIndex' is never reassigned. Use 'const' instead          prefer-const
  142:17  error  'aScore' is never reassigned. Use 'const' instead            prefer-const
  143:15  error  'bScore' is never reassigned. Use 'const' instead            prefer-const

/Users/connorshea/code/mastodon/app/javascript/mastodon/features/emoji/emoji_utils.js
    9:7   error  'addToSearch' is never reassigned. Use 'const' instead      prefer-const
   36:7   error  'MAX_SIZE' is never reassigned. Use 'const' instead         prefer-const
   37:7   error  'codeUnits' is never reassigned. Use 'const' instead        prefer-const
   41:7   error  'length' is never reassigned. Use 'const' instead           prefer-const
   83:7   error  'unicodes' is never reassigned. Use 'const' instead         prefer-const
   84:5   error  'codePoints' is never reassigned. Use 'const' instead       prefer-const
   90:9   error  'name' is never reassigned. Use 'const' instead             prefer-const
   90:15  error  'short_names' is never reassigned. Use 'const' instead      prefer-const
   90:28  error  'skin_tone' is never reassigned. Use 'const' instead        prefer-const
   90:39  error  'skin_variations' is never reassigned. Use 'const' instead  prefer-const
   90:56  error  'emoticons' is never reassigned. Use 'const' instead        prefer-const
   90:67  error  'unified' is never reassigned. Use 'const' instead          prefer-const
   90:76  error  'custom' is never reassigned. Use 'const' instead           prefer-const
   90:84  error  'imageUrl' is never reassigned. Use 'const' instead         prefer-const
   91:5   error  'id' is never reassigned. Use 'const' instead               prefer-const
  128:9   error  'matches' is never reassigned. Use 'const' instead          prefer-const
  171:9   error  'skinKey' is never reassigned. Use 'const' instead          prefer-const
  172:7   error  'variationData' is never reassigned. Use 'const' instead    prefer-const
  181:16  error  'k' is never reassigned. Use 'const' instead                prefer-const
  182:13  error  'v' is never reassigned. Use 'const' instead                prefer-const

/Users/connorshea/code/mastodon/app/javascript/mastodon/selectors/index.js
  59:9  error  'mediaFilters' is never reassigned. Use 'const' instead  prefer-const

oxlint:

/Users/connorshea/code/mastodon/app/javascript/mastodon/actions/compose.js
  334:9  error  'total' is never reassigned  eslint(prefer-const)
  469:9  error  'media' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/mastodon/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
  43:9  error  'uniqueDefaults' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/mastodon/app/javascript/mastodon/features/emoji/emoji_compressed.mjs
  92:22  error  'search' is never reassigned  eslint(prefer-const)
  92:30  error  'unified' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/mastodon/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
  7:5     error  'originalPool' is never reassigned  eslint(prefer-const)
  9:5     error  'emojisList' is never reassigned  eslint(prefer-const)
  10:5    error  'emoticonsList' is never reassigned  eslint(prefer-const)
  13:10   error  'emoji' is never reassigned  eslint(prefer-const)
  14:7    error  'emojiData' is never reassigned  eslint(prefer-const)
  15:9    error  'short_names' is never reassigned  eslint(prefer-const)
  15:22   error  'emoticons' is never reassigned  eslint(prefer-const)
  16:7    error  'id' is never reassigned  eslint(prefer-const)
  34:9    error  'emojiId' is never reassigned  eslint(prefer-const)
  45:9    error  'emojiId' is never reassigned  eslint(prefer-const)
  88:13   error  'isIncluded' is never reassigned  eslint(prefer-const)
  89:13   error  'isExcluded' is never reassigned  eslint(prefer-const)
  98:13   error  'customIsIncluded' is never reassigned  eslint(prefer-const)
  99:13   error  'customIsExcluded' is never reassigned  eslint(prefer-const)
  119:15  error  'scores' is never reassigned  eslint(prefer-const)
  124:20  error  'id' is never reassigned  eslint(prefer-const)
  125:17  error  'emoji' is never reassigned  eslint(prefer-const)
  126:17  error  'search' is never reassigned  eslint(prefer-const)
  127:15  error  'sub' is never reassigned  eslint(prefer-const)
  128:15  error  'subIndex' is never reassigned  eslint(prefer-const)
  142:17  error  'aScore' is never reassigned  eslint(prefer-const)
  143:15  error  'bScore' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/mastodon/app/javascript/mastodon/features/emoji/emoji_utils.js
  9:7     error  'addToSearch' is never reassigned  eslint(prefer-const)
  36:7    error  'MAX_SIZE' is never reassigned  eslint(prefer-const)
  37:7    error  'codeUnits' is never reassigned  eslint(prefer-const)
  41:7    error  'length' is never reassigned  eslint(prefer-const)
  83:7    error  'unicodes' is never reassigned  eslint(prefer-const)
  84:5    error  'codePoints' is never reassigned  eslint(prefer-const)
  90:9    error  'name' is never reassigned  eslint(prefer-const)
  90:15   error  'short_names' is never reassigned  eslint(prefer-const)
  90:28   error  'skin_tone' is never reassigned  eslint(prefer-const)
  90:39   error  'skin_variations' is never reassigned  eslint(prefer-const)
  90:56   error  'emoticons' is never reassigned  eslint(prefer-const)
  90:67   error  'unified' is never reassigned  eslint(prefer-const)
  90:76   error  'custom' is never reassigned  eslint(prefer-const)
  90:84   error  'imageUrl' is never reassigned  eslint(prefer-const)
  91:5    error  'id' is never reassigned  eslint(prefer-const)
  128:9   error  'matches' is never reassigned  eslint(prefer-const)
  171:9   error  'skinKey' is never reassigned  eslint(prefer-const)
  172:7   error  'variationData' is never reassigned  eslint(prefer-const)
  181:16  error  'k' is never reassigned  eslint(prefer-const)
  182:13  error  'v' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/mastodon/app/javascript/mastodon/selectors/index.js
  59:9  error  'mediaFilters' is never reassigned  eslint(prefer-const)

✖ 48 problems (48 errors, 0 warnings)

@connorshea
Copy link
Member Author

connorshea commented Nov 16, 2025

Compared the violations for VS Code between ESLint and oxlint since VS Code is huge and has this rule enabled. and I think this implementation actually catches a few that ESLint missed?

EDIT: actually I think disregard the examples after this, some of these may be invalid code if changed to const, so this part still needs to be fixed and these examples should be added to the tests.


incorrect observation
vscode % node ../oxc/apps/oxlint/dist/cli.js -f=stylish

/Users/connorshea/code/vscode/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalMultiLineLinkDetector.test.ts
  119:7  warning  'to' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/vscode/src/vs/base/common/observableInternal/reactions/autorun.ts
  165:6  warning  'ar' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/vscode/extensions/search-result/src/extension.ts
  233:13  warning  'match' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/vscode/src/vs/editor/contrib/snippet/browser/snippetParser.ts
  764:7  warning  'value' is never reassigned  eslint(prefer-const)
  782:7  warning  'index' is never reassigned  eslint(prefer-const)
  897:7  warning  'name' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/vscode/src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts
  144:12  warning  'seen' is never reassigned  eslint(prefer-const)

/Users/connorshea/code/vscode/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts
  203:7  warning  'to' is never reassigned  eslint(prefer-const)

✖ 8 problems (0 errors, 8 warnings)

e.g. match is never reassigned here, and ESLint does not warn but oxlint does:

for (let match: RegExpExecArray | null; (match = ELISION_REGEX.exec(line));) {
    locations.push({
        targetRange,
        targetSelectionRange: new vscode.Range(lineNumber, offset, lineNumber, offset),
        targetUri: currentTarget,
        originSelectionRange: new vscode.Range(i, lastEnd, i, ELISION_REGEX.lastIndex - match[0].length),
    });

    offset += (ELISION_REGEX.lastIndex - lastEnd - match[0].length) + Number(match[1]);
    lastEnd = ELISION_REGEX.lastIndex;
}

and then value here, same thing:

private _parseTabstopOrVariableName(parent: Marker): boolean {
    let value: string;
    const token = this._token;
    const match = this._accept(TokenType.Dollar)
        && (value = this._accept(TokenType.VariableName, true) || this._accept(TokenType.Int, true));

    if (!match) {
        return this._backTo(token);
    }

    parent.appendChild(/^\d+$/.test(value!)
        ? new Placeholder(Number(value!))
        : new Variable(value!)
    );
    return true;
}

So that's neat. I didn't see any obvious false-positives.

`let foo;` is valid, but `const foo;` is not. So this kind of cases should be a violation.
…valid.

Add a test for this case so it doesn't regress.
Just to be sure that we don't misinterpret commented out code as actual code.
…sed.

inside for statements, it's not really practical to change let to const like this.

Also add some basic cases for typescript code.
@connorshea
Copy link
Member Author

Got it down to two cases where the rule raises a violation in the vscode codebase:

  × eslint(prefer-const): `ar` is never reassigned.
     ╭─[src/vs/base/common/observableInternal/reactions/autorun.ts:165:6]
 164 │ export function autorunSelfDisposable(fn: (reader: IReaderWithDispose) => void, debugLocation = DebugLocation.ofCaller()): IDisposable {
 165 │     let ar: IDisposable | undefined;
     ·         ───────────────────────────
 166 │     let disposed = false;
     ╰────
  help: Use `const` instead.

  × eslint(prefer-const): `seen` is never reassigned.
     ╭─[src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts:144:12]
 143 │         let result: IConfig | null | undefined = config;
 144 │         for (let seen = new Set(); result && !seen.has(resolvedType);) {
     ·                  ────
 145 │             seen.add(resolvedType);
     ╰────
  help: Use `const` instead.

Found 0 warnings and 2 errors.

And I think these are both correct/fine.

The first is just because of a difference in where the highlighted violation occurs, it's disabled on a different line than is necessary for the oxlint rule, because the oxlint rule highlights the let ar line:

export function autorunSelfDisposable(fn: (reader: IReaderWithDispose) => void, debugLocation = DebugLocation.ofCaller()): IDisposable {
	let ar: IDisposable | undefined;
	let disposed = false;

	// eslint-disable-next-line prefer-const
	ar = autorun(reader => {
		fn({
			delayedStore: reader.delayedStore,
			store: reader.store,
			readObservable: reader.readObservable.bind(reader),
			dispose: () => {
				ar?.dispose();
				disposed = true;
			}
		});
	}, debugLocation);

	if (disposed) {
		ar.dispose();
	}

	return ar;
}

And the second seems like it works fine if seen is changed to a const, in the same way that array.push wouldn't be a violation either?:

for (let seen = new Set(); result && !seen.has(resolvedType);) {
	seen.add(resolvedType);
	result = await resolveDebugConfigurationForType(resolvedType, result);
	result = await resolveDebugConfigurationForType('*', result);
	resolvedType = result?.type ?? type!;
}

return result;

So I think we're fully good to go now :)

Comment on lines 369 to 377
let has_read_before_write = references.iter().any(|r| {
if !r.is_read() || r.node_id() == write_node_id {
return false;
}
// Simple span comparison - if read comes before write in source
let read_span = ctx.nodes().get_node(r.node_id()).kind().span();
let write_span = ctx.nodes().get_node(write_node_id).kind().span();
read_span.start < write_span.start
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does eslint do this. Relying on spans will be unreliable as functions are hoisted ect.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't even think about hoisted functions breaking here while going through the code tbh, good point.

I tried adding a test case for ensuring this works with a basic hoisted function, but it wasn't able to trigger an issue with the previous implementation on it, so it's probably insufficient as a test. I'd appreciate any example you can give me of code where this'd be problematic :)

I've updated it to avoid using span comparison and I think it now will work correctly because this should now go through it in semantic order. But I'm relying heavily on claude here, so I'm not 100% sure.

This is the ESLint implementation: https://github.com/eslint/eslint/blob/ca4d3b40085de47561f89656a2207d09946ed45e/lib/rules/prefer-const.js#L239-L241

I assume this part should also be updated to avoid span start comparisons? https://github.com/connorshea/oxc/blob/328ec563a69a8763fbb0f3d7892f088945227eb7/crates/oxc_linter/src/rules/eslint/prefer_const.rs#L331-L344

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🫠 looks like eslint relies on the order of the references being in the same order as they will be evaluated.

Let me think about this more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After an attempt of implementing this rule I discovered this PR 😂 So I wanna see it get finished...

I haven't looked into the source code for ESLint too deeply, but tested the core hoisting behaviour of both ESLint and this implementation and they both behave the same in the following tests:

vec![
        (
            "function foo() { bar(x); } let x = 0;",
            Some(serde_json::json!([{ "ignoreReadBeforeAssign": true }])),
        ), // Pass
        (
            "let x = 0; function foo() { bar(x); }",
            Some(serde_json::json!([{ "ignoreReadBeforeAssign": true }])),
        ), // Fail (Missing exact test in PR)
        (
            "function foo() { bar(x); } let x = 0;",
            Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
        ), // Fail
        (
            "let x = 0; function foo() { bar(x); }",
            Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
        ), // Fail
]

Avoid using `collect()` when unnecessary while checking for write-only refs.
Comparing span starts doesn't necessarily work, because of hoisting, so compare nodes instead to rely on the semantic order.
graphite-app bot pushed a commit that referenced this pull request Dec 3, 2025
Dogfood Oxlint JS plugins by enabling ESLint's `prefer-const` rule, running as a JS plugin.

#15707 will implement this rule natively in Oxlint, but in meantime this is a good one to use to test JS plugins.
taearls pushed a commit to taearls/oxc that referenced this pull request Dec 11, 2025
Dogfood Oxlint JS plugins by enabling ESLint's `prefer-const` rule, running as a JS plugin.

oxc-project#15707 will implement this rule natively in Oxlint, but in meantime this is a good one to use to test JS plugins.
@camc314 camc314 self-assigned this Dec 18, 2025
"let { name, ...otherStuff } = obj; otherStuff = {};",
Some(serde_json::json!([{ "destructuring": "any" }])),
), // { "parser": require(fixtureParser("babel-eslint5/destructuring-object-spread"), ), },
("let x; function foo() { bar(x); } x = 0;", None),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate test

Suggested change
("let x; function foo() { bar(x); } x = 0;", None),

Comment on lines +962 to +975
(
"function square(n) { x * x }
let x = 5;
console.log(square(4));",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
),
(
"function foo() { bar(x); } let x = 0;",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
),
(
"let x = 0; function foo() { bar(x); }",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test for the last hoisting/configuration possibility/direction:

Suggested change
(
"function square(n) { x * x }
let x = 5;
console.log(square(4));",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
),
(
"function foo() { bar(x); } let x = 0;",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
),
(
"let x = 0; function foo() { bar(x); }",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
),
(
"function square(n) { x * x }
let x = 5;
console.log(square(4));",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
),
(
"function foo() { bar(x); } let x = 0;",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
),
(
"let x = 0; function foo() { bar(x); }",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": false }])),
),
(
"let x = 0; function foo() { bar(x); }",
Some(serde_json::json!([{ "ignoreReadBeforeAssign": true }])),
),

@camchenry
Copy link
Member

closing in favor of #18687

@camchenry camchenry closed this Jan 29, 2026
graphite-app bot pushed a commit that referenced this pull request Jan 29, 2026
- closes #12645
- redux of #15707

A slightly refreshed version of @connorshea's PR here: #15707. This includes a set of oxc-exclusive tests that try to test a few additional scenarios. This PR also includes a conditional fix which the previous PR did not. This is currently passing all of the ESLint tests, plus our own oxc tests too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-linter Area - Linter C-enhancement Category - New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add eslint/prefer-const rule

4 participants