eslint-plugin-kerfjs 5.0.0-beta.29 → 5.0.0-beta.30
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 +8 -2
- package/docs/rules/ui-composition.md +2 -2
- package/docs/rules/ui-css-values.md +15 -0
- package/index.js +4 -1
- package/lib/rules/ui-composition.js +27 -12
- package/lib/rules/ui-css-values.js +243 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ Applications using the UI catalog can select one of two additive flat configs:
|
|
|
55
55
|
export default [
|
|
56
56
|
// parser/files configuration as above
|
|
57
57
|
kerfjs.configs['recommended-ui'], // preferences and wiring are warnings
|
|
58
|
-
// or kerfjs.configs['strict-ui'] // all
|
|
58
|
+
// or kerfjs.configs['strict-ui'] // all UI contracts are errors
|
|
59
59
|
];
|
|
60
60
|
```
|
|
61
61
|
|
|
@@ -77,7 +77,12 @@ settings: {
|
|
|
77
77
|
|
|
78
78
|
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. 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.
|
|
79
79
|
|
|
80
|
-
`
|
|
80
|
+
`ui-css-values` consumes each catalog entry's property-specific grammar and
|
|
81
|
+
emits `KUI-L013`–`KUI-L017` with a preferred shorthand or helper. It covers JSX,
|
|
82
|
+
direct component calls, nested catalog paths, and explicitly configured
|
|
83
|
+
third-party catalogs; dynamic values remain governed by TypeScript.
|
|
84
|
+
|
|
85
|
+
`KUI-L090` is reserved for catalog/profile loading and configuration failures. The supported-ESLint matrix installs real packed `eslint-plugin-kerfjs` and `@kerfjs/ui` tarballs in a downstream flat-config fixture, ensuring these defaults resolve from the consumer rather than this repository's source tree.
|
|
81
86
|
|
|
82
87
|
## Legacy `.eslintrc` configs are not supported
|
|
83
88
|
|
|
@@ -99,6 +104,7 @@ Use flat config (`eslint.config.js`), shown above. The `legacy-recommended` expo
|
|
|
99
104
|
| [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
|
|
100
105
|
| [`ui-public-boundaries`](docs/rules/ui-public-boundaries.md) | — (cataloged CSS boundaries) | `error` |
|
|
101
106
|
| [`ui-composition`](docs/rules/ui-composition.md) | — (cataloged parents and zones) | `error` |
|
|
107
|
+
| [`ui-css-values`](docs/rules/ui-css-values.md) | — (cataloged property-specific value grammar) | `error` |
|
|
102
108
|
| [`ui-preferences`](docs/rules/ui-preferences.md) | — (application component choices) | `warn` |
|
|
103
109
|
| [`ui-wiring`](docs/rules/ui-wiring.md) | — (required setup and cleanup) | `warn` |
|
|
104
110
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# `ui-composition`
|
|
2
2
|
|
|
3
|
-
Enforces composition facts published by the versioned UI catalog. `KUI-L201` reports a catalog component under a known, disallowed direct parent. `KUI-L202` reports a statically visible zone child—including an intrinsic or unknown JSX element—that is not one of the zone's cataloged `accepts` entries. `KUI-L203` reports a statically provable zone cardinality violation.
|
|
3
|
+
Enforces composition facts published by the versioned UI catalog. `KUI-L201` reports a catalog component under a known, disallowed direct parent. `KUI-L202` reports a statically visible zone child—including an intrinsic or unknown JSX element—that is not one of the zone's cataloged `accepts` entries. `KUI-L203` reports a statically provable zone cardinality violation. A zone participates only when it declares an explicit `jsx.prop` binding, including `children`; the rule never guesses that a zone id is a JSX prop. Toolbar, Pane, TabBar, AppTab, feedback, search, and layout validation is therefore catalog-driven rather than duplicated in rule-local component cases.
|
|
4
4
|
|
|
5
|
-
The parent check is intentionally conservative: it reports only when both child and direct parent resolve to cataloged imports.
|
|
5
|
+
The parent check is intentionally conservative: it reports only when both child and direct parent resolve to cataloged imports. Bound zone checks understand aliases, namespace imports, fragments, arrays, and conditional/logical branches, but expressions whose content cannot be resolved statically remain runtime and accessibility work. Accepted concepts that do not identify a catalog entry remain machine-readable guidance while the rule enforces their statically provable cardinality only. No autofix is offered because wrapping or moving UI changes structure and behavior.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# `ui-css-values`
|
|
2
|
+
|
|
3
|
+
Checks literal JSX and direct component-function calls against each component's
|
|
4
|
+
cataloged `cssValueProps` contracts. The catalog keeps length, size, flex,
|
|
5
|
+
color, declaration-list, media-query, and numeric-pixel grammars distinct and
|
|
6
|
+
names their accepted helpers, finite shorthands, and raw-value policy.
|
|
7
|
+
|
|
8
|
+
Stable diagnostics are `KUI-L013` (raw or unknown shorthand), `KUI-L014`
|
|
9
|
+
(wrong property helper), `KUI-L015` (non-standalone expression), `KUI-L016`
|
|
10
|
+
(forbidden declaration list), and review-oriented `KUI-L017` (exceptional
|
|
11
|
+
spacing shorthand). Messages name the preferred shorthand or helper. Dynamic
|
|
12
|
+
values are left to TypeScript rather than guessed.
|
|
13
|
+
|
|
14
|
+
Third-party component suites can provide the same contracts in a composition
|
|
15
|
+
catalog through `settings.kerfjs.ui.catalog` and its matching selection catalog.
|
package/index.js
CHANGED
|
@@ -7,12 +7,13 @@ import preferModuleJsxAugmentation from './lib/rules/prefer-module-jsx-augmentat
|
|
|
7
7
|
import requireDataKeyInEach from './lib/rules/require-data-key-in-each.js';
|
|
8
8
|
import requireDelegateDisposer from './lib/rules/require-delegate-disposer.js';
|
|
9
9
|
import uiComposition from './lib/rules/ui-composition.js';
|
|
10
|
+
import uiCssValues from './lib/rules/ui-css-values.js';
|
|
10
11
|
import uiPreferences from './lib/rules/ui-preferences.js';
|
|
11
12
|
import uiPublicBoundaries from './lib/rules/ui-public-boundaries.js';
|
|
12
13
|
import uiWiring from './lib/rules/ui-wiring.js';
|
|
13
14
|
|
|
14
15
|
const plugin = {
|
|
15
|
-
meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.
|
|
16
|
+
meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.30' },
|
|
16
17
|
rules: {
|
|
17
18
|
'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
|
|
18
19
|
'no-raw-with-dynamic-arg': noRawWithDynamicArg,
|
|
@@ -23,6 +24,7 @@ const plugin = {
|
|
|
23
24
|
'prefer-attr-selector': preferAttrSelector,
|
|
24
25
|
'ai-assistant-configs': aiAssistantConfigs,
|
|
25
26
|
'ui-composition': uiComposition,
|
|
27
|
+
'ui-css-values': uiCssValues,
|
|
26
28
|
'ui-preferences': uiPreferences,
|
|
27
29
|
'ui-public-boundaries': uiPublicBoundaries,
|
|
28
30
|
'ui-wiring': uiWiring,
|
|
@@ -62,6 +64,7 @@ const recommendedUiRules = {
|
|
|
62
64
|
...recommendedRules,
|
|
63
65
|
'kerfjs/ui-public-boundaries': 'error',
|
|
64
66
|
'kerfjs/ui-composition': 'error',
|
|
67
|
+
'kerfjs/ui-css-values': 'error',
|
|
65
68
|
'kerfjs/ui-preferences': 'warn',
|
|
66
69
|
'kerfjs/ui-wiring': 'warn',
|
|
67
70
|
};
|
|
@@ -94,6 +94,28 @@ function directParentKey(node, registry, contract) {
|
|
|
94
94
|
: undefined;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
function boundZone(openingElement, zone, registry, contract) {
|
|
98
|
+
const prop = zone.jsx?.prop;
|
|
99
|
+
if (!prop) return undefined;
|
|
100
|
+
if (prop === 'children')
|
|
101
|
+
return {
|
|
102
|
+
node: openingElement.name,
|
|
103
|
+
shape: combine(
|
|
104
|
+
(openingElement.parent?.children ?? []).map((child) =>
|
|
105
|
+
zoneShape(child, registry, contract),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
};
|
|
109
|
+
const attribute = openingElement.attributes.find(
|
|
110
|
+
(candidate) =>
|
|
111
|
+
candidate.type === 'JSXAttribute' && candidate.name.name === prop,
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
node: attribute ?? openingElement.name,
|
|
115
|
+
shape: zoneShape(attribute?.value, registry, contract),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
97
119
|
export default {
|
|
98
120
|
meta: {
|
|
99
121
|
type: 'problem',
|
|
@@ -143,17 +165,10 @@ export default {
|
|
|
143
165
|
data: { child: key, parents: entry.parents.entries.join(', ') },
|
|
144
166
|
});
|
|
145
167
|
}
|
|
146
|
-
// Catalog zone ids are not universally JSX prop names. Toolbar's public
|
|
147
|
-
// API deliberately exposes its three zones as same-named props, so only
|
|
148
|
-
// that component has a sound static prop-to-zone mapping here.
|
|
149
|
-
if (entry.id !== 'toolbar') return;
|
|
150
168
|
for (const zone of entry.zones ?? []) {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
candidate.name.name === zone.id,
|
|
155
|
-
);
|
|
156
|
-
const shape = zoneShape(attribute?.value, registry, contract);
|
|
169
|
+
const bound = boundZone(node, zone, registry, contract);
|
|
170
|
+
if (!bound) continue;
|
|
171
|
+
const { node: reportNode, shape } = bound;
|
|
157
172
|
const allowed = new Set(
|
|
158
173
|
zone.accepts
|
|
159
174
|
.map((accepted) =>
|
|
@@ -167,7 +182,7 @@ export default {
|
|
|
167
182
|
for (const child of shape.items)
|
|
168
183
|
if (!child.key || !allowed.has(child.key))
|
|
169
184
|
context.report({
|
|
170
|
-
node:
|
|
185
|
+
node: reportNode,
|
|
171
186
|
messageId: 'zone',
|
|
172
187
|
data: {
|
|
173
188
|
component: key,
|
|
@@ -182,7 +197,7 @@ export default {
|
|
|
182
197
|
const tooMany = max !== 'unbounded' && shape.min > max;
|
|
183
198
|
if (tooFew || tooMany)
|
|
184
199
|
context.report({
|
|
185
|
-
node:
|
|
200
|
+
node: reportNode,
|
|
186
201
|
messageId: 'cardinality',
|
|
187
202
|
data: {
|
|
188
203
|
component: key,
|
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
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);
|
|
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
|
+
};
|