Skip to content

fix: type compatibility issue between Markdown v8 and ESLint v9.39.x#648

Merged
fasttime merged 22 commits into
mainfrom
fix/resolve-eslint-markdown-v8-and-eslint-v9-compatibility-issue
Jun 15, 2026
Merged

fix: type compatibility issue between Markdown v8 and ESLint v9.39.x#648
fasttime merged 22 commits into
mainfrom
fix/resolve-eslint-markdown-v8-and-eslint-v9-compatibility-issue

Conversation

@lumirlumir

@lumirlumir lumirlumir commented Apr 21, 2026

Copy link
Copy Markdown
Member

Prerequisites checklist

AI acknowledgment

  • I did not use AI to generate this PR.
  • (If the above is not checked) I have reviewed the AI-generated content before submitting.

What is the purpose of this pull request?

This PR fixes a type compatibility issue that makes Markdown plugin v8 incompatible with ESLint v9.39.x, which is now in maintenance mode.

Cause of the type incompatibility problem

Currently, the incompatibility comes from the export { rules }; statement in dist/index.d.ts (line 17).

image

This happens because rules is exposed such as Record<RuleId, MarkdownRuleDefinition>, and MarkdownRuleDefinition depends on CustomRuleDefinitionType, CustomRuleTypeDefinitions, and CustomRuleVisitorWithExit. Those types were changed as part of ESLint v10’s breaking changes, so some type signatures no longer match, making Markdown plugin v8 incompatible with ESLint v9.39.x.

Interestingly, if we avoid coercing the rule types to MarkdownRuleDefinition and keep them in their original object form instead, there are no type errors. That is because the rule objects themselves have not changed between versions.

I tried a number of different approaches to preserve the original rule object form while still keeping the benefits of type restrictions. In the end, the simplest approach was to coerce the built src/build/rules.js object to Record<RuleId, any>.

This lets us keep type restrictions when writing the code, while preventing end users from running into type incompatibilities caused by major ESLint version bumps.

Potential downsides of this change

Since the user-facing rule type changes from Record<RuleId, MarkdownRuleDefinition> to Record<RuleId, any>, directly accessing rule metadata through the index file becomes less strictly typed.

However, because directly accessing rules is considered an unstable API and is not treated as a breaking change under our policy, this seems acceptable.

Ref: https://github.com/eslint/eslint/blob/main/lib/unsupported-api.js

Compatibility with ESLint versions earlier than v9.39.0?

For ESLint versions earlier than v9.39.0, type errors occur not only with MarkdownRuleDefinition but also in other parts, such as processor.

I'm not sure there is an easy way to make this work with ESLint < v9.39.0. If there is, I'd be happy to follow it.

What changes did you make? (Give an overview)

ci.yml, types.test.ts, and README.md

Updated tests to ensure type compatibility with ESLint v9.39.0, v9.39.x, and v10.x.

src/index.js, and src/processor.js

Updated the types to use the ones from @eslint/core.

Before this change, @eslint/markdown depended on types exported from the eslint package. This is problematic because those types do not come from a direct dependency; instead, they effectively come from the peer-like eslint package.

First, eslint is currently not declared as a peer dependency, which can cause type errors when @eslint/markdown is used without the eslint package. For example, in Code Explorer, we use eslint-linter-browserify instead of eslint.

Second, the ESLint types come from the host package and depend on the version installed by the user, making the behavior unpredictable. This is especially problematic because some internal implementation code uses types from @eslint/core, while types from eslint are also mixed in.

tools/build-rules.js

Updated the auto-generated src/build/rules.js file to use the type Record<RuleId, any>. I believe this will also help avoid type inconsistency errors when updating the RuleDefinition type in the future.

Related Issues

Ref: eslint/json#213

Is there anything you'd like reviewers to focus on?

N/A

@eslintbot eslintbot added this to Triage Apr 21, 2026
@github-project-automation github-project-automation Bot moved this to Needs Triage in Triage Apr 21, 2026
@lumirlumir lumirlumir moved this from Needs Triage to Implementing in Triage Apr 21, 2026
@lumirlumir lumirlumir force-pushed the fix/resolve-eslint-markdown-v8-and-eslint-v9-compatibility-issue branch 2 times, most recently from b8b4ffd to 0917752 Compare April 21, 2026 06:30
@lumirlumir lumirlumir force-pushed the fix/resolve-eslint-markdown-v8-and-eslint-v9-compatibility-issue branch from 0917752 to 38fd019 Compare April 21, 2026 06:32
@lumirlumir lumirlumir changed the title fix: resolve type compatibility issue between Markdown v8 and ESLint v9 fix: type compatibility issue between Markdown v8 and ESLint v9.39.x Apr 21, 2026
@lumirlumir lumirlumir moved this from Implementing to Needs Triage in Triage Apr 26, 2026
@lumirlumir lumirlumir marked this pull request as ready for review April 26, 2026 14:45
@lumirlumir lumirlumir requested a review from Copilot April 26, 2026 14:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes TypeScript type compatibility between @eslint/markdown v8 and ESLint v9.39.x by decoupling the published rule types from ESLint’s evolving internal rule-definition typings, while expanding CI/type checks across supported ESLint versions.

