@starci/eslint-canon-fe 1.3.2 → 1.4.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.
package/lint-adoption.mjs CHANGED
@@ -1,116 +1,116 @@
1
- /**
2
- * Repository-level audit for effective StarCi FE lint adoption.
3
- *
4
- * An ESLint config can import a plugin, copy a plugin, or call itself StarCi while still leaving
5
- * canonical rules switched off. The only useful evidence is the effective config ESLint applies
6
- * to a real production file. This audit compares that printed config with this canon.
7
- */
8
- /**
9
- * Where the component tree sits, per supported repository shape.
10
- *
11
- * ONE BRANCH, IN ONE PLACE. The difference between a monorepo and a single app is not a difference
12
- * of law - the rules, their severities and the inline-config refusal are identical. It is only
13
- * WHERE the law applies. Written out in each repository's config, that fact became two hand-kept
14
- * lists, and the lists did what hand-kept lists do: one enabled 43 rules, the other 53, and canon
15
- * had 49. Neither repository was wrong on purpose; nothing told either of them.
16
- *
17
- * The candidate path is included deliberately. A Preview candidate is the exact source Apply later
18
- * ports into production, and leaving it ungoverned means the one file that becomes production was
19
- * judged by nothing.
20
- */
21
- const LAYOUT_GLOBS = {
22
- monorepo: [
23
- "packages/ui/src/**/*.{ts,tsx}",
24
- "apps/*/src/**/*.{ts,tsx}",
25
- "**/candidate/src/**/*.{ts,tsx}",
26
- ],
27
- "single-app": [
28
- "src/**/*.{ts,tsx}",
29
- "**/candidate/src/**/*.{ts,tsx}",
30
- ],
31
- }
32
-
33
- /** The repository shapes this canon knows how to govern. */
34
- export const LAYOUTS = Object.keys(LAYOUT_GLOBS)
35
-
36
- /**
37
- * The one flat-config block that attaches this canon to a repository.
38
- *
39
- * A consuming `eslint.config.mjs` spreads the result and owns nothing else about the rules: not
40
- * which are on, not their severity, not whether an inline comment may switch one off. Its own
41
- * plugins, its own ignores and its own language options stay its own business - what it may not
42
- * keep is a second opinion about the law.
43
- *
44
- * @param {object} input - Attachment options.
45
- * @param {"monorepo" | "single-app"} input.layout - Which shape this repository has.
46
- * @param {object} input.plugin - The plugin object, imported from this canon.
47
- * @param {object} input.recommended - The recommendation, imported from this canon.
48
- * @param {object} input.linterOptions - The linter options, imported from this canon.
49
- * @returns {object} One flat-config block.
50
- */
51
- export const starciFeConfig = ({ layout, plugin, recommended, linterOptions }) => {
52
- const files = LAYOUT_GLOBS[layout]
53
- if (!files) {
54
- throw new Error(`unknown layout "${layout}" - expected one of: ${LAYOUTS.join(", ")}`)
55
- }
56
- /*
57
- * `recommended` is a FLAT rule map here, not a config object with a `rules` key - and reaching
58
- * for `.rules` on it is the mistake this line exists to stop repeating. Spread that way it
59
- * yields `undefined`, the block ends up with zero rules, and the repository lints clean while
60
- * being governed by nothing. That is the worst possible failure for this file: green, silent,
61
- * and indistinguishable from success. Both spellings are accepted so neither shape can produce
62
- * an empty block.
63
- */
64
- const rules = recommended?.rules ?? recommended
65
- if (!rules || Object.keys(rules).length === 0) {
66
- throw new Error("starciFeConfig received an empty recommendation - a config with no rules is not adoption")
67
- }
68
- return {
69
- files,
70
- linterOptions,
71
- plugins: { "starci-fe": plugin },
72
- rules: { ...rules },
73
- }
74
- }
75
-
76
- /** Convert every flat-config severity spelling to its numeric meaning. */
77
- const severityOf = (setting) => {
78
- const severity = Array.isArray(setting) ? setting[0] : setting
79
- if (severity === "error") return 2
80
- if (severity === "warn") return 1
81
- if (severity === "off") return 0
82
- return severity
83
- }
84
-
85
- /**
86
- * Compare an `eslint --print-config` result with the canonical recommendation.
87
- *
88
- * @param {object} printedConfig - Parsed effective ESLint config for one production probe file.
89
- * @param {Record<string, string | number | unknown[]>} expectedRules - Canonical rule levels.
90
- */
91
- export const auditLintAdoption = (printedConfig, expectedRules) => {
92
- const actualRules = printedConfig?.rules ?? {}
93
- const missing = []
94
- const nonError = []
95
-
96
- for (const name of Object.keys(expectedRules).sort()) {
97
- if (actualRules[name] === undefined) {
98
- missing.push(name)
99
- continue
100
- }
101
- if (severityOf(actualRules[name]) !== 2) nonError.push(name)
102
- }
103
-
104
- const refusesInlineConfig = printedConfig?.linterOptions?.noInlineConfig === true
105
- return {
106
- ok: missing.length === 0 && nonError.length === 0 && refusesInlineConfig,
107
- missing,
108
- nonError,
109
- refusesInlineConfig,
110
- }
111
- }
112
-
113
- /** Repository audits are gathered beside rules, but are not ESLint AST rules themselves. */
114
- export const audits = { "effective-config": auditLintAdoption }
115
- export const rules = {}
116
- export const recommended = {}
1
+ /**
2
+ * Repository-level audit for effective StarCi FE lint adoption.
3
+ *
4
+ * An ESLint config can import a plugin, copy a plugin, or call itself StarCi while still leaving
5
+ * canonical rules switched off. The only useful evidence is the effective config ESLint applies
6
+ * to a real production file. This audit compares that printed config with this canon.
7
+ */
8
+ /**
9
+ * Where the component tree sits, per supported repository shape.
10
+ *
11
+ * ONE BRANCH, IN ONE PLACE. The difference between a monorepo and a single app is not a difference
12
+ * of law - the rules, their severities and the inline-config refusal are identical. It is only
13
+ * WHERE the law applies. Written out in each repository's config, that fact became two hand-kept
14
+ * lists, and the lists did what hand-kept lists do: one enabled 43 rules, the other 53, and canon
15
+ * had 49. Neither repository was wrong on purpose; nothing told either of them.
16
+ *
17
+ * The candidate path is included deliberately. A Preview candidate is the exact source Apply later
18
+ * ports into production, and leaving it ungoverned means the one file that becomes production was
19
+ * judged by nothing.
20
+ */
21
+ const LAYOUT_GLOBS = {
22
+ monorepo: [
23
+ "packages/ui/src/**/*.{ts,tsx}",
24
+ "apps/*/src/**/*.{ts,tsx}",
25
+ "**/candidate/src/**/*.{ts,tsx}",
26
+ ],
27
+ "single-app": [
28
+ "src/**/*.{ts,tsx}",
29
+ "**/candidate/src/**/*.{ts,tsx}",
30
+ ],
31
+ }
32
+
33
+ /** The repository shapes this canon knows how to govern. */
34
+ export const LAYOUTS = Object.keys(LAYOUT_GLOBS)
35
+
36
+ /**
37
+ * The one flat-config block that attaches this canon to a repository.
38
+ *
39
+ * A consuming `eslint.config.mjs` spreads the result and owns nothing else about the rules: not
40
+ * which are on, not their severity, not whether an inline comment may switch one off. Its own
41
+ * plugins, its own ignores and its own language options stay its own business - what it may not
42
+ * keep is a second opinion about the law.
43
+ *
44
+ * @param {object} input - Attachment options.
45
+ * @param {"monorepo" | "single-app"} input.layout - Which shape this repository has.
46
+ * @param {object} input.plugin - The plugin object, imported from this canon.
47
+ * @param {object} input.recommended - The recommendation, imported from this canon.
48
+ * @param {object} input.linterOptions - The linter options, imported from this canon.
49
+ * @returns {object} One flat-config block.
50
+ */
51
+ export const starciFeConfig = ({ layout, plugin, recommended, linterOptions }) => {
52
+ const files = LAYOUT_GLOBS[layout]
53
+ if (!files) {
54
+ throw new Error(`unknown layout "${layout}" - expected one of: ${LAYOUTS.join(", ")}`)
55
+ }
56
+ /*
57
+ * `recommended` is a FLAT rule map here, not a config object with a `rules` key - and reaching
58
+ * for `.rules` on it is the mistake this line exists to stop repeating. Spread that way it
59
+ * yields `undefined`, the block ends up with zero rules, and the repository lints clean while
60
+ * being governed by nothing. That is the worst possible failure for this file: green, silent,
61
+ * and indistinguishable from success. Both spellings are accepted so neither shape can produce
62
+ * an empty block.
63
+ */
64
+ const rules = recommended?.rules ?? recommended
65
+ if (!rules || Object.keys(rules).length === 0) {
66
+ throw new Error("starciFeConfig received an empty recommendation - a config with no rules is not adoption")
67
+ }
68
+ return {
69
+ files,
70
+ linterOptions,
71
+ plugins: { "starci-fe": plugin },
72
+ rules: { ...rules },
73
+ }
74
+ }
75
+
76
+ /** Convert every flat-config severity spelling to its numeric meaning. */
77
+ const severityOf = (setting) => {
78
+ const severity = Array.isArray(setting) ? setting[0] : setting
79
+ if (severity === "error") return 2
80
+ if (severity === "warn") return 1
81
+ if (severity === "off") return 0
82
+ return severity
83
+ }
84
+
85
+ /**
86
+ * Compare an `eslint --print-config` result with the canonical recommendation.
87
+ *
88
+ * @param {object} printedConfig - Parsed effective ESLint config for one production probe file.
89
+ * @param {Record<string, string | number | unknown[]>} expectedRules - Canonical rule levels.
90
+ */
91
+ export const auditLintAdoption = (printedConfig, expectedRules) => {
92
+ const actualRules = printedConfig?.rules ?? {}
93
+ const missing = []
94
+ const nonError = []
95
+
96
+ for (const name of Object.keys(expectedRules).sort()) {
97
+ if (actualRules[name] === undefined) {
98
+ missing.push(name)
99
+ continue
100
+ }
101
+ if (severityOf(actualRules[name]) !== 2) nonError.push(name)
102
+ }
103
+
104
+ const refusesInlineConfig = printedConfig?.linterOptions?.noInlineConfig === true
105
+ return {
106
+ ok: missing.length === 0 && nonError.length === 0 && refusesInlineConfig,
107
+ missing,
108
+ nonError,
109
+ refusesInlineConfig,
110
+ }
111
+ }
112
+
113
+ /** Repository audits are gathered beside rules, but are not ESLint AST rules themselves. */
114
+ export const audits = { "effective-config": auditLintAdoption }
115
+ export const rules = {}
116
+ export const recommended = {}
package/package.json CHANGED
@@ -1,38 +1,38 @@
1
- {
2
- "name": "@starci/eslint-canon-fe",
3
- "version": "1.3.2",
4
- "description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
- "type": "module",
6
- "license": "UNLICENSED",
7
- "private": false,
8
- "exports": {
9
- ".": "./index.mjs",
10
- "./contracts": "./contracts.ts",
11
- "./props": "./props.ts"
12
- },
13
- "main": "./index.mjs",
14
- "files": [
15
- "*.mjs",
16
- "*.ts",
17
- "!*.test.mjs",
18
- "README.md"
19
- ],
20
- "engines": {
21
- "node": ">=20.9.0"
22
- },
23
- "peerDependencies": {
24
- "eslint": ">=9"
25
- },
26
- "publishConfig": {
27
- "access": "public"
28
- },
29
- "scripts": {
30
- "test": "node --test \"*.test.mjs\""
31
- },
32
- "keywords": [
33
- "eslint",
34
- "eslintplugin",
35
- "starci",
36
- "canon"
37
- ]
38
- }
1
+ {
2
+ "name": "@starci/eslint-canon-fe",
3
+ "version": "1.4.1",
4
+ "description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "private": false,
8
+ "exports": {
9
+ ".": "./index.mjs",
10
+ "./contracts": "./contracts.ts",
11
+ "./props": "./props.ts"
12
+ },
13
+ "main": "./index.mjs",
14
+ "files": [
15
+ "*.mjs",
16
+ "*.ts",
17
+ "!*.test.mjs",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20.9.0"
22
+ },
23
+ "peerDependencies": {
24
+ "eslint": ">=9"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "test": "node --test \"*.test.mjs\""
31
+ },
32
+ "keywords": [
33
+ "eslint",
34
+ "eslintplugin",
35
+ "starci",
36
+ "canon"
37
+ ]
38
+ }
@@ -12,7 +12,7 @@
12
12
  * a shape is findable.
