eslint-plugin-kerfjs 4.4.0 → 4.5.0-beta.1

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.
@@ -22,13 +22,13 @@ const meta = {
22
22
  type: 'problem',
23
23
  docs: {
24
24
  description:
25
- "Disallow `raw()` with dynamic arguments; `raw()` bypasses HTML escaping and passing dynamic values is an XSS vector.",
25
+ 'Disallow `raw()` with dynamic arguments; `raw()` bypasses HTML escaping and passing dynamic values is an XSS vector.',
26
26
  url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/no-raw-with-dynamic-arg.md',
27
27
  },
28
28
  schema: [],
29
29
  messages: {
30
30
  dynamic:
31
- "`raw()` bypasses HTML auto-escaping. Passing a dynamic value is an XSS risk unless the input has been sanitized. Sanitize first (`DOMPurify.sanitize(...)`) then pass to `raw()`, or add `// eslint-disable-next-line kerfjs/no-raw-with-dynamic-arg` to mark a call as intentionally safe.",
31
+ '`raw()` bypasses HTML auto-escaping. Passing a dynamic value is an XSS risk unless the input has been sanitized. Sanitize first (`DOMPurify.sanitize(...)`) then pass to `raw()`, or add `// eslint-disable-next-line kerfjs/no-raw-with-dynamic-arg` to mark a call as intentionally safe.',
32
32
  },
33
33
  };
34
34
 