Changes:

  • Switch JSDoc typing in runtime JS (src/index.js, src/processor.js) to use types from @eslint/core instead of eslint.
  • Update rule build generation to emit a Record<RuleId, any>-typed rules map to avoid cross-major ESLint type incompatibilities.
  • Expand type-compatibility testing (CI matrix + tests/types) and clarify README version guidance.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tools/build-rules.js Generates a typed RuleId union and coerces exported rules map to a looser type to avoid ESLint type coupling.
tests/types/types.test.ts Adds type assertions intended to validate compatibility with ESLint v9.39.x/v9.x/v10.x and rule key presence.
src/processor.js Replaces eslint-sourced types with @eslint/core types in JSDoc for fixes/messages/ranges.
src/index.js Replaces eslint-sourced config/rules typing with @eslint/core equivalents for generated declarations.
README.md Updates installation note to clarify which ESLint versions are type-compatible.
.github/workflows/ci.yml Adds an ESLint version matrix for the type-checking job and installs matching ESLint versions in CI.
Comments suppressed due to low confidence (1)

src/processor.js:445

  • excludeUnsatisfiableRules() is used after group.map(adjust) where adjust can return null, but the parameter is typed as LintMessage. Consider typing the parameter as LintMessage | null and/or making this a type guard so postprocess() is correctly typed as returning LintMessage[].
/**
 * Excludes unsatisfiable rules from the list of messages.
 * @param {LintMessage} message A message from the linter.
 * @returns {boolean} True if the message should be included in output.
 */
function excludeUnsatisfiableRules(message) {
	return message && !UNSATISFIABLE_RULES.has(message.ruleId);
}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/types/types.test.ts Outdated
Comment thread tools/build-rules.js Outdated
Comment thread tools/build-rules.js Outdated
Comment thread src/processor.js
…solve-eslint-markdown-v8-and-eslint-v9-compatibility-issue
@lumirlumir lumirlumir marked this pull request as draft April 28, 2026 10:22
@lumirlumir lumirlumir moved this from Needs Triage to Implementing in Triage Apr 28, 2026
@lumirlumir lumirlumir force-pushed the fix/resolve-eslint-markdown-v8-and-eslint-v9-compatibility-issue branch 2 times, most recently from fc0ec6a to 3cf80ce Compare May 18, 2026 16:05
@lumirlumir

lumirlumir commented May 18, 2026

Copy link
Copy Markdown
Member Author

Related commit: 3cf80ce, 3c25400


But this would stop working with this change. Is there another way to access the rule types that I'm missing?

First, I updated the following auto-generated built file from the first version to the second version.

import rule0 from "../rules/fenced-code-language.js";
import rule1 from "../rules/fenced-code-meta.js";
import rule2 from "../rules/heading-increment.js";
import rule3 from "../rules/no-bare-urls.js";
import rule4 from "../rules/no-duplicate-definitions.js";
import rule5 from "../rules/no-duplicate-headings.js";
import rule6 from "../rules/no-empty-definitions.js";
import rule7 from "../rules/no-empty-images.js";
import rule8 from "../rules/no-empty-links.js";
import rule9 from "../rules/no-html.js";
import rule10 from "../rules/no-invalid-label-refs.js";
import rule11 from "../rules/no-missing-atx-heading-space.js";
import rule12 from "../rules/no-missing-label-refs.js";
import rule13 from "../rules/no-missing-link-fragments.js";
import rule14 from "../rules/no-multiple-h1.js";
import rule15 from "../rules/no-reference-like-urls.js";
import rule16 from "../rules/no-reversed-media-syntax.js";
import rule17 from "../rules/no-space-in-emphasis.js";
import rule18 from "../rules/no-unused-definitions.js";
import rule19 from "../rules/require-alt-text.js";
import rule20 from "../rules/table-column-count.js";

