@ethisyscore/eslint-plugin-coreconnect 1.71.2 → 1.71.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethisyscore/eslint-plugin-coreconnect",
3
- "version": "1.71.2",
3
+ "version": "1.71.4",
4
4
  "description": "ESLint rules enforcing EthisysCore plugin frontend conventions. Published so a new rule reaches every plugin on a version bump, rather than being copied into each scaffold and drifting.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -85,9 +85,18 @@ export function looksLikeComponent(init) {
85
85
  * @param {string} options.messageId
86
86
  * @param {string} options.description
87
87
  * @param {string} options.message
88
+ * @param {{ source: string, name: string }} options.canonical The promoted component this rule
89
+ * points people at. A local declaration that COMPOSES it is exempt - see below.
88
90
  * @returns {import("eslint").Rule.RuleModule}
89
91
  */
90
- export function createNoLocalComponentRule({ pattern, messageId, description, message }) {
92
+ export function createNoLocalComponentRule({ pattern, messageId, description, message, canonical }) {
93
+ // Fail at construction, not on the first source file that happens to contain an import. Without
94
+ // this a fifth rule added without a `canonical` line throws a TypeError from inside the
95
+ // ImportDeclaration visitor, which surfaces as an opaque lint crash on an arbitrary file.
96
+ if (!canonical?.source || !canonical?.name) {
97
+ throw new Error(`${messageId}: createNoLocalComponentRule needs canonical { source, name }`);
98
+ }
99
+
91
100
  return {
92
101
  meta: {
93
102
  type: "problem",
@@ -96,7 +105,49 @@ export function createNoLocalComponentRule({ pattern, messageId, description, me
96
105
  schema: [],
97
106
  },
98
107
  create(context) {
99
- const check = (idNode) => {
108
+ const sourceCode = context.sourceCode;
109
+
110
+ /**
111
+ * Local names bound to the canonical export, so `import { DateInput as Picker }` still counts.
112
+ */
113
+ const canonicalNames = new Set();
114
+
115
+ /**
116
+ * The scope variables those names declare.
117
+ *
118
+ * Names alone are not enough. An identifier spelled like the canonical import may resolve to
119
+ * something else entirely - `({ DateInput }) => <DateInput />` uses a destructured PROP - and
120
+ * counting it granted the exemption to a component that composes nothing. That made the rule
121
+ * bypassable by accident: any wrapper taking a prop named after the component would quietly
122
+ * stop being checked. Comparing the resolved variable is what closes it.
123
+ */
124
+ const canonicalVariables = new Set();
125
+
126
+ /** Source positions where the canonical binding is used. */
127
+ const canonicalUses = [];
128
+
129
+ /** Matching declarations, judged at `Program:exit` once the uses above are known. */
130
+ const candidates = [];
131
+
132
+ /**
133
+ * A WRAPPER, not a re-implementation.
134
+ *
135
+ * The false positive this closes. Each of these rules exists to stop a plugin hand-rolling a
136
+ * primitive that `plugin-ui` already ships, and every one of them documents that importing the
137
+ * promoted component and rendering it is the compliant outcome. But the test is a name suffix,
138
+ * so it also fired on components that do exactly that: `legal`'s `DateField` (a pass-through
139
+ * that maps `error?: string` onto `error` + `helperText`) and `projects`' `PeriodDateInput`
140
+ * (adds commit-on-change), both of which render the SDK's `DateInput`. Two repos hit it the
141
+ * day the rule turned on, and every future wrapper would too.
142
+ *
143
+ * Range containment is what keeps this narrow: only the declaration whose own body reaches the
144
+ * canonical binding is exempt, so a file that legitimately wraps it AND hand-rolls a second
145
+ * one next door still reports the second.
146
+ */
147
+ const composesCanonical = (node) =>
148
+ canonicalUses.some((at) => at >= node.range[0] && at <= node.range[1]);
149
+
150
+ const check = (idNode, declarationNode) => {
100
151
  if (!idNode || idNode.type !== "Identifier") {
101
152
  return;
102
153
  }
@@ -104,15 +155,79 @@ export function createNoLocalComponentRule({ pattern, messageId, description, me
104
155
  return;
105
156
  }
106
157
 
107
- context.report({ node: idNode, messageId, data: { name: idNode.name } });
158
+ candidates.push({ idNode, declarationNode });
159
+ };
160
+
161
+ /**
162
+ * The nearest binding of `name` visible from `scope`.
163
+ *
164
+ * Resolved by walking the scope chain rather than reading the import variable's `references`,
165
+ * because whether a JSX element name counts as a reference depends on the parser: the
166
+ * TypeScript parser's scope manager records one, plain espree does not - which is why
167
+ * `jsx-uses-vars` exists at all. This asks the question the same way under both.
168
+ */
169
+ const resolveByName = (scope, name) => {
170
+ for (let current = scope; current; current = current.upper) {
171
+ const found = current.variables.find((variable) => variable.name === name);
172
+ if (found) {
173
+ return found;
174
+ }
175
+ }
176
+ return null;
177
+ };
178
+
179
+ const noteCanonicalUse = (node) => {
180
+ if (!canonicalNames.has(node.name)) {
181
+ return;
182
+ }
183
+
184
+ const resolved = resolveByName(sourceCode.getScope(node), node.name);
185
+ if (resolved && !canonicalVariables.has(resolved)) {
186
+ return;
187
+ }
188
+
189
+ canonicalUses.push(node.range[0]);
108
190
  };
109
191
 
110
192
  return {
111
- FunctionDeclaration: (node) => check(node.id),
112
- ClassDeclaration: (node) => check(node.id),
193
+ ImportDeclaration: (node) => {
194
+ if (node.source.value !== canonical.source) {
195
+ return;
196
+ }
197
+ const declared = sourceCode.getDeclaredVariables(node);
198
+ for (const specifier of node.specifiers) {
199
+ if (
200
+ specifier.type === "ImportSpecifier" &&
201
+ specifier.imported.type === "Identifier" &&
202
+ specifier.imported.name === canonical.name
203
+ ) {
204
+ canonicalNames.add(specifier.local.name);
205
+ const variable = declared.find((candidate) =>
206
+ candidate.defs.some((def) => def.name === specifier.local),
207
+ );
208
+ if (variable) {
209
+ canonicalVariables.add(variable);
210
+ }
211
+ }
212
+ }
213
+ },
214
+ // Both node types are needed: `<DateInput />` is a JSXIdentifier, while `styled(DateInput)`,
215
+ // `React.createElement(DateInput)` and passing it as a prop are plain Identifiers.
216
+ Identifier: noteCanonicalUse,
217
+ JSXIdentifier: noteCanonicalUse,
218
+ FunctionDeclaration: (node) => check(node.id, node),
219
+ ClassDeclaration: (node) => check(node.id, node),
113
220
  VariableDeclarator: (node) => {
114
221
  if (looksLikeComponent(node.init)) {
115
- check(node.id);
222
+ check(node.id, node);
223
+ }
224
+ },
225
+ "Program:exit": () => {
226
+ for (const { idNode, declarationNode } of candidates) {
227
+ if (composesCanonical(declarationNode)) {
228
+ continue;
229
+ }
230
+ context.report({ node: idNode, messageId, data: { name: idNode.name } });
116
231
  }
117
232
  },
118
233
  };
@@ -22,6 +22,7 @@ export default createNoLocalComponentRule({
22
22
  // Anchored at the end so `DataGridProps`, `useDataGridState` and a `ProjectsDataGridColumns`
23
23
  // helper are left alone; the shared helper's guard keeps hooks out of it.
24
24
  pattern: /(PersistentDataGrid|DataGridPagination|GridStatePersistence)$/,
25
+ canonical: { source: "@ethisyscore/plugin-ui/components/data-grid", name: "PersistentDataGrid" },
25
26
  messageId: "noLocalDataGrid",
26
27
  description:
27
28
  "Disallow a locally-declared persistent data grid - import from @ethisyscore/plugin-ui/components/data-grid",
@@ -23,6 +23,7 @@ export default createNoLocalComponentRule({
23
23
  // Anchored at the end so `DateInputProps` and `DateRangeFilter` are left alone; the shared helper
24
24
  // excludes hooks, so `useDatePicker` and `useDateInputValidation` do not match either.
25
25
  pattern: /Date(Time)?(Input|Picker|Field)$/,
26
+ canonical: { source: "@ethisyscore/plugin-ui/components/date", name: "DateInput" },
26
27
  messageId: "noLocalDateInput",
27
28
  description:
28
29
  "Disallow a locally-declared date input - import DateInput from @ethisyscore/plugin-ui/components/date",
@@ -17,6 +17,7 @@ export default createNoLocalComponentRule({
17
17
  // Loose enough to catch the names the same panel gets rewritten under, while the shared helper's
18
18
  // hook guard keeps `useEmptyState` out of it.
19
19
  pattern: /(EmptyState|NoResults|NoData|BlankSlate)$/,
20
+ canonical: { source: "@ethisyscore/plugin-ui/components/shared", name: "EmptyState" },
20
21
  messageId: "noLocalEmptyState",
21
22
  description:
22
23
  "Disallow a locally-declared empty state - import EmptyState from @ethisyscore/plugin-ui/components/shared",
@@ -19,6 +19,7 @@ export default createNoLocalComponentRule({
19
19
  // composes the shared input is a legitimate local component; a component that IS the search field
20
20
  // is not. Hook names are excluded by the shared helper.
21
21
  pattern: /Search(Input|Bar|Field)$/,
22
+ canonical: { source: "@ethisyscore/plugin-ui/components/ui", name: "DebouncedSearchInput" },
22
23
  messageId: "noLocalSearchInput",
23
24
  description:
24
25
  "Disallow a locally-declared search input - import DebouncedSearchInput from @ethisyscore/plugin-ui",