@ethisyscore/eslint-plugin-coreconnect 1.134.1 → 1.134.3

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.134.1",
3
+ "version": "1.134.3",
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
@@ -1,3 +1,4 @@
1
+ import noBareDatePicker from "./rules/no-bare-date-picker.js";
1
2
  import noHardcodedColors from "./rules/no-hardcoded-colors.js";
2
3
  import noInlineObjectType from "./rules/no-inline-object-type.js";
3
4
  import noInlineStringUnion from "./rules/no-inline-string-union.js";
@@ -14,6 +15,7 @@ import noSecondMarkdownRenderer, {
14
15
  } from "./rules/no-second-markdown-renderer.js";
15
16
 
16
17
  const rules = {
18
+ "no-bare-date-picker": noBareDatePicker,
17
19
  "no-hardcoded-colors": noHardcodedColors,
18
20
  "no-inline-object-type": noInlineObjectType,
19
21
  "no-inline-string-union": noInlineStringUnion,
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Disallow rendering a MUI X date picker directly instead of the shared `DateInput`.
3
+ *
4
+ * The companion to `no-local-date-input`, and the gap it left. That rule fires on a DECLARATION -
5
+ * a local `DateInput` / `*DatePicker` component - so it stops a seventh copy of the field being
6
+ * written. It says nothing about a view that skips the field entirely and renders
7
+ * `<LocalizationProvider dateAdapter={AdapterDayjs}><DatePicker ... /></LocalizationProvider>`
8
+ * inline, and that is the shape that shipped the defect this rule exists for.
9
+ *
10
+ * A bare picker carries neither of the things the shared field is for:
11
+ *
12
+ * - no `format`, so the editable mask ignores the organisation's configured date format
13
+ * (`getOrgDateInputFormat`, stamped per surface by `useOrgFormatSync`);
14
+ * - and, in every instance found, no `adapterLocale` either, so MUI X expands dayjs's global
15
+ * `en` locale and renders US MM/DD/YYYY. For a day <= 12 that is a plausible-looking WRONG
16
+ * date to a UK reader, which is why it survives review.
17
+ *
18
+ * Measured on origin/develop at 1.131.0, after AB#5140 had "fixed" the order app-wide through the
19
+ * shared field: nine pickers across hr (4), finance, projects and timesheets still rendered
20
+ * month-first, because each imported `DatePicker` from `@mui/x-date-pickers` and none of them
21
+ * declared a component this package's other rule could see. The skills already said "replace any
22
+ * raw DatePicker that carries no format="; prose did not hold the line, so this does.
23
+ *
24
+ * WHAT IS REPORTED. A value import (or re-export) of a date-bearing picker or field - `DatePicker`,
25
+ * `DateTimePicker`, their `Desktop` / `Mobile` / `Static` variants, `DateField`, `DateTimeField` -
26
+ * from `@mui/x-date-pickers` or `@mui/x-date-pickers-pro`, root or sub-path. A namespace, default
27
+ * or dynamic import of a package ROOT names no export at the import, so it is judged where it is
28
+ * read: `<Pickers.DatePicker />`, `Pickers.DateTimePicker`, `const { DatePicker } = await import(...)`.
29
+ *
30
+ * WHAT IS NOT, and why:
31
+ * - `TimePicker` and friends: there is no shared time field, and a time carries no day/month
32
+ * order to get wrong. Banning it would leave the author nothing to use instead.
33
+ * - `LocalizationProvider` / `AdapterDayjs`: needed by a TimePicker, and harmless on their own.
34
+ * - Range pickers (`DateRangePicker`, pro): no shared equivalent exists yet either.
35
+ * - Type-only imports (`import type { DatePickerProps }`): nothing renders.
36
+ * - A computed specifier (`import(name)`): cannot be resolved statically; reporting it is a guess.
37
+ * - A ROOT binding that only ever reads `TimePicker` (or anything else not date-bearing): the
38
+ * import itself is not reported, only a date picker read from it.
39
+ *
40
+ * The SDK's own `plugin-ui` DateInput imports `DatePicker` - it is the one sanctioned wrapper - and
41
+ * is not linted with this config. A consumer that genuinely needs a raw picker turns the rule off
42
+ * for that file after the `recommended` spread, where the exemption is visible.
43
+ *
44
+ * @type {import("eslint").Rule.RuleModule}
45
+ */
46
+
47
+ /** The picker packages. The pro package re-exports every community picker under the same names. */
48
+ const PICKER_PACKAGE = /^@mui\/x-date-pickers(-pro)?(\/(?<subpath>[^/]+))?$/;
49
+
50
+ /**
51
+ * Date-bearing pickers and fields. Anchored both ends so `DatePickerProps`, `DateRangePicker`,
52
+ * `TimePicker` and `DateCalendar` stay out of it.
53
+ */
54
+ const DATE_PICKER_NAME = /^(Desktop|Mobile|Static)?Date(Time)?(Picker|Field)$/;
55
+
56
+ const CANONICAL_SOURCE = "@ethisyscore/plugin-ui/components/date";
57
+
58
+ /**
59
+ * The sub-path of a picker package import, or null when the source is not a picker package.
60
+ * Returns "" for the package root.
61
+ *
62
+ * @param {string} source
63
+ */
64
+ function pickerSubpath(source) {
65
+ const match = PICKER_PACKAGE.exec(source);
66
+ if (!match) return null;
67
+ return match.groups?.subpath ?? "";
68
+ }
69
+
70
+ /** `import type {...}` / `export type {...}` or `{ type X }` - nothing is rendered. */
71
+ function isTypeOnly(node) {
72
+ return node.importKind === "type" || node.exportKind === "type";
73
+ }
74
+
75
+ /** The name an import or export specifier takes FROM the module. */
76
+ function importedName(specifier) {
77
+ const imported = specifier.type === "ExportSpecifier" ? specifier.local : specifier.imported;
78
+ if (!imported) return null;
79
+ return imported.type === "Identifier" ? imported.name : imported.value;
80
+ }
81
+
82
+ export default {
83
+ meta: {
84
+ type: "problem",
85
+ docs: {
86
+ description:
87
+ "Disallow rendering a MUI X date picker directly - import DateInput from @ethisyscore/plugin-ui/components/date",
88
+ },
89
+ messages: {
90
+ noBareDatePicker:
91
+ "'{{name}}' from '{{source}}' renders a date field outside the shared DateInput, so its mask ignores the organisation's date format and falls back to MUI X's en-US MM/DD/YYYY. Import { DateInput } from '" +
92
+ CANONICAL_SOURCE +
93
+ "' instead: it takes and emits ISO YYYY-MM-DD strings, with min/max, label, helperText and error.",
94
+ },
95
+ schema: [],
96
+ },
97
+ create(context) {
98
+ const report = (node, name, source) =>
99
+ context.report({ node, messageId: "noBareDatePicker", data: { name, source } });
100
+
101
+ /**
102
+ * Named specifiers are judged by the name they take from the module. A default or namespace
103
+ * specifier has no such name, so it is judged by the sub-path it came from: `import * as P from
104
+ * "@mui/x-date-pickers/DatePicker"` is the picker, the same import of the root is not knowable.
105
+ */
106
+ const checkSpecifiers = (node, source, subpath) => {
107
+ if (isTypeOnly(node)) return;
108
+
109
+ for (const specifier of node.specifiers ?? []) {
110
+ if (isTypeOnly(specifier)) continue;
111
+
112
+ const named = specifier.type === "ImportSpecifier" || specifier.type === "ExportSpecifier";
113
+ // `{ default as X }` takes the sub-path's default export, so it is the sub-path that names it.
114
+ const taken = named ? importedName(specifier) : null;
115
+ const name = taken && taken !== "default" ? taken : subpath;
116
+ if (name && DATE_PICKER_NAME.test(name)) {
117
+ report(specifier, name, source);
118
+ }
119
+ }
120
+ };
121
+
122
+ const literalSource = (node) =>
123
+ node.source && node.source.type === "Literal" && typeof node.source.value === "string"
124
+ ? node.source.value
125
+ : null;
126
+
127
+ /**
128
+ * The DEFINING nodes of bindings to a picker package ROOT as a whole - the `import * as P` /
129
+ * default specifier, or the `const P = await import(...)` declarator - mapped to the source.
130
+ * Which export such a binding renders is only visible where it is read (`<P.DatePicker />`,
131
+ * `P.DateTimePicker`), so it is judged there, after resolving the read through ESLint's scope
132
+ * manager: a same-named parameter or local that shadows the import is a different variable and
133
+ * must not turn a correct file red at "error".
134
+ */
135
+ const rootBindings = new Map();
136
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
137
+
138
+ /** The picker-root source a read of `identifier` resolves to, or undefined. */
139
+ const resolveRootBinding = (identifier) => {
140
+ for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
141
+ const variable = scope.set.get(identifier.name);
142
+ if (variable) {
143
+ const def = variable.defs[0];
144
+ return def ? rootBindings.get(def.node) : undefined;
145
+ }
146
+ }
147
+ return undefined;
148
+ };
149
+
150
+ /** `await import("x")` and `import("x")` alike: the source when it is a picker package root. */
151
+ const rootImportSource = (init) => {
152
+ const expression = init && init.type === "AwaitExpression" ? init.argument : init;
153
+ if (!expression || expression.type !== "ImportExpression") return null;
154
+ const source = literalSource(expression);
155
+ return source !== null && pickerSubpath(source) === "" ? source : null;
156
+ };
157
+
158
+ /** The static name of `obj.name` / `obj["name"]`, or null when it is computed. */
159
+ const memberName = (property, computed) => {
160
+ if (!computed && (property.type === "Identifier" || property.type === "JSXIdentifier")) return property.name;
161
+ if (computed && property.type === "Literal" && typeof property.value === "string") return property.value;
162
+ return null;
163
+ };
164
+
165
+ const checkMember = (node, source, property, computed) => {
166
+ const name = source === undefined || source === null ? null : memberName(property, computed);
167
+ if (name && DATE_PICKER_NAME.test(name)) {
168
+ report(node, name, source);
169
+ }
170
+ };
171
+
172
+ return {
173
+ ImportDeclaration(node) {
174
+ const source = literalSource(node);
175
+ const subpath = source === null ? null : pickerSubpath(source);
176
+ if (subpath === null) return;
177
+ checkSpecifiers(node, source, subpath);
178
+
179
+ if (subpath !== "" || isTypeOnly(node)) return;
180
+ for (const specifier of node.specifiers ?? []) {
181
+ if (specifier.type === "ImportNamespaceSpecifier" || specifier.type === "ImportDefaultSpecifier") {
182
+ rootBindings.set(specifier, source);
183
+ }
184
+ }
185
+ },
186
+ // `const { DatePicker } = await import("@mui/x-date-pickers")` - or `= P` for an existing root
187
+ // binding - names the picker at the binding; `const Q = await import(...)` / `const Q = P`
188
+ // defers the judgement to where `Q.DatePicker` is read.
189
+ VariableDeclarator(node) {
190
+ const source =
191
+ rootImportSource(node.init) ??
192
+ (node.init && node.init.type === "Identifier" ? resolveRootBinding(node.init) ?? null : null);
193
+ if (source === null) return;
194
+
195
+ if (node.id.type === "Identifier") {
196
+ rootBindings.set(node, source);
197
+ return;
198
+ }
199
+ if (node.id.type !== "ObjectPattern") return;
200
+ for (const property of node.id.properties) {
201
+ if (property.type !== "Property") continue;
202
+ const name = memberName(property.key, property.computed);
203
+ if (name && DATE_PICKER_NAME.test(name)) {
204
+ report(property, name, source);
205
+ }
206
+ }
207
+ },
208
+ // `P.DatePicker` / `P["DatePicker"]` on a root binding, and `(await import(root)).DatePicker`
209
+ // read straight off the import with no binding in between.
210
+ MemberExpression(node) {
211
+ if (node.object.type === "Identifier") {
212
+ checkMember(node, resolveRootBinding(node.object), node.property, node.computed);
213
+ } else {
214
+ checkMember(node, rootImportSource(node.object), node.property, node.computed);
215
+ }
216
+ },
217
+ // `<P.DatePicker />` on a root binding.
218
+ JSXMemberExpression(node) {
219
+ if (node.object.type === "JSXIdentifier") {
220
+ checkMember(node, resolveRootBinding(node.object), node.property, false);
221
+ }
222
+ },
223
+ // `export { DatePicker } from "@mui/x-date-pickers"` - a local re-export barrel is the same
224
+ // bare picker one file further away.
225
+ ExportNamedDeclaration(node) {
226
+ const source = literalSource(node);
227
+ const subpath = source === null ? null : pickerSubpath(source);
228
+ if (subpath === null) return;
229
+ checkSpecifiers(node, source, subpath);
230
+ },
231
+ // `export * from "@mui/x-date-pickers"` re-exports every picker at once.
232
+ ExportAllDeclaration(node) {
233
+ const source = literalSource(node);
234
+ const subpath = source === null ? null : pickerSubpath(source);
235
+ if (subpath === null || isTypeOnly(node)) return;
236
+ if (subpath === "" || DATE_PICKER_NAME.test(subpath)) {
237
+ report(node.source, subpath || "*", source);
238
+ }
239
+ },
240
+ // `await import("@mui/x-date-pickers/DatePicker")`. Espree parses this as an
241
+ // ImportExpression, not a CallExpression, which is the form a call site reaches for to dodge a
242
+ // static-import rule. A sub-path that names a picker is reported here; a ROOT import is judged
243
+ // by what its binding reads (VariableDeclarator / MemberExpression above).
244
+ ImportExpression(node) {
245
+ const source = literalSource(node);
246
+ const subpath = source === null ? null : pickerSubpath(source);
247
+ if (subpath && DATE_PICKER_NAME.test(subpath)) {
248
+ report(node.source, subpath, source);
249
+ }
250
+ },
251
+ };
252
+ },
253
+ };