Skip to content

Commit 6fcf250

Browse files
NullVoxPopuliclaude
andcommitted
fix: route JS path through @babel/eslint-parser so decorators parse
The JS-fallback path (taken when @typescript-eslint/parser isn't installed) parsed with oxc-parser, then briefly with espree. Both broke real ember apps: * oxc emits only start/end byte offsets — no loc, no range array, no token stream — so ESLint's SourceCode.validate() rejected the Program node and rules walking tokens (no-dupe-args et al) crashed. * espree carries loc/tokens but is acorn-based, and acorn rejects `@decorator` syntax outright. Every JS-only ember app failed with `SyntaxError: Unexpected character '@'` the first time it tried to lint a `.gjs` file with `@tracked` or `@service`. Switch the JS path to `@babel/eslint-parser/experimental-worker`, which is the same parser ember-cli already wires up for plain `.js` files in JS-only apps. It picks up the project's babel config and applies its plugins — decorators included — so syntax accepted in the rest of the project parses here too. `@babel/eslint-parser` is declared as an optional peer alongside `@typescript-eslint/parser`. If neither is installed the JS path throws a clear "install one of these" error rather than the cryptic acorn one. The TS path is unchanged. The repo's own tests now exercise babel config discovery against a minimal `babel.config.cjs` that enables the legacy decorators plugin, and `tests/js-path.test.js` pins a regression case for a class field decorator on a `.gjs` file. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent af34821 commit 6fcf250

5 files changed

Lines changed: 135 additions & 44 deletions

File tree

babel.config.cjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Used by tests that exercise the JS-path of ember-eslint-parser, which
2+
// delegates to @babel/eslint-parser. The decorators plugin lets fixtures
3+
// pin behaviour for `@tracked` / `@service`-style class-field decorators
4+
// — the syntax that broke when the JS fallback was raw espree.
5+
module.exports = {
6+
plugins: [['@babel/plugin-proposal-decorators', { version: 'legacy' }]],
7+
};

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,14 @@
3636
"content-tag": "^4.1.1",
3737
"ember-estree": "^0.6.2",
3838
"eslint-scope": "^9.1.2",
39-
"espree": "^10.4.0",
4039
"html-tags": "^5.1.0",
4140
"mathml-tag-names": "^4.0.0",
4241
"svg-tags": "^1.0.0"
4342
},
4443
"devDependencies": {
4544
"@babel/core": "^7.23.6",
45+
"@babel/eslint-parser": "^7.28.6",
46+
"@babel/plugin-proposal-decorators": "^7.25.9",
4647
"@typescript-eslint/parser": "^7.1.0",
4748
"@typescript-eslint/scope-manager": "^7.1.0",
4849
"@typescript-eslint/visitor-keys": "^7.1.0",
@@ -65,9 +66,13 @@
6566
"vitest": "^1.2.2"
6667
},
6768
"peerDependencies": {
69+
"@babel/eslint-parser": "^7.28.6",
6870
"@typescript-eslint/parser": "*"
6971
},
7072
"peerDependenciesMeta": {
73+
"@babel/eslint-parser": {
74+
"optional": true
75+
},
7176
"@typescript-eslint/parser": {
7277
"optional": true
7378
}

pnpm-lock.yaml

Lines changed: 24 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/parser/gjs-gts-parser.js

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,25 @@ import { registerParsedFile } from '../preprocessor/noop.js';
44
import { patchTs, replaceExtensions, syncMtsGtsSourceFiles, typescriptParser } from './ts-patch.js';
55
import { buildGlimmerVisitors } from './transforms.js';
66
import { toTree } from 'ember-estree';
7-
import * as eslintScope from 'eslint-scope';
8-
import * as espree from 'espree';
97

108
const require = createRequire(import.meta.url);
119

10+
// Lazily resolved at first use so the absence of @babel/eslint-parser is only
11+
// surfaced for projects that actually take the JS path. The experimental-worker
12+
// entry point runs babel in a worker and matches the parser ember-cli wires up
13+
// for plain `.js` files in JS-only apps, so config discovery (decorators &
14+
// friends) lines up with how the rest of the project is being parsed.
15+
let babelParser;
16+
function loadBabelParser() {
17+
if (babelParser !== undefined) return babelParser;
18+
try {
19+
babelParser = require('@babel/eslint-parser/experimental-worker');
20+
} catch {
21+
babelParser = null;
22+
}
23+
return babelParser;
24+
}
25+
1226
/**
1327
* implements https://eslint.org/docs/latest/extend/custom-parsers
1428
*
@@ -186,30 +200,26 @@ export function parseForESLint(code, options) {
186200
return tsResult;
187201
}
188202
: (placeholderJS) => {
189-
// JS path: parse with espree so the AST already carries loc/range,
190-
// tokens, and comments — what ESLint validates and rules consume.
191-
//
192-
// We'd prefer oxc-parser here (faster, already used elsewhere in
193-
// the pipeline), but its JS API exposes only `start`/`end` byte
194-
// offsets and an opt-in `range` array — no `loc`, no token
195-
// stream — so its output fails ESLint's SourceCode validation
196-
// on the Program node and breaks any rule that walks tokens.
197-
// Switch back once oxc lands native loc support:
198-
// https://github.com/oxc-project/oxc/issues/10307
199-
const program = espree.parse(placeholderJS, {
200-
ecmaVersion: 'latest',
201-
sourceType: 'module',
202-
loc: true,
203-
range: true,
204-
tokens: true,
205-
comment: true,
206-
});
207-
scopeManager = eslintScope.analyze(program, {
208-
ecmaVersion: 2022,
209-
sourceType: 'module',
210-
range: true,
203+
// JS path: defer to @babel/eslint-parser, which discovers the
204+
// consuming app's babel config and applies its plugins (notably
205+
// decorators) when parsing. That keeps gjs aligned with how the
206+
// app's plain `.js` files are parsed, so syntax accepted there
207+
// is accepted here without a second parser config.
208+
const parser = loadBabelParser();
209+
if (!parser) {
210+
throw new Error(
211+
'ember-eslint-parser: linting `.gjs`/`.gts` files in a JS-only ' +
212+
'project requires `@babel/eslint-parser`. Install it as a ' +
213+
'dev dependency, or install `@typescript-eslint/parser` to ' +
214+
'opt into the TypeScript-based path.'
215+
);
216+
}
217+
const babelResult = parser.parseForESLint(placeholderJS, {
218+
...options,
219+
filePath,
211220
});
212-
return { ast: program, scopeManager };
221+
scopeManager = babelResult.scopeManager;
222+
return babelResult;
213223
},
214224
// Factory form: scopeManager is set by the parser callback before this
215225
// runs. Returns null when unavailable so ember-estree skips the walk.

tests/js-path.test.js

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,23 @@
22
* Regression tests for the JS-fallback parse path.
33
*
44
* The JS path runs when @typescript-eslint/parser isn't available (or when
5-
* `useBabel: true` is set). Historically this path produced an AST that
6-
* ESLint refused — `oxc-parser` only emits `start`/`end` byte offsets, so
7-
* `Program.loc` and `Program.range` were missing and `Program.tokens` was
8-
* empty. Lint of any .gjs file blew up with `TypeError: AST is missing
9-
* location information.`, and rules that walk tokens (e.g. no-dupe-args)
10-
* crashed even after that.
5+
* `useBabel: true` is set). It now defers to @babel/eslint-parser, which
6+
* picks up the consuming app's babel config — so any plugin enabled there
7+
* (decorators, etc.) is honoured when linting `.gjs` files.
118
*
12-
* These tests force the JS path via `useBabel: true` and pin the contract
13-
* at two layers: the AST shape the parser returns, and a real Linter pass
14-
* driving an ESLint rule that walks tokens. Both fail under oxc.
9+
* Two earlier shapes of this path each broke ESLint differently:
10+
* - oxc-parser emitted only `start`/`end` byte offsets, so `Program.loc`
11+
* and `Program.range` were missing and `Program.tokens` was empty.
12+
* Lint of any .gjs file blew up with `TypeError: AST is missing
13+
* location information.`, and rules that walk tokens (e.g.
14+
* no-dupe-args) crashed even after that.
15+
* - raw espree carries loc/tokens but rejects modern syntax outright —
16+
* `@tracked count = 0;` produced `SyntaxError: Unexpected character '@'`
17+
* for every JS-only ember app.
1518
*
16-
* Switch back to oxc once https://github.com/oxc-project/oxc/issues/10307
17-
* lands native loc support.
19+
* These tests force the JS path via `useBabel: true` and pin three
20+
* contracts: the AST shape ESLint requires, decorator syntax parses
21+
* cleanly, and a real Linter pass walks tokens without throwing.
1822
*/
1923

2024
import { describe, expect, it } from 'vitest';
@@ -62,6 +66,50 @@ describe('JS path (useBabel) — AST shape ESLint requires', () => {
6266
});
6367
});
6468

69+
describe('JS path (useBabel) — class decorators', () => {
70+
// Regression: when @typescript-eslint/parser isn't installed, the JS path
71+
// historically fell back to raw espree, which rejects `@decorator` syntax
72+
// outright (`SyntaxError: Unexpected character '@'`). Routing the JS path
73+
// through @babel/eslint-parser picks up the consuming app's babel config —
74+
// the decorators plugin lives there for ember apps — so a `.gjs` file with
75+
// a `@tracked` field parses cleanly without any TypeScript tooling.
76+
const code = [
77+
"import Component from '@glimmer/component';",
78+
"import { tracked } from '@glimmer/tracking';",
79+
'',
80+
'export default class Counter extends Component {',
81+
' @tracked count = 0;',
82+
' <template>{{this.count}}</template>',
83+
'}',
84+
].join('\n');
85+
86+
it('parses a class field decorator without throwing', () => {
87+
const result = parseForESLint(code, {
88+
filePath: 'fixture.gjs',
89+
useBabel: true,
90+
});
91+
expect(result.ast.type).toBe('Program');
92+
93+
// Walk the AST to confirm the decorator landed on the class member.
94+
let decoratorName = null;
95+
function walk(node) {
96+
if (!node || typeof node !== 'object') return;
97+
if (Array.isArray(node.decorators) && node.decorators.length > 0) {
98+
const expr = node.decorators[0].expression;
99+
decoratorName = expr?.name ?? expr?.callee?.name ?? null;
100+
}
101+
for (const key of Object.keys(node)) {
102+
if (key === 'loc' || key === 'parent') continue;
103+
const child = node[key];
104+
if (Array.isArray(child)) child.forEach(walk);
105+
else if (child && typeof child === 'object') walk(child);
106+
}
107+
}
108+
walk(result.ast);
109+
expect(decoratorName).toBe('tracked');
110+
});
111+
});
112+
65113
describe('JS path (useBabel) — end-to-end Linter pass', () => {
66114
function makeLinter() {
67115
const linter = new Linter();
@@ -83,10 +131,10 @@ describe('JS path (useBabel) — end-to-end Linter pass', () => {
83131

84132
// `no-dupe-args` is a core rule that calls
85133
// `astUtils.getOpeningParenOfParams(...).loc.start`, which reaches
86-
// through the token stream. Under the oxc path this throws because
87-
// `program.tokens` is empty. Enabling the rule keeps that surface
88-
// covered even if a future change quietly restores `loc` but still
89-
// ships an empty token array.
134+
// through the token stream. Enabling it pins the contract that the
135+
// JS path emits a populated `program.tokens` — earlier oxc-based
136+
// implementations shipped an empty array and crashed every rule
137+
// that walked tokens.
90138
const messages = linter.verify(
91139
code,
92140
{

0 commit comments

Comments
 (0)