export default {
    "fenced-code-language": rule0,
    "fenced-code-meta": rule1,
    "heading-increment": rule2,
    "no-bare-urls": rule3,
    "no-duplicate-definitions": rule4,
    "no-duplicate-headings": rule5,
    "no-empty-definitions": rule6,
    "no-empty-images": rule7,
    "no-empty-links": rule8,
    "no-html": rule9,
    "no-invalid-label-refs": rule10,
    "no-missing-atx-heading-space": rule11,
    "no-missing-label-refs": rule12,
    "no-missing-link-fragments": rule13,
    "no-multiple-h1": rule14,
    "no-reference-like-urls": rule15,
    "no-reversed-media-syntax": rule16,
    "no-space-in-emphasis": rule17,
    "no-unused-definitions": rule18,
    "require-alt-text": rule19,
    "table-column-count": rule20,
};
import rule0 from "../rules/fenced-code-language.js";
import rule1 from "../rules/fenced-code-meta.js";
import rule2 from "../rules/heading-increment.js";
import rule3 from "../rules/no-bare-urls.js";
import rule4 from "../rules/no-duplicate-definitions.js";
import rule5 from "../rules/no-duplicate-headings.js";
import rule6 from "../rules/no-empty-definitions.js";
import rule7 from "../rules/no-empty-images.js";
import rule8 from "../rules/no-empty-links.js";
import rule9 from "../rules/no-html.js";
import rule10 from "../rules/no-invalid-label-refs.js";
import rule11 from "../rules/no-missing-atx-heading-space.js";
import rule12 from "../rules/no-missing-label-refs.js";
import rule13 from "../rules/no-missing-link-fragments.js";
import rule14 from "../rules/no-multiple-h1.js";
import rule15 from "../rules/no-reference-like-urls.js";
import rule16 from "../rules/no-reversed-media-syntax.js";
import rule17 from "../rules/no-space-in-emphasis.js";
import rule18 from "../rules/no-unused-definitions.js";
import rule19 from "../rules/require-alt-text.js";
import rule20 from "../rules/table-column-count.js";

export default {
    "fenced-code-language": /** @type {{meta: typeof rule0.meta; create: (context: unknown) => any}} */ (rule0),
    "fenced-code-meta": /** @type {{meta: typeof rule1.meta; create: (context: unknown) => any}} */ (rule1),
    "heading-increment": /** @type {{meta: typeof rule2.meta; create: (context: unknown) => any}} */ (rule2),
    "no-bare-urls": /** @type {{meta: typeof rule3.meta; create: (context: unknown) => any}} */ (rule3),
    "no-duplicate-definitions": /** @type {{meta: typeof rule4.meta; create: (context: unknown) => any}} */ (rule4),
    "no-duplicate-headings": /** @type {{meta: typeof rule5.meta; create: (context: unknown) => any}} */ (rule5),
    "no-empty-definitions": /** @type {{meta: typeof rule6.meta; create: (context: unknown) => any}} */ (rule6),
    "no-empty-images": /** @type {{meta: typeof rule7.meta; create: (context: unknown) => any}} */ (rule7),
    "no-empty-links": /** @type {{meta: typeof rule8.meta; create: (context: unknown) => any}} */ (rule8),
    "no-html": /** @type {{meta: typeof rule9.meta; create: (context: unknown) => any}} */ (rule9),
    "no-invalid-label-refs": /** @type {{meta: typeof rule10.meta; create: (context: unknown) => any}} */ (rule10),
    "no-missing-atx-heading-space": /** @type {{meta: typeof rule11.meta; create: (context: unknown) => any}} */ (rule11),
    "no-missing-label-refs": /** @type {{meta: typeof rule12.meta; create: (context: unknown) => any}} */ (rule12),
    "no-missing-link-fragments": /** @type {{meta: typeof rule13.meta; create: (context: unknown) => any}} */ (rule13),
    "no-multiple-h1": /** @type {{meta: typeof rule14.meta; create: (context: unknown) => any}} */ (rule14),
    "no-reference-like-urls": /** @type {{meta: typeof rule15.meta; create: (context: unknown) => any}} */ (rule15),
    "no-reversed-media-syntax": /** @type {{meta: typeof rule16.meta; create: (context: unknown) => any}} */ (rule16),
    "no-space-in-emphasis": /** @type {{meta: typeof rule17.meta; create: (context: unknown) => any}} */ (rule17),
    "no-unused-definitions": /** @type {{meta: typeof rule18.meta; create: (context: unknown) => any}} */ (rule18),
    "require-alt-text": /** @type {{meta: typeof rule19.meta; create: (context: unknown) => any}} */ (rule19),
    "table-column-count": /** @type {{meta: typeof rule20.meta; create: (context: unknown) => any}} */ (rule20),
};

