@ethisyscore/eslint-plugin-coreconnect 1.91.0 → 1.92.1

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.91.0",
3
+ "version": "1.92.1",
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",
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import noLocalCompletenessBanner from "./rules/no-local-completeness-banner.js";
5
5
  import noLocalDataGrid from "./rules/no-local-data-grid.js";
6
6
  import noLocalDateInput from "./rules/no-local-date-input.js";
7
7
  import noLocalEmptyState from "./rules/no-local-empty-state.js";
8
+ import noLocalInProgressBanner from "./rules/no-local-in-progress-banner.js";
8
9
  import noLocalMappingEditor from "./rules/no-local-mapping-editor.js";
9
10
  import noLocalSearchInput from "./rules/no-local-search-input.js";
10
11
  import noParamInNavPath from "./rules/no-param-in-nav-path.js";
@@ -17,6 +18,7 @@ const rules = {
17
18
  "no-local-data-grid": noLocalDataGrid,
18
19
  "no-local-date-input": noLocalDateInput,
19
20
  "no-local-empty-state": noLocalEmptyState,
21
+ "no-local-in-progress-banner": noLocalInProgressBanner,
20
22
  "no-local-mapping-editor": noLocalMappingEditor,
21
23
  "no-local-search-input": noLocalSearchInput,
22
24
  "no-param-in-nav-path": noParamInNavPath,
@@ -87,9 +87,32 @@ export function looksLikeComponent(init) {
87
87
  * @param {string} options.message
88
88
  * @param {{ source: string, name: string }} options.canonical The promoted component this rule
89
89
  * points people at. A local declaration that COMPOSES it is exempt - see below.
90
+ * @param {boolean} [options.fileScopedExemption] Exempt EVERY matching declaration in a file
91
+ * that composes the canonical component anywhere, instead of only the declaration whose own
92
+ * body reaches it.
93
+ *
94
+ * <p>Off by default, because range containment is what keeps these rules narrow. Opt in when
95
+ * the pattern deliberately matches FEATURE-SURFACE names rather than component names - the
96
+ * in-progress-banner rule matches `TechImportIndicator`, whose legitimate shape is a top-level
97
+ * component that renders the shared banner through a SIBLING helper. Range containment cannot
98
+ * express that: the helper composes the canonical component, the outer name is what matches the
99
+ * pattern, and the two are different declarations. Without this the rule fires on correctly
100
+ * adopted code - which it did, on the first real adoption, because the test asserting the
101
+ * exemption used the easy shape where the wrapper renders the canonical component directly.</p>
102
+ *
103
+ * <p>The cost, stated: a file that composes the canonical component AND hand-rolls a second
104
+ * banner beside it goes unreported. Accepted for that rule only - a false positive on every
105
+ * correct adoption is the worse failure, because it trains people to switch the rule off.</p>
90
106
  * @returns {import("eslint").Rule.RuleModule}
91
107
  */
92
- export function createNoLocalComponentRule({ pattern, messageId, description, message, canonical }) {
108
+ export function createNoLocalComponentRule({
109
+ pattern,
110
+ messageId,
111
+ description,
112
+ message,
113
+ canonical,
114
+ fileScopedExemption = false,
115
+ }) {
93
116
  // Fail at construction, not on the first source file that happens to contain an import. Without
94
117
  // this a fifth rule added without a `canonical` line throws a TypeError from inside the
95
118
  // ImportDeclaration visitor, which surfaces as an opaque lint crash on an arbitrary file.
@@ -145,7 +168,9 @@ export function createNoLocalComponentRule({ pattern, messageId, description, me
145
168
  * one next door still reports the second.
146
169
  */
147
170
  const composesCanonical = (node) =>
148
- canonicalUses.some((at) => at >= node.range[0] && at <= node.range[1]);
171
+ fileScopedExemption
172
+ ? canonicalUses.length > 0
173
+ : canonicalUses.some((at) => at >= node.range[0] && at <= node.range[1]);
149
174
 
150
175
  const check = (idNode, declarationNode) => {
151
176
  if (!idNode || idNode.type !== "Identifier") {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Disallow a plugin declaring its own in-progress / background-job banner.
3
+ *
4
+ * The backstop to a promotion, and the sibling of `no-local-empty-state` - deliberately, because
5
+ * the two components are one decision. A surface with no in-progress state does not stay silent: it
6
+ * renders its EMPTY state instead, which claims the operation finished and produced nothing. That
7
+ * defect shipped twice in one week on a single page (engineering-skills UX-113).
8
+ *
9
+ * Three hand-written copies existed at promotion time - the tech repo-import indicator, the
10
+ * self-healing fix-run banner, and the monolith's own OperationGlobalBanner - with a byte-identical
11
+ * `height: 6, borderRadius: 3` progress bar, and that same pair repeated in ten more places. A rule
12
+ * describing the correct markup did not stop the second occurrence; one implementation can.
13
+ *
14
+ * Fires on a DECLARATION, never a use, so importing `InProgressBanner` from
15
+ * `@ethisyscore/plugin-ui/components/shared` and rendering it is what the rule wants - including
16
+ * wrapping it in a plugin-specific component that feeds it that plugin's own progress data, which
17
+ * the shared helper exempts by recognising the canonical binding.
18
+ *
19
+ * @type {import("eslint").Rule.RuleModule}
20
+ */
21
+ import { createNoLocalComponentRule } from "./componentDeclarations.js";
22
+
23
+ export default createNoLocalComponentRule({
24
+ // The names this banner keeps being rewritten under. `Indicator` is included because the tech
25
+ // copy was called `TechImportIndicator`, and a suffix match is what catches the plugin-prefixed
26
+ // spellings; the shared helper's hook guard keeps `useImportIndicator` out of it.
27
+ pattern: /(InProgressBanner|ImportIndicator|ProgressBanner|ProgressIndicator|OperationBanner|BusyBanner)$/,
28
+ canonical: { source: "@ethisyscore/plugin-ui/components/shared", name: "InProgressBanner" },
29
+ messageId: "noLocalInProgressBanner",
30
+ description:
31
+ "Disallow a locally-declared in-progress banner - import InProgressBanner from @ethisyscore/plugin-ui/components/shared",
32
+ message:
33
+ "'{{name}}' looks like a local in-progress banner. Import { InProgressBanner } from '@ethisyscore/plugin-ui/components/shared' instead, so a background job looks the same wherever it appears. It takes title, children, value (omit for an indeterminate bar), action and sx.",
34
+ // FILE-SCOPED, unlike its four siblings, because this pattern matches feature-surface names.
35
+ // `TechImportIndicator` is a top-level component that renders the shared banner through a
36
+ // SIBLING helper (`ImportingGroup`), so the declaration matching the pattern and the one
37
+ // composing the canonical component are different nodes and range containment cannot exempt it.
38
+ // Without this the rule fired on the very first correct adoption.
39
+ fileScopedExemption: true,
40
+ });