13
13
  */
14
14
 
15
- import { COMPONENT_ROOTS, isContractTableFile, isInComponentTier, isTestFile } from "./contract.mjs"
15
+ import { COMPONENT_ROOTS, isContractTableFile, isInComponentTier } from "./contract.mjs"
16
16
 
17
17
  /** True when a parameter type contains an anonymous object shape, including inside intersections. */
18
18
  const isInlineObjectType = (node) => {
@@ -58,6 +58,9 @@ export const noInlineParameterType = {
58
58
 
59
59
  // -- SLOTS-4 ---------------------------------------------------------------------------------------
60
60
 
61
+ /** The only three components where a React `children` hole is lawful. */
62
+ const CHILDREN_SHELLS = ["ModalShell", "DrawerShell", "DropdownShell", "RouteShell"]
63
+
61
64
  /**
62
65
  * True for a component file the slot fence governs.
63
66
  *
@@ -71,11 +74,15 @@ export const noInlineParameterType = {
71
74
  * NAMED CHILD GRAMMAR that replaces one. Reporting it asks the file that abolished the anonymous
72
75
  * slot to stop describing what it admits instead.
73
76
  *
77
+ * The three shells stay exempt for the reason canon already gives them: they pass real children
78
+ * through and arrange nothing.
79
+ *
74
80
  * @param filename - the file being linted.
75
81
  */
76
82
  const isGoverned = (filename) => {
77
83
  const path = String(filename || "").replace(/\\/g, "/")
78
84
  if (isContractTableFile(path)) return false
85
+ if (CHILDREN_SHELLS.some((shell) => isInComponentTier(path, `shells/${shell}`))) return false
79
86
  /*
80
87
  * The bare `src` root is dropped here, and only here. `COMPONENT_ROOTS` carries it as a
81
88
  * catch-all so a reader that walks up from any file still finds the table; used as a FENCE it
@@ -85,69 +92,6 @@ const isGoverned = (filename) => {
85
92
  return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
86
93
  }
87
94
 
88
- /**
89
- * True when a function's whole body does nothing but turn `children` into a component
90
- * reference and hand that reference to exactly one other component under a named prop.
91
- *
92
- * THIS IS SLOTS-4'S OWN EXCEPTION, READ FROM SHAPE, NOT FROM A NAME. The rule's message
93
- * already promises it: a framework route layout may receive `ReactNode` outside the
94
- * component tier, but must close it into a named projection before a component sees it.
95
- * A shell that crosses `children` over a client/server boundary - wrapping it in
96
- * `useCallback` so it survives as a function reference rather than an unserialisable
97
- * inline component - IS that closing act; there is no contract key to name because the
98
- * shell is deliberately generic across callers. What makes the exception SAFE to keep
99
- * narrow is that the shape disqualifies itself the moment the body does one more thing:
100
- * a second return, a conditional, a data read, an element of its own, or a forwarded
101
- * value that never passes through the closure all fall through to the ordinary report.
102
- *
103
- * - Every statement but the last is a `const x = ...` naming either a plain member/param
104
- * read (`const Frame = input.frame`) or a `useCallback(...)` call - nothing else.
105
- * - The last statement is the only `return`, and it returns exactly one JSX element.
106
- * - That element's attributes only spread or forward existing bindings, and at least one
107
- * of them carries the `useCallback` result - the children-derived reference - by name.
108
- */
109
- const isChildrenBoundaryConverter = (fn) => {
110
- if (!fn || fn.body?.type !== "BlockStatement") return false
111
- const statements = fn.body.body
112
- let closureVar = null
113
- let returnNode = null
114
-
115
- for (const statement of statements) {
116
- if (statement.type === "ReturnStatement") {
117
- if (returnNode) return false // a second return means this does more than one thing
118
- returnNode = statement.argument
119
- continue
120
- }
121
- if (statement.type !== "VariableDeclaration" || statement.declarations.length !== 1) return false
122
- const [declarator] = statement.declarations
123
- const init = declarator.init
124
- if (declarator.id.type !== "Identifier" || !init) return false
125
- if (init.type === "MemberExpression" || init.type === "Identifier") continue // a plain prop read
126
- if (init.type === "CallExpression" && init.callee?.type === "Identifier" && init.callee.name === "useCallback") {
127
- const callback = init.arguments[0]
128
- if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) {
129
- return false
130
- }
131
- closureVar = declarator.id.name
132
- continue
133
- }
134
- return false // any other statement is the component doing something of its own
135
- }
136
-
137
- if (!closureVar || !returnNode || returnNode.type !== "JSXElement") return false
138
-
139
- let carriesClosure = false
140
- for (const attribute of returnNode.openingElement.attributes || []) {
141
- if (attribute.type === "JSXSpreadAttribute") continue
142
- if (attribute.type !== "JSXAttribute" || !attribute.value) return false
143
- const raw = attribute.value.type === "JSXExpressionContainer" ? attribute.value.expression : attribute.value
144
- const value = raw?.type === "TSAsExpression" ? raw.expression : raw
145
- if (value?.type !== "Identifier") return false
146
- if (value.name === closureVar) carriesClosure = true
147
- }
148
- return carriesClosure
149
- }
150
-
151
95
  /**
152
96
  * A container takes `contract` and `render`, never `children`.
153
97
  *
@@ -155,65 +99,25 @@ const isChildrenBoundaryConverter = (fn) => {
155
99
  * aliases it defines, but nothing stops a file declaring its own props shape by hand and putting
156
100
  * `children` in it. What the alias makes unrepresentable, a hand-written interface makes ordinary.
157
101
  *
158
- * Vendor mechanics branches still take a named contract/render projection. Framework route
159
- * layouts sit outside the component tier and close their ReactNode into that projection before it
160
- * reaches a component.
161
- *
162
- * ONE CLOSED EXCEPTION LIVES INSIDE THE COMPONENT TIER: a boundary-converter shell whose
163
- * whole body matches {@link isChildrenBoundaryConverter}. It is closed by shape, checked
164
- * against the actual function the props type belongs to, never by filename or export name
165
- * - so nothing can opt in by being called `RouteShell`, and nothing loses the exemption by
166
- * being renamed.
102
+ * ModalShell, DrawerShell and DropdownShell are exempt because they ignore the interior shape and
103
+ * pass it directly to the vendor body. No folder-wide exemption exists.
167
104
  */
168
105
  export const noChildrenSlot = {
169
106
  meta: {
170
107
  type: "problem",
171
- docs: { description: "Every component container takes contract and render, never children." },
108
+ docs: { description: "A container takes contract and render; only the three closed shells may take children." },
172
109
  schema: [],
173
110
  messages: {
174
111
  slot:
175
- "`children` accepts markup that has already been built, so its shape cannot be checked. Take contract + render instead. A framework route layout may receive ReactNode outside the component tier, but it must close that value into a named contract projection before handing it to a component.",
112
+ "`children` accepts markup that has already been built, so its shape cannot be checked. Take contract + render instead. Only ModalShell, DrawerShell, DropdownShell and RouteShell may take one - the first three pass an interior straight to vendor mechanics, and RouteShell converts the children a framework layout is handed.",
176
113
  },
177
114
  },
178
115
  create(context) {
179
116
  if (!isGoverned(context.filename || context.getFilename())) return {}
180
-
181
- // Deferred to Program:exit so the boundary-converter exemption - which has to look at
182
- // the whole file to find the function a props type belongs to - can clear a candidate
183
- // before it is ever reported.
184
- const candidates = []
185
- const componentsByPropsType = new Map()
186
-
187
- const enclosingFunction = (node) => {
188
- for (let current = node.parent; current; current = current.parent) {
189
- if (["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"].includes(current.type)) {
190
- return current
191
- }
192
- }
193
- return null
194
- }
195
-
196
- const enclosingTypeName = (node) => {
197
- for (let current = node.parent; current; current = current.parent) {
198
- if (current.type === "TSInterfaceDeclaration" || current.type === "TSTypeAliasDeclaration") {
199
- return current.id?.name || null
200
- }
201
- }
202
- return null
203
- }
204
-
205
- /** The name of the type a function's single parameter is annotated with, if any. */
206
- const parameterTypeName = (fn) => {
207
- const annotation = fn.params?.[0]?.typeAnnotation?.typeAnnotation
208
- return annotation?.type === "TSTypeReference" && annotation.typeName?.type === "Identifier"
209
- ? annotation.typeName.name
210
- : null
211
- }
212
-
213
117
  return {
214
118
  TSPropertySignature(node) {
215
119
  if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
216
- candidates.push({ node: node.key, typeName: enclosingTypeName(node) })
120
+ context.report({ node: node.key, messageId: "slot" })
217
121
  }
218
122
  },
219
123
  Property(node) {
@@ -221,173 +125,9 @@ export const noChildrenSlot = {
221
125
  if (node.parent && node.parent.type !== "ObjectPattern") return
222
126
  if (node.parent.parent && node.parent.parent.type === "VariableDeclarator") return
223
127
  if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
224
- candidates.push({ node: node.key, fn: enclosingFunction(node) })
225
- }
226
- },
227
- ":function"(fn) {
228
- const typeName = parameterTypeName(fn)
229
- if (typeName) componentsByPropsType.set(typeName, fn)
230
- },
231
- "Program:exit"() {
232
- for (const candidate of candidates) {
233
- const fn = candidate.fn || (candidate.typeName && componentsByPropsType.get(candidate.typeName))
234
- if (fn && isChildrenBoundaryConverter(fn)) continue
235
- context.report({ node: candidate.node, messageId: "slot" })
236
- }
237
- },
238
- }
239
- },
240
- }
241
-
242
- /** Name of a plain TypeScript property key. */
243
- const propertyName = (node) => {
244
- const key = node?.key ?? node?.property
245
- if (!key) return null
246
- if (key.type === "Identifier") return key.name
247
- if (key.type === "Literal" && typeof key.value === "string") return key.value
248
- return null
249
- }
250
-
251
- /** Product source inside a supported component root. */
252
- const isComponentSource = (filename) => {
253
- const path = String(filename || "").replace(/\\/g, "/")
254
- return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
255
- }
256
-
257
- // -- SLOTS-5 · SLOTS-6 ---------------------------------------------------------------------------
258
-
259
- /** A caller cannot reach into one named internal part and style it. */
260
- export const noPerPartClassNameProp = {
261
- meta: {
262
- type: "problem",
263
- docs: { description: "No <part>ClassName prop exposes a component's internal nodes." },
264
- schema: [],
265
- messages: {
266
- perPart:
267
- "`{{prop}}` lets a caller restyle a node it does not own. Replace the CSS door with a named semantic prop, and keep the appearance decision inside the component or its contract.",
268
- },
269
- },
270
- create(context) {
271
- const filename = context.filename || context.getFilename()
272
- if (isTestFile(filename) || !isComponentSource(filename)) return {}
273
- return {
274
- TSPropertySignature(node) {
275
- const name = propertyName(node)
276
- if (!name || name === "className" || !/^[a-z][A-Za-z0-9]*ClassName$/.test(name)) return
277
- context.report({ node, messageId: "perPart", data: { prop: name } })
278
- },
279
- }
280
- },
281
- }
282
-
283
- /** Public house components never expose className/classNames placement doors. */
284
- export const noPublicClassNameProp = {
285
- meta: {
286
- type: "problem",
287
- docs: { description: "House components own appearance and expose no public className/classNames prop." },
288
- schema: [],
289
- messages: {
290
- declaration:
291
- "Public component prop `{{prop}}` is a CSS placement door. Expose a semantic variant or a named contract instead.",
292
- usage:
293
- "Do not pass `{{prop}}` to house component `{{component}}`; its owner or parent contract decides appearance.",
294
- },
295
- },
296
- create(context) {
297
- const filename = context.filename || context.getFilename()
298
- if (isTestFile(filename)) return {}
299
- const inComponent = isComponentSource(filename)
300
- const bindings = new Set()
301
- return {
302
- ImportDeclaration(node) {
303
- const source = String(node.source?.value || "").replace(/\\/g, "/")
304
- if (!/(?:^|\/)components\//.test(source)) return
305
- for (const specifier of node.specifiers || []) {
306
- if (specifier.local?.name) bindings.add(specifier.local.name)
128
+ context.report({ node: node.key, messageId: "slot" })
307
129
  }
308
130
  },
309
- TSPropertySignature(node) {
310
- if (!inComponent) return
311
- const name = propertyName(node)
312
- if (name === "className" || name === "classNames") {
313
- context.report({ node, messageId: "declaration", data: { prop: name } })
314
- }
315
- },
316
- JSXOpeningElement(node) {
317
- const component = node.name?.type === "JSXIdentifier" ? node.name.name : null
318
- if (!component || !bindings.has(component)) return
319
- for (const attribute of node.attributes || []) {
320
- if (attribute.type !== "JSXAttribute") continue
321
- const prop = attribute.name?.type === "JSXIdentifier" ? attribute.name.name : null
322
- if (prop === "className" || prop === "classNames") {
323
- context.report({ node: attribute, messageId: "usage", data: { prop, component } })
324
- }
325
- }
326
- },
327
- }
328
- },
329
- }
330
-
331
- /** CSS-shaped layout props are not public API above the atomic leaf tier. */
332
- const FRAME_CSS_PROPS = new Set(["gap", "padding", "align", "justify", "className", "classNames", "style", "inline", "nested"])
333
-
334
- export const noPublicFrameCssProps = {
335
- meta: {
336
- type: "problem",
337
- docs: { description: "Non-leaf component contracts expose semantic decisions, not CSS-shaped frame props." },
338
- schema: [],
339
- messages: {
340
- css:
341
- "`{{prop}}` is a public CSS/frame decision above the leaf tier. Move the arrangement into a named contract or expose the semantic state that selects one.",
342
- },
343
- },
344
- create(context) {
345
- const filename = context.filename || context.getFilename()
346
- if (isTestFile(filename) || !isComponentSource(filename) || isInComponentTier(filename, "leaves")) return {}
347
- return {
348
- TSPropertySignature(node) {
349
- const name = propertyName(node)
350
- if (name && FRAME_CSS_PROPS.has(name)) context.report({ node, messageId: "css", data: { prop: name } })
351
- },
352
- }
353
- },
354
- }
355
-
356
- /** String literal keys named by Omit/Pick/Exclude. */
357
- const typeKeys = (node, out = []) => {
358
- if (!node) return out
359
- if (node.type === "TSUnionType") {
360
- for (const member of node.types || []) typeKeys(member, out)
361
- return out
362
- }
363
- if (node.type === "TSLiteralType" && node.literal?.type === "Literal" && typeof node.literal.value === "string") {
364
- out.push(node.literal.value)
365
- }
366
- return out
367
- }
368
-
369
- /** Hiding a CSS door with a utility type is not closing the owner that exposed it. */
370
- export const noCssDoorTypeLaundering = {
371
- meta: {
372
- type: "problem",
373
- docs: { description: "Omit/Pick/Exclude cannot hide className/classNames/style doors." },
374
- schema: [],
375
- messages: {
376
- utility:
377
- "`{{utility}}` of `{{prop}}` launders a CSS door instead of closing it. Remove the prop from the owning public contract and every consumer.",
378
- },
379
- },
380
- create(context) {
381
- const file = String(context.filename || context.getFilename()).replace(/\\/g, "/")
382
- if (isTestFile(file) || !file.includes("/src/")) return {}
383
- return {
384
- TSTypeReference(node) {
385
- const utility = node.typeName?.type === "Identifier" ? node.typeName.name : null
386
- if (!utility || !["Omit", "Pick", "Exclude"].includes(utility)) return
387
- const params = node.typeArguments?.params || node.typeParameters?.params || []
388
- const prop = typeKeys(params[1]).find((key) => key === "className" || key === "classNames" || key === "style")
389
- if (prop) context.report({ node, messageId: "utility", data: { utility, prop } })
390
- },
391
131
  }
392
132
  },
393
133
  }
@@ -436,10 +176,6 @@ export const noSurfaceListItemsSlot = {
436
176
  export const rules = {
437
177
  "no-inline-parameter-type": noInlineParameterType,
438
178
  "no-children-slot": noChildrenSlot,
439
- "no-per-part-classname-prop": noPerPartClassNameProp,
440
- "no-public-classname-prop": noPublicClassNameProp,
441
- "no-public-frame-css-props": noPublicFrameCssProps,
442
- "no-css-door-type-laundering": noCssDoorTypeLaundering,
443
179
  "no-surface-list-items-slot": noSurfaceListItemsSlot,
444
180
  }
445
181