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 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 four UI contracts are errors
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
- `KUI-L090` is reserved for catalog/profile loading and configuration failures; it does not collide with the static analyzer's `KUI-L001`–`KUI-L009` findings. 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.
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. Toolbar's `leading`, `center`, and `trailing` validation is therefore derived from its catalog entry rather than a duplicated rule-local allowlist.
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. The Toolbar zone check understands fragments, arrays, and conditional/logical branches, but expressions whose content cannot be resolved statically remain runtime and accessibility work. Other catalog zone ids are not assumed to be JSX prop names. No autofix is offered because wrapping or moving UI changes structure and behavior.
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.29' },
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 attribute = node.attributes.find(
152
- (candidate) =>
153
- candidate.type === 'JSXAttribute' &&
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: attribute ?? node.name,
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: attribute ?? node.name,
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "5.0.0-beta.29",
3
+ "version": "5.0.0-beta.30",
4
4
  "description": "ESLint rules that enforce kerf's hard rules — catches AI-shaped bugs at edit time.",
5
5
  "type": "module",
6
6
  "license": "MIT",