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';
+ }
+
+
+ {{this.greet}}
+
+}
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();
+
+
+ {{greeting}}
+
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{{name}}\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('');
+ });
+
+ it('offers the virtual twin when TypeScript scans the directory', () => {
+ const found = ts.sys.readDirectory(dir, ['.ts', '.gts']);
+
+ expect(found).toContain(path.join(dir, 'component.mts'));
+ });
+});