This type casting made TypeScript happy, since the type incompatibility issue came from the create(context) function type.

The return type of (context: unknown) => any needs to be any rather than unknown, since using unknown here causes types.test.ts to fail.


Also, I changed the following from the first version to the second version, since using @type type-casts the rule module to MarkdownRuleDefinition, and typeof rule0.meta in src/build/rules.js then carries the MarkdownRuleDefinition type. To break that relationship, I used @satisfies instead of @type to make TypeScript happy.

/** @type {FencedCodeMetaRuleDefinition} */
export default {
    // ...
export default /** @satisfies {FencedCodeLanguageRuleDefinition} */ ({
    // ...

Now, the NoHtmlRuleDefinition type can access the rule's meta information as described in the original rule. However, I'm not sure whether the create(context) type can also be typed well. If there's an easy way to do that, I'd be happy to, but I think leaving create(context) as (context: unknown) => any might be fine here, since it's rarely accessed by users and is considered a non-stable API.

@lumirlumir lumirlumir marked this pull request as ready for review May 18, 2026 16:42
@lumirlumir lumirlumir requested a review from fasttime May 18, 2026 16:43
@fasttime

Copy link
Copy Markdown
Member

However, I'm not sure whether the create(context) type can also be typed well. If there's an easy way to do that, I'd be happy to, but I think leaving create(context) as (context: unknown) => any might be fine here, since it's rarely accessed by users and is considered a non-stable API.

I also think that the create method isn't central to the rule definition since it's primarily intended to be called by ESLint.

@fasttime fasttime left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think these changes make sense if we want to use this approach to mitigate the type compatibility issues with ESLint v9.x. If possible, I would like to hear @nzakas's perspective before marking this PR and the related issue eslint/json#213 as accepted.

If we decide not to move forward with the approach in this PR, I think some of the changes could still be worth keeping, such as replacing ESLint types with core types in a few places and maybe the stricter typing for rule meta objects via @satisfies annotations.

@fasttime fasttime moved this from Triaging to Feedback Needed in Triage May 26, 2026
@lumirlumir

Copy link
Copy Markdown
Member Author

Friendly ping @nzakas

@lumirlumir lumirlumir requested a review from nzakas June 4, 2026 04:55
@lumirlumir

Copy link
Copy Markdown
Member Author

TSC Summary: This PR is seeking consensus on the current approach to mitigating type compatibility issues with ESLint v9.x for the Markdown, JSON, and CSS language plugins, as mentioned in eslint/json#213.

TSC Question: This PR includes only typing changes and no runtime behavior changes. Is the current change acceptable? If so, would it be fine to mark eslint/json#213 and this PR as accepted and move forward with JSON and CSS using this approach?

@lumirlumir lumirlumir added the tsc agenda This issue will be discussed by ESLint's TSC at the next meeting label Jun 9, 2026
@lumirlumir

lumirlumir commented Jun 13, 2026

Copy link
Copy Markdown
Member Author

In the TSC meeting on June 11, 2026, it was determined to use this approach for the time being. However, we’d like to remove it after v11.

https://discord.com/channels/688543509199716507/688545247843713092/1514729335200813147


@fasttime Are there any other things I should consider in this PR, or is it fine as is?

@lumirlumir lumirlumir moved this from Feedback Needed to Implementing in Triage Jun 13, 2026
@lumirlumir lumirlumir added accepted and removed tsc agenda This issue will be discussed by ESLint's TSC at the next meeting labels Jun 13, 2026
@lumirlumir lumirlumir requested a review from fasttime June 13, 2026 13:00
Comment thread tests/types/types.test.ts Outdated
…solve-eslint-markdown-v8-and-eslint-v9-compatibility-issue
@lumirlumir lumirlumir requested a review from fasttime June 14, 2026 14:15

@fasttime fasttime left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just a suggestion, then LGTM.

Comment thread .github/workflows/ci.yml Outdated
Co-authored-by: Francesco Trotta <github@fasttime.org>

@fasttime fasttime left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thanks!

@fasttime fasttime merged commit 3986839 into main Jun 15, 2026
39 checks passed
@github-project-automation github-project-automation Bot moved this from Implementing to Complete in Triage Jun 15, 2026
@fasttime fasttime deleted the fix/resolve-eslint-markdown-v8-and-eslint-v9-compatibility-issue branch June 15, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

4 participants