diff --git a/README.md b/README.md index 028383f..f43c4ee 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,21 @@ It's recommended to only use _overrides_ when defining your eslint config, so us if we detect a typescript parser, it will also be used for all files, otherwise babel parser will be used. If we cannot find a typescript parser when linting gts we throw an error. +If you use type-aware rules, route `.js`/`.ts` through this parser too, as +`eslint-plugin-ember`'s configs do: + +```js + { + files: ['**/*.{js,ts}'], + parser: 'ember-eslint-parser', + // ... + }, +``` + +`@typescript-eslint/parser` hands TypeScript the source as ESLint read it, so a +`.ts` file importing from a `.gts` gets an `error` type for that import. This +parser rewrites the specifier first. + ## HBS (Handlebars) support For `.hbs` template files, use the `ember-eslint-parser/hbs` parser. In ESLint's flat config format (ESLint 9+): diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dd4d44..9982d0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,6 +410,24 @@ importers: specifier: ^9.0.0 version: 9.17.0 + test-projects/parser-order: + devDependencies: + '@typescript-eslint/eslint-plugin': + specifier: ^8.46.4 + version: 8.46.4(@typescript-eslint/parser@8.46.4(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.46.4 + version: 8.46.4(eslint@8.57.1)(typescript@5.9.3) + ember-eslint-parser: + specifier: workspace:* + version: link:../.. + eslint: + specifier: ^8.0.1 + version: 8.57.1 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + test-projects/rules/padding-line-between-statements: devDependencies: '@typescript-eslint/eslint-plugin': @@ -7989,6 +8007,23 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.46.4(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/type-utils': 8.46.4(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.4 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.19.1(eslint@8.57.1)(typescript@5.7.2)': dependencies: '@typescript-eslint/scope-manager': 8.19.1 @@ -8048,7 +8083,6 @@ snapshots: typescript: 5.9.3 transitivePeerDependencies: - supports-color - optional: true '@typescript-eslint/project-service@8.46.4(typescript@5.7.2)': dependencies: @@ -8136,6 +8170,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.46.4(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.4(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.19.1': {} '@typescript-eslint/types@8.46.4': {} @@ -8230,6 +8276,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.46.4(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.46.4 + '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.9.3) + eslint: 8.57.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.19.1': dependencies: '@typescript-eslint/types': 8.19.1 diff --git a/src/parser/gjs-gts-parser.js b/src/parser/gjs-gts-parser.js index bc1d0bd..cc32a39 100644 --- a/src/parser/gjs-gts-parser.js +++ b/src/parser/gjs-gts-parser.js @@ -1,6 +1,6 @@ import { createRequire } from 'node:module'; import { registerParsedFile } from '../preprocessor/noop.js'; -import { patchTs, replaceExtensions, syncMtsGtsSourceFiles, typescriptParser } from './ts-patch.js'; +import { replaceExtensions, syncMtsGtsSourceFiles, typescriptParser } from './ts-patch.js'; import { buildGlimmerVisitors } from './transforms.js'; import { toTree } from 'ember-estree'; @@ -48,10 +48,6 @@ export const meta = { }; export function parseForESLint(code, options) { - // Only patch TypeScript if we actually need it. - if (options.programs || options.projectService || options.project) { - patchTs(); - } registerParsedFile(options.filePath); const isTypescript = options.filePath.endsWith('.gts') || options.filePath.endsWith('.ts'); diff --git a/src/parser/ts-patch.js b/src/parser/ts-patch.js index c937df4..e28a13a 100644 --- a/src/parser/ts-patch.js +++ b/src/parser/ts-patch.js @@ -123,6 +123,12 @@ try { return jsCode; }; + // typescript-eslint copies `ts.sys` by value when it builds a program or + // project service, on the first type-aware parse in the process — which may be + // a plain .ts file that never reaches this parser. Patching at module load is + // what puts the wrappers in that copy, and in the project's first file scan. + patchTs(); + /** * * @param program {ts.Program} diff --git a/test-projects/parser-order/.eslintrc.cjs b/test-projects/parser-order/.eslintrc.cjs new file mode 100644 index 0000000..69715f0 --- /dev/null +++ b/test-projects/parser-order/.eslintrc.cjs @@ -0,0 +1,32 @@ +'use strict'; + +// The README's setup: only .gjs/.gts go through ember-eslint-parser, .ts is left +// to @typescript-eslint/parser. See check.mjs. + +const manifest = require('@typescript-eslint/parser/package.json'); +const isV8 = parseInt(manifest.version, 10) >= 8; + +// projectService landed in v8 under this name; older versions get the classic +// watch program. +const useProjectService = process.env.PROJECT_SERVICE && isV8; + +module.exports = { + root: true, + parserOptions: { + ...(useProjectService ? { projectService: true } : { project: './tsconfig.json' }), + tsconfigRootDir: __dirname, + extraFileExtensions: ['.gts', '.gjs'], + }, + overrides: [ + { + files: ['**/*.ts'], + parser: '@typescript-eslint/parser', + extends: ['plugin:@typescript-eslint/recommended-type-checked'], + }, + { + files: ['**/*.gts'], + parser: 'ember-eslint-parser', + extends: ['plugin:@typescript-eslint/recommended-type-checked'], + }, + ], +}; diff --git a/test-projects/parser-order/check.mjs b/test-projects/parser-order/check.mjs new file mode 100644 index 0000000..b931806 --- /dev/null +++ b/test-projects/parser-order/check.mjs @@ -0,0 +1,34 @@ +/** + * Lints the .ts file in a pass of its own, so the program is built before this + * parser is asked for anything. Unless ts.sys is patched by then, the .gts import + * in uses-dep.gts resolves to `error` and the no-unsafe-* rules fire on it. + */ +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; + +const eslint = new ESLint({ cwd: fileURLToPath(new URL('.', import.meta.url)) }); + +async function lint(files) { + const results = await eslint.lintFiles(files); + if (results.length !== files.length) { + throw new Error(`expected ${files.length} file(s) linted, got ${results.length}`); + } + return results; +} + +const results = [ + ...(await lint(['src/plain.ts'])), + ...(await lint(['src/dep.gts', 'src/uses-dep.gts'])), +]; + +const problems = results.flatMap((result) => + result.messages.map((m) => `${result.filePath}:${m.line}:${m.column} ${m.message} (${m.ruleId})`) +); + +if (problems.length > 0) { + console.error(`${problems.length} unexpected problem(s):`); + for (const problem of problems) console.error(` ${problem}`); + process.exit(1); +} + +console.log(`ok — ${results.length} files linted, no problems`); diff --git a/test-projects/parser-order/package.json b/test-projects/parser-order/package.json new file mode 100644 index 0000000..133d9ce --- /dev/null +++ b/test-projects/parser-order/package.json @@ -0,0 +1,16 @@ +{ + "name": "@test-project/parser-order", + "private": true, + "scripts": { + "test:check": "pnpm run /test:check:.*/", + "test:check:project": "node ./check.mjs", + "test:check:project-service": "PROJECT_SERVICE=true node ./check.mjs" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.46.4", + "@typescript-eslint/parser": "^8.46.4", + "ember-eslint-parser": "workspace:*", + "eslint": "^8.0.1", + "typescript": "^5.3.3" + } +} diff --git a/test-projects/parser-order/src/dep.gts b/test-projects/parser-order/src/dep.gts new file mode 100644 index 0000000..30542c8 --- /dev/null +++ b/test-projects/parser-order/src/dep.gts @@ -0,0 +1,9 @@ +export class Dep { + greet(): string { + return 'hello'; + } + + +} diff --git a/test-projects/parser-order/src/plain.ts b/test-projects/parser-order/src/plain.ts new file mode 100644 index 0000000..07e3b00 --- /dev/null +++ b/test-projects/parser-order/src/plain.ts @@ -0,0 +1,2 @@ +// Linted first, and on its own, by check.mjs. +export const first: number = 1; diff --git a/test-projects/parser-order/src/uses-dep.gts b/test-projects/parser-order/src/uses-dep.gts new file mode 100644 index 0000000..1a5eab6 --- /dev/null +++ b/test-projects/parser-order/src/uses-dep.gts @@ -0,0 +1,8 @@ +import { Dep } from './dep.gts'; + +const dep = new Dep(); +export const greeting: string = dep.greet(); + + diff --git a/test-projects/parser-order/tsconfig.json b/test-projects/parser-order/tsconfig.json new file mode 100644 index 0000000..4336738 --- /dev/null +++ b/test-projects/parser-order/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2019", + "lib": [ + "ES2018", + "DOM" + ], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.gts" + ] +} diff --git a/tests/ts-patch-load-order.test.js b/tests/ts-patch-load-order.test.js new file mode 100644 index 0000000..0561cd3 --- /dev/null +++ b/tests/ts-patch-load-order.test.js @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// typescript-eslint copies ts.sys by value when it builds a program, on the +// first type-aware parse in the process — possibly a plain .ts file that never +// reaches this parser. So importing the parser, with no parseForESLint call, has +// to be enough to leave ts.sys patched. +import '../src/parser/gjs-gts-parser.js'; + +const require = createRequire(import.meta.url); +const parserPath = require.resolve('@typescript-eslint/parser'); +const ts = require(require.resolve('typescript', { paths: [parserPath] })); + +describe('ts.sys is patched by importing the parser', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ee-parser-load-order-')); + const gts = path.join(dir, 'component.gts'); + fs.writeFileSync(gts, 'export const name = "x";\n\n'); + + it('reports the virtual .mts twin of a .gts as existing', () => { + expect(ts.sys.fileExists(path.join(dir, 'component.mts'))).toBe(true); + }); + + it('reads the .gts through its virtual twin, transformed', () => { + const content = ts.sys.readFile(path.join(dir, 'component.mts')); + + expect(content).toContain('export const name'); + expect(content).not.toContain('