Skip to content

Commit b78a558

Browse files
Merge pull request #199 from NullVoxPopuli-ai-agent/fix/js-path-loc-range
fix: JS path uses espree so ESLint sees loc, range, tokens
2 parents 287355e + 4f37d0a commit b78a558

4 files changed

Lines changed: 166 additions & 15 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"content-tag": "^4.1.1",
3737
"ember-estree": "^0.6.2",
3838
"eslint-scope": "^9.1.2",
39+
"espree": "^10.4.0",
3940
"html-tags": "^5.1.0",
4041
"mathml-tag-names": "^4.0.0",
4142
"svg-tags": "^1.0.0"

pnpm-lock.yaml

Lines changed: 16 additions & 9 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: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { patchTs, replaceExtensions, syncMtsGtsSourceFiles, typescriptParser } f
55
import { buildGlimmerVisitors } from './transforms.js';
66
import { toTree } from 'ember-estree';
77
import * as eslintScope from 'eslint-scope';
8+
import * as espree from 'espree';
89

910
const require = createRequire(import.meta.url);
1011

@@ -185,12 +186,24 @@ export function parseForESLint(code, options) {
185186
return tsResult;
186187
}
187188
: (placeholderJS) => {
188-
// JS path: parse with oxc, create scope manager from placeholder AST
189-
const { parseSync } = require('oxc-parser');
190-
const oxcResult = parseSync(filePath || 'input.js', placeholderJS);
191-
const program = oxcResult.program;
192-
program.tokens = oxcResult.tokens || [];
193-
program.comments = oxcResult.comments || [];
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+
});
194207
scopeManager = eslintScope.analyze(program, {
195208
ecmaVersion: 2022,
196209
sourceType: 'module',

tests/js-path.test.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Regression tests for the JS-fallback parse path.
3+
*
4+
* 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.
11+
*
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.
15+
*
16+
* Switch back to oxc once https://github.com/oxc-project/oxc/issues/10307
17+
* lands native loc support.
18+
*/
19+
20+
import { describe, expect, it } from 'vitest';
21+
import { Linter } from 'eslint';
22+
import { parseForESLint } from '../src/parser/gjs-gts-parser.js';
23+
24+
describe('JS path (useBabel) — AST shape ESLint requires', () => {
25+
const code = [
26+
"import currentYear from './helper.js';",
27+
'',
28+
'<template>',
29+
' <p>{{(currentYear)}}, hi</p>',
30+
'</template>',
31+
].join('\n');
32+
33+
const result = parseForESLint(code, {
34+
filePath: 'fixture.gjs',
35+
useBabel: true,
36+
});
37+
38+
it('Program node carries loc, range, tokens, and comments', () => {
39+
const ast = result.ast;
40+
expect(ast.type).toBe('Program');
41+
expect(ast.loc).toBeDefined();
42+
expect(ast.loc.start).toMatchObject({ line: expect.any(Number), column: expect.any(Number) });
43+
expect(ast.loc.end).toMatchObject({ line: expect.any(Number), column: expect.any(Number) });
44+
expect(ast.range).toEqual([expect.any(Number), expect.any(Number)]);
45+
expect(Array.isArray(ast.tokens)).toBe(true);
46+
expect(ast.tokens.length).toBeGreaterThan(0);
47+
expect(Array.isArray(ast.comments)).toBe(true);
48+
});
49+
50+
it('inner JS nodes carry loc and range (rules read these everywhere)', () => {
51+
const importDecl = result.ast.body[0];
52+
expect(importDecl.type).toBe('ImportDeclaration');
53+
expect(importDecl.loc).toBeDefined();
54+
expect(importDecl.range).toEqual([expect.any(Number), expect.any(Number)]);
55+
});
56+
57+
it('produces a scope manager that resolves the import', () => {
58+
expect(result.scopeManager).toBeDefined();
59+
const moduleScope = result.scopeManager.scopes.find((s) => s.type === 'module');
60+
expect(moduleScope).toBeDefined();
61+
expect(moduleScope.set.has('currentYear')).toBe(true);
62+
});
63+
});
64+
65+
describe('JS path (useBabel) — end-to-end Linter pass', () => {
66+
function makeLinter() {
67+
const linter = new Linter();
68+
linter.defineParser('ember-eslint-parser', {
69+
parseForESLint: (code, options) => parseForESLint(code, { ...options, useBabel: true }),
70+
});
71+
return linter;
72+
}
73+
74+
it('lints a .gjs file without throwing on missing loc/tokens', () => {
75+
const linter = makeLinter();
76+
const code = [
77+
"import currentYear from './helper.js';",
78+
'',
79+
'<template>',
80+
' <p>{{(currentYear)}}, hi</p>',
81+
'</template>',
82+
].join('\n');
83+
84+
// `no-dupe-args` is a core rule that calls
85+
// `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.
90+
const messages = linter.verify(
91+
code,
92+
{
93+
parser: 'ember-eslint-parser',
94+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
95+
rules: { 'no-dupe-args': 'error' },
96+
},
97+
{ filename: 'fixture.gjs' }
98+
);
99+
100+
// The fatal "AST is missing location information" / "Cannot read
101+
// properties of null (reading 'loc')" errors land here as
102+
// `fatal: true` messages — assert there are none.
103+
const fatal = messages.filter((m) => m.fatal);
104+
expect(fatal).toEqual([]);
105+
});
106+
107+
it('marks template-only references as used (scope wiring is intact)', () => {
108+
const linter = makeLinter();
109+
const code = [
110+
"import currentYear from './helper.js';",
111+
'',
112+
'<template>',
113+
' <p>{{(currentYear)}}, hi</p>',
114+
'</template>',
115+
].join('\n');
116+
117+
const messages = linter.verify(
118+
code,
119+
{
120+
parser: 'ember-eslint-parser',
121+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
122+
rules: { 'no-unused-vars': 'error' },
123+
},
124+
{ filename: 'fixture.gjs' }
125+
);
126+
127+
const unused = messages.filter((m) => m.ruleId === 'no-unused-vars');
128+
expect(unused).toEqual([]);
129+
});
130+
});

0 commit comments

Comments
 (0)