@@ -47,10 +47,11 @@ function create(context) {
47
47
  const callee = node.callee;
48
48
  if (!callee) return;
49
49
  // Match bare `raw(...)` and `kerfjs.raw(...)` / `kerf.raw(...)` shapes.
50
- const isRaw = (callee.type === 'Identifier' && callee.name === 'raw')
51
- || (callee.type === 'MemberExpression'
52
- && callee.property.type === 'Identifier'
53
- && callee.property.name === 'raw');
50
+ const isRaw =
51
+ (callee.type === 'Identifier' && callee.name === 'raw') ||
52
+ (callee.type === 'MemberExpression' &&
53
+ callee.property.type === 'Identifier' &&
54
+ callee.property.name === 'raw');
54
55
  if (!isRaw) return;
55
56
  if (node.arguments.length === 0) return;
56
57
  const arg = node.arguments[0];
@@ -10,7 +10,8 @@ function findGlobalJSXIntrinsics(moduleBlock) {
10
10
  for (const m of moduleBlock.body) {
11
11
  if (m.type !== 'TSModuleDeclaration') continue;
12
12
  const idNode = m.id;
13
- if (!idNode || idNode.type !== 'Identifier' || idNode.name !== 'JSX') continue;
13
+ if (!idNode || idNode.type !== 'Identifier' || idNode.name !== 'JSX')
14
+ continue;
14
15
  const inner = m.body;
15
16
  if (!inner || inner.type !== 'TSModuleBlock') continue;
16
17
  for (const member of inner.body) {
@@ -31,7 +32,7 @@ const meta = {
31
32
  type: 'problem',
32
33
  docs: {
33
34
  description:
34
- "Declaration-merge `JSX.IntrinsicElements` into `kerfjs/jsx-runtime`, not the global namespace.",
35
+ 'Declaration-merge `JSX.IntrinsicElements` into `kerfjs/jsx-runtime`, not the global namespace.',
35
36
  url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/prefer-module-jsx-augmentation.md',
36
37
  },
37
38
  schema: [],
@@ -38,10 +38,15 @@ const meta = {
38
38
  function create(context) {
39
39
  return {
40
40
  CallExpression(node) {
41
- if (node.callee.type !== 'Identifier' || node.callee.name !== 'each') return;
41
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'each')
42
+ return;
42
43
  const cb = node.arguments[1];
43
44
  if (!cb) return;
44
- if (cb.type !== 'ArrowFunctionExpression' && cb.type !== 'FunctionExpression') return;
45
+ if (
46
+ cb.type !== 'ArrowFunctionExpression' &&
47
+ cb.type !== 'FunctionExpression'
48
+ )
49
+ return;
45
50
  const root = findRootJSXFromBody(cb.body);
46
51
  if (!root) return;
47
52
  if (root.type === 'JSXFragment') {
@@ -52,7 +52,8 @@ function create(context) {
52
52
  // the disposer is being routed somewhere reachable — accept.
53
53
  const parent = node.parent;
54
54
  if (!parent) return;
55
- if (parent.type === 'UnaryExpression' && parent.operator === 'void') return;
55
+ if (parent.type === 'UnaryExpression' && parent.operator === 'void')
56
+ return;
56
57
  if (parent.type !== 'ExpressionStatement') return;
57
58
 
58
59
  context.report({
@@ -0,0 +1,198 @@
1
+ import {
2
+ importRegistry,
3
+ isExcepted,
4
+ jsxKey,
5
+ loadUiContract,
6
+ UI_CONTRACT_LOAD_CODE,
7
+ UI_RULE_SCHEMA,
8
+ } from '../ui-contract.js';
9
+
10
+ const PARENT_CODE = 'KUI-L201';
11
+ const ZONE_CODE = 'KUI-L202';
12
+ const CARDINALITY_CODE = 'KUI-L203';
13
+
14
+ const emptyShape = () => ({ min: 0, max: 0, items: [] });
15
+
16
+ function combine(shapes) {
17
+ return shapes.reduce(
18
+ (result, shape) => ({
19
+ min: result.min + shape.min,
20
+ max: result.max + shape.max,
21
+ items: [...result.items, ...shape.items],
22
+ }),
23
+ emptyShape(),
24
+ );
25
+ }
26
+
27
+ function jsxLabel(name) {
28
+ if (name.type === 'JSXIdentifier') return `<${name.name}>`;
29
+ if (name.type === 'JSXMemberExpression')
30
+ return `${jsxLabel(name.object).slice(0, -1)}.${name.property.name}>`;
31
+ return '<unknown>';
32
+ }
33
+
34
+ function zoneShape(node, registry, contract) {
35
+ if (!node || node.type === 'JSXEmptyExpression') return emptyShape();
36
+ if (node.type === 'JSXElement') {
37
+ const key = jsxKey(node.openingElement.name, registry, contract);
38
+ return {
39
+ min: 1,
40
+ max: 1,
41
+ items: [{ key, label: key ?? jsxLabel(node.openingElement.name) }],
42
+ };
43
+ }
44
+ if (node.type === 'JSXFragment')
45
+ return combine(
46
+ node.children.map((child) => zoneShape(child, registry, contract)),
47
+ );
48
+ if (node.type === 'JSXText')
49
+ return node.value.trim()
50
+ ? { min: 1, max: 1, items: [{ label: 'text' }] }
51
+ : emptyShape();
52
+ if (node.type === 'JSXExpressionContainer')
53
+ return zoneShape(node.expression, registry, contract);
54
+ if (node.type === 'ArrayExpression')
55
+ return combine(
56
+ node.elements.map((child) => zoneShape(child, registry, contract)),
57
+ );
58
+ if (node.type === 'ConditionalExpression') {
59
+ const consequent = zoneShape(node.consequent, registry, contract);
60
+ const alternate = zoneShape(node.alternate, registry, contract);
61
+ return {
62
+ min: Math.min(consequent.min, alternate.min),
63
+ max: Math.max(consequent.max, alternate.max),
64
+ items: [...consequent.items, ...alternate.items],
65
+ };
66
+ }
67
+ if (node.type === 'LogicalExpression') {
68
+ const left = zoneShape(node.left, registry, contract);
69
+ const right = zoneShape(node.right, registry, contract);
70
+ return node.operator === '&&'
71
+ ? { min: 0, max: right.max, items: right.items }
72
+ : {
73
+ min: Math.min(left.min, right.min),
74
+ max: Math.max(left.max, right.max),
75
+ items: [...left.items, ...right.items],
76
+ };
77
+ }
78
+ if (node.type === 'Literal')
79
+ return node.value === null || typeof node.value === 'boolean'
80
+ ? emptyShape()
81
+ : { min: 1, max: 1, items: [{ label: 'text' }] };
82
+ return { min: 0, max: Number.POSITIVE_INFINITY, items: [] };
83
+ }
84
+
85
+ function directParentKey(node, registry, contract) {
86
+ let parent = node.parent?.parent;
87
+ while (
88
+ parent &&
89
+ ['JSXExpressionContainer', 'JSXFragment'].includes(parent.type)
90
+ )
91
+ parent = parent.parent;
92
+ return parent?.type === 'JSXElement'
93
+ ? { key: jsxKey(parent.openingElement.name, registry, contract) }
94
+ : undefined;
95
+ }
96
+
97
+ export default {
98
+ meta: {
99
+ type: 'problem',
100
+ docs: {
101
+ description:
102
+ 'Enforce low-false-positive catalog parent and zone contracts.',
103
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-composition.md',
104
+ },
105
+ schema: UI_RULE_SCHEMA,
106
+ messages: {
107
+ config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
108
+ parent:
109
+ 'KUI-L201: `{{child}}` requires one of these cataloged parents: {{parents}}.',
110
+ zone: 'KUI-L202: `{{component}}` zone `{{zone}}` does not accept {{child}}; catalog accepts: {{accepts}}.',
111
+ cardinality:
112
+ 'KUI-L203: `{{component}}` zone `{{zone}}` requires {{cardinality}} direct children; statically found {{count}}.',
113
+ },
114
+ },
115
+ create(context) {
116
+ const contract = loadUiContract(context);
117
+ const filename = context.filename ?? context.getFilename();
118
+ let registry;
119
+ return {
120
+ Program(node) {
121
+ if (contract.error)
122
+ context.report({
123
+ node,
124
+ messageId: 'config',
125
+ data: { error: contract.error },
126
+ });
127
+ else registry = importRegistry(node, contract);
128
+ },
129
+ JSXOpeningElement(node) {
130
+ if (!registry) return;
131
+ const key = jsxKey(node.name, registry, contract);
132
+ const entry = contract.entries.get(key);
133
+ if (!entry) return;
134
+ if (
135
+ entry.parents.mode === 'listed' &&
136
+ !isExcepted(contract, PARENT_CODE, filename)
137
+ ) {
138
+ const parent = directParentKey(node, registry, contract);
139
+ if (parent && !entry.parents.entries.includes(parent.key))
140
+ context.report({
141
+ node: node.name,
142
+ messageId: 'parent',
143
+ data: { child: key, parents: entry.parents.entries.join(', ') },
144
+ });
145
+ }
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
+ 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);
157
+ const allowed = new Set(
158
+ zone.accepts
159
+ .map((accepted) =>
160
+ accepted.includes(':')
161
+ ? accepted
162
+ : `${entry.package}:${accepted}`,
163
+ )
164
+ .filter((accepted) => contract.entries.has(accepted)),
165
+ );
166
+ if (allowed.size && !isExcepted(contract, ZONE_CODE, filename))
167
+ for (const child of shape.items)
168
+ if (!child.key || !allowed.has(child.key))
169
+ context.report({
170
+ node: attribute ?? node.name,
171
+ messageId: 'zone',
172
+ data: {
173
+ component: key,
174
+ zone: zone.id,
175
+ child: child.label,
176
+ accepts: [...allowed].join(', '),
177
+ },
178
+ });
179
+ if (isExcepted(contract, CARDINALITY_CODE, filename)) continue;
180
+ const { min, max } = zone.cardinality;
181
+ const tooFew = shape.max < min;
182
+ const tooMany = max !== 'unbounded' && shape.min > max;
183
+ if (tooFew || tooMany)
184
+ context.report({
185
+ node: attribute ?? node.name,
186
+ messageId: 'cardinality',
187
+ data: {
188
+ component: key,
189
+ zone: zone.id,
190
+ cardinality: `${min}..${max}`,
191
+ count: tooFew ? shape.max : shape.min,
192
+ },
193
+ });
194
+ }
195
+ },
196
+ };
197
+ },
198
+ };
@@ -0,0 +1,63 @@
1
+ import {
2
+ importRegistry,
3
+ isExcepted,
4
+ jsxKey,
5
+ loadUiContract,
6
+ UI_CONTRACT_LOAD_CODE,
7
+ UI_RULE_SCHEMA,
8
+ } from '../ui-contract.js';
9
+
10
+ const CODE = 'KUI-L301';
11
+
12
+ export default {
13
+ meta: {
14
+ type: 'suggestion',
15
+ docs: {
16
+ description: 'Apply application-profile preferred UI component choices.',
17
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-preferences.md',
18
+ },
19
+ schema: UI_RULE_SCHEMA,
20
+ messages: {
21
+ config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
22
+ preferred:
23
+ 'KUI-L301: `{{actual}}` is discouraged for `{{concept}}`; this profile prefers `{{preferred}}`. {{rationale}}',
24
+ },
25
+ },
26
+ create(context) {
27
+ const contract = loadUiContract(context);
28
+ const filename = context.filename ?? context.getFilename();
29
+ let registry;
30
+ return {
31
+ Program(node) {
32
+ if (contract.error)
33
+ context.report({
34
+ node,
35
+ messageId: 'config',
36
+ data: { error: contract.error },
37
+ });
38
+ else registry = importRegistry(node, contract);
39
+ },
40
+ JSXOpeningElement(node) {
41
+ if (!registry || isExcepted(contract, CODE, filename)) return;
42
+ const actual = jsxKey(node.name, registry, contract);
43
+ if (!actual) return;
44
+ for (const [concept, preference] of Object.entries(
45
+ contract.profile.preferences ?? {},
46
+ ))
47
+ if ((preference.avoid ?? []).includes(actual))
48
+ context.report({
49
+ node: node.name,
50
+ messageId: 'preferred',
51
+ data: {
52
+ actual,
53
+ concept,
54
+ preferred: preference.preferred,
55
+ rationale:
56
+ preference.rationale ??
57
+ 'Use the project preference unless an exact profile exception applies.',
58
+ },
59
+ });
60
+ },
61
+ };
62
+ },
63
+ };
@@ -0,0 +1,79 @@
1
+ import {
2
+ isExcepted,
3
+ loadUiContract,
4
+ UI_CONTRACT_LOAD_CODE,
5
+ UI_RULE_SCHEMA,
6
+ } from '../ui-contract.js';
7
+
8
+ const CLASS_CODE = 'KUI-L101';
9
+ const TOKEN_CODE = 'KUI-L102';
10
+
11
+ export default {
12
+ meta: {
13
+ type: 'problem',
14
+ docs: {
15
+ description:
16
+ 'Restrict Kerf UI classes and tokens to cataloged public boundaries.',
17
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-public-boundaries.md',
18
+ },
19
+ schema: UI_RULE_SCHEMA,
20
+ messages: {
21
+ config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
22
+ class:
23
+ 'KUI-L101: `{{name}}` is private or uncataloged Kerf UI anatomy. Use a cataloged public class, prop, token, or component instead.',
24
+ token:
25
+ 'KUI-L102: `{{name}}` is not a cataloged public Kerf UI token. Use a public semantic/component token.',
26
+ },
27
+ },
28
+ create(context) {
29
+ const contract = loadUiContract(context);
30
+ const filename = context.filename ?? context.getFilename();
31
+ let configured = false;
32
+ const inspect = (node, value, classContext = false) => {
33
+ if (typeof value !== 'string') return;
34
+ if (classContext && !isExcepted(contract, CLASS_CODE, filename))
35
+ for (const name of value
36
+ .split(/\s+/)
37
+ .filter((item) => item.startsWith('kui-')))
38
+ if (!contract.publicClasses.has(name))
39
+ context.report({ node, messageId: 'class', data: { name } });
40
+ if (!isExcepted(contract, TOKEN_CODE, filename))
41
+ for (const match of value.matchAll(/--kui-[a-z0-9-]+/g))
42
+ if (!contract.publicTokens.has(match[0]))
43
+ context.report({
44
+ node,
45
+ messageId: 'token',
46
+ data: { name: match[0] },
47
+ });
48
+ };
49
+ return {
50
+ Program(node) {
51
+ if (contract.error && !configured) {
52
+ configured = true;
53
+ context.report({
54
+ node,
55
+ messageId: 'config',
56
+ data: { error: contract.error },
57
+ });
58
+ }
59
+ },
60
+ JSXAttribute(node) {
61
+ if (contract.error || !['class', 'className'].includes(node.name?.name))
62
+ return;
63
+ if (node.value?.type === 'Literal')
64
+ inspect(node, node.value.value, true);
65
+ if (
66
+ node.value?.type === 'JSXExpressionContainer' &&
67
+ node.value.expression.type === 'Literal'
68
+ )
69
+ inspect(node, node.value.expression.value, true);
70
+ },
71
+ Literal(node) {
72
+ if (!contract.error) inspect(node, node.value, false);
73
+ },
74
+ TemplateElement(node) {
75
+ if (!contract.error) inspect(node, node.value.raw, false);
76
+ },
77
+ };
78
+ },
79
+ };
@@ -0,0 +1,102 @@
1
+ import {
2
+ helperCall,
3
+ importRegistry,
4
+ isExcepted,
5
+ jsxKey,
6
+ loadUiContract,
7
+ UI_RULE_SCHEMA,
8
+ UI_CONTRACT_LOAD_CODE,
9
+ } from '../ui-contract.js';
10
+
11
+ const MISSING_CODE = 'KUI-L401';
12
+ const CLEANUP_CODE = 'KUI-L402';
13
+
14
+ const retained = (call) => call.parent?.type !== 'ExpressionStatement';
15
+
16
+ export default {
17
+ meta: {
18
+ type: 'problem',
19
+ docs: {
20
+ description: 'Require cataloged Kerf UI wiring and retained cleanup.',
21
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-wiring.md',
22
+ },
23
+ schema: UI_RULE_SCHEMA,
24
+ messages: {
25
+ config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
26
+ missing:
27
+ 'KUI-L401: `{{component}}` requires `{{helper}}`; import and invoke the documented wiring contract.',
28
+ cleanup:
29
+ 'KUI-L402: Retain or return the disposer from `{{helper}}`; use `void` only for an intentional page-lifetime binding.',
30
+ },
31
+ },
32
+ create(context) {
33
+ const contract = loadUiContract(context);
34
+ const filename = context.filename ?? context.getFilename();
35
+ let registry;
36
+ const used = new Map();
37
+ const calls = new Map();
38
+ return {
39
+ Program(node) {
40
+ if (contract.error)
41
+ context.report({
42
+ node,
43
+ messageId: 'config',
44
+ data: { error: contract.error },
45
+ });
46
+ else registry = importRegistry(node, contract);
47
+ },
48
+ JSXOpeningElement(node) {
49
+ if (!registry) return;
50
+ const key = jsxKey(node.name, registry, contract);
51
+ if (key && !used.has(key)) used.set(key, node.name);
52
+ },
53
+ CallExpression(node) {
54
+ if (!registry) return;
55
+ const imported = helperCall(node.callee, registry, contract);
56
+ if (!imported) return;
57
+ const list = calls.get(imported.imported) ?? [];
58
+ list.push({ node, source: imported.source });
59
+ calls.set(imported.imported, list);
60
+ },
61
+ 'Program:exit'(node) {
62
+ if (!registry) return;
63
+ for (const [key, usage] of used) {
64
+ const entry = contract.entries.get(key);
65
+ if (!entry?.wiring.required) continue;
66
+ for (const helper of entry.wiring.helpers) {
67
+ if (helper.startsWith('@')) {
68
+ if (
69
+ !registry.sources.has(helper) &&
70
+ !isExcepted(contract, MISSING_CODE, filename)
71
+ )
72
+ context.report({
73
+ node: usage,
74
+ messageId: 'missing',
75
+ data: { component: key, helper },
76
+ });
77
+ continue;
78
+ }
79
+ const helperCalls = calls.get(helper) ?? [];
80
+ if (
81
+ !helperCalls.length &&
82
+ !isExcepted(contract, MISSING_CODE, filename)
83
+ )
84
+ context.report({
85
+ node: usage,
86
+ messageId: 'missing',
87
+ data: { component: key, helper },
88
+ });
89
+ if (!isExcepted(contract, CLEANUP_CODE, filename))
90
+ for (const { node: call } of helperCalls)
91
+ if (!retained(call))
92
+ context.report({
93
+ node: call,
94
+ messageId: 'cleanup',
95
+ data: { helper },
96
+ });
97
+ }
98
+ }
99
+ },
100
+ };
101
+ },
102
+ };