Skip to content

Commit c89576d

Browse files
mirkaaduth
andauthored
ESLint: Support private API component denylist (WordPress#78451)
* ESLint: Support private API component denylist * Simplify tests * Suppress existing usages * Fix private API unlock detection * Align unlock detection with use-import-as * Remove const-only private API check * Simplify denylist lookup * Simplify import name handling * Add example to readme * Extract shared private API unlock helpers * Improve JSDocs in utility file * Stop exporting isUnlockCall from private-apis util * Merge unlock and privateApis import tracking helpers * Simplify private API unlock checks * Remove redundant parent guard Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: aduth <aduth@git.wordpress.org>
1 parent b383bb9 commit c89576d

7 files changed

Lines changed: 330 additions & 129 deletions

File tree

packages/eslint-plugin/docs/rules/use-recommended-components.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Encourages the use of recommended UI components in a WordPress environment by flagging imports that have a better alternative. The component lists are maintained directly in the rule and represent an evolving, up-to-date set of guidance.
44

5+
The rule checks named imports and direct destructuring from `unlock( privateApis )` when the `privateApis` identifier can be traced back to a package with a denylist entry.
6+
57
## Rule details
68

79
Examples of **incorrect** code for this rule:
@@ -11,7 +13,12 @@ Examples of **incorrect** code for this rule:
1113
import { SomeComponent } from '@wordpress/ui';
1214

1315
// @wordpress/components — a newer alternative is available.
14-
import { TextControl } from '@wordpress/components';
16+
import { Tabs } from '@wordpress/components';
17+
18+
import { privateApis as componentsPrivateApis } from '@wordpress/components';
19+
import { unlock } from '../../lock-unlock';
20+
21+
const { Tabs } = unlock( componentsPrivateApis );
1522
```
1623

1724
Examples of **correct** code for this rule:
@@ -22,4 +29,11 @@ import { Button } from '@wordpress/components';
2229

2330
// Default and namespace imports are not checked.
2431
import UI from '@wordpress/ui';
32+
33+
import { Tabs } from '@wordpress/ui';
34+
35+
import { privateApis as componentsPrivateApis } from '@wordpress/components';
36+
import { unlock } from '../../lock-unlock';
37+
38+
const { SomethingElse } = unlock( componentsPrivateApis );
2539
```

packages/eslint-plugin/rules/__tests__/use-import-as.js

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,6 @@ ruleTester.run( 'use-import-as', rule, {
6666
code: "import { VisuallyHidden as WCVisuallyHidden } from '@wordpress/components';",
6767
options,
6868
},
69-
{
70-
code: 'import { "VisuallyHidden" as WCVisuallyHidden } from \'@wordpress/components\';',
71-
options,
72-
},
7369
{
7470
code: "import { Button, VisuallyHidden as WCVisuallyHidden } from '@wordpress/components';",
7571
options,
@@ -95,9 +91,9 @@ ruleTester.run( 'use-import-as', rule, {
9591
{
9692
code: `
9793
import { privateApis } from '@wordpress/components';
98-
import { unlock as open } from '../../lock-unlock';
94+
import { unlock } from '../../lock-unlock';
9995
100-
const { Badge: WCBadge = fallbackBadge } = open( privateApis );
96+
const { Badge: WCBadge = fallbackBadge } = unlock( privateApis );
10197
`,
10298
options,
10399
},
@@ -161,17 +157,6 @@ ruleTester.run( 'use-import-as', rule, {
161157
),
162158
],
163159
},
164-
{
165-
code: 'import { "VisuallyHidden" as Hidden } from \'@wordpress/components\';',
166-
options,
167-
errors: [
168-
withSuggestions(
169-
'`VisuallyHidden` from `@wordpress/components` must be imported as `WCVisuallyHidden`.',
170-
'import { "VisuallyHidden" as WCVisuallyHidden } from \'@wordpress/components\';',
171-
'Import as `WCVisuallyHidden`.'
172-
),
173-
],
174-
},
175160
{
176161
code: "import { Button, VisuallyHidden } from '@wordpress/components';",
177162
options,

packages/eslint-plugin/rules/__tests__/use-recommended-components.js

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
/**
2-
* External dependencies
3-
*/
41
import { RuleTester } from 'eslint';
5-
6-
/**
7-
* Internal dependencies
8-
*/
92
import rule, { ALLOWLIST, DENYLIST } from '../use-recommended-components';
103

114
const ruleTester = new RuleTester( {
@@ -32,6 +25,23 @@ ruleTester.run( 'use-recommended-components', rule, {
3225
"import { Stack } from '@wordpress/ui';",
3326
"import { Text } from '@wordpress/ui';",
3427
"import { Badge, Icon, Link, Stack, Tabs, Text } from '@wordpress/ui';",
28+
29+
// Unlocked private APIs are only checked for denied names.
30+
"import { privateApis } from '@wordpress/components'; import { unlock } from '../../lock-unlock'; const { SomethingElse } = unlock( privateApis );",
31+
`
32+
import { privateApis } from '@wordpress/components';
33+
import { unlock } from '../../lock-unlock';
34+
35+
function test() {
36+
function unlock( value ) {
37+
return value;
38+
}
39+
40+
const { Tabs } = unlock( privateApis );
41+
42+
return Tabs;
43+
}
44+
`,
3545
],
3646

3747
invalid: [
@@ -88,6 +98,22 @@ ruleTester.run( 'use-recommended-components', rule, {
8898
},
8999
],
90100
},
101+
{
102+
code: "import { privateApis } from '@wordpress/components'; import { unlock } from '../../lock-unlock'; const { Tabs } = unlock( privateApis );",
103+
errors: [
104+
{
105+
message: 'Use `Tabs` from `@wordpress/ui` instead.',
106+
},
107+
],
108+
},
109+
{
110+
code: "import { privateApis as componentsPrivateApis } from '@wordpress/components'; import { unlock } from '../../lock-unlock'; const { Tabs: WCTabs } = unlock( componentsPrivateApis );",
111+
errors: [
112+
{
113+
message: 'Use `Tabs` from `@wordpress/ui` instead.',
114+
},
115+
],
116+
},
91117
],
92118
} );
93119

packages/eslint-plugin/rules/use-import-as.js

Lines changed: 30 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
const {
2+
createPrivateApisState,
3+
trackPrivateApisSpecifier,
4+
getPropertyName,
5+
getUnlockDestructuring,
6+
} = require( '../utils/private-apis' );
7+
18
/** @type {import('eslint').Rule.RuleModule} */
29
const rule = {
310
meta: {
@@ -34,8 +41,7 @@ const rule = {
3441
typeof context.options[ 0 ] === 'object'
3542
? context.options[ 0 ]
3643
: {};
37-
const privateApisSources = new Map();
38-
const trackedUnlockImports = new Set();
44+
const privateApisState = createPrivateApisState();
3945

4046
return {
4147
/** @param {import('estree').ImportDeclaration} node */
@@ -52,21 +58,24 @@ const rule = {
5258
return;
5359
}
5460

55-
const importedName = getImportedName( specifier );
56-
if ( importedName === 'unlock' ) {
57-
trackedUnlockImports.add( specifier.local.name );
58-
}
61+
const importedName = specifier.imported.name;
62+
trackPrivateApisSpecifier(
63+
privateApisState,
64+
specifier,
65+
source,
66+
!! sourceMap
67+
);
5968

6069
if ( ! sourceMap ) {
6170
return;
6271
}
6372

64-
if ( importedName === 'privateApis' ) {
65-
privateApisSources.set( specifier.local.name, source );
73+
if ( ! sourceMap.hasOwnProperty( importedName ) ) {
74+
return;
6675
}
67-
const localName = sourceMap[ importedName ];
6876

69-
if ( ! localName || specifier.local.name === localName ) {
77+
const localName = sourceMap[ importedName ];
78+
if ( specifier.local.name === localName ) {
7079
return;
7180
}
7281

@@ -101,50 +110,31 @@ const rule = {
101110
},
102111
/** @param {import('estree').VariableDeclarator} node */
103112
VariableDeclarator( node ) {
104-
if (
105-
node.parent.type !== 'VariableDeclaration' ||
106-
node.parent.kind !== 'const' ||
107-
node.id.type !== 'ObjectPattern' ||
108-
! isUnlockCall(
109-
node.init,
110-
context.sourceCode,
111-
trackedUnlockImports
112-
)
113-
) {
114-
return;
115-
}
116-
117-
const privateApisIdentifier = node.init.arguments[ 0 ];
118-
if ( privateApisIdentifier.type !== 'Identifier' ) {
119-
return;
120-
}
121-
122-
const source = privateApisSources.get(
123-
privateApisIdentifier.name
113+
const unlockDestructuring = getUnlockDestructuring(
114+
node,
115+
context.sourceCode,
116+
privateApisState
124117
);
125-
if ( ! source ) {
118+
if ( ! unlockDestructuring ) {
126119
return;
127120
}
128121

122+
const { source, properties } = unlockDestructuring;
129123
const sourceMap = importAsMap[ source ];
130124
if ( ! sourceMap ) {
131125
return;
132126
}
133127

134-
node.id.properties.forEach( ( property ) => {
135-
if ( property.type !== 'Property' || property.computed ) {
136-
return;
137-
}
138-
128+
properties.forEach( ( property ) => {
139129
const importedName = getPropertyName( property.key );
140-
if ( ! importedName ) {
130+
if (
131+
! importedName ||
132+
! sourceMap.hasOwnProperty( importedName )
133+
) {
141134
return;
142135
}
143136

144137
const localName = sourceMap[ importedName ];
145-
if ( ! localName ) {
146-
return;
147-
}
148138

149139
const propertyLocalName = getPropertyLocalName(
150140
property.value
@@ -189,16 +179,6 @@ const rule = {
189179
},
190180
};
191181

192-
/**
193-
* @param {import('estree').ImportSpecifier} specifier
194-
* @return {string} Imported name.
195-
*/
196-
function getImportedName( specifier ) {
197-
return specifier.imported.type === 'Identifier'
198-
? specifier.imported.name
199-
: String( specifier.imported.value );
200-
}
201-
202182
/**
203183
* @param {import('estree').ImportSpecifier} specifier
204184
* @param {import('eslint').SourceCode} sourceCode
@@ -209,52 +189,6 @@ function getImportSpecifierSuggestionText( specifier, sourceCode, localName ) {
209189
return `${ sourceCode.getText( specifier.imported ) } as ${ localName }`;
210190
}
211191

212-
/**
213-
* @param {import('estree').CallExpression|import('estree').Expression|null} node
214-
* @param {import('eslint').SourceCode} sourceCode
215-
* @param {ReadonlySet<string>} trackedUnlockImports
216-
* @return {node is import('estree').CallExpression} Whether this is an `unlock()` call with one argument.
217-
*/
218-
function isUnlockCall( node, sourceCode, trackedUnlockImports ) {
219-
if (
220-
node &&
221-
node.type === 'CallExpression' &&
222-
node.callee.type === 'Identifier' &&
223-
node.arguments.length === 1
224-
) {
225-
if ( ! trackedUnlockImports.has( node.callee.name ) ) {
226-
return false;
227-
}
228-
229-
const { references } = sourceCode.getScope( node.callee );
230-
const reference = references.find(
231-
( currentReference ) => currentReference.identifier === node.callee
232-
);
233-
234-
return !! reference?.resolved?.defs.some(
235-
( definition ) => definition.type === 'ImportBinding'
236-
);
237-
}
238-
239-
return false;
240-
}
241-
242-
/**
243-
* @param {import('estree').Expression|import('estree').PrivateIdentifier} key
244-
* @return {string|null} Property name.
245-
*/
246-
function getPropertyName( key ) {
247-
if ( key.type === 'Identifier' ) {
248-
return key.name;
249-
}
250-
251-
if ( key.type === 'Literal' ) {
252-
return String( key.value );
253-
}
254-
255-
return null;
256-
}
257-
258192
/**
259193
* @param {import('estree').Property} property
260194
* @param {import('eslint').SourceCode} sourceCode

0 commit comments

Comments
 (0)