@nxds/eslint-plugin 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,22 @@ Enforces `.claude/rules/extract-inline-handlers.md`: inline JSX handler props (`
32
32
 
33
33
  Enforces `.claude/rules/code-comments.md`: JSDoc blocks must not carry `@default` or `@example` tags. The docs read prop defaults from code and usage from `docs` stories, so a tag is a second copy that can drift. Line comments and plain block comments are not checked. Wired in the root `eslint.config.js` for `packages/react/src` and `packages/vue/src`.
34
34
 
35
+ ### `@nxds/no-react-19-only`
36
+
37
+ Reports React 19-only APIs in `packages/react`, the source `packages/react-18` is generated from. The generator adds `forwardRef` and changes nothing else, so a React 19-only call would compile there and then break on React 18. It reports:
38
+
39
+ | Pattern | Use instead |
40
+ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
41
+ | `use`, `useActionState`, `useOptimistic`, `useEffectEvent`, `Activity`, `captureOwnerStack` from `react` | An API React 18.3 has |
42
+ | `useFormStatus`, `requestFormReset`, `preload*`, `preinit*`, `preconnect`, `prefetchDNS` from `react-dom` | An API React 18.3 has |
43
+ | `<SomeContext value={…}>` | `<SomeContext.Provider value={…}>` |
44
+ | A ref callback that returns a cleanup | The node in state, with setup and teardown in an effect |
45
+ | `useDeferredValue(value, initialValue)` | One argument |
46
+ | A function passed to `action` or `formAction` | `onSubmit` or `onClick` |
47
+ | `<title>` outside an `<svg>`, `<meta>`, `<link>`, `<style precedence>` | Metadata the app's framework renders |
48
+
49
+ Wired in the root `eslint.config.js` for `packages/react/src` only. It is not in `nxdsComponentConfig`, because a React 19 app may use these APIs.
50
+
35
51
  ## Source of truth
36
52
 
37
53
  The token-layer rules are self-contained: the canonical step set is `src/canonical-step-set.json`. The component rules map 1:1 to the `.claude/rules/*.md` cited above — the rule code plus those docs are the spec. Every rule has RuleTester coverage in `__tests__/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxds/eslint-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "ESLint rules that enforce nxds conventions in copied components.",
6
6
  "keywords": [
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
3
3
  import canonicalSpacingSteps from './rules/canonical-spacing-steps.js';
4
4
  import noJsdocDataTags from './rules/no-jsdoc-data-tags.js';
5
5
  import noMultiStatementJsxHandler from './rules/no-multi-statement-jsx-handler.js';
6
+ import noReact19Only from './rules/no-react-19-only.js';
6
7
  import noRenderPropTypes from './rules/no-render-prop-types.js';
7
8
  import nxClassConventions from './rules/nx-class-conventions.js';
8
9
 
@@ -18,6 +19,7 @@ const plugin = {
18
19
  'no-render-prop-types': noRenderPropTypes,
19
20
  'no-multi-statement-jsx-handler': noMultiStatementJsxHandler,
20
21
  'no-jsdoc-data-tags': noJsdocDataTags,
22
+ 'no-react-19-only': noReact19Only,
21
23
  },
22
24
  };
23
25
 
@@ -0,0 +1,280 @@
1
+ // `packages/react-18` is generated from `packages/react` by wrapping each
2
+ // component in `forwardRef`, and nothing else changes. So `packages/react`
3
+ // must not use anything React 18 lacks: the generated copy would compile and
4
+ // then break at runtime. This rule reports the React 19-only APIs and
5
+ // behaviours an nxds component could reach for.
6
+
7
+ const REACT_ONLY_19 = new Set([
8
+ 'use',
9
+ 'useActionState',
10
+ 'useOptimistic',
11
+ 'useEffectEvent',
12
+ 'Activity',
13
+ 'captureOwnerStack',
14
+ 'cache',
15
+ 'cacheSignal',
16
+ ]);
17
+
18
+ const REACT_DOM_ONLY_19 = new Set([
19
+ 'useFormStatus',
20
+ 'requestFormReset',
21
+ 'preload',
22
+ 'preloadModule',
23
+ 'preinit',
24
+ 'preinitModule',
25
+ 'preconnect',
26
+ 'prefetchDNS',
27
+ ]);
28
+
29
+ const ONLY_19_BY_SOURCE = {
30
+ react: REACT_ONLY_19,
31
+ 'react-dom': REACT_DOM_ONLY_19,
32
+ };
33
+
34
+ const FUNCTION_TYPES = new Set([
35
+ 'ArrowFunctionExpression',
36
+ 'FunctionExpression',
37
+ 'FunctionDeclaration',
38
+ ]);
39
+
40
+ function isFunction(node) {
41
+ return node != null && FUNCTION_TYPES.has(node.type);
42
+ }
43
+
44
+ /** Whether `fn` returns a function from its own body, not a nested one. */
45
+ function returnsFunction(fn) {
46
+ if (
47
+ fn.type === 'ArrowFunctionExpression' &&
48
+ fn.body.type !== 'BlockStatement'
49
+ ) {
50
+ return isFunction(fn.body);
51
+ }
52
+ let found = false;
53
+ const visit = (node) => {
54
+ if (found || node == null || typeof node.type !== 'string') return;
55
+ if (node.type === 'ReturnStatement') {
56
+ if (isFunction(node.argument)) found = true;
57
+ return;
58
+ }
59
+ if (isFunction(node)) return;
60
+ for (const [key, value] of Object.entries(node)) {
61
+ if (key === 'parent') continue;
62
+ if (Array.isArray(value)) value.forEach(visit);
63
+ else if (value && typeof value === 'object') visit(value);
64
+ }
65
+ };
66
+ visit(fn.body);
67
+ return found;
68
+ }
69
+
70
+ /** The name a call's callee ends in: `useCallback` for `React.useCallback`. */
71
+ function calleeName(call) {
72
+ const { callee } = call;
73
+ if (callee.type === 'Identifier') return callee.name;
74
+ if (
75
+ callee.type === 'MemberExpression' &&
76
+ callee.property.type === 'Identifier'
77
+ ) {
78
+ return callee.property.name;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** The function an identifier names, when it is declared in reach. */
84
+ function functionNamed(context, identifier) {
85
+ const scope = context.sourceCode.getScope(identifier);
86
+ let variable = null;
87
+ for (let current = scope; current && !variable; current = current.upper) {
88
+ variable = current.set.get(identifier.name) ?? null;
89
+ }
90
+ const definition = variable?.defs[0];
91
+ if (!definition) return null;
92
+ if (definition.node.type === 'FunctionDeclaration') return definition.node;
93
+ if (definition.node.type !== 'VariableDeclarator') return null;
94
+ const init = definition.node.init;
95
+ if (isFunction(init)) return init;
96
+ if (init?.type === 'CallExpression' && calleeName(init) === 'useCallback') {
97
+ return isFunction(init.arguments[0]) ? init.arguments[0] : null;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function expressionFunction(context, expression) {
103
+ if (isFunction(expression)) return expression;
104
+ if (expression.type === 'Identifier')
105
+ return functionNamed(context, expression);
106
+ return null;
107
+ }
108
+
109
+ function attributeExpression(attribute) {
110
+ const { value } = attribute;
111
+ return value?.type === 'JSXExpressionContainer' ? value.expression : null;
112
+ }
113
+
114
+ function hasAncestorElement(node, name) {
115
+ for (let current = node.parent; current; current = current.parent) {
116
+ if (
117
+ current.type === 'JSXElement' &&
118
+ current.openingElement.name.type === 'JSXIdentifier' &&
119
+ current.openingElement.name.name === name
120
+ ) {
121
+ return true;
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
127
+ export default {
128
+ meta: {
129
+ type: 'problem',
130
+ docs: {
131
+ description:
132
+ 'Disallow React 19-only APIs in source that packages/react-18 is generated from.',
133
+ },
134
+ schema: [],
135
+ messages: {
136
+ api: '`{{name}}` is React 19 only, so the generated packages/react-18 breaks. Use an API React 18.3 has.',
137
+ contextProvider:
138
+ 'Rendering a context as a provider is React 19 only. Render `<{{name}}.Provider value={…}>`.',
139
+ refCleanup:
140
+ 'A ref callback that returns a cleanup is React 18-incompatible: React 18 ignores the cleanup and calls the ref with `null`. Keep the node in state and set up and tear down in an effect.',
141
+ deferredInitialValue:
142
+ "`useDeferredValue`'s initial value is React 19 only. Pass one argument.",
143
+ formAction:
144
+ 'A function `{{name}}` is React 19 only. Handle the form in `onSubmit` or the button in `onClick`.',
145
+ documentMetadata:
146
+ '`<{{name}}>` is hoisted into the document head by React 19 only. React 18 renders it in place.',
147
+ },
148
+ },
149
+ create(context) {
150
+ // Local names bound to the `react` / `react-dom` namespace or default import.
151
+ const namespaces = new Map();
152
+ const contexts = new Set();
153
+
154
+ function reportMember(node, source, name) {
155
+ if (ONLY_19_BY_SOURCE[source]?.has(name)) {
156
+ context.report({ node, messageId: 'api', data: { name } });
157
+ }
158
+ }
159
+
160
+ return {
161
+ ImportDeclaration(node) {
162
+ const source = node.source.value;
163
+ if (!(source in ONLY_19_BY_SOURCE)) return;
164
+ for (const specifier of node.specifiers) {
165
+ if (specifier.type === 'ImportSpecifier') {
166
+ const imported =
167
+ specifier.imported.name ?? specifier.imported.value;
168
+ reportMember(specifier, source, imported);
169
+ } else {
170
+ namespaces.set(specifier.local.name, source);
171
+ }
172
+ }
173
+ },
174
+
175
+ MemberExpression(node) {
176
+ if (
177
+ node.object.type !== 'Identifier' ||
178
+ node.property.type !== 'Identifier'
179
+ ) {
180
+ return;
181
+ }
182
+ const source = namespaces.get(node.object.name);
183
+ if (source) reportMember(node.property, source, node.property.name);
184
+ },
185
+
186
+ JSXMemberExpression(node) {
187
+ if (node.object.type !== 'JSXIdentifier') return;
188
+ const source = namespaces.get(node.object.name);
189
+ if (source) reportMember(node.property, source, node.property.name);
190
+ },
191
+
192
+ VariableDeclarator(node) {
193
+ if (
194
+ node.id.type === 'Identifier' &&
195
+ node.init?.type === 'CallExpression' &&
196
+ calleeName(node.init) === 'createContext'
197
+ ) {
198
+ contexts.add(node.id.name);
199
+ }
200
+ },
201
+
202
+ CallExpression(node) {
203
+ if (
204
+ calleeName(node) === 'useDeferredValue' &&
205
+ node.arguments.length > 1
206
+ ) {
207
+ context.report({ node, messageId: 'deferredInitialValue' });
208
+ }
209
+ },
210
+
211
+ JSXOpeningElement(node) {
212
+ if (node.name.type !== 'JSXIdentifier') return;
213
+ const { name } = node.name;
214
+ const attributes = node.attributes.filter(
215
+ (attribute) => attribute.type === 'JSXAttribute'
216
+ );
217
+
218
+ const isContext = contexts.has(name) || /^[A-Z]\w*Context$/.test(name);
219
+ if (
220
+ isContext &&
221
+ attributes.some((attribute) => attribute.name.name === 'value')
222
+ ) {
223
+ context.report({
224
+ node,
225
+ messageId: 'contextProvider',
226
+ data: { name },
227
+ });
228
+ }
229
+
230
+ if (name === 'meta' || name === 'link') {
231
+ context.report({
232
+ node,
233
+ messageId: 'documentMetadata',
234
+ data: { name },
235
+ });
236
+ }
237
+ if (name === 'title' && !hasAncestorElement(node.parent, 'svg')) {
238
+ context.report({
239
+ node,
240
+ messageId: 'documentMetadata',
241
+ data: { name },
242
+ });
243
+ }
244
+ if (
245
+ name === 'style' &&
246
+ attributes.some((attribute) => attribute.name.name === 'precedence')
247
+ ) {
248
+ context.report({
249
+ node,
250
+ messageId: 'documentMetadata',
251
+ data: { name },
252
+ });
253
+ }
254
+
255
+ for (const attribute of attributes) {
256
+ const attributeName = attribute.name.name;
257
+ const expression = attributeExpression(attribute);
258
+ if (!expression) continue;
259
+
260
+ if (attributeName === 'ref') {
261
+ const fn = expressionFunction(context, expression);
262
+ if (fn && returnsFunction(fn)) {
263
+ context.report({ node: attribute, messageId: 'refCleanup' });
264
+ }
265
+ }
266
+ if (
267
+ (attributeName === 'action' || attributeName === 'formAction') &&
268
+ expressionFunction(context, expression)
269
+ ) {
270
+ context.report({
271
+ node: attribute,
272
+ messageId: 'formAction',
273
+ data: { name: attributeName },
274
+ });
275
+ }
276
+ }
277
+ },
278
+ };
279
+ },
280
+ };
@@ -2,7 +2,9 @@
2
2
  // should not be typed as render callbacks (`(...) => ReactNode`), component
3
3
  // references (`ComponentType<...>`, `FC<...>`), or unions thereof. Event
4
4
  // handler props (`onClick`, `onSubmit`, ...) return data, not JSX, and are
5
- // exempt.
5
+ // exempt. So is a prop whose JSDoc carries `@thirdPartyShape <reason>`: a
6
+ // third-party API mandates its shape. It is a plain tag, not an
7
+ // `eslint-disable`, so copied source lints clean in apps without this plugin.
6
8
  //
7
9
  // Known scope gaps — these patterns sneak through the AST visitors below and
8
10
  // must be caught by code review:
@@ -14,6 +16,8 @@
14
16
  // only).
15
17
 
16
18
  const EVENT_HANDLER = /^on[A-Z]/;
19
+ // The tag counts only with a reason after it.
20
+ const THIRD_PARTY_TAG = /@thirdPartyShape[ \t]+[^\s*]/;
17
21
 
18
22
  const REACT_NODE_NAMES = new Set([
19
23
  'ReactNode',
@@ -113,6 +117,10 @@ export default {
113
117
  if (EVENT_HANDLER.test(name)) {
114
118
  return;
115
119
  }
120
+ const comments = context.sourceCode.getCommentsBefore(node);
121
+ if (comments.some((comment) => THIRD_PARTY_TAG.test(comment.value))) {
122
+ return;
123
+ }
116
124
  const ann = node.typeAnnotation?.typeAnnotation;
117
125
  if (!ann) {
118
126
  return;
@@ -129,14 +129,14 @@ function isDocsSpecimenPage(filename) {
129
129
  const RAW_TYPOGRAPHY_EXCEPTIONS = [
130
130
  {
131
131
  filename:
132
- /(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]avatar[/\\]avatar\.tsx$/,
132
+ /(?:^|[/\\])packages[/\\]react(?:-18)?[/\\]src[/\\]components[/\\]avatar[/\\]avatar\.tsx$/,
133
133
  className:
134
134
  'nx:flex nx:size-full nx:items-center nx:justify-center nx:rounded-[inherit] nx:bg-muted nx:text-foreground nx:font-medium nx:leading-none',
135
135
  messageIds: new Set(['rawFontWeight', 'rawLineHeight']),
136
136
  },
137
137
  {
138
138
  filename:
139
- /(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]chart[/\\]chart\.tsx$/,
139
+ /(?:^|[/\\])packages[/\\]react(?:-18)?[/\\]src[/\\]components[/\\]chart[/\\]chart\.tsx$/,
140
140
  className: 'nx:text-foreground nx:font-mono nx:font-medium nx:tabular-nums',
141
141
  messageIds: new Set(['rawFontWeight']),
142
142
  },