eslint-plugin-kerfjs 5.0.0-beta.90 → 5.0.0-beta.92
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 +11 -2
- package/docs/rules/ui-css-values.md +5 -3
- package/docs/rules/ui-exceptional-shorthands.md +22 -0
- package/index.js +12 -1
- package/lib/rules/ui-css-values.js +2 -242
- package/lib/rules/ui-exceptional-shorthands.js +3 -0
- package/lib/ui-css-value-contracts.js +261 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,9 +80,17 @@ settings: {
|
|
|
80
80
|
For deterministic generated configs, the same settings accept parsed `catalog`, `selectionCatalog`, and `profile` objects. File-based precedence is package defaults, then workspace profile, then root-to-leaf directory profiles. The rules load `application-ui-profile-sync.cjs` from the installed `@kerfjs/ui` package by default because ESLint rule creation is synchronous; `profileContractPath` is only needed when artifacts are vendored, and then its directory must also hold `application-ui-diagnostic-ids-v1.json` and `component-class-owners.cjs` (the class-to-component rule `KUI-L103` shares with `@kerfjs/ui`'s generated component pages) from the same `@kerfjs/ui` release. Every raw layer is schema- and catalog-reference-validated before merge, so a child cannot hide malformed or stale parent policy. Exceptions use exact `KUI-L*` diagnostic ids and repository-relative file or directory targets.
|
|
81
81
|
|
|
82
82
|
`ui-css-values` consumes each catalog entry's property-specific grammar and
|
|
83
|
-
emits `KUI-L013`–`KUI-
|
|
83
|
+
emits `KUI-L013`–`KUI-L016` with a preferred shorthand or helper. It covers JSX,
|
|
84
84
|
direct component calls, nested catalog paths, and explicitly configured
|
|
85
|
-
third-party catalogs; dynamic values remain governed by TypeScript.
|
|
85
|
+
third-party catalogs; dynamic values remain governed by TypeScript. Its
|
|
86
|
+
companion `ui-exceptional-shorthands` walks the same contracts and emits the
|
|
87
|
+
review finding `KUI-L017` for a valid but exceptional off-scale shorthand. It
|
|
88
|
+
is `warn` in both `recommended-ui` and `strict-ui`, matching the analyzer's
|
|
89
|
+
`review` classification, so `kerf-ui-doctor` merges the ESLint and analyzer
|
|
90
|
+
reports into one review finding.
|
|
91
|
+
|
|
92
|
+
UI rule severities follow the analyzer's classification of the codes each rule
|
|
93
|
+
reports: an `error` code ships as `error`, and a `review` code ships as `warn`.
|
|
86
94
|
|
|
87
95
|
`ui-component-ownership` keeps code out of another package's component
|
|
88
96
|
internals: `KUI-L020` reports a reference to a component's private `--_*`
|
|
@@ -115,6 +123,7 @@ Use flat config (`eslint.config.js`), shown above. The `legacy-recommended` expo
|
|
|
115
123
|
| [`ui-component-ownership`](docs/rules/ui-component-ownership.md) | — (configure components, never override them) | `error` |
|
|
116
124
|
| [`ui-composition`](docs/rules/ui-composition.md) | — (cataloged parents and zones) | `error` |
|
|
117
125
|
| [`ui-css-values`](docs/rules/ui-css-values.md) | — (cataloged property-specific value grammar) | `error` |
|
|
126
|
+
| [`ui-exceptional-shorthands`](docs/rules/ui-exceptional-shorthands.md) | — (review deliberate off-scale shorthands) | `warn` |
|
|
118
127
|
| [`ui-preferences`](docs/rules/ui-preferences.md) | — (application component choices) | `warn` |
|
|
119
128
|
| [`ui-wiring`](docs/rules/ui-wiring.md) | — (required setup and cleanup) | `warn` |
|
|
120
129
|
|
|
@@ -7,9 +7,11 @@ names their accepted helpers, finite shorthands, and raw-value policy.
|
|
|
7
7
|
|
|
8
8
|
Stable diagnostics are `KUI-L013` (raw or unknown shorthand), `KUI-L014`
|
|
9
9
|
(wrong property helper), `KUI-L015` (non-standalone expression), `KUI-L016`
|
|
10
|
-
(forbidden declaration list)
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
(forbidden declaration list). Messages name the preferred shorthand or helper.
|
|
11
|
+
Dynamic values are left to TypeScript rather than guessed. The review finding
|
|
12
|
+
`KUI-L017` (exceptional spacing shorthand) is reported by the companion
|
|
13
|
+
[`ui-exceptional-shorthands`](ui-exceptional-shorthands.md) rule so it can carry
|
|
14
|
+
the `warn` severity that matches the analyzer's `review` classification.
|
|
13
15
|
|
|
14
16
|
Only component exports carry an entry's contracts. A catalog entry's
|
|
15
17
|
`publicExports` also lists the helpers its props consume (`Select` ships
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# `ui-exceptional-shorthands`
|
|
2
|
+
|
|
3
|
+
Reports `KUI-L017` when a Kerf UI component prop uses a valid but exceptional
|
|
4
|
+
off-scale shorthand — for example `<List gap="s" />` or `<Row gap="xl" />`,
|
|
5
|
+
where the catalog lists `s` and `xl` under the prop's `exceptionalShorthands`.
|
|
6
|
+
The value renders correctly; the finding asks whether the off-scale choice is
|
|
7
|
+
deliberate. The message names the canonical shorthands and helpers to prefer.
|
|
8
|
+
|
|
9
|
+
This is a review finding, not a contract violation. The analyzer classifies
|
|
10
|
+
`KUI-L017` as `review`, so the rule is `warn` in both `recommended-ui` and
|
|
11
|
+
`strict-ui`, and `kerf-ui-doctor` merges the ESLint and analyzer reports into a
|
|
12
|
+
single review finding. To make review findings fail a run, use the doctor's
|
|
13
|
+
`failOn: 'review'` rather than promoting this rule to `error`.
|
|
14
|
+
|
|
15
|
+
Keep a deliberate choice by recording a profile exception for `KUI-L017` with a
|
|
16
|
+
rationale, or move to a canonical shorthand or a typed helper such as
|
|
17
|
+
`space('s')`.
|
|
18
|
+
|
|
19
|
+
The rule shares its catalog walk with [`ui-css-values`](ui-css-values.md),
|
|
20
|
+
which reports the `KUI-L013`–`KUI-L016` contract violations at `error`. It
|
|
21
|
+
covers JSX and direct component calls against the bundled `@kerfjs/ui` catalog
|
|
22
|
+
and any third-party catalog configured through `settings.kerfjs.ui.catalog`.
|
package/index.js
CHANGED
|
@@ -9,12 +9,13 @@ import requireDelegateDisposer from './lib/rules/require-delegate-disposer.js';
|
|
|
9
9
|
import uiComponentOwnership from './lib/rules/ui-component-ownership.js';
|
|
10
10
|
import uiComposition from './lib/rules/ui-composition.js';
|
|
11
11
|
import uiCssValues from './lib/rules/ui-css-values.js';
|
|
12
|
+
import uiExceptionalShorthands from './lib/rules/ui-exceptional-shorthands.js';
|
|
12
13
|
import uiPreferences from './lib/rules/ui-preferences.js';
|
|
13
14
|
import uiPublicBoundaries from './lib/rules/ui-public-boundaries.js';
|
|
14
15
|
import uiWiring from './lib/rules/ui-wiring.js';
|
|
15
16
|
|
|
16
17
|
const plugin = {
|
|
17
|
-
meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.
|
|
18
|
+
meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.92' },
|
|
18
19
|
rules: {
|
|
19
20
|
'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
|
|
20
21
|
'no-raw-with-dynamic-arg': noRawWithDynamicArg,
|
|
@@ -27,6 +28,7 @@ const plugin = {
|
|
|
27
28
|
'ui-component-ownership': uiComponentOwnership,
|
|
28
29
|
'ui-composition': uiComposition,
|
|
29
30
|
'ui-css-values': uiCssValues,
|
|
31
|
+
'ui-exceptional-shorthands': uiExceptionalShorthands,
|
|
30
32
|
'ui-preferences': uiPreferences,
|
|
31
33
|
'ui-public-boundaries': uiPublicBoundaries,
|
|
32
34
|
'ui-wiring': uiWiring,
|
|
@@ -62,12 +64,21 @@ plugin.configs.recommended = {
|
|
|
62
64
|
plugins: { kerfjs: plugin },
|
|
63
65
|
rules: recommendedRules,
|
|
64
66
|
};
|
|
67
|
+
// UI rule severities follow the analyzer's classification of the KUI codes a
|
|
68
|
+
// rule reports (`UI_ANALYSIS_RULES` in `@kerfjs/ui/analyzer`): an `error`
|
|
69
|
+
// code ships as `error`, a `review` code as `warn`. `ui-exceptional-shorthands`
|
|
70
|
+
// (`KUI-L017`) is a review finding — a deliberate off-scale choice to justify,
|
|
71
|
+
// not a defect — so it stays `warn` in strict-ui too; promoting it would
|
|
72
|
+
// contradict the analyzer and make kerf-ui-doctor report the same finding
|
|
73
|
+
// twice. Gate review findings with the doctor's `failOn: 'review'` instead.
|
|
74
|
+
// `@kerfjs/ui`'s eslint-severity-contract test pins this agreement.
|
|
65
75
|
const recommendedUiRules = {
|
|
66
76
|
...recommendedRules,
|
|
67
77
|
'kerfjs/ui-public-boundaries': 'error',
|
|
68
78
|
'kerfjs/ui-component-ownership': 'error',
|
|
69
79
|
'kerfjs/ui-composition': 'error',
|
|
70
80
|
'kerfjs/ui-css-values': 'error',
|
|
81
|
+
'kerfjs/ui-exceptional-shorthands': 'warn',
|
|
71
82
|
'kerfjs/ui-preferences': 'warn',
|
|
72
83
|
'kerfjs/ui-wiring': 'warn',
|
|
73
84
|
};
|
|
@@ -1,243 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
helperCall,
|
|
3
|
-
importRegistry,
|
|
4
|
-
isExcepted,
|
|
5
|
-
jsxKey,
|
|
6
|
-
loadUiContract,
|
|
7
|
-
UI_CONTRACT_LOAD_CODE,
|
|
8
|
-
UI_RULE_SCHEMA,
|
|
9
|
-
} from '../ui-contract.js';
|
|
1
|
+
import { createCssValueRule } from '../ui-css-value-contracts.js';
|
|
10
2
|
|
|
11
|
-
|
|
12
|
-
raw: 'KUI-L013',
|
|
13
|
-
helper: 'KUI-L014',
|
|
14
|
-
expression: 'KUI-L015',
|
|
15
|
-
declarations: 'KUI-L016',
|
|
16
|
-
exceptional: 'KUI-L017',
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
function propertyName(node) {
|
|
20
|
-
if (!node || node.computed) return undefined;
|
|
21
|
-
return node.key?.name ?? node.key?.value;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function objectProperty(object, name) {
|
|
25
|
-
return object?.properties?.find(
|
|
26
|
-
(property) =>
|
|
27
|
-
property.type === 'Property' && propertyName(property) === name,
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function jsxAttribute(opening, name) {
|
|
32
|
-
return opening.attributes.find(
|
|
33
|
-
(attribute) =>
|
|
34
|
-
attribute.type === 'JSXAttribute' && attribute.name.name === name,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function jsxValue(attribute) {
|
|
39
|
-
if (!attribute?.value) return undefined;
|
|
40
|
-
if (attribute.value.type === 'Literal') return attribute.value;
|
|
41
|
-
if (attribute.value.type === 'JSXExpressionContainer')
|
|
42
|
-
return attribute.value.expression;
|
|
43
|
-
return undefined;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function nestedValues(value, tail) {
|
|
47
|
-
if (!tail) return [value];
|
|
48
|
-
if (value?.type !== 'ArrayExpression') return [];
|
|
49
|
-
return value.elements.flatMap((element) => {
|
|
50
|
-
if (element?.type !== 'ObjectExpression') return [];
|
|
51
|
-
const property = objectProperty(element, tail);
|
|
52
|
-
return property ? [property.value] : [];
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function contractValuesFromJsx(opening, path) {
|
|
57
|
-
const [head, tail] = path.split('[].');
|
|
58
|
-
return nestedValues(jsxValue(jsxAttribute(opening, head)), tail);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function contractValuesFromCall(call, path) {
|
|
62
|
-
const options = call.arguments[0];
|
|
63
|
-
if (options?.type !== 'ObjectExpression') return [];
|
|
64
|
-
const [head, tail] = path.split('[].');
|
|
65
|
-
const property = objectProperty(options, head);
|
|
66
|
-
return property ? nestedValues(property.value, tail) : [];
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export default {
|
|
70
|
-
meta: {
|
|
71
|
-
type: 'problem',
|
|
72
|
-
docs: {
|
|
73
|
-
description:
|
|
74
|
-
'Enforce cataloged property-specific CSS value contracts for Kerf UI components.',
|
|
75
|
-
url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-css-values.md',
|
|
76
|
-
},
|
|
77
|
-
schema: UI_RULE_SCHEMA,
|
|
78
|
-
messages: {
|
|
79
|
-
config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
|
|
80
|
-
raw: '{{code}}: `{{component}}.{{path}}` uses {{grammar}} grammar; replace raw `{{value}}` with {{preferred}}.',
|
|
81
|
-
helper:
|
|
82
|
-
'{{code}}: `{{helper}}()` produces the wrong grammar for `{{component}}.{{path}}`; use {{preferred}}.',
|
|
83
|
-
expression:
|
|
84
|
-
'{{code}}: `{{helper}}()` is not a standalone CSS value for `{{component}}.{{path}}`; wrap it with an accepted composer such as `calc()`.',
|
|
85
|
-
declarations:
|
|
86
|
-
'{{code}}: `{{component}}.{{path}}` is a forbidden declaration-list escape hatch; use `className`, public tokens, or cataloged props.',
|
|
87
|
-
exceptional:
|
|
88
|
-
'{{code}}: `{{value}}` is an exceptional shorthand for `{{component}}.{{path}}`; prefer {{preferred}} unless the off-scale choice is deliberate.',
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
create(context) {
|
|
92
|
-
const loaded = loadUiContract(context);
|
|
93
|
-
const filename = context.filename ?? context.getFilename();
|
|
94
|
-
let registry;
|
|
95
|
-
|
|
96
|
-
const inspect = (node, entry, valuesFor) => {
|
|
97
|
-
for (const cssContract of entry.cssValueProps ?? []) {
|
|
98
|
-
const preferred =
|
|
99
|
-
[
|
|
100
|
-
...(cssContract.canonicalShorthands ?? []).map(
|
|
101
|
-
(item) => `\`${item}\``,
|
|
102
|
-
),
|
|
103
|
-
...(cssContract.helpers ?? []).map((item) => `\`${item}()\``),
|
|
104
|
-
].join(' or ') || 'a cataloged component prop';
|
|
105
|
-
for (const value of valuesFor(cssContract.path)) {
|
|
106
|
-
if (!value) continue;
|
|
107
|
-
if (
|
|
108
|
-
cssContract.grammar === 'declarations' &&
|
|
109
|
-
cssContract.rawPolicy !== 'allow'
|
|
110
|
-
) {
|
|
111
|
-
if (!isExcepted(loaded, CODES.declarations, filename))
|
|
112
|
-
context.report({
|
|
113
|
-
node: value,
|
|
114
|
-
messageId: 'declarations',
|
|
115
|
-
data: {
|
|
116
|
-
code: CODES.declarations,
|
|
117
|
-
component: entry.name,
|
|
118
|
-
path: cssContract.path,
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
const literal =
|
|
124
|
-
value.type === 'Literal' && typeof value.value === 'string'
|
|
125
|
-
? value.value
|
|
126
|
-
: value.type === 'TemplateLiteral' &&
|
|
127
|
-
value.expressions.length === 0
|
|
128
|
-
? value.quasis[0].value.cooked
|
|
129
|
-
: undefined;
|
|
130
|
-
if (literal !== undefined) {
|
|
131
|
-
if (cssContract.exceptionalShorthands?.includes(literal)) {
|
|
132
|
-
if (!isExcepted(loaded, CODES.exceptional, filename))
|
|
133
|
-
context.report({
|
|
134
|
-
node: value,
|
|
135
|
-
messageId: 'exceptional',
|
|
136
|
-
data: {
|
|
137
|
-
code: CODES.exceptional,
|
|
138
|
-
component: entry.name,
|
|
139
|
-
path: cssContract.path,
|
|
140
|
-
value: literal,
|
|
141
|
-
preferred,
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
} else if (
|
|
145
|
-
!cssContract.shorthands?.includes(literal) &&
|
|
146
|
-
cssContract.rawPolicy !== 'allow'
|
|
147
|
-
) {
|
|
148
|
-
const code = CODES.raw;
|
|
149
|
-
if (!isExcepted(loaded, code, filename))
|
|
150
|
-
context.report({
|
|
151
|
-
node: value,
|
|
152
|
-
messageId: 'raw',
|
|
153
|
-
data: {
|
|
154
|
-
code,
|
|
155
|
-
component: entry.name,
|
|
156
|
-
path: cssContract.path,
|
|
157
|
-
grammar: cssContract.grammar,
|
|
158
|
-
value: literal,
|
|
159
|
-
preferred,
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (value.type !== 'CallExpression') continue;
|
|
166
|
-
const helper = helperCall(value.callee, registry, loaded);
|
|
167
|
-
if (!helper) continue;
|
|
168
|
-
if (cssContract.nonStandaloneHelpers?.includes(helper.imported)) {
|
|
169
|
-
if (!isExcepted(loaded, CODES.expression, filename))
|
|
170
|
-
context.report({
|
|
171
|
-
node: value,
|
|
172
|
-
messageId: 'expression',
|
|
173
|
-
data: {
|
|
174
|
-
code: CODES.expression,
|
|
175
|
-
component: entry.name,
|
|
176
|
-
path: cssContract.path,
|
|
177
|
-
helper: helper.imported,
|
|
178
|
-
},
|
|
179
|
-
});
|
|
180
|
-
} else if (
|
|
181
|
-
!cssContract.helpers?.includes(helper.imported) &&
|
|
182
|
-
cssContract.unsafeHelper !== helper.imported
|
|
183
|
-
) {
|
|
184
|
-
if (!isExcepted(loaded, CODES.helper, filename))
|
|
185
|
-
context.report({
|
|
186
|
-
node: value,
|
|
187
|
-
messageId: 'helper',
|
|
188
|
-
data: {
|
|
189
|
-
code: CODES.helper,
|
|
190
|
-
component: entry.name,
|
|
191
|
-
path: cssContract.path,
|
|
192
|
-
helper: helper.imported,
|
|
193
|
-
preferred,
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
return {
|
|
202
|
-
Program(node) {
|
|
203
|
-
if (loaded.error)
|
|
204
|
-
context.report({
|
|
205
|
-
node,
|
|
206
|
-
messageId: 'config',
|
|
207
|
-
data: { error: loaded.error },
|
|
208
|
-
});
|
|
209
|
-
else registry = importRegistry(node, loaded, filename);
|
|
210
|
-
},
|
|
211
|
-
JSXOpeningElement(node) {
|
|
212
|
-
if (!registry) return;
|
|
213
|
-
const key = jsxKey(node.name, registry, loaded);
|
|
214
|
-
const entry = loaded.entries.get(key);
|
|
215
|
-
if (entry)
|
|
216
|
-
inspect(node, entry, (path) => contractValuesFromJsx(node, path));
|
|
217
|
-
},
|
|
218
|
-
CallExpression(node) {
|
|
219
|
-
if (!registry) return;
|
|
220
|
-
let key;
|
|
221
|
-
if (node.callee.type === 'Identifier')
|
|
222
|
-
key = registry.locals.get(node.callee.name);
|
|
223
|
-
else if (
|
|
224
|
-
node.callee.type === 'MemberExpression' &&
|
|
225
|
-
!node.callee.computed &&
|
|
226
|
-
node.callee.object.type === 'Identifier' &&
|
|
227
|
-
node.callee.property.type === 'Identifier'
|
|
228
|
-
) {
|
|
229
|
-
const source = registry.namespaces.get(node.callee.object.name);
|
|
230
|
-
const exportedKey = loaded.exports.get(node.callee.property.name);
|
|
231
|
-
if (
|
|
232
|
-
source === loaded.package ||
|
|
233
|
-
loaded.imports.get(source) === exportedKey
|
|
234
|
-
)
|
|
235
|
-
key = exportedKey;
|
|
236
|
-
}
|
|
237
|
-
const entry = loaded.entries.get(key);
|
|
238
|
-
if (entry)
|
|
239
|
-
inspect(node, entry, (path) => contractValuesFromCall(node, path));
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
},
|
|
243
|
-
};
|
|
3
|
+
export default createCssValueRule({ review: false });
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import {
|
|
2
|
+
helperCall,
|
|
3
|
+
importRegistry,
|
|
4
|
+
isExcepted,
|
|
5
|
+
jsxKey,
|
|
6
|
+
loadUiContract,
|
|
7
|
+
UI_CONTRACT_LOAD_CODE,
|
|
8
|
+
UI_RULE_SCHEMA,
|
|
9
|
+
} from './ui-contract.js';
|
|
10
|
+
|
|
11
|
+
const CODES = {
|
|
12
|
+
raw: 'KUI-L013',
|
|
13
|
+
helper: 'KUI-L014',
|
|
14
|
+
expression: 'KUI-L015',
|
|
15
|
+
declarations: 'KUI-L016',
|
|
16
|
+
exceptional: 'KUI-L017',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function propertyName(node) {
|
|
20
|
+
if (!node || node.computed) return undefined;
|
|
21
|
+
return node.key?.name ?? node.key?.value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function objectProperty(object, name) {
|
|
25
|
+
return object?.properties?.find(
|
|
26
|
+
(property) =>
|
|
27
|
+
property.type === 'Property' && propertyName(property) === name,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jsxAttribute(opening, name) {
|
|
32
|
+
return opening.attributes.find(
|
|
33
|
+
(attribute) =>
|
|
34
|
+
attribute.type === 'JSXAttribute' && attribute.name.name === name,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function jsxValue(attribute) {
|
|
39
|
+
if (!attribute?.value) return undefined;
|
|
40
|
+
if (attribute.value.type === 'Literal') return attribute.value;
|
|
41
|
+
if (attribute.value.type === 'JSXExpressionContainer')
|
|
42
|
+
return attribute.value.expression;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nestedValues(value, tail) {
|
|
47
|
+
if (!tail) return [value];
|
|
48
|
+
if (value?.type !== 'ArrayExpression') return [];
|
|
49
|
+
return value.elements.flatMap((element) => {
|
|
50
|
+
if (element?.type !== 'ObjectExpression') return [];
|
|
51
|
+
const property = objectProperty(element, tail);
|
|
52
|
+
return property ? [property.value] : [];
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function contractValuesFromJsx(opening, path) {
|
|
57
|
+
const [head, tail] = path.split('[].');
|
|
58
|
+
return nestedValues(jsxValue(jsxAttribute(opening, head)), tail);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function contractValuesFromCall(call, path) {
|
|
62
|
+
const options = call.arguments[0];
|
|
63
|
+
if (options?.type !== 'ObjectExpression') return [];
|
|
64
|
+
const [head, tail] = path.split('[].');
|
|
65
|
+
const property = objectProperty(options, head);
|
|
66
|
+
return property ? nestedValues(property.value, tail) : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CONTRACT_MESSAGES = {
|
|
70
|
+
raw: 'KUI-L013: `{{component}}.{{path}}` uses {{grammar}} grammar; replace raw `{{value}}` with {{preferred}}.',
|
|
71
|
+
helper:
|
|
72
|
+
'KUI-L014: `{{helper}}()` produces the wrong grammar for `{{component}}.{{path}}`; use {{preferred}}.',
|
|
73
|
+
expression:
|
|
74
|
+
'KUI-L015: `{{helper}}()` is not a standalone CSS value for `{{component}}.{{path}}`; wrap it with an accepted composer such as `calc()`.',
|
|
75
|
+
declarations:
|
|
76
|
+
'KUI-L016: `{{component}}.{{path}}` is a forbidden declaration-list escape hatch; use `className`, public tokens, or cataloged props.',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const REVIEW_MESSAGES = {
|
|
80
|
+
exceptional:
|
|
81
|
+
'KUI-L017: `{{value}}` is an exceptional shorthand for `{{component}}.{{path}}`; prefer {{preferred}} unless the off-scale choice is deliberate.',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// One catalog walk backs two rules because ESLint assigns severity per rule,
|
|
85
|
+
// not per message. `KUI-L013`–`KUI-L016` are contract violations (analyzer
|
|
86
|
+
// severity `error`); `KUI-L017` is a review finding (analyzer severity
|
|
87
|
+
// `review`), so it lives in its own rule that the presets configure as `warn`.
|
|
88
|
+
// Reporting all five from one `error` rule made a deliberate off-scale choice
|
|
89
|
+
// fail lint while the analyzer and doctor queued it for review.
|
|
90
|
+
export function createCssValueRule({ review }) {
|
|
91
|
+
const slug = review ? 'ui-exceptional-shorthands' : 'ui-css-values';
|
|
92
|
+
const reported = review ? REVIEW_MESSAGES : CONTRACT_MESSAGES;
|
|
93
|
+
return {
|
|
94
|
+
meta: {
|
|
95
|
+
type: review ? 'suggestion' : 'problem',
|
|
96
|
+
docs: {
|
|
97
|
+
description: review
|
|
98
|
+
? 'Flag valid but exceptional off-scale shorthands for Kerf UI components for review.'
|
|
99
|
+
: 'Enforce cataloged property-specific CSS value contracts for Kerf UI components.',
|
|
100
|
+
url: `https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/${slug}.md`,
|
|
101
|
+
},
|
|
102
|
+
schema: UI_RULE_SCHEMA,
|
|
103
|
+
messages: {
|
|
104
|
+
config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
|
|
105
|
+
...reported,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
create(context) {
|
|
109
|
+
return createVisitors(context, (descriptor) => {
|
|
110
|
+
if (descriptor.messageId in reported) context.report(descriptor);
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createVisitors(context, report) {
|
|
117
|
+
const loaded = loadUiContract(context);
|
|
118
|
+
const filename = context.filename ?? context.getFilename();
|
|
119
|
+
let registry;
|
|
120
|
+
|
|
121
|
+
const inspect = (node, entry, valuesFor) => {
|
|
122
|
+
for (const cssContract of entry.cssValueProps ?? []) {
|
|
123
|
+
const preferred =
|
|
124
|
+
[
|
|
125
|
+
...(cssContract.canonicalShorthands ?? []).map(
|
|
126
|
+
(item) => `\`${item}\``,
|
|
127
|
+
),
|
|
128
|
+
...(cssContract.helpers ?? []).map((item) => `\`${item}()\``),
|
|
129
|
+
].join(' or ') || 'a cataloged component prop';
|
|
130
|
+
for (const value of valuesFor(cssContract.path)) {
|
|
131
|
+
if (!value) continue;
|
|
132
|
+
if (
|
|
133
|
+
cssContract.grammar === 'declarations' &&
|
|
134
|
+
cssContract.rawPolicy !== 'allow'
|
|
135
|
+
) {
|
|
136
|
+
if (!isExcepted(loaded, CODES.declarations, filename))
|
|
137
|
+
report({
|
|
138
|
+
node: value,
|
|
139
|
+
messageId: 'declarations',
|
|
140
|
+
data: {
|
|
141
|
+
component: entry.name,
|
|
142
|
+
path: cssContract.path,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const literal =
|
|
148
|
+
value.type === 'Literal' && typeof value.value === 'string'
|
|
149
|
+
? value.value
|
|
150
|
+
: value.type === 'TemplateLiteral' && value.expressions.length === 0
|
|
151
|
+
? value.quasis[0].value.cooked
|
|
152
|
+
: undefined;
|
|
153
|
+
if (literal !== undefined) {
|
|
154
|
+
if (cssContract.exceptionalShorthands?.includes(literal)) {
|
|
155
|
+
if (!isExcepted(loaded, CODES.exceptional, filename))
|
|
156
|
+
report({
|
|
157
|
+
node: value,
|
|
158
|
+
messageId: 'exceptional',
|
|
159
|
+
data: {
|
|
160
|
+
component: entry.name,
|
|
161
|
+
path: cssContract.path,
|
|
162
|
+
value: literal,
|
|
163
|
+
preferred,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
} else if (
|
|
167
|
+
!cssContract.shorthands?.includes(literal) &&
|
|
168
|
+
cssContract.rawPolicy !== 'allow'
|
|
169
|
+
) {
|
|
170
|
+
const code = CODES.raw;
|
|
171
|
+
if (!isExcepted(loaded, code, filename))
|
|
172
|
+
report({
|
|
173
|
+
node: value,
|
|
174
|
+
messageId: 'raw',
|
|
175
|
+
data: {
|
|
176
|
+
component: entry.name,
|
|
177
|
+
path: cssContract.path,
|
|
178
|
+
grammar: cssContract.grammar,
|
|
179
|
+
value: literal,
|
|
180
|
+
preferred,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (value.type !== 'CallExpression') continue;
|
|
187
|
+
const helper = helperCall(value.callee, registry, loaded);
|
|
188
|
+
if (!helper) continue;
|
|
189
|
+
if (cssContract.nonStandaloneHelpers?.includes(helper.imported)) {
|
|
190
|
+
if (!isExcepted(loaded, CODES.expression, filename))
|
|
191
|
+
report({
|
|
192
|
+
node: value,
|
|
193
|
+
messageId: 'expression',
|
|
194
|
+
data: {
|
|
195
|
+
component: entry.name,
|
|
196
|
+
path: cssContract.path,
|
|
197
|
+
helper: helper.imported,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
} else if (
|
|
201
|
+
!cssContract.helpers?.includes(helper.imported) &&
|
|
202
|
+
cssContract.unsafeHelper !== helper.imported
|
|
203
|
+
) {
|
|
204
|
+
if (!isExcepted(loaded, CODES.helper, filename))
|
|
205
|
+
report({
|
|
206
|
+
node: value,
|
|
207
|
+
messageId: 'helper',
|
|
208
|
+
data: {
|
|
209
|
+
component: entry.name,
|
|
210
|
+
path: cssContract.path,
|
|
211
|
+
helper: helper.imported,
|
|
212
|
+
preferred,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
Program(node) {
|
|
222
|
+
if (loaded.error)
|
|
223
|
+
context.report({
|
|
224
|
+
node,
|
|
225
|
+
messageId: 'config',
|
|
226
|
+
data: { error: loaded.error },
|
|
227
|
+
});
|
|
228
|
+
else registry = importRegistry(node, loaded, filename);
|
|
229
|
+
},
|
|
230
|
+
JSXOpeningElement(node) {
|
|
231
|
+
if (!registry) return;
|
|
232
|
+
const key = jsxKey(node.name, registry, loaded);
|
|
233
|
+
const entry = loaded.entries.get(key);
|
|
234
|
+
if (entry)
|
|
235
|
+
inspect(node, entry, (path) => contractValuesFromJsx(node, path));
|
|
236
|
+
},
|
|
237
|
+
CallExpression(node) {
|
|
238
|
+
if (!registry) return;
|
|
239
|
+
let key;
|
|
240
|
+
if (node.callee.type === 'Identifier')
|
|
241
|
+
key = registry.locals.get(node.callee.name);
|
|
242
|
+
else if (
|
|
243
|
+
node.callee.type === 'MemberExpression' &&
|
|
244
|
+
!node.callee.computed &&
|
|
245
|
+
node.callee.object.type === 'Identifier' &&
|
|
246
|
+
node.callee.property.type === 'Identifier'
|
|
247
|
+
) {
|
|
248
|
+
const source = registry.namespaces.get(node.callee.object.name);
|
|
249
|
+
const exportedKey = loaded.exports.get(node.callee.property.name);
|
|
250
|
+
if (
|
|
251
|
+
source === loaded.package ||
|
|
252
|
+
loaded.imports.get(source) === exportedKey
|
|
253
|
+
)
|
|
254
|
+
key = exportedKey;
|
|
255
|
+
}
|
|
256
|
+
const entry = loaded.entries.get(key);
|
|
257
|
+
if (entry)
|
|
258
|
+
inspect(node, entry, (path) => contractValuesFromCall(node, path));
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|