Skip to content

Commit 6e65e23

Browse files
Merge pull request #205 from NullVoxPopuli-ai-agent/fix/js-path-babel-parser
fix: route JS path through @babel/eslint-parser so decorators parse
2 parents af34821 + bef1a9b commit 6e65e23

5 files changed

Lines changed: 128 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,26 @@ 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+
// Resolved once at module load via createRequire — top-level `await import()`
11+
// would mark the module as async, and the bench harness loads parsers
12+
// synchronously through `createRequire`. `@babel/eslint-parser` is an optional
13+
// peer (TS-only setups don't need it), so a missing dep yields null and the JS
14+
// path below emits a targeted error.
15+
//
16+
// The experimental-worker entry point runs babel in a worker and matches the
17+
// parser ember-cli wires up for plain `.js` files in JS-only apps, so config
18+
// discovery (decorators & friends) lines up with how the rest of the project
19+
// is being parsed.
20+
let babelParser = null;
21+
try {
22+
babelParser = require('@babel/eslint-parser/experimental-worker');
23+
} catch {
24+
// optional peer; left null
25+
}
26+
1227
/**
1328
* implements https://eslint.org/docs/latest/extend/custom-parsers
1429
*
@@ -186,30 +201,25 @@ export function parseForESLint(code, options) {
186201
return tsResult;
187202
}
188203
: (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,
204+
// JS path: defer to @babel/eslint-parser, which discovers the
205+
// consuming app's babel config and applies its plugins (notably
206+
// decorators) when parsing. That keeps gjs aligned with how the
207+
// app's plain `.js` files are parsed, so syntax accepted there
208+
// is accepted here without a second parser config.
209+
if (!babelParser) {
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 = babelParser.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: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,29 @@
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';
2125
import { Linter } from 'eslint';
2226
import { parseForESLint } from '../src/parser/gjs-gts-parser.js';
27+
import { traverse } from '../src/parser/transforms.js';
2328

2429
describe('JS path (useBabel) — AST shape ESLint requires', () => {
2530
const code = [
@@ -62,6 +67,42 @@ describe('JS path (useBabel) — AST shape ESLint requires', () => {
6267
});
6368
});
6469

70+
describe('JS path (useBabel) — class decorators', () => {
71+
// Regression: when @typescript-eslint/parser isn't installed, the JS path
72+
// historically fell back to raw espree, which rejects `@decorator` syntax
73+
// outright (`SyntaxError: Unexpected character '@'`). Routing the JS path
74+
// through @babel/eslint-parser picks up the consuming app's babel config —
75+
// the decorators plugin lives there for ember apps — so a `.gjs` file with
76+
// a `@tracked` field parses cleanly without any TypeScript tooling.
77+
const code = [
78+
"import Component from '@glimmer/component';",
79+
"import { tracked } from '@glimmer/tracking';",
80+
'',
81+
'export default class Counter extends Component {',
82+
' @tracked count = 0;',
83+
' <template>{{this.count}}</template>',
84+
'}',
85+
].join('\n');
86+
87+
it('parses a class field decorator without throwing', () => {
88+
const result = parseForESLint(code, {
89+
filePath: 'fixture.gjs',
90+
useBabel: true,
91+
});
92+
expect(result.ast.type).toBe('Program');
93+
94+
let decoratorName = null;
95+
traverse(result.visitorKeys, result.ast, (path) => {
96+
const decorators = path.node?.decorators;
97+
if (Array.isArray(decorators) && decorators.length > 0) {
98+
const expr = decorators[0].expression;
99+
decoratorName = expr?.name ?? expr?.callee?.name ?? null;
100+
}
101+
});
102+
expect(decoratorName).toBe('tracked');
103+
});
104+
});
105+
65106
describe('JS path (useBabel) — end-to-end Linter pass', () => {
66107
function makeLinter() {
67108
const linter = new Linter();
@@ -83,10 +124,10 @@ describe('JS path (useBabel) — end-to-end Linter pass', () => {
83124

84125
// `no-dupe-args` is a core rule that calls
85126
// `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.
127+
// through the token stream. Enabling it pins the contract that the
128+
// JS path emits a populated `program.tokens` — earlier oxc-based
129+
// implementations shipped an empty array and crashed every rule
130+
// that walked tokens.
90131
const messages = linter.verify(
91132
code,
92133
{

0 commit comments

Comments
 (0)