@starci/eslint-canon-fe 1.2.0 → 1.3.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.
@@ -1,339 +1,452 @@
1
- /**
2
- * The rules that hold `props-and-slots.md`.
3
- *
4
- * ALMOST ALL OF THIS LAW IS HELD BY A TYPE, not by a rule. The slot aliases in `props.ts` are the
5
- * fence: a fifth slot does not fail review, it fails to compile, and there is nothing left for a
6
- * rule to patrol once the shape itself refuses. That is the stronger arrangement and it is why this
7
- * module is small.
8
- *
9
- * What a type cannot see is a shape with no NAME. An inline object type at the parameter satisfies
10
- * every constraint the alias imposes and is still wrong, because the wrongness is not about which
11
- * fields exist - it is that nothing else can refer to them. A compiler has no opinion about whether
12
- * a shape is findable.
13
- */
14
-
15
- import { COMPONENT_ROOTS, isContractTableFile, isInComponentTier, isTestFile } from "./contract.mjs"
16
-
17
- /** True when a parameter type contains an anonymous object shape, including inside intersections. */
18
- const isInlineObjectType = (node) => {
19
- if (!node) return false
20
- if (node.type === "TSTypeLiteral") return true
21
- if (node.type === "TSParenthesizedType") return isInlineObjectType(node.typeAnnotation)
22
- if (node.type === "TSIntersectionType" || node.type === "TSUnionType") {
23
- return (node.types || []).some(isInlineObjectType)
24
- }
25
- return false
26
- }
27
-
28
- // -- SLOTS-3 ---------------------------------------------------------------------------------------
29
-
30
- /** A parameter's complete shape is named in the module, never assembled at the parameter. */
31
- export const noInlineParameterType = {
32
- meta: {
33
- type: "suggestion",
34
- docs: { description: "A parameter takes one named props type, not an inline or intersected object shape." },
35
- schema: [],
36
- messages: {
37
- inline:
38
- "This parameter's shape is written inline, so it has no name and nothing can refer to it - not an import, not the twin that tests this file, not somebody looking for what this component accepts. Declare it in the module and name it here. The cost is one line; the difference is between a contract and a signature.",
39
- },
40
- },
41
- create(context) {
42
- const checkParams = (node) => {
43
- for (const param of node.params || []) {
44
- if (!param) continue
45
- const declared = param.typeAnnotation?.typeAnnotation
46
- if (!isInlineObjectType(declared)) continue
47
- context.report({ node: param.typeAnnotation || param, messageId: "inline" })
48
- }
49
- }
50
- return {
51
- ArrowFunctionExpression: checkParams,
52
- FunctionExpression: checkParams,
53
- FunctionDeclaration: checkParams,
54
- TSEmptyBodyFunctionExpression: checkParams,
55
- }
56
- },
57
- }
58
-
59
- // -- SLOTS-4 ---------------------------------------------------------------------------------------
60
-
61
- /**
62
- * True for a component file the slot fence governs.
63
- *
64
- * TWO EXEMPTIONS AND ONE LAYOUT FIX, all of which this predicate got wrong at once.
65
- *
66
- * It read `/src/components/` as a literal, so in a monorepo - where the same tier sits at
67
- * `packages/ui/src/*` - the rule applied to NOTHING. That repository reported no violations, which
68
- * looked like compliance and was silence: the fence was not holding anywhere in it.
69
- *
70
- * The registry table is exempt because `ContractSpec.children` is not a children hole; it is the
71
- * NAMED CHILD GRAMMAR that replaces one. Reporting it asks the file that abolished the anonymous
72
- * slot to stop describing what it admits instead.
73
- *
74
- * @param filename - the file being linted.
75
- */
76
- const isGoverned = (filename) => {
77
- const path = String(filename || "").replace(/\\/g, "/")
78
- if (isContractTableFile(path)) return false
79
- /*
80
- * The bare `src` root is dropped here, and only here. `COMPONENT_ROOTS` carries it as a
81
- * catch-all so a reader that walks up from any file still finds the table; used as a FENCE it
82
- * matches every file under `src/`, which pulls routed pages into a rule about component slots and
83
- * reports a page for taking children - the one thing a page legitimately does.
84
- */
85
- return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
86
- }
87
-
88
- /**
89
- * A container takes `contract` and `render`, never `children`.
90
- *
91
- * THE TYPE CANNOT CATCH THIS ONE, which is why it is here. `props.ts` refuses a fourth slot on the
92
- * aliases it defines, but nothing stops a file declaring its own props shape by hand and putting
93
- * `children` in it. What the alias makes unrepresentable, a hand-written interface makes ordinary.
94
- *
95
- * Vendor mechanics branches still take a named contract/render projection. Framework route
96
- * layouts sit outside the component tier and close their ReactNode into that projection before it
97
- * reaches a component.
98
- */
99
- export const noChildrenSlot = {
100
- meta: {
101
- type: "problem",
102
- docs: { description: "Every component container takes contract and render, never children." },
103
- schema: [],
104
- messages: {
105
- slot:
106
- "`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.",
107
- },
108
- },
109
- create(context) {
110
- if (!isGoverned(context.filename || context.getFilename())) return {}
111
- return {
112
- TSPropertySignature(node) {
113
- if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
114
- context.report({ node: node.key, messageId: "slot" })
115
- }
116
- },
117
- Property(node) {
118
- // a destructured `children` in a parameter, which is the same slot arriving by another door
119
- if (node.parent && node.parent.type !== "ObjectPattern") return
120
- if (node.parent.parent && node.parent.parent.type === "VariableDeclarator") return
121
- if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
122
- context.report({ node: node.key, messageId: "slot" })
123
- }
124
- },
125
- }
126
- },
127
- }
128
-
129
- /** Name of a plain TypeScript property key. */
130
- const propertyName = (node) => {
131
- const key = node?.key ?? node?.property
132
- if (!key) return null
133
- if (key.type === "Identifier") return key.name
134
- if (key.type === "Literal" && typeof key.value === "string") return key.value
135
- return null
136
- }
137
-
138
- /** Product source inside a supported component root. */
139
- const isComponentSource = (filename) => {
140
- const path = String(filename || "").replace(/\\/g, "/")
141
- return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
142
- }
143
-
144
- // -- SLOTS-5 · SLOTS-6 ---------------------------------------------------------------------------
145
-
146
- /** A caller cannot reach into one named internal part and style it. */
147
- export const noPerPartClassNameProp = {
148
- meta: {
149
- type: "problem",
150
- docs: { description: "No <part>ClassName prop exposes a component's internal nodes." },
151
- schema: [],
152
- messages: {
153
- perPart:
154
- "`{{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.",
155
- },
156
- },
157
- create(context) {
158
- const filename = context.filename || context.getFilename()
159
- if (isTestFile(filename) || !isComponentSource(filename)) return {}
160
- return {
161
- TSPropertySignature(node) {
162
- const name = propertyName(node)
163
- if (!name || name === "className" || !/^[a-z][A-Za-z0-9]*ClassName$/.test(name)) return
164
- context.report({ node, messageId: "perPart", data: { prop: name } })
165
- },
166
- }
167
- },
168
- }
169
-
170
- /** Public house components never expose className/classNames placement doors. */
171
- export const noPublicClassNameProp = {
172
- meta: {
173
- type: "problem",
174
- docs: { description: "House components own appearance and expose no public className/classNames prop." },
175
- schema: [],
176
- messages: {
177
- declaration:
178
- "Public component prop `{{prop}}` is a CSS placement door. Expose a semantic variant or a named contract instead.",
179
- usage:
180
- "Do not pass `{{prop}}` to house component `{{component}}`; its owner or parent contract decides appearance.",
181
- },
182
- },
183
- create(context) {
184
- const filename = context.filename || context.getFilename()
185
- if (isTestFile(filename)) return {}
186
- const inComponent = isComponentSource(filename)
187
- const bindings = new Set()
188
- return {
189
- ImportDeclaration(node) {
190
- const source = String(node.source?.value || "").replace(/\\/g, "/")
191
- if (!/(?:^|\/)components\//.test(source)) return
192
- for (const specifier of node.specifiers || []) {
193
- if (specifier.local?.name) bindings.add(specifier.local.name)
194
- }
195
- },
196
- TSPropertySignature(node) {
197
- if (!inComponent) return
198
- const name = propertyName(node)
199
- if (name === "className" || name === "classNames") {
200
- context.report({ node, messageId: "declaration", data: { prop: name } })
201
- }
202
- },
203
- JSXOpeningElement(node) {
204
- const component = node.name?.type === "JSXIdentifier" ? node.name.name : null
205
- if (!component || !bindings.has(component)) return
206
- for (const attribute of node.attributes || []) {
207
- if (attribute.type !== "JSXAttribute") continue
208
- const prop = attribute.name?.type === "JSXIdentifier" ? attribute.name.name : null
209
- if (prop === "className" || prop === "classNames") {
210
- context.report({ node: attribute, messageId: "usage", data: { prop, component } })
211
- }
212
- }
213
- },
214
- }
215
- },
216
- }
217
-
218
- /** CSS-shaped layout props are not public API above the atomic leaf tier. */
219
- const FRAME_CSS_PROPS = new Set(["gap", "padding", "align", "justify", "className", "classNames", "style", "inline", "nested"])
220
-
221
- export const noPublicFrameCssProps = {
222
- meta: {
223
- type: "problem",
224
- docs: { description: "Non-leaf component contracts expose semantic decisions, not CSS-shaped frame props." },
225
- schema: [],
226
- messages: {
227
- css:
228
- "`{{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.",
229
- },
230
- },
231
- create(context) {
232
- const filename = context.filename || context.getFilename()
233
- if (isTestFile(filename) || !isComponentSource(filename) || isInComponentTier(filename, "leaves")) return {}
234
- return {
235
- TSPropertySignature(node) {
236
- const name = propertyName(node)
237
- if (name && FRAME_CSS_PROPS.has(name)) context.report({ node, messageId: "css", data: { prop: name } })
238
- },
239
- }
240
- },
241
- }
242
-
243
- /** String literal keys named by Omit/Pick/Exclude. */
244
- const typeKeys = (node, out = []) => {
245
- if (!node) return out
246
- if (node.type === "TSUnionType") {
247
- for (const member of node.types || []) typeKeys(member, out)
248
- return out
249
- }
250
- if (node.type === "TSLiteralType" && node.literal?.type === "Literal" && typeof node.literal.value === "string") {
251
- out.push(node.literal.value)
252
- }
253
- return out
254
- }
255
-
256
- /** Hiding a CSS door with a utility type is not closing the owner that exposed it. */
257
- export const noCssDoorTypeLaundering = {
258
- meta: {
259
- type: "problem",
260
- docs: { description: "Omit/Pick/Exclude cannot hide className/classNames/style doors." },
261
- schema: [],
262
- messages: {
263
- utility:
264
- "`{{utility}}` of `{{prop}}` launders a CSS door instead of closing it. Remove the prop from the owning public contract and every consumer.",
265
- },
266
- },
267
- create(context) {
268
- const file = String(context.filename || context.getFilename()).replace(/\\/g, "/")
269
- if (isTestFile(file) || !file.includes("/src/")) return {}
270
- return {
271
- TSTypeReference(node) {
272
- const utility = node.typeName?.type === "Identifier" ? node.typeName.name : null
273
- if (!utility || !["Omit", "Pick", "Exclude"].includes(utility)) return
274
- const params = node.typeArguments?.params || node.typeParameters?.params || []
275
- const prop = typeKeys(params[1]).find((key) => key === "className" || key === "classNames" || key === "style")
276
- if (prop) context.report({ node, messageId: "utility", data: { utility, prop } })
277
- },
278
- }
279
- },
280
- }
281
-
282
- // -- SLOTS-7 ---------------------------------------------------------------------------------------
283
-
284
- /** A joined-list surface receives domain collections through named props, never a generic items lane. */
285
- export const noSurfaceListItemsSlot = {
286
- meta: {
287
- type: "problem",
288
- docs: { description: "SurfaceListCard receives collection data through named props, never items." },
289
- schema: [],
290
- messages: {
291
- items:
292
- "`items` creates a second runtime-data lane beside `props` and makes SurfaceListCard know every domain collection. Put the collection in the render component's named props type (for example `tasks`) and pass it through `props`.",
293
- },
294
- },
295
- create(context) {
296
- const filename = String(context.filename || context.getFilename()).replace(/\\/g, "/")
297
- if (!filename.includes("/src/")) return {}
298
- const bindings = new Set()
299
- return {
300
- ImportDeclaration(node) {
301
- const source = String(node.source?.value || "").replace(/\\/g, "/")
302
- if (!/(?:^|\/)components\/branches\/SurfaceListCard$/.test(source)) return
303
- for (const specifier of node.specifiers || []) {
304
- const imported = specifier.imported?.name
305
- if (imported === "SurfaceListCard" && specifier.local?.name) bindings.add(specifier.local.name)
306
- }
307
- },
308
- JSXOpeningElement(node) {
309
- const component = node.name?.type === "JSXIdentifier" ? node.name.name : null
310
- if (!component || !bindings.has(component)) return
311
- for (const attribute of node.attributes || []) {
312
- if (attribute.type !== "JSXAttribute") continue
313
- if (attribute.name?.type === "JSXIdentifier" && attribute.name.name === "items") {
314
- context.report({ node: attribute, messageId: "items" })
315
- }
316
- }
317
- },
318
- }
319
- },
320
- }
321
-
322
- /** The rules this law contributes to the plugin. */
323
- export const rules = {
324
- "no-inline-parameter-type": noInlineParameterType,
325
- "no-children-slot": noChildrenSlot,
326
- "no-per-part-classname-prop": noPerPartClassNameProp,
327
- "no-public-classname-prop": noPublicClassNameProp,
328
- "no-public-frame-css-props": noPublicFrameCssProps,
329
- "no-css-door-type-laundering": noCssDoorTypeLaundering,
330
- "no-surface-list-items-slot": noSurfaceListItemsSlot,
331
- }
332
-
333
- /**
334
- * The level this law asks for, as the plugin's own opinion.
335
- *
336
- * Exact and mechanical: it fires on a syntactic shape rather than on a judgement, so there is no
337
- * false-positive risk that would justify adopting it at `warn`.
338
- */
339
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
1
+ /**
2
+ * The rules that hold `props-and-slots.md`.
3
+ *
4
+ * ALMOST ALL OF THIS LAW IS HELD BY A TYPE, not by a rule. The slot aliases in `props.ts` are the
5
+ * fence: a fifth slot does not fail review, it fails to compile, and there is nothing left for a
6
+ * rule to patrol once the shape itself refuses. That is the stronger arrangement and it is why this
7
+ * module is small.
8
+ *
9
+ * What a type cannot see is a shape with no NAME. An inline object type at the parameter satisfies
10
+ * every constraint the alias imposes and is still wrong, because the wrongness is not about which
11
+ * fields exist - it is that nothing else can refer to them. A compiler has no opinion about whether
12
+ * a shape is findable.
13
+ */
14
+
15
+ import { COMPONENT_ROOTS, isContractTableFile, isInComponentTier, isTestFile } from "./contract.mjs"
16
+
17
+ /** True when a parameter type contains an anonymous object shape, including inside intersections. */
18
+ const isInlineObjectType = (node) => {
19
+ if (!node) return false
20
+ if (node.type === "TSTypeLiteral") return true
21
+ if (node.type === "TSParenthesizedType") return isInlineObjectType(node.typeAnnotation)
22
+ if (node.type === "TSIntersectionType" || node.type === "TSUnionType") {
23
+ return (node.types || []).some(isInlineObjectType)
24
+ }
25
+ return false
26
+ }
27
+
28
+ // -- SLOTS-3 ---------------------------------------------------------------------------------------
29
+
30
+ /** A parameter's complete shape is named in the module, never assembled at the parameter. */
31
+ export const noInlineParameterType = {
32
+ meta: {
33
+ type: "suggestion",
34
+ docs: { description: "A parameter takes one named props type, not an inline or intersected object shape." },
35
+ schema: [],
36
+ messages: {
37
+ inline:
38
+ "This parameter's shape is written inline, so it has no name and nothing can refer to it - not an import, not the twin that tests this file, not somebody looking for what this component accepts. Declare it in the module and name it here. The cost is one line; the difference is between a contract and a signature.",
39
+ },
40
+ },
41
+ create(context) {
42
+ const checkParams = (node) => {
43
+ for (const param of node.params || []) {
44
+ if (!param) continue
45
+ const declared = param.typeAnnotation?.typeAnnotation
46
+ if (!isInlineObjectType(declared)) continue
47
+ context.report({ node: param.typeAnnotation || param, messageId: "inline" })
48
+ }
49
+ }
50
+ return {
51
+ ArrowFunctionExpression: checkParams,
52
+ FunctionExpression: checkParams,
53
+ FunctionDeclaration: checkParams,
54
+ TSEmptyBodyFunctionExpression: checkParams,
55
+ }
56
+ },
57
+ }
58
+
59
+ // -- SLOTS-4 ---------------------------------------------------------------------------------------
60
+
61
+ /**
62
+ * True for a component file the slot fence governs.
63
+ *
64
+ * TWO EXEMPTIONS AND ONE LAYOUT FIX, all of which this predicate got wrong at once.
65
+ *
66
+ * It read `/src/components/` as a literal, so in a monorepo - where the same tier sits at
67
+ * `packages/ui/src/*` - the rule applied to NOTHING. That repository reported no violations, which
68
+ * looked like compliance and was silence: the fence was not holding anywhere in it.
69
+ *
70
+ * The registry table is exempt because `ContractSpec.children` is not a children hole; it is the
71
+ * NAMED CHILD GRAMMAR that replaces one. Reporting it asks the file that abolished the anonymous
72
+ * slot to stop describing what it admits instead.
73
+ *
74
+ * @param filename - the file being linted.
75
+ */
76
+ const isGoverned = (filename) => {
77
+ const path = String(filename || "").replace(/\\/g, "/")
78
+ if (isContractTableFile(path)) return false
79
+ /*
80
+ * The bare `src` root is dropped here, and only here. `COMPONENT_ROOTS` carries it as a
81
+ * catch-all so a reader that walks up from any file still finds the table; used as a FENCE it
82
+ * matches every file under `src/`, which pulls routed pages into a rule about component slots and
83
+ * reports a page for taking children - the one thing a page legitimately does.
84
+ */
85
+ return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
86
+ }
87
+
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
+ /**
152
+ * A container takes `contract` and `render`, never `children`.
153
+ *
154
+ * THE TYPE CANNOT CATCH THIS ONE, which is why it is here. `props.ts` refuses a fourth slot on the
155
+ * aliases it defines, but nothing stops a file declaring its own props shape by hand and putting
156
+ * `children` in it. What the alias makes unrepresentable, a hand-written interface makes ordinary.
157
+ *
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.
167
+ */
168
+ export const noChildrenSlot = {
169
+ meta: {
170
+ type: "problem",
171
+ docs: { description: "Every component container takes contract and render, never children." },
172
+ schema: [],
173
+ messages: {
174
+ 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.",
176
+ },
177
+ },
178
+ create(context) {
179
+ 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
+ return {
214
+ TSPropertySignature(node) {
215
+ if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
216
+ candidates.push({ node: node.key, typeName: enclosingTypeName(node) })
217
+ }
218
+ },
219
+ Property(node) {
220
+ // a destructured `children` in a parameter, which is the same slot arriving by another door
221
+ if (node.parent && node.parent.type !== "ObjectPattern") return
222
+ if (node.parent.parent && node.parent.parent.type === "VariableDeclarator") return
223
+ 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)
307
+ }
308
+ },
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
+ }
392
+ },
393
+ }
394
+
395
+ // -- SLOTS-7 ---------------------------------------------------------------------------------------
396
+
397
+ /** A joined-list surface receives domain collections through named props, never a generic items lane. */
398
+ export const noSurfaceListItemsSlot = {
399
+ meta: {
400
+ type: "problem",
401
+ docs: { description: "SurfaceListCard receives collection data through named props, never items." },
402
+ schema: [],
403
+ messages: {
404
+ items:
405
+ "`items` creates a second runtime-data lane beside `props` and makes SurfaceListCard know every domain collection. Put the collection in the render component's named props type (for example `tasks`) and pass it through `props`.",
406
+ },
407
+ },
408
+ create(context) {
409
+ const filename = String(context.filename || context.getFilename()).replace(/\\/g, "/")
410
+ if (!filename.includes("/src/")) return {}
411
+ const bindings = new Set()
412
+ return {
413
+ ImportDeclaration(node) {
414
+ const source = String(node.source?.value || "").replace(/\\/g, "/")
415
+ if (!/(?:^|\/)components\/branches\/SurfaceListCard$/.test(source)) return
416
+ for (const specifier of node.specifiers || []) {
417
+ const imported = specifier.imported?.name
418
+ if (imported === "SurfaceListCard" && specifier.local?.name) bindings.add(specifier.local.name)
419
+ }
420
+ },
421
+ JSXOpeningElement(node) {
422
+ const component = node.name?.type === "JSXIdentifier" ? node.name.name : null
423
+ if (!component || !bindings.has(component)) return
424
+ for (const attribute of node.attributes || []) {
425
+ if (attribute.type !== "JSXAttribute") continue
426
+ if (attribute.name?.type === "JSXIdentifier" && attribute.name.name === "items") {
427
+ context.report({ node: attribute, messageId: "items" })
428
+ }
429
+ }
430
+ },
431
+ }
432
+ },
433
+ }
434
+
435
+ /** The rules this law contributes to the plugin. */
436
+ export const rules = {
437
+ "no-inline-parameter-type": noInlineParameterType,
438
+ "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
+ "no-surface-list-items-slot": noSurfaceListItemsSlot,
444
+ }
445
+
446
+ /**
447
+ * The level this law asks for, as the plugin's own opinion.
448
+ *
449
+ * Exact and mechanical: it fires on a syntactic shape rather than on a judgement, so there is no
450
+ * false-positive risk that would justify adopting it at `warn`.
451
+ */
452
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))