Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Use updated types from @eslint/core #66

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open

Conversation

nzakas
Copy link
Member

@nzakas nzakas commented Nov 21, 2024

Prerequisites checklist

What is the purpose of this pull request?

Update the plugin to use the latest types from @eslint/core.

What changes did you make? (Give an overview)

  • Added types.ts files to define types.
  • Updated rules to use RuleDefinition type
  • Updated JSONLanguage to use IJSONLanguage type
  • Updated JSONSourceCode to use IJSONSourceCode type

Related Issues

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

The types test fails with this, and I don't understand why. The project builds just fine. Maybe @fasttime can take a look?

@eslint-github-bot eslint-github-bot bot added the bug Something isn't working label Nov 21, 2024
@fasttime
Copy link
Member

It looks like the type tests are failing because the strict option is not compatible with the @eslint/core types:

"strict": true

We could remove that option for the moment since it's only used in the tests. In the long term I think it would be good to make sure that the @eslint/core types are strict compliant, so they can be used by TS projects with that option enabled. As I said in a previous comment, I don't think it's necessary to enable strict mode for the whole rewrite repo.

@nzakas
Copy link
Member Author

nzakas commented Nov 22, 2024

It looks like the type tests are failing because the strict option is not compatible with the @eslint/core types

Can you explain what the compatibility issue is? And why does running tsc on the project build fine but the type tests fails?

@fasttime
Copy link
Member

Can you explain what the compatibility issue is?

The issue is in the definition of RuleVisitor in @eslint/core:

export interface RuleVisitor {
	/**
	 * Called for each node in the AST or at specific times during the traversal.
	 */
	[key: string]: (...args: any[]) => void;
}

from https://github.com/eslint/rewrite/blob/3591a7805a060cb130d40d61f200431b782431d8/packages/core/src/types.ts#L102-L107

When the TypeScript strict option is set (or strictNullChecks more specifically) it's no longer possible to assign null or undefined to a property of the RuleVisitorInterface, or to extend it with an optional method (where the value can be a function or undefined).

For example:

let ruleVisitor: RuleVisitor = {};
ruleVisitor.foo = undefined; // Error with strictNullChecks, else OK

interface Bar extends RuleVisitor {
    bar?(): void; // Error with strictNullChecks, else OK
}

The JSONRuleVisitor interface extends RuleVisitor but also defines optional methods, that's why it fails to compile with the strict option.

json/src/types.ts

Lines 58 to 84 in fdde59d

export interface JSONRuleVisitor extends RuleVisitor {
Document?(node: DocumentNode): void;
Member?(node: MemberNode, parent?: ObjectNode): void;
Element?(node: ElementNode, parent?: ArrayNode): void;
Object?(node: ObjectNode, parent?: ValueNodeParent): void;
Array?(node: ArrayNode, parent?: ValueNodeParent): void;
String?(node: StringNode, parent?: ValueNodeParent): void;
Null?(node: NullNode, parent?: ValueNodeParent): void;
Number?(node: NumberNode, parent?: ValueNodeParent): void;
Boolean?(node: BooleanNode, parent?: ValueNodeParent): void;
NaN?(node: NaNNode, parent?: ValueNodeParent): void;
Infinity?(node: InfinityNode, parent?: ValueNodeParent): void;
Identifier?(node: IdentifierNode, parent?: ValueNodeParent): void;
"Document:exit"?(node: DocumentNode): void;
"Member:exit"?(node: MemberNode, parent?: ObjectNode): void;
"Element:exit"?(node: ElementNode, parent?: ArrayNode): void;
"Object:exit"?(node: ObjectNode, parent?: ValueNodeParent): void;
"Array:exit"?(node: ArrayNode, parent?: ValueNodeParent): void;
"String:exit"?(node: StringNode, parent?: ValueNodeParent): void;
"Null:exit"?(node: NullNode, parent?: ValueNodeParent): void;
"Number:exit"?(node: NumberNode, parent?: ValueNodeParent): void;
"Boolean:exit"?(node: BooleanNode, parent?: ValueNodeParent): void;
"NaN:exit"?(node: NaNNode, parent?: ValueNodeParent): void;
"Infinity:exit"?(node: InfinityNode, parent?: ValueNodeParent): void;
"Identifier:exit"?(node: IdentifierNode, parent?: ValueNodeParent): void;
}

It's possible that there are more incompatibilities in the core types but in this is the only one that's causing troubles with this PR. I tried manually changing the RuleVisitor interface to accept optional methods and then all type tests passed:

export type RuleVisitor = {
    /**
     * Called for each node in the AST or at specific times during the traversal.
     */
-   [key: string]: (...args: any[]) => void;
+   [key in string]?: (...args: any[]) => void;
};

And why does running tsc on the project build fine but the type tests fails?

Because the strict option is only enabled in tests/types/tsconfig.json (the config for type tests), not in the root tsconfig.json which is used for building.

@nzakas
Copy link
Member Author

nzakas commented Nov 22, 2024

Ah! That is very helpful, thank you.

I think we should change RuleVisitor, then, as I've run into other problems caused by this.

@nzakas
Copy link
Member Author

nzakas commented Dec 4, 2024

Okay, looks like the RuleVisitor update did the trick. 👍

Copy link
Member

@fasttime fasttime left a comment

Choose a reason for hiding this comment

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

When I build the package everything works well but if I try to import the built package into another TypeScript package with "type": "commonjs", I'm getting errors:

node_modules/@eslint/json/dist/cjs/index.d.cts(12,38): error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.
node_modules/@eslint/json/dist/cjs/index.d.cts(13,40): error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.
node_modules/@eslint/json/dist/cjs/index.d.cts(17,36): error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.
node_modules/@eslint/json/dist/cjs/index.d.cts(18,42): error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.
node_modules/@eslint/json/dist/cjs/index.d.cts(20,52): error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.
node_modules/@eslint/json/dist/cjs/index.d.cts(23,48): error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.
node_modules/@eslint/json/dist/cjs/index.d.cts(25,51): error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.
node_modules/@eslint/json/node_modules/@eslint/plugin-kit/dist/cjs/index.d.cts(12,35): error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.
node_modules/@eslint/json/node_modules/@eslint/plugin-kit/dist/cjs/index.d.cts(13,36): error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.

I think this is because types.ts is considered an ESM module due to the .ts extension, but it's re-exported by dist/cts/index.d.cts which is CommonJS. I'll try to set up a repro.

Comment on lines +10 to +16
import type {
RuleVisitor,
TextSourceCode,
Language,
LanguageOptions,
RuleDefinition,
} from "@eslint/core";
Copy link
Member

Choose a reason for hiding this comment

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

@eslint/core and is imported in the types. Should it be a runtime dependency?

Copy link
Member Author

Choose a reason for hiding this comment

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

At this point I honestly don't understand when types are supposed to be a dev dependency vs. a runtime dependency. What would you do?

Choose a reason for hiding this comment

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

If references to @eslint/core are in the distributed .d.ts files, then I believe @eslint/core will need to be some kind of a runtime dependency. Otherwise end-users might import types from this package and get type errors on @eslint/core not being found.

It does feel weird making a types-only package be a runtime dependency. But 🤷 without first-party support from package managers on delineating "runtime" vs. "types-only" dependencies, it's all "runtime".

tools/dedupe-types.js Outdated Show resolved Hide resolved
Co-authored-by: Francesco Trotta <[email protected]>
@nzakas
Copy link
Member Author

nzakas commented Dec 12, 2024

I think this is because types.ts is considered an ESM module due to the .ts extension, but it's re-exported by dist/cts/index.d.cts which is CommonJS. I'll try to set up a repro.

That's strange. This is set up the same way I have the packages in the rewrite repo...unless those also have the same problem and we're just not aware.

Copy link

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

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

I'm not confident enough in this repo to explicitly approve or request changes, but left some comments and questions 🙂

): string;
}

export type IJSONLanguage = Language<{

Choose a reason for hiding this comment

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

[Style] Was the I an intentional addition? It's not typical in TypeScript code.

Suggested change
export type IJSONLanguage = Language<{
export type JSONLanguage = Language<{

Here and with IJSONSourceCode.

Copy link
Member Author

@nzakas nzakas Dec 16, 2024

Choose a reason for hiding this comment

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

Yes, because there's already a class called JSONLanguage, so the type is IJSONLanguage. The I stands for interface.

I have seen this in other projects, and it's fairly typical in languages like Java where you can define an interface separate from a class.

Is there a definitive TypeScript way to handle this? "This" being, there's an interface I want a class to adhere to, and it's only used for that class. What should the name be?

Copy link

@JoshuaKGoldberg JoshuaKGoldberg Dec 17, 2024

Choose a reason for hiding this comment

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

Quick answer: there isn't a single standard, but a name like JSONLanguageLike would be reasonable.


Longer answer: the "interface only used for one class" pattern isn't common in TypeScript-land. If the interface is really only used for that one class then most of the time folks would just use the class name.

Since we're not beholden to classic Java-style class hierarchies, it's less common AFAIU to put everything in a single shape the way the @eslint/core Language type is set up. A lot of architectures avoid classes altogether and instead just go with generic factory functions.

Quickly sketching a theoretical vague equivalent:

declare function createLanguage<Settings extends LanguageSettings>
  (settings: Settings): Language<Settings>;

export const jsonLanguage = createLanguage({
  fileType: "text",
  lineStart: 1,
  // ...
});

In that world, there wouldn't be a need for a JSONLanguage to be explicitly declared. It could be inferred from typeof jsonLanguage with a helper.

Not suggesting changes, just posting context for why this naming problem isn't as commonly solved.

copy({
targets: [
{ src: "src/types.ts", dest: "dist/cjs" },
{ src: "src/types.ts", dest: "dist/esm" },
Copy link

@JoshuaKGoldberg JoshuaKGoldberg Dec 13, 2024

Choose a reason for hiding this comment

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

I now see dist/(cjs|esm)/types.d.ts and dst/(cjs|esm)/types.ts files locally. They're identical other than comments and an auto-generated export {}. I don't see anything importing from the types.d.ts. Is the duplication intentional?

FWIW I believe it's more common to have just .d.ts files. My instinct is that the expected path here would be to just have dist/(cjs|esm)/types.d.ts and typedefs use {import("./types.d.ts")}.

Copy link
Member Author

@nzakas nzakas Dec 16, 2024

Choose a reason for hiding this comment

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

Because we run tsc on dist/esm and dist/cjs, types.ts needs to be present in both of those directories in order for the project to compile. types.d.ts is output by tsc from types.ts

Choose a reason for hiding this comment

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

Gotcha. I tried renaming src/types.ts to src/types.d.ts with a find-and-replace. I think it's all working here: JoshuaKGoldberg@631810d. At least npm run build passes and the imports all work in editor.

src/rules/no-duplicate-keys.js Outdated Show resolved Hide resolved
Comment on lines +10 to +16
import type {
RuleVisitor,
TextSourceCode,
Language,
LanguageOptions,
RuleDefinition,
} from "@eslint/core";

Choose a reason for hiding this comment

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

If references to @eslint/core are in the distributed .d.ts files, then I believe @eslint/core will need to be some kind of a runtime dependency. Otherwise end-users might import types from this package and get type errors on @eslint/core not being found.

It does feel weird making a types-only package be a runtime dependency. But 🤷 without first-party support from package managers on delineating "runtime" vs. "types-only" dependencies, it's all "runtime".

/**
* The `SourceCode` implementation for JSON files.
*/
export interface IJSONSourceCode

Choose a reason for hiding this comment

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

[Style] Was the I an intentional addition? It's not typical in TypeScript code.

Suggested change
export interface IJSONSourceCode
export interface JSONSourceCode

Here and with IJSONLanguage.

Aside: I've always found this discussion around the choice to not preserve that naming in TypeScript ... interesting: microsoft/TypeScript-Handbook#121

@fasttime
Copy link
Member

fasttime commented Dec 26, 2024

I think this is because types.ts is considered an ESM module due to the .ts extension, but it's re-exported by dist/cts/index.d.cts which is CommonJS. I'll try to set up a repro.

That's strange. This is set up the same way I have the packages in the rewrite repo...unless those also have the same problem and we're just not aware.

In fact it looks like we have the same problem in the rewrite repo. When I try to use the plugin-kit types in a CommonJS TS file I'm getting an error from the tsc compiler:

node_modules/@eslint/plugin-kit/dist/cjs/index.d.cts:12:35 - error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.

12 export type StringConfig = import("./types.ts").StringConfig;
                                     ~~~~~~~~~~~~

node_modules/@eslint/plugin-kit/dist/cjs/index.d.cts:13:36 - error TS1542: Type import of an ECMAScript module from a CommonJS module must have a 'resolution-mode' attribute.

13 export type BooleanConfig = import("./types.ts").BooleanConfig;
                                      ~~~~~~~~~~~~

Repro

The suggested fix - using a module resolution attribute - works well in current TypeScript v5:

- /** @typedef {import("./types.ts").StringConfig} StringConfig */
- /** @typedef {import("./types.ts").BooleanConfig} BooleanConfig */
+ /** @typedef {import("./types.ts", { with: { "resolution-mode": "import" } }).StringConfig} StringConfig */
+ /** @typedef {import("./types.ts", { with: { "resolution-mode": "import" } }).BooleanConfig} BooleanConfig */

Fix 1

The other option I had in mind was renaming cjs/types.ts to cjs/types.cts and updating the imports accordingly. That works also:

Fix 2

@fasttime
Copy link
Member

I opened eslint/rewrite#143 to fix the types in plugin-kit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
Status: Implementing
Development

Successfully merging this pull request may close these issues.

3 participants