Skip to content

Commit cf2a5cd

Browse files
Remove package.json FS reads; add BUILTIN_INVOKABLES; always auto-fix when matched
Co-authored-by: NullVoxPopuli <[email protected]>
1 parent 3eec111 commit cf2a5cd

2 files changed

Lines changed: 133 additions & 131 deletions

File tree

lib/rules/template-missing-invokable.js

Lines changed: 24 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,24 @@
11
'use strict';
22

3-
const path = require('node:path');
4-
const fs = require('node:fs');
5-
6-
// Packages that ship with Ember/Glimmer are always available to auto-fix.
7-
function isBuiltinPackage(moduleName) {
8-
return moduleName.startsWith('@ember/') || moduleName.startsWith('@glimmer/');
9-
}
10-
11-
// Returns the root package name from a module specifier, e.g.
12-
// 'ember-truth-helpers' -> 'ember-truth-helpers'
13-
// 'ember-truth-helpers/helpers' -> 'ember-truth-helpers'
14-
// '@scope/pkg/deep' -> '@scope/pkg'
15-
function rootPackageName(moduleName) {
16-
if (moduleName.startsWith('@')) {
17-
const parts = moduleName.split('/');
18-
return parts.slice(0, 2).join('/');
19-
}
20-
return moduleName.split('/')[0];
21-
}
22-
23-
// Walk up the directory tree from startDir to find the nearest package.json.
24-
function findNearestPackageJson(startDir) {
25-
let dir = startDir;
26-
let parent = path.dirname(dir);
27-
while (dir !== parent) {
28-
const candidate = path.join(dir, 'package.json');
29-
if (fs.existsSync(candidate)) {
30-
return candidate;
31-
}
32-
dir = parent;
33-
parent = path.dirname(dir);
34-
}
35-
return null;
36-
}
37-
38-
// Cache: `${pkg}\0${fileDir}` -> boolean. Survives the lifetime of the process
39-
// (one ESLint run), avoiding repeated FS reads for the same file directory.
40-
const packageInDepsCache = new Map();
41-
42-
function isPackageInProjectDeps(moduleName, fileDir) {
43-
const pkg = rootPackageName(moduleName);
44-
const cacheKey = `${pkg}::${fileDir}`;
45-
if (packageInDepsCache.has(cacheKey)) {
46-
return packageInDepsCache.get(cacheKey);
47-
}
48-
let result = false;
49-
try {
50-
const pkgPath = findNearestPackageJson(fileDir);
51-
if (pkgPath) {
52-
const packageJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
53-
result = Boolean(
54-
(packageJson.dependencies && pkg in packageJson.dependencies) ||
55-
(packageJson.devDependencies && pkg in packageJson.devDependencies) ||
56-
(packageJson.peerDependencies && pkg in packageJson.peerDependencies)
57-
);
58-
}
59-
} catch {
60-
result = false;
61-
}
62-
packageInDepsCache.set(cacheKey, result);
63-
return result;
64-
}
3+
// Invokables that are available in every Ember project without any extra
4+
// packages. User-provided `invokables` config is merged on top of these so
5+
// any entry here can be overridden by the consuming project.
6+
const BUILTIN_INVOKABLES = {
7+
fn: ['fn', '@ember/helper'],
8+
get: ['get', '@ember/helper'],
9+
hash: ['hash', '@ember/helper'],
10+
array: ['array', '@ember/helper'],
11+
concat: ['concat', '@ember/helper'],
12+
htmlSafe: ['htmlSafe', '@ember/template'],
13+
trustedHTML: ['trustedHTML', '@ember/template'],
14+
LinkTo: ['LinkTo', '@ember/routing'],
15+
on: ['on', '@ember/modifier'],
16+
trackedArray: ['trackedArray', '@ember/reactive/collections'],
17+
trackedObject: ['trackedObject', '@ember/reactive/collections'],
18+
trackedSet: ['trackedSet', '@ember/reactive/collections'],
19+
trackedWeakSet: ['trackedWeakSet', '@ember/reactive/collections'],
20+
trackedWeakMap: ['trackedWeakMap', '@ember/reactive/collections'],
21+
};
6522

6623
/** @type {import('eslint').Rule.RuleModule} */
6724
module.exports = {
@@ -99,30 +56,22 @@ module.exports = {
9956

10057
create: (context) => {
10158
const sourceCode = context.sourceCode;
102-
const fileDir = path.dirname(
103-
path.resolve(context.getPhysicalFilename?.() ?? context.getFilename())
104-
);
59+
const invokables = { ...BUILTIN_INVOKABLES, ...context.options[0]?.invokables };
10560

10661
// takes a node with a `.path` property
10762
function checkInvokable(node) {
10863
if (node.path.type === 'GlimmerPathExpression' && node.path.tail.length === 0) {
10964
if (!isBound(node.path.head, sourceCode.getScope(node.path))) {
110-
const matched = context.options[0]?.invokables?.[node.path.head.name];
65+
const matched = invokables[node.path.head.name];
11166
if (matched) {
11267
const [name, moduleName] = matched;
113-
const canAutoFix =
114-
isBuiltinPackage(moduleName) ||
115-
isPackageInProjectDeps(moduleName, fileDir);
116-
11768
const importStatement = buildImportStatement(node.path.head.name, name, moduleName);
11869
context.report({
11970
node: node.path,
12071
messageId: 'missing-invokable',
121-
fix: canAutoFix
122-
? function (fixer) {
123-
return fixer.insertTextBeforeRange([0, 0], `${importStatement};\n`);
124-
}
125-
: null,
72+
fix(fixer) {
73+
return fixer.insertTextBeforeRange([0, 0], `${importStatement};\n`);
74+
},
12675
});
12776
}
12877
}

tests/lib/rules/template-missing-invokable.js

Lines changed: 109 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,9 @@
22
// Requirements
33
//------------------------------------------------------------------------------
44

5-
const path = require('node:path');
65
const rule = require('../../../lib/rules/template-missing-invokable');
76
const RuleTester = require('eslint').RuleTester;
87

9-
//------------------------------------------------------------------------------
10-
// Helpers
11-
//------------------------------------------------------------------------------
12-
13-
// A filename inside a fixture project that has ember-truth-helpers installed.
14-
const filenameInProjectWithTruthHelpers = path.join(
15-
__dirname,
16-
'../../fixtures/projects/has-ember-truth-helpers/test.gjs'
17-
);
18-
198
//------------------------------------------------------------------------------
209
// Tests
2110
//------------------------------------------------------------------------------
@@ -76,33 +65,25 @@ ruleTester.run('template-missing-invokable', rule, {
7665
<button {{on "click" doSomething}}>Go</button>
7766
</template>
7867
`,
79-
],
8068

81-
invalid: [
82-
// Subexpression invocations — no auto-fix when package is not in project deps
83-
{
84-
code: `
69+
// Built-in invokables are not reported when already imported
70+
`
71+
import { fn } from '@ember/helper';
8572
<template>
86-
{{#if (eq 1 1)}}
87-
They're equal
88-
{{/if}}
73+
{{fn myFunc 1}}
8974
</template>
90-
`,
91-
output: null,
92-
options: [
93-
{
94-
invokables: {
95-
eq: ['eq', 'ember-truth-helpers'],
96-
},
97-
},
98-
],
99-
100-
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
101-
},
75+
`,
76+
`
77+
import { LinkTo } from '@ember/routing';
78+
<template>
79+
<LinkTo @route="index">Home</LinkTo>
80+
</template>
81+
`,
82+
],
10283

103-
// Subexpression invocations — auto-fix when package IS in project deps
84+
invalid: [
85+
// Subexpression invocations — always auto-fixes when invokable is configured
10486
{
105-
filename: filenameInProjectWithTruthHelpers,
10687
code: `
10788
<template>
10889
{{#if (eq 1 1)}}
@@ -129,25 +110,8 @@ ruleTester.run('template-missing-invokable', rule, {
129110
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
130111
},
131112

132-
// Mustache Invocations — no auto-fix when package is not in project deps
113+
// Mustache Invocations — always auto-fixes when invokable is configured
133114
{
134-
code: `
135-
<template>
136-
{{eq 1 1}}
137-
</template>
138-
`,
139-
output: null,
140-
options: [
141-
{
142-
invokables: {
143-
eq: ['eq', 'ember-truth-helpers'],
144-
},
145-
},
146-
],
147-
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
148-
},
149-
{
150-
filename: filenameInProjectWithTruthHelpers,
151115
code: `
152116
import MyComponent from 'somewhere';
153117
<template>
@@ -171,7 +135,7 @@ ruleTester.run('template-missing-invokable', rule, {
171135
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
172136
},
173137

174-
// Modifier Invocations — built-in package always auto-fixes
138+
// Modifier Invocations — always auto-fixes when invokable is configured
175139
{
176140
code: `
177141
function doSomething() {}
@@ -196,9 +160,8 @@ ruleTester.run('template-missing-invokable', rule, {
196160
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
197161
},
198162

199-
// Multiple copies of a fixable invocation — with package installed
163+
// Multiple copies of a fixable invocation
200164
{
201-
filename: filenameInProjectWithTruthHelpers,
202165
code: `
203166
let other = <template>
204167
{{#if (eq 3 3) }}
@@ -246,9 +209,8 @@ ruleTester.run('template-missing-invokable', rule, {
246209
],
247210
},
248211

249-
// Auto-fix with a default export — package installed
212+
// Auto-fix with a default export
250213
{
251-
filename: filenameInProjectWithTruthHelpers,
252214
code: `
253215
<template>
254216
{{#if (eq 1 1)}}
@@ -274,5 +236,96 @@ ruleTester.run('template-missing-invokable', rule, {
274236

275237
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
276238
},
239+
240+
// Built-in: fn — auto-fixes without any user config
241+
{
242+
code: `
243+
<template>
244+
{{fn myFunc 1}}
245+
</template>
246+
`,
247+
output: `import { fn } from '@ember/helper';
248+
249+
<template>
250+
{{fn myFunc 1}}
251+
</template>
252+
`,
253+
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
254+
},
255+
256+
// Built-in: hash — auto-fixes without any user config
257+
{
258+
code: `
259+
<template>
260+
<MyComp @opts={{hash a=1}} />
261+
</template>
262+
`,
263+
output: `import { hash } from '@ember/helper';
264+
265+
<template>
266+
<MyComp @opts={{hash a=1}} />
267+
</template>
268+
`,
269+
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
270+
},
271+
272+
// Built-in: on modifier — auto-fixes without any user config
273+
{
274+
code: `
275+
function doSomething() {}
276+
<template>
277+
<button {{on "click" doSomething}}>Go</button>
278+
</template>
279+
`,
280+
output: `import { on } from '@ember/modifier';
281+
282+
function doSomething() {}
283+
<template>
284+
<button {{on "click" doSomething}}>Go</button>
285+
</template>
286+
`,
287+
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
288+
},
289+
290+
// Built-in: LinkTo — auto-fixes without any user config
291+
{
292+
code: `
293+
<template>
294+
<LinkTo @route="index">Home</LinkTo>
295+
</template>
296+
`,
297+
output: `import { LinkTo } from '@ember/routing';
298+
299+
<template>
300+
<LinkTo @route="index">Home</LinkTo>
301+
</template>
302+
`,
303+
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
304+
},
305+
306+
// User config overrides a built-in
307+
{
308+
code: `
309+
function doSomething() {}
310+
<template>
311+
<button {{on "click" doSomething}}>Go</button>
312+
</template>
313+
`,
314+
output: `import { on } from 'my-custom-modifier-package';
315+
316+
function doSomething() {}
317+
<template>
318+
<button {{on "click" doSomething}}>Go</button>
319+
</template>
320+
`,
321+
options: [
322+
{
323+
invokables: {
324+
on: ['on', 'my-custom-modifier-package'],
325+
},
326+
},
327+
],
328+
errors: [{ type: 'GlimmerPathExpression', message: rule.meta.messages['missing-invokable'] }],
329+
},
277330
],
278331
});

0 commit comments

Comments
 (0)