@ethisyscore/eslint-plugin-coreconnect 1.64.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/package.json +34 -0
- package/src/index.js +56 -0
- package/src/rules/componentDeclarations.js +102 -0
- package/src/rules/no-hardcoded-colors.js +37 -0
- package/src/rules/no-inline-object-type.js +108 -0
- package/src/rules/no-inline-string-union.js +78 -0
- package/src/rules/no-local-data-grid.js +30 -0
- package/src/rules/no-local-date-input.js +26 -0
- package/src/rules/no-local-empty-state.js +25 -0
- package/src/rules/no-local-search-input.js +27 -0
- package/src/rules/no-param-in-nav-path.js +58 -0
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ethisyscore/eslint-plugin-coreconnect",
|
|
3
|
+
"version": "1.64.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"license": "Apache-2.0",
|
|
14
|
+
"author": "Ethisys",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"eslint",
|
|
17
|
+
"eslintplugin",
|
|
18
|
+
"ethisyscore",
|
|
19
|
+
"plugin",
|
|
20
|
+
"conventions"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/*.test.js",
|
|
24
|
+
"lint:goldens": "node tools/lint-goldens.mjs"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"eslint": ">=9.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
|
31
|
+
"@typescript-eslint/parser": "^8.67.0",
|
|
32
|
+
"eslint": "^9.39.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import noHardcodedColors from "./rules/no-hardcoded-colors.js";
|
|
2
|
+
import noInlineObjectType from "./rules/no-inline-object-type.js";
|
|
3
|
+
import noInlineStringUnion from "./rules/no-inline-string-union.js";
|
|
4
|
+
import noLocalDataGrid from "./rules/no-local-data-grid.js";
|
|
5
|
+
import noLocalDateInput from "./rules/no-local-date-input.js";
|
|
6
|
+
import noLocalEmptyState from "./rules/no-local-empty-state.js";
|
|
7
|
+
import noLocalSearchInput from "./rules/no-local-search-input.js";
|
|
8
|
+
import noParamInNavPath from "./rules/no-param-in-nav-path.js";
|
|
9
|
+
|
|
10
|
+
const rules = {
|
|
11
|
+
"no-hardcoded-colors": noHardcodedColors,
|
|
12
|
+
"no-inline-object-type": noInlineObjectType,
|
|
13
|
+
"no-inline-string-union": noInlineStringUnion,
|
|
14
|
+
"no-local-data-grid": noLocalDataGrid,
|
|
15
|
+
"no-local-date-input": noLocalDateInput,
|
|
16
|
+
"no-local-empty-state": noLocalEmptyState,
|
|
17
|
+
"no-local-search-input": noLocalSearchInput,
|
|
18
|
+
"no-param-in-nav-path": noParamInNavPath,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* EthisysCore plugin frontend conventions as ESLint rules.
|
|
23
|
+
*
|
|
24
|
+
* Published as a package rather than copied into each scaffold. A rule vendored into the template
|
|
25
|
+
* can never reach a plugin that was generated before it existed, and seven copies of a rule drift.
|
|
26
|
+
* This way a new rule arrives on a version bump.
|
|
27
|
+
*
|
|
28
|
+
* A generated plugin extends `configs.recommended` rather than listing rules. That distinction is
|
|
29
|
+
* the whole delivery mechanism: an explicit rule list would freeze the plugin at the rules that
|
|
30
|
+
* existed on the day it was scaffolded, so a rule added later would ship and enforce nothing. A
|
|
31
|
+
* plugin that needs to opt out of one rule turns that rule off after the spread, which leaves the
|
|
32
|
+
* opt-out visible in its own config instead of hidden as an absence.
|
|
33
|
+
*/
|
|
34
|
+
const plugin = {
|
|
35
|
+
// No `version` in meta on purpose: the publish pipeline stamps the release version onto
|
|
36
|
+
// package.json, so a hardcoded value here would be wrong from the first release onward. ESLint
|
|
37
|
+
// treats meta.version as optional.
|
|
38
|
+
meta: {
|
|
39
|
+
name: "@ethisyscore/eslint-plugin-coreconnect",
|
|
40
|
+
},
|
|
41
|
+
rules,
|
|
42
|
+
configs: {},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every rule at error severity. A convention worth a rule is worth failing the build for; a rule
|
|
47
|
+
* that should only warn belongs in review instead, per the enforcement audit's classification.
|
|
48
|
+
*/
|
|
49
|
+
plugin.configs.recommended = {
|
|
50
|
+
name: "coreconnect/recommended",
|
|
51
|
+
plugins: { coreconnect: plugin },
|
|
52
|
+
rules: Object.fromEntries(Object.keys(rules).map((name) => [`coreconnect/${name}`, "error"])),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default plugin;
|
|
56
|
+
export { rules };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared machinery for the "don't re-declare a promoted primitive" rules.
|
|
3
|
+
*
|
|
4
|
+
* The three rules that back a `plugin-ui` promotion ask the same question - "is this a local
|
|
5
|
+
* declaration of a component whose name matches X?" - so the question is answered once here. Three
|
|
6
|
+
* copies of this predicate would have been an unusually poor look for rules whose entire purpose is
|
|
7
|
+
* to stop code being copied three times.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Wrappers a component declaration is commonly built from. */
|
|
11
|
+
const COMPONENT_WRAPPERS = new Set(["memo", "forwardRef", "observer"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A hook, not a component.
|
|
15
|
+
*
|
|
16
|
+
* These rules match on a name SUFFIX, so without this guard `useEmptyState` trips the empty-state
|
|
17
|
+
* rule and `useDatePicker` trips the date rule - both false positives on code that is not a
|
|
18
|
+
* component at all. React's own convention (`use` + an uppercase letter) is what makes this safe to
|
|
19
|
+
* detect by name.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} name
|
|
22
|
+
*/
|
|
23
|
+
export function isHookName(name) {
|
|
24
|
+
return /^use[A-Z]/.test(name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Is this initializer a component definition rather than a string, config object or hook result?
|
|
29
|
+
*
|
|
30
|
+
* @param {import("estree").Expression | null | undefined} init
|
|
31
|
+
*/
|
|
32
|
+
export function looksLikeComponent(init) {
|
|
33
|
+
if (!init) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression") {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (init.type === "CallExpression") {
|
|
42
|
+
const callee = init.callee;
|
|
43
|
+
if (callee.type === "Identifier" && COMPONENT_WRAPPERS.has(callee.name)) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// React.memo(...) / React.forwardRef(...) - the shape the promoted files themselves used.
|
|
48
|
+
if (
|
|
49
|
+
callee.type === "MemberExpression" &&
|
|
50
|
+
callee.property.type === "Identifier" &&
|
|
51
|
+
COMPONENT_WRAPPERS.has(callee.property.name)
|
|
52
|
+
) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builds a rule that reports a locally-declared component whose name matches `pattern`.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} options
|
|
64
|
+
* @param {RegExp} options.pattern Name pattern, anchored at the end by the caller.
|
|
65
|
+
* @param {string} options.messageId
|
|
66
|
+
* @param {string} options.description
|
|
67
|
+
* @param {string} options.message
|
|
68
|
+
* @returns {import("eslint").Rule.RuleModule}
|
|
69
|
+
*/
|
|
70
|
+
export function createNoLocalComponentRule({ pattern, messageId, description, message }) {
|
|
71
|
+
return {
|
|
72
|
+
meta: {
|
|
73
|
+
type: "problem",
|
|
74
|
+
docs: { description },
|
|
75
|
+
messages: { [messageId]: message },
|
|
76
|
+
schema: [],
|
|
77
|
+
},
|
|
78
|
+
create(context) {
|
|
79
|
+
const check = (idNode) => {
|
|
80
|
+
if (!idNode || idNode.type !== "Identifier") {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isHookName(idNode.name) || !pattern.test(idNode.name)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
context.report({ node: idNode, messageId, data: { name: idNode.name } });
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
FunctionDeclaration: (node) => check(node.id),
|
|
93
|
+
ClassDeclaration: (node) => check(node.id),
|
|
94
|
+
VariableDeclarator: (node) => {
|
|
95
|
+
if (looksLikeComponent(node.init)) {
|
|
96
|
+
check(node.id);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disallow hardcoded colour values.
|
|
3
|
+
*
|
|
4
|
+
* A hardcoded colour renders fine on the author's machine, looks subtly wrong in the other theme,
|
|
5
|
+
* and never follows the design system when it changes. Six files in a shipped plugin carry them
|
|
6
|
+
* today, and this rule existed in the scaffold template the whole time without anything running it.
|
|
7
|
+
*
|
|
8
|
+
* @type {import("eslint").Rule.RuleModule}
|
|
9
|
+
*/
|
|
10
|
+
const COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}(?:[0-9a-fA-F]{2})?$|^rgba?\(|^hsla?\(/;
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
meta: {
|
|
14
|
+
type: "suggestion",
|
|
15
|
+
docs: {
|
|
16
|
+
description:
|
|
17
|
+
"Disallow hardcoded color values - use --cc-* CSS variables or cc-* Tailwind classes",
|
|
18
|
+
},
|
|
19
|
+
messages: {
|
|
20
|
+
noHardcodedColor:
|
|
21
|
+
"Detected hardcoded color '{{value}}'. Use 'var(--cc-*)' or 'cc-*' Tailwind classes so the value follows the theme and dark mode.",
|
|
22
|
+
},
|
|
23
|
+
schema: [],
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
return {
|
|
27
|
+
Literal(node) {
|
|
28
|
+
if (typeof node.value !== "string") {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (COLOR_REGEX.test(node.value.trim())) {
|
|
32
|
+
context.report({ node, messageId: "noHardcodedColor", data: { value: node.value } });
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disallow an inline object type in a parameter, a return type, or an exported variable annotation.
|
|
3
|
+
*
|
|
4
|
+
* One of the four things a lead reports as most of their review comments. An inline `{ id: string;
|
|
5
|
+
* name: string }` cannot be referenced, imported, or found by name, so the next person who needs the
|
|
6
|
+
* same shape writes it again slightly differently - and the two drift with nothing to notice. A
|
|
7
|
+
* named `interface` or `type` costs one line and makes the shape a thing you can point at.
|
|
8
|
+
*
|
|
9
|
+
* Scoped to three positions on purpose. A blanket ban on `TSTypeLiteral` would flag legitimate
|
|
10
|
+
* inline shapes in generic arguments (`useState<{ open: boolean }>`), mapped types and `Record`
|
|
11
|
+
* values, which would make the rule noise and get it switched off.
|
|
12
|
+
*
|
|
13
|
+
* @type {import("eslint").Rule.RuleModule}
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FUNCTION_LIKE = new Set([
|
|
17
|
+
"FunctionDeclaration",
|
|
18
|
+
"FunctionExpression",
|
|
19
|
+
"ArrowFunctionExpression",
|
|
20
|
+
"TSDeclareFunction",
|
|
21
|
+
"TSFunctionType",
|
|
22
|
+
"MethodDefinition",
|
|
23
|
+
"TSMethodSignature",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const PARAMETER_HOLDERS = new Set([
|
|
27
|
+
"Identifier",
|
|
28
|
+
"ObjectPattern",
|
|
29
|
+
"ArrayPattern",
|
|
30
|
+
"RestElement",
|
|
31
|
+
"AssignmentPattern",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Is this node exported, directly or via a containing declaration? */
|
|
35
|
+
function isExported(node) {
|
|
36
|
+
for (let current = node; current; current = current.parent) {
|
|
37
|
+
if (
|
|
38
|
+
current.type === "ExportNamedDeclaration" ||
|
|
39
|
+
current.type === "ExportDefaultDeclaration"
|
|
40
|
+
) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Classifies where a type literal sits, or returns null when the position is not one this rule
|
|
50
|
+
* governs.
|
|
51
|
+
*/
|
|
52
|
+
function positionOf(typeLiteral) {
|
|
53
|
+
const annotation = typeLiteral.parent;
|
|
54
|
+
if (!annotation || annotation.type !== "TSTypeAnnotation") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const holder = annotation.parent;
|
|
59
|
+
if (!holder) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Return type: the annotation hangs directly off the function.
|
|
64
|
+
if (FUNCTION_LIKE.has(holder.type) && holder.returnType === annotation) {
|
|
65
|
+
return "return type";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Parameter: the annotation hangs off a binding that the function lists as a parameter.
|
|
69
|
+
if (PARAMETER_HOLDERS.has(holder.type)) {
|
|
70
|
+
const owner = holder.parent;
|
|
71
|
+
if (owner && FUNCTION_LIKE.has(owner.type) && (owner.params ?? []).includes(holder)) {
|
|
72
|
+
return "parameter";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// An exported `const x: { ... } = ...` - the same unnameable shape, and exported, so other
|
|
76
|
+
// modules end up re-describing it.
|
|
77
|
+
if (owner && owner.type === "VariableDeclarator" && owner.id === holder && isExported(owner)) {
|
|
78
|
+
return "exported variable";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default {
|
|
86
|
+
meta: {
|
|
87
|
+
type: "suggestion",
|
|
88
|
+
docs: {
|
|
89
|
+
description:
|
|
90
|
+
"Disallow inline object types in parameters, return types and exported variable annotations - name the type",
|
|
91
|
+
},
|
|
92
|
+
messages: {
|
|
93
|
+
noInlineObjectType:
|
|
94
|
+
"Inline object type in a {{position}}. Declare a named `interface` or `type` instead: an inline shape cannot be imported or found by name, so the next person who needs it writes their own slightly different copy.",
|
|
95
|
+
},
|
|
96
|
+
schema: [],
|
|
97
|
+
},
|
|
98
|
+
create(context) {
|
|
99
|
+
return {
|
|
100
|
+
TSTypeLiteral(node) {
|
|
101
|
+
const position = positionOf(node);
|
|
102
|
+
if (position) {
|
|
103
|
+
context.report({ node, messageId: "noInlineObjectType", data: { position } });
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disallow an inline union of three or more string literals.
|
|
3
|
+
*
|
|
4
|
+
* `"draft" | "sent" | "bounced" | "opened"` written inline is a domain vocabulary with no name, so
|
|
5
|
+
* it gets restated at every boundary it crosses - a prop, a query parameter, a switch - and the
|
|
6
|
+
* copies fall out of step one value at a time. Naming it once means adding a value is a single edit
|
|
7
|
+
* the compiler then propagates.
|
|
8
|
+
*
|
|
9
|
+
* Two-member unions are allowed. `"asc" | "desc"` needs no ceremony, and requiring a name for every
|
|
10
|
+
* pair is the kind of rule people disable wholesale.
|
|
11
|
+
*
|
|
12
|
+
* @type {import("eslint").Rule.RuleModule}
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const MINIMUM_MEMBERS = 3;
|
|
16
|
+
|
|
17
|
+
/** A `"literal"` type node, across the parser versions that spell it differently. */
|
|
18
|
+
function isStringLiteralType(node) {
|
|
19
|
+
if (node.type === "TSLiteralType") {
|
|
20
|
+
return node.literal?.type === "Literal" && typeof node.literal.value === "string";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
meta: {
|
|
28
|
+
type: "suggestion",
|
|
29
|
+
docs: {
|
|
30
|
+
description:
|
|
31
|
+
"Disallow inline unions of three or more string literals - name the union with a type alias",
|
|
32
|
+
},
|
|
33
|
+
messages: {
|
|
34
|
+
noInlineStringUnion:
|
|
35
|
+
"Inline union of {{count}} string literals. Give it a name (`type {{suggestion}} = ...`) and reference that: an unnamed vocabulary gets restated at every boundary it crosses, and the copies drift a value at a time.",
|
|
36
|
+
},
|
|
37
|
+
schema: [],
|
|
38
|
+
},
|
|
39
|
+
create(context) {
|
|
40
|
+
return {
|
|
41
|
+
TSUnionType(node) {
|
|
42
|
+
const members = node.types ?? [];
|
|
43
|
+
if (members.length < MINIMUM_MEMBERS || !members.every(isStringLiteralType)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Already named: `type Status = "a" | "b" | "c"` is exactly what this rule asks for.
|
|
48
|
+
if (node.parent?.type === "TSTypeAliasDeclaration") {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A union inside a named alias's composition (e.g. `type X = ("a"|"b"|"c")[]`) is still
|
|
53
|
+
// named at the point that matters, so walking up through wrapper type nodes to find an
|
|
54
|
+
// alias keeps the rule from demanding a second name for the same thing.
|
|
55
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
56
|
+
if (current.type === "TSTypeAliasDeclaration") {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Stop at the first node that is not part of a type expression: beyond that we are in a
|
|
61
|
+
// parameter, a return type or a variable, which is precisely what should be reported.
|
|
62
|
+
if (!current.type.startsWith("TS")) {
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
context.report({
|
|
68
|
+
node,
|
|
69
|
+
messageId: "noInlineStringUnion",
|
|
70
|
+
data: {
|
|
71
|
+
count: members.length,
|
|
72
|
+
suggestion: "SomethingStatus",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disallow a plugin declaring its own persistent data grid or its pagination helpers.
|
|
3
|
+
*
|
|
4
|
+
* The backstop to the largest promotion in this work. Measured on origin/develop across five
|
|
5
|
+
* repos: `PersistentDataGrid.tsx` 167 lines in four copies, `gridStatePersistence.ts` 174 in four,
|
|
6
|
+
* `dataGridPagination.ts` 94 in four, plus a re-export barrel - and `sales` had already drifted on
|
|
7
|
+
* four of the six files.
|
|
8
|
+
*
|
|
9
|
+
* Two fixes were riding in every copy, which is what makes duplication here expensive rather than
|
|
10
|
+
* merely untidy: the pagination helper distinguishes a page change from a page-size change (the
|
|
11
|
+
* naive version pinned the user to page 1), and the grid reads `window.location.pathname` directly
|
|
12
|
+
* so the leaf wrapper needs no Router context.
|
|
13
|
+
*
|
|
14
|
+
* Fires on a DECLARATION, never a use, so importing from `@ethisyscore/plugin-ui/components/data-grid`
|
|
15
|
+
* is exactly what the rule wants.
|
|
16
|
+
*
|
|
17
|
+
* @type {import("eslint").Rule.RuleModule}
|
|
18
|
+
*/
|
|
19
|
+
import { createNoLocalComponentRule } from "./componentDeclarations.js";
|
|
20
|
+
|
|
21
|
+
export default createNoLocalComponentRule({
|
|
22
|
+
// Anchored at the end so `DataGridProps`, `useDataGridState` and a `ProjectsDataGridColumns`
|
|
23
|
+
// helper are left alone; the shared helper's guard keeps hooks out of it.
|
|
24
|
+
pattern: /(PersistentDataGrid|DataGridPagination|GridStatePersistence)$/,
|
|
25
|
+
messageId: "noLocalDataGrid",
|
|
26
|
+
description:
|
|
27
|
+
"Disallow a locally-declared persistent data grid - import from @ethisyscore/plugin-ui/components/data-grid",
|
|
28
|
+
message:
|
|
29
|
+
"'{{name}}' re-declares the shared data grid. Import { PersistentDataGrid, buildDataGridPaginationProps } from '@ethisyscore/plugin-ui/components/data-grid' instead: the local copies had already drifted, and each one carries the page-size fix and the no-Router-context behaviour independently.",
|
|
30
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disallow a plugin declaring its own date or datetime input component.
|
|
3
|
+
*
|
|
4
|
+
* The backstop to a promotion. The same 134-line `date-input.tsx` sat in six of the ten plugin
|
|
5
|
+
* repos, and each copy carried two fixes that are easy to lose: `altBoundary` popper modifiers, so
|
|
6
|
+
* the calendar flips correctly inside an `overflow:hidden` MUI Dialog, and an end-of-day upper
|
|
7
|
+
* bound, without which a `datetime-local` field rejects every time on its final valid date. Six
|
|
8
|
+
* copies is six places for either to be dropped, and nothing would have noticed.
|
|
9
|
+
*
|
|
10
|
+
* Fires on a DECLARATION, never a use, so importing `DateInput` from
|
|
11
|
+
* `@ethisyscore/plugin-ui/components/ui` and rendering it is what the rule wants.
|
|
12
|
+
*
|
|
13
|
+
* @type {import("eslint").Rule.RuleModule}
|
|
14
|
+
*/
|
|
15
|
+
import { createNoLocalComponentRule } from "./componentDeclarations.js";
|
|
16
|
+
|
|
17
|
+
export default createNoLocalComponentRule({
|
|
18
|
+
// Anchored at the end so `DateInputProps` and `DateRangeFilter` are left alone; the shared helper
|
|
19
|
+
// excludes hooks, so `useDatePicker` and `useDateInputValidation` do not match either.
|
|
20
|
+
pattern: /Date(Time)?(Input|Picker|Field)$/,
|
|
21
|
+
messageId: "noLocalDateInput",
|
|
22
|
+
description:
|
|
23
|
+
"Disallow a locally-declared date input - import DateInput from @ethisyscore/plugin-ui",
|
|
24
|
+
message:
|
|
25
|
+
"'{{name}}' looks like a local date input. Date fields must come from one implementation or the popper and bounds fixes drift: import { DateInput } from '@ethisyscore/plugin-ui/components/ui' instead. If this is genuinely something else, rename it so it does not end in Input/Picker/Field.",
|
|
26
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disallow a plugin declaring its own empty-state panel.
|
|
3
|
+
*
|
|
4
|
+
* The backstop to a promotion. The same 32-line `EmptyState.tsx` was identical in four of the ten
|
|
5
|
+
* plugin repos. It is small, which is exactly why it kept being rewritten - and why "empty states
|
|
6
|
+
* look different in every app" is a complaint no rule about shapes could answer. One implementation
|
|
7
|
+
* can, and this stops a fifth.
|
|
8
|
+
*
|
|
9
|
+
* Fires on a DECLARATION, never a use, so importing `EmptyState` from
|
|
10
|
+
* `@ethisyscore/plugin-ui/shared` and rendering it is what the rule wants.
|
|
11
|
+
*
|
|
12
|
+
* @type {import("eslint").Rule.RuleModule}
|
|
13
|
+
*/
|
|
14
|
+
import { createNoLocalComponentRule } from "./componentDeclarations.js";
|
|
15
|
+
|
|
16
|
+
export default createNoLocalComponentRule({
|
|
17
|
+
// Loose enough to catch the names the same panel gets rewritten under, while the shared helper's
|
|
18
|
+
// hook guard keeps `useEmptyState` out of it.
|
|
19
|
+
pattern: /(EmptyState|NoResults|NoData|BlankSlate)$/,
|
|
20
|
+
messageId: "noLocalEmptyState",
|
|
21
|
+
description:
|
|
22
|
+
"Disallow a locally-declared empty state - import EmptyState from @ethisyscore/plugin-ui/shared",
|
|
23
|
+
message:
|
|
24
|
+
"'{{name}}' looks like a local empty state. Import { EmptyState } from '@ethisyscore/plugin-ui/shared' instead, so every app's empty state matches. It takes icon, title, description and fill; pass description=\"\" for a heading only.",
|
|
25
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disallow a plugin declaring its own search input component.
|
|
3
|
+
*
|
|
4
|
+
* The backstop to a promotion, not a style preference. `DebouncedSearchInput` was identical in six
|
|
5
|
+
* of the ten plugin repos, and a third search field had been hand-mirrored from it in a shared
|
|
6
|
+
* toolbar - none of which a lint rule could have made consistent. The fix is one implementation,
|
|
7
|
+
* exported from `@ethisyscore/plugin-ui/components/ui`; this rule stops the next copy, since
|
|
8
|
+
* nothing else would notice.
|
|
9
|
+
*
|
|
10
|
+
* Fires on a DECLARATION, never a use, so importing the shared component and rendering
|
|
11
|
+
* `<DebouncedSearchInput />` is exactly what the rule wants.
|
|
12
|
+
*
|
|
13
|
+
* @type {import("eslint").Rule.RuleModule}
|
|
14
|
+
*/
|
|
15
|
+
import { createNoLocalComponentRule } from "./componentDeclarations.js";
|
|
16
|
+
|
|
17
|
+
export default createNoLocalComponentRule({
|
|
18
|
+
// Anchored at the end so `SearchInputProps` and `SearchToolbar` are not matched. A toolbar that
|
|
19
|
+
// composes the shared input is a legitimate local component; a component that IS the search field
|
|
20
|
+
// is not. Hook names are excluded by the shared helper.
|
|
21
|
+
pattern: /Search(Input|Bar|Field)$/,
|
|
22
|
+
messageId: "noLocalSearchInput",
|
|
23
|
+
description:
|
|
24
|
+
"Disallow a locally-declared search input - import DebouncedSearchInput from @ethisyscore/plugin-ui",
|
|
25
|
+
message:
|
|
26
|
+
"'{{name}}' looks like a local search input. Search fields must come from one implementation or they drift: import { DebouncedSearchInput } from '@ethisyscore/plugin-ui/components/ui' instead. If this is genuinely something else, rename it so it does not end in Input/Bar/Field.",
|
|
27
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disallow a route parameter in a navigation path.
|
|
3
|
+
*
|
|
4
|
+
* A menu is rendered without a specific entity, so a path containing `:id` cannot resolve: the item
|
|
5
|
+
* either navigates nowhere or navigates to a literal colon segment. Parameterised routes belong in
|
|
6
|
+
* `pages[]`, which the host resolves with an entity in hand.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the manifest-side gate in the SDK's convention tests, so the same mistake is caught in the
|
|
9
|
+
* TypeScript menu config and in the compiled manifest. Two mechanisms, one rule, because the two
|
|
10
|
+
* declarations are hand-mirrored and drift.
|
|
11
|
+
*
|
|
12
|
+
* @type {import("eslint").Rule.RuleModule}
|
|
13
|
+
*/
|
|
14
|
+
export default {
|
|
15
|
+
meta: {
|
|
16
|
+
type: "problem",
|
|
17
|
+
docs: {
|
|
18
|
+
description:
|
|
19
|
+
"Disallow :param in navigation paths - use pages[] for parameterized routes",
|
|
20
|
+
},
|
|
21
|
+
messages: {
|
|
22
|
+
noParamInNavPath:
|
|
23
|
+
"Navigation path '{{value}}' contains a dynamic parameter. A menu is rendered without an entity, so this cannot resolve. Use pages[] for parameterized routes.",
|
|
24
|
+
},
|
|
25
|
+
schema: [],
|
|
26
|
+
},
|
|
27
|
+
create(context) {
|
|
28
|
+
// A colon only means a route parameter when it OPENS a path segment: `leads/:id`, or `:id` on
|
|
29
|
+
// its own. Testing for a bare colon anywhere in the string also matches things that are not
|
|
30
|
+
// parameters at all - a protocol (`https://...`), a `mailto:`/`tel:` target, a port - and this
|
|
31
|
+
// rule reports a problem, so a false positive fails a build over a correct path.
|
|
32
|
+
const ROUTE_PARAM = /(^|\/):[A-Za-z_]/;
|
|
33
|
+
|
|
34
|
+
// The key may be written either way in an object literal, and both forms appear in real menu
|
|
35
|
+
// configs: `path:` unquoted, and `"path":` quoted (JSON pasted into a .ts file, or a config
|
|
36
|
+
// emitted by a generator). Keying only on the Identifier form silently ignored the quoted one.
|
|
37
|
+
const isPathKey = (key) =>
|
|
38
|
+
(key.type === "Identifier" && key.name === "path") ||
|
|
39
|
+
(key.type === "Literal" && key.value === "path");
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
Property(node) {
|
|
43
|
+
if (
|
|
44
|
+
isPathKey(node.key) &&
|
|
45
|
+
node.value.type === "Literal" &&
|
|
46
|
+
typeof node.value.value === "string" &&
|
|
47
|
+
ROUTE_PARAM.test(node.value.value)
|
|
48
|
+
) {
|
|
49
|
+
context.report({
|
|
50
|
+
node: node.value,
|
|
51
|
+
messageId: "noParamInNavPath",
|
|
52
|
+
data: { value: node.value.value },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|