@starci/eslint-canon-fe 9.3.0 → 9.4.0

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.
Files changed (177) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -1
  3. package/dist/brand-values.d.ts +28 -0
  4. package/dist/brand-values.js +172 -0
  5. package/dist/button-intent.d.ts +11 -0
  6. package/dist/button-intent.js +361 -0
  7. package/dist/callback-shape.d.ts +7 -0
  8. package/dist/callback-shape.js +396 -0
  9. package/dist/class-names.d.ts +19 -0
  10. package/dist/class-names.js +170 -0
  11. package/dist/client-boundary.d.ts +42 -0
  12. package/dist/client-boundary.js +404 -0
  13. package/dist/comments.d.ts +47 -0
  14. package/dist/comments.js +156 -0
  15. package/dist/control-height.d.ts +7 -0
  16. package/dist/control-height.js +83 -0
  17. package/dist/drawing.d.ts +31 -0
  18. package/dist/drawing.js +376 -0
  19. package/dist/env-owner.d.ts +22 -0
  20. package/dist/env-owner.js +291 -0
  21. package/dist/fe-stack.d.ts +10 -0
  22. package/dist/fe-stack.js +73 -0
  23. package/dist/file-layout.d.ts +71 -0
  24. package/dist/file-layout.js +465 -0
  25. package/dist/formatting.d.ts +25 -0
  26. package/dist/formatting.js +104 -0
  27. package/dist/function-budget.d.ts +9 -0
  28. package/dist/function-budget.js +48 -0
  29. package/dist/grammar-shim.d.ts +5 -0
  30. package/dist/grammar-shim.js +126 -0
  31. package/dist/hooks-folder.d.ts +34 -0
  32. package/dist/hooks-folder.js +320 -0
  33. package/dist/hygiene.d.ts +39 -0
  34. package/dist/hygiene.js +540 -0
  35. package/dist/index.d.ts +105 -0
  36. package/dist/index.js +310 -0
  37. package/{landmark.mjs → dist/landmark.d.ts} +4 -6
  38. package/dist/landmark.js +6 -0
  39. package/dist/lib/ast.d.ts +36 -0
  40. package/dist/lib/ast.js +94 -0
  41. package/dist/lib/bindings.d.ts +84 -0
  42. package/dist/lib/bindings.js +168 -0
  43. package/dist/lib/class-text.d.ts +3 -0
  44. package/dist/lib/class-text.js +30 -0
  45. package/dist/lib/config.d.ts +81 -0
  46. package/dist/lib/config.js +141 -0
  47. package/dist/lib/drawing-scope.d.ts +5 -0
  48. package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
  49. package/dist/lib/eslint-boundary.d.ts +23 -0
  50. package/dist/lib/eslint-boundary.js +6 -0
  51. package/dist/lib/fe-stack.d.ts +49 -0
  52. package/dist/lib/fe-stack.js +98 -0
  53. package/dist/lib/hfs-view.d.ts +74 -0
  54. package/dist/lib/hfs-view.js +6 -0
  55. package/dist/lib/hfs.d.ts +34 -0
  56. package/dist/lib/hfs.js +72 -0
  57. package/dist/lib/next.d.ts +4 -0
  58. package/dist/lib/next.js +10 -0
  59. package/dist/lib/own-hook-origin.d.ts +17 -0
  60. package/dist/lib/own-hook-origin.js +52 -0
  61. package/dist/lib/package-root.d.ts +4 -0
  62. package/dist/lib/package-root.js +10 -0
  63. package/dist/lib/path.d.ts +2 -0
  64. package/{lib/path.mjs → dist/lib/path.js} +1 -1
  65. package/dist/lib/platform.d.ts +26 -0
  66. package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
  67. package/dist/lib/program-for.d.ts +5 -0
  68. package/dist/lib/program-for.js +23 -0
  69. package/dist/lib/pure-owner-helper.d.ts +10 -0
  70. package/dist/lib/pure-owner-helper.js +156 -0
  71. package/dist/lib/react.d.ts +16 -0
  72. package/dist/lib/react.js +22 -0
  73. package/dist/lib/redux-hook-origin.d.ts +4 -0
  74. package/dist/lib/redux-hook-origin.js +50 -0
  75. package/dist/lib/rule.d.ts +28 -0
  76. package/dist/lib/rule.js +5 -0
  77. package/dist/lib/scope.d.ts +88 -0
  78. package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
  79. package/dist/lib/typed-class-values.d.ts +13 -0
  80. package/dist/lib/typed-class-values.js +92 -0
  81. package/dist/lib/types.d.ts +44 -0
  82. package/dist/lib/types.js +64 -0
  83. package/dist/lib/why.d.ts +350 -0
  84. package/dist/lib/why.js +350 -0
  85. package/dist/lint-escape-hatch.d.ts +24 -0
  86. package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
  87. package/dist/lists.d.ts +31 -0
  88. package/dist/lists.js +202 -0
  89. package/dist/loading.d.ts +34 -0
  90. package/dist/loading.js +157 -0
  91. package/dist/naming.d.ts +33 -0
  92. package/dist/naming.js +176 -0
  93. package/dist/native-controls.d.ts +24 -0
  94. package/dist/native-controls.js +105 -0
  95. package/dist/next-conventions.d.ts +48 -0
  96. package/dist/next-conventions.js +452 -0
  97. package/dist/project-graph.d.ts +5 -0
  98. package/{project-graph.mjs → dist/project-graph.js} +8 -12
  99. package/dist/props-and-slots.d.ts +19 -0
  100. package/dist/props-and-slots.js +320 -0
  101. package/dist/served-locale.d.ts +38 -0
  102. package/dist/served-locale.js +263 -0
  103. package/dist/shape-slot.d.ts +18 -0
  104. package/dist/shape-slot.js +324 -0
  105. package/dist/size-and-state-budget.d.ts +46 -0
  106. package/dist/size-and-state-budget.js +424 -0
  107. package/dist/size-growth.d.ts +24 -0
  108. package/dist/size-growth.js +64 -0
  109. package/dist/sonar-parity.d.ts +5 -0
  110. package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
  111. package/dist/surface-rules.d.ts +18 -0
  112. package/dist/surface-rules.js +491 -0
  113. package/dist/swr-family.d.ts +15 -0
  114. package/dist/swr-family.js +419 -0
  115. package/dist/the-split.d.ts +27 -0
  116. package/dist/the-split.js +157 -0
  117. package/dist/tokens.d.ts +9 -0
  118. package/dist/tokens.js +35 -0
  119. package/dist/translation.d.ts +51 -0
  120. package/dist/translation.js +403 -0
  121. package/dist/transport.d.ts +73 -0
  122. package/dist/transport.js +885 -0
  123. package/dist/type-safety.d.ts +43 -0
  124. package/dist/type-safety.js +139 -0
  125. package/dist/ui-rulings.d.ts +11 -0
  126. package/dist/ui-rulings.js +121 -0
  127. package/package.json +31 -9
  128. package/runtime/knowledge/hfs/canon-pins.yaml +17 -10
  129. package/brand-values.mjs +0 -172
  130. package/button-intent.mjs +0 -320
  131. package/callback-shape.mjs +0 -332
  132. package/class-names.mjs +0 -149
  133. package/client-boundary.mjs +0 -400
  134. package/comments.mjs +0 -159
  135. package/control-height.mjs +0 -77
  136. package/drawing.mjs +0 -321
  137. package/env-owner.mjs +0 -244
  138. package/fe-stack.mjs +0 -86
  139. package/file-layout.mjs +0 -472
  140. package/formatting.mjs +0 -110
  141. package/function-budget.mjs +0 -49
  142. package/grammar-shim.mjs +0 -123
  143. package/hooks-folder.mjs +0 -300
  144. package/hygiene.mjs +0 -513
  145. package/index.mjs +0 -317
  146. package/lib/ast.mjs +0 -97
  147. package/lib/bindings.mjs +0 -159
  148. package/lib/class-text.mjs +0 -29
  149. package/lib/config.mjs +0 -142
  150. package/lib/fe-stack.mjs +0 -77
  151. package/lib/hfs.mjs +0 -49
  152. package/lib/next.mjs +0 -6
  153. package/lib/own-hook-origin.mjs +0 -53
  154. package/lib/program-for.mjs +0 -22
  155. package/lib/pure-owner-helper.mjs +0 -148
  156. package/lib/react.mjs +0 -28
  157. package/lib/redux-hook-origin.mjs +0 -44
  158. package/lib/typed-class-values.mjs +0 -81
  159. package/lib/types.mjs +0 -63
  160. package/lib/why.mjs +0 -352
  161. package/lists.mjs +0 -193
  162. package/loading.mjs +0 -156
  163. package/naming.mjs +0 -185
  164. package/native-controls.mjs +0 -108
  165. package/next-conventions.mjs +0 -447
  166. package/props-and-slots.mjs +0 -276
  167. package/served-locale.mjs +0 -243
  168. package/size-and-state-budget.mjs +0 -376
  169. package/size-growth.mjs +0 -62
  170. package/surface-rules.mjs +0 -443
  171. package/swr-family.mjs +0 -326
  172. package/the-split.mjs +0 -155
  173. package/tokens.mjs +0 -39
  174. package/translation.mjs +0 -392
  175. package/transport.mjs +0 -830
  176. package/type-safety.mjs +0 -147
  177. package/ui-rulings.mjs +0 -134
package/comments.mjs DELETED
@@ -1,159 +0,0 @@
1
- /**
2
- * The rules that hold `comments.md`.
3
- *
4
- * Both reach further than their names suggest, and deliberately. An emoji rule that read only JSX
5
- * would miss the one in a log message, so it walks comments, identifiers, string literals, template
6
- * chunks and JSX text alike - everywhere prose can hide in a source file.
7
- *
8
- * `no-vietnamese-in-source` holds the English-only law: identifiers, string literals, template text, JSX text,
9
- * comments and test titles carry no Vietnamese letter. There is no exemption marker. Detection is
10
- * structural on characters (`scripts/lib/language.mjs`, folded to NFC), never a word list.
11
- *
12
- * The exceptions that remain are placements, not judgements, and that is on purpose: a locale dictionary
13
- * IS the other language, and a fixture reproducing a real string has to reproduce it exactly. A
14
- * judgement-based exception would be argued per file forever. The Vietnamese rule has no placement exemption at all.
15
- */
16
-
17
- import { hasSecondLanguage } from "./runtime/scripts/lib/language.mjs"
18
- import { jsdocBefore } from "./runtime/scripts/lib/jsdoc.mjs"
19
- import { fileOf, inSlot } from "./lib/scope.mjs"
20
-
21
- /**
22
- * Extended pictographs, or a regional-indicator pair.
23
- *
24
- * Two tests rather than one character class, so the class stays free of joiners and combining marks
25
- * - a single class covering both trips the misleading-character-class rule, and a rule that has to
26
- * be silenced to exist is one nobody trusts.
27
- */
28
- const hasEmoji = (text) =>
29
- typeof text === "string" &&
30
- (/\p{Extended_Pictographic}/u.test(text) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text))
31
-
32
- /**
33
- * Files whose second-language text or emoji is CONTENT rather than authoring, decided by what the file IS.
34
- *
35
- * One kind only: the locale dictionaries (a `.json` file of an i18n slot, `fe.modules.i18n` or `fe.package.i18n`),
36
- * which are the product's other language. There is no "copy module": a `resources/` folder of strings is copy that
37
- * skipped the catalogue.
38
- *
39
- * @param {object} context - The ESLint rule context.
40
- * @returns {boolean} True when the linted file holds content rather than authoring.
41
- */
42
- export const isContentFile = (context) => {
43
- const file = fileOf(context)
44
- return inSlot(context, "fe.modules.i18n", "fe.package.i18n") && file.endsWith(".json")
45
- }
46
-
47
- /** Walk every place prose can hide, and hand each to one check. */
48
- const proseVisitors = (context, report) => {
49
- const source = context.sourceCode || context.getSourceCode()
50
- return {
51
- Program() {
52
- for (const comment of source.getAllComments()) report(comment, comment.value)
53
- },
54
- Identifier(node) {
55
- report(node, node.name)
56
- },
57
- JSXIdentifier(node) {
58
- report(node, node.name)
59
- },
60
- PrivateIdentifier(node) {
61
- report(node, node.name)
62
- },
63
- Literal(node) {
64
- if (typeof node.value === "string") report(node, node.value)
65
- },
66
- TemplateElement(node) {
67
- report(node, node.value && node.value.cooked)
68
- },
69
- JSXText(node) {
70
- report(node, node.value)
71
- },
72
- }
73
- }
74
-
75
- // -- COMMENTS-1 ------------------------------------------------------------------------------------
76
-
77
- /** Every export opens with a documentation block. */
78
- export const requireExportJsdoc = {
79
- meta: {
80
- type: "suggestion",
81
- docs: { description: "Exported declarations open with a documentation block." },
82
- schema: [],
83
- messages: {
84
- jsdoc:
85
- "`{{name}}` is exported without an adjacent JSDoc description. An export is read far more often than it is written, and by people who never open the body - name the ROLE it plays, not the signature, which the signature already states.",
86
- },
87
- },
88
- create(context) {
89
- const source = context.sourceCode || context.getSourceCode()
90
- const hasBlock = (node) => jsdocBefore(source, node) !== null
91
- const check = (node) => {
92
- const declaration = node.declaration
93
- if (!declaration) return
94
- const kinds = ["VariableDeclaration", "TSInterfaceDeclaration", "FunctionDeclaration", "TSTypeAliasDeclaration"]
95
- if (!kinds.includes(declaration.type) || hasBlock(node)) return
96
- const id =
97
- declaration.id || (declaration.declarations && declaration.declarations[0] && declaration.declarations[0].id)
98
- context.report({ node: id || declaration, messageId: "jsdoc", data: { name: (id && id.name) || "this export" } })
99
- }
100
- return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check }
101
- },
102
- }
103
-
104
- // -- COMMENTS-4 ------------------------------------------------------------------------------------
105
-
106
- /** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
107
- export const noEmojiInSource = {
108
- meta: {
109
- type: "problem",
110
- docs: { description: "No emoji in source authoring." },
111
- schema: [],
112
- messages: {
113
- emoji:
114
- "Unicode emoji in source authoring. It renders differently on every platform, sorts unpredictably, breaks a terminal that is not expecting it, and does not mean the same thing in two countries. Generic marks belong to the icon vocabulary; product reactions use the attributed checked-in SVG artwork through the reaction leaf.",
115
- },
116
- },
117
- create(context) {
118
- if (isContentFile(context)) return {}
119
- return proseVisitors(context, (node, text) => {
120
- if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
121
- })
122
- },
123
- }
124
-
125
- // -- COMMENTS-5 ------------------------------------------------------------------------------------
126
-
127
- /** Source prose is English: no Vietnamese letter in an identifier, string, comment, JSX text or test title. */
128
- export const noVietnameseInSource = {
129
- meta: {
130
- type: "problem",
131
- docs: { description: "No Vietnamese in source authoring, specs included." },
132
- schema: [],
133
- messages: {
134
- vietnamese:
135
- "A Vietnamese letter in source. Every reader has to be able to read all of the code, its comments and its test titles, and half of a two-language file is unavailable to somebody. Write it in English; product copy lives in the `messages/<locale>.json` catalogue behind `t()`; there is no pragma.",
136
- },
137
- },
138
- create(context) {
139
- return proseVisitors(context, (node, text) => {
140
- if (hasSecondLanguage(text)) context.report({ node, messageId: "vietnamese" })
141
- })
142
- },
143
- }
144
-
145
- /** The rules this law contributes to the plugin. */
146
- export const rules = {
147
- "require-export-jsdoc": requireExportJsdoc,
148
- "no-emoji-in-source": noEmojiInSource,
149
- "no-vietnamese-in-source": noVietnameseInSource,
150
- }
151
-
152
- /**
153
- * The level this law asks for, as the plugin's own opinion.
154
- *
155
- * `require-export-jsdoc` is the one a repository adopting this with history should expect a count
156
- * from - every undocumented export in the tree reports at once. The other two are usually already
157
- * at zero, because a second language in source is noticed by readers long before a rule arrives.
158
- */
159
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -1,77 +0,0 @@
1
- /** CONTROL-HEIGHT-1: one control height; the app never picks a larger step or mixes steps in one row. */
2
- import { attributeValue, stringOf } from "./lib/ast.mjs"
3
- import { importOf } from "./lib/bindings.mjs"
4
-
5
- const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|offset-pop))?$/
6
- const CONTROLS = new Set(["Button", "IconButton", "Switch", "Input", "SearchField", "Select", "Tabs", "SegmentedControl", "Textarea", "NumberField", "ComboBox"])
7
- /** A marketing hero call to action on a landing page is the one place the 48px step is allowed. */
8
- const LANDING_PATH = /[\\/](?:landing|hero|marketing)[\\/]|(?:Landing|Hero)[^\\/]*[\\/]/
9
- /** The default size and its two spellings: the vendor `md` and Grammar's `control` are the same 40px height. */
10
- const DEFAULT_SIZE = new Set(["md", "control"])
11
-
12
- /** The Grammar control a JSX name resolves to; a shadowed or foreign component of the same name is not one. */
13
- const controlOf = (context, name) => {
14
- let root = name
15
- const parts = []
16
- while (root.type === "JSXMemberExpression") {
17
- parts.unshift(root.property.name)
18
- root = root.object
19
- }
20
- if (root.type !== "JSXIdentifier") return null
21
- const imported = importOf(context, root)
22
- if (!imported || !GRAMMAR_ENTRY.test(imported.source)) return null
23
- const role = imported.imported ?? parts.shift()
24
- return role !== undefined && parts.length === 0 && CONTROLS.has(role) ? role : null
25
- }
26
-
27
- /** The statically authored size of a control: `control` when absent, null when it is dynamic. */
28
- const sizeOf = (opening) => {
29
- const attribute = opening.attributes.find((entry) => entry.type === "JSXAttribute" && entry.name.name === "size")
30
- if (!attribute) return "control"
31
- const text = stringOf(attributeValue(attribute))
32
- if (text === null) return null
33
- return DEFAULT_SIZE.has(text) ? "control" : text
34
- }
35
-
36
- /** The Grammar controls a row holds directly, looking through fragments. */
37
- const rowControls = (context, element) => element.children.flatMap((child) => {
38
- if (child.type === "JSXFragment") return rowControls(context, child)
39
- if (child.type !== "JSXElement") return []
40
- return controlOf(context, child.openingElement.name) === null ? [] : [child.openingElement]
41
- })
42
-
43
- const controlHeight = {
44
- meta: { type: "problem", docs: { description: "CONTROL-HEIGHT-1: one 40px control height, no larger step outside a landing hero, no mixed sizes in one row." }, schema: [], messages: {
45
- FE_CONTROL_HEIGHT: "FE_CONTROL_HEIGHT: {{detail}} Grammar draws every standalone control at one 40px height; omit size (sm is for a control nested in an item or a dense toolbar, lg only for a landing hero call to action).",
46
- } },
47
- create(context) {
48
- const landing = LANDING_PATH.test(context.filename)
49
- const reported = new Set()
50
- const report = (node, detail) => {
51
- if (reported.has(node)) return
52
- reported.add(node)
53
- context.report({ node, messageId: "FE_CONTROL_HEIGHT", data: { detail } })
54
- }
55
- return {
56
- JSXOpeningElement(opening) {
57
- const role = controlOf(context, opening.name)
58
- if (role === null) return
59
- const size = sizeOf(opening)
60
- if (size === "xl" || (size === "lg" && !landing)) report(opening, `${role} size="${size}" is a larger control step than the app chrome allows.`)
61
- },
62
- "JSXElement, JSXFragment"(element) {
63
- const controls = rowControls(context, element)
64
- const sizes = controls.map(sizeOf)
65
- if (sizes.includes(null) || new Set(sizes).size < 2) return
66
- const index = sizes.findIndex((size) => size !== sizes[0])
67
- report(controls[index], "this control sits in a row with controls of another size.")
68
- },
69
- }
70
- },
71
- }
72
-
73
- /** The one rule of the law. */
74
- export const rules = { "control-height": controlHeight }
75
-
76
- /** The rule reaches consumers at error. */
77
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/drawing.mjs DELETED
@@ -1,321 +0,0 @@
1
- /**
2
- * The rules that hold the drawing law (the `drawing` slot role: a surface's pure `component.tsx`).
3
- *
4
- * TWO AXES, AND ONLY ONE IS DRAWN. A surface's `state` is its SHAPE: a layout the owner approves as
5
- * one drawing. A slot's data status (loading, forbidden, error, empty) is not a shape; it is a
6
- * status recipe applied per resource, and it is never a drawn shape. Mixing the two is what made
7
- * every block ship four hand-drawn variants that disagreed with each other.
8
- *
9
- * THE PURE HALF TAKES ATOMS. `component.tsx` receives `{ state, props, on }`: `props` is data
10
- * (strings, numbers, booleans, arrays, plain objects), `on` is actions whose arguments are data,
11
- * and feature composition may also take named `render` callbacks inside `props` to render supplied
12
- * blocks. A layout alone also takes `children`, because the router puts the page there.
13
- *
14
- * ONLY ITS OWN INDEX MAY IMPORT IT. Other tiers use the connected export, so the pure half stays a
15
- * private drawing surface that audit can render from a fixture.
16
- *
17
- * Every rule reads one file. Types imported from elsewhere are trusted as data; only names this
18
- * file declares are followed, which is exact for the tree the convention produces and silent,
19
- * never wrong, beyond it.
20
- */
21
-
22
- import { classifyImport, roleOfFile } from "./lib/scope.mjs"
23
-
24
- import { inSplitTier, pureHalf } from "./lib/drawing-scope.mjs"
25
-
26
- /** Type names that carry a rendered tree rather than data. */
27
- const NON_ATOM_TYPE = /^(?:ReactNode|ReactElement|ReactPortal|JSX\.Element|Element|ComponentType|FC|FunctionComponent|ElementType|ComponentProps|RenderFunction)$/
28
-
29
- /** Feature composition's render callbacks return a rendered value and take only data. */
30
- const RENDER_RESULT_TYPE = /^(?:ReactNode|ReactElement|JSX\.Element)$/
31
- const RENDER_PROP_NAME = /^render(?:[A-Z][A-Za-z0-9]*)?$/
32
-
33
- /** The printed name of a type reference, qualified names included. */
34
- const typeNameOf = (node) => {
35
- const name = node?.typeName
36
- if (!name) return ""
37
- if (name.type === "Identifier") return name.name
38
- if (name.type === "TSQualifiedName") return `${typeNameOf({ typeName: name.left })}.${name.right.name}`
39
- return ""
40
- }
41
-
42
- /** Every type this file declares, by name, so references can be followed locally. */
43
- const collectLocalTypes = (program) => {
44
- const types = new Map()
45
- const visit = (statement) => {
46
- const declaration = statement?.type === "ExportNamedDeclaration" ? statement.declaration : statement
47
- if (declaration?.type === "TSTypeAliasDeclaration") types.set(declaration.id.name, declaration.typeAnnotation)
48
- if (declaration?.type === "TSInterfaceDeclaration") types.set(declaration.id.name, declaration.body)
49
- }
50
- for (const statement of program.body) visit(statement)
51
- return types
52
- }
53
-
54
- /** The members of an object-shaped type node, following local names and intersections. */
55
- const membersOf = (node, types, seen = new Set()) => {
56
- if (!node) return []
57
- if (node.type === "TSTypeLiteral") return node.members
58
- if (node.type === "TSInterfaceBody") return node.body
59
- if (node.type === "TSIntersectionType") return node.types.flatMap((part) => membersOf(part, types, seen))
60
- if (node.type === "TSTypeReference") {
61
- const name = typeNameOf(node)
62
- if (seen.has(name) || !types.has(name)) return []
63
- seen.add(name)
64
- return membersOf(types.get(name), types, seen)
65
- }
66
- return []
67
- }
68
-
69
- /** The key a property signature declares. */
70
- const keyOf = (member) => (member.key?.type === "Identifier" ? member.key.name : member.key?.value)
71
-
72
- // -- DRAWING-1 ----------------------------------------------------------------------------------
73
-
74
- /** The pure half takes atom data and actions; feature composition may receive named render callbacks. */
75
- export const basePropsAtom = {
76
- meta: {
77
- type: "problem",
78
- docs: { description: "A pure `XBase` takes `shape`, atom `props` and `on` actions; feature composition may receive named render callbacks in `props`." },
79
- schema: [],
80
- messages: {
81
- tree:
82
- "`{{path}}` hands a rendered tree (`{{type}}`) into the pure half. Write the child inside `component.tsx` and pass it atoms (ids, strings, flags); a tree handed in cannot be drawn, audited or reused.",
83
- action:
84
- "`{{path}}` is a function inside `props`. Actions live in `on`; feature composition may receive named `render` callbacks that take atoms and return rendered blocks.",
85
- onData:
86
- "`on.{{key}}` is not an action. `on` holds functions only; data belongs in `props`.",
87
- member:
88
- "`{{key}}` is not part of the pure contract. A pure half takes `shape`, `props` and `on`{{children}}.",
89
- renamed:
90
- "`state` is the retired name of the drawn shape: rename it `shape` (owner ruling fe-shape-not-state).",
91
- },
92
- },
93
- create(context) {
94
- const tier = pureHalf(context)
95
- if (!tier) return {}
96
- let types = new Map()
97
-
98
- /** Resolve render results locally; an optional empty result does not make data a render prop. */
99
- const isRenderResult = (node, seen = new Set()) => {
100
- if (!node) return false
101
- if (node.type === "TSUnionType") {
102
- const empty = (part) => ["TSNullKeyword", "TSUndefinedKeyword"].includes(part.type)
103
- || part.type === "TSLiteralType" && part.literal.value === null
104
- return node.types.some((part) => isRenderResult(part, new Set(seen)))
105
- && node.types.every((part) => empty(part) || isRenderResult(part, new Set(seen)))
106
- }
107
- if (node.type !== "TSTypeReference") return false
108
- const name = typeNameOf(node)
109
- if (RENDER_RESULT_TYPE.test(name.replace(/^React\./, ""))) return true
110
- if (!types.has(name) || seen.has(name)) return false
111
- return isRenderResult(types.get(name), new Set(seen).add(name))
112
- }
113
-
114
- const checkRenderCallback = (node, path, allowed) => {
115
- if (!allowed || !isRenderResult(node.returnType?.typeAnnotation)) return false
116
- for (const parameter of node.params ?? node.parameters ?? []) {
117
- checkData(parameter.typeAnnotation?.typeAnnotation, `${path}(${parameter.name ?? "arg"})`, new Set(), false, true)
118
- }
119
- return true
120
- }
121
-
122
- const checkDataReference = (node, path, seen, renderProp, renderParameter) => {
123
- const name = typeNameOf(node)
124
- if (NON_ATOM_TYPE.test(renderParameter ? name.replace(/^React\./, "") : name)) {
125
- context.report({ node, messageId: "tree", data: { path, type: name } })
126
- return
127
- }
128
- for (const argument of node.typeArguments?.params ?? node.typeParameters?.params ?? []) checkData(argument, path, seen, false, renderParameter)
129
- if (types.has(name) && !seen.has(name)) {
130
- const nextSeen = renderProp || renderParameter ? new Set(seen) : seen
131
- nextSeen.add(name)
132
- checkData(types.get(name), path, nextSeen, renderProp, renderParameter)
133
- }
134
- }
135
-
136
- const checkDataMember = (member, path, seen, renderParameter) => {
137
- const memberPath = `${path}.${keyOf(member)}`
138
- const allowsRender = tier !== "blocks" && path === "props" && RENDER_PROP_NAME.test(keyOf(member))
139
- if (member.type === "TSMethodSignature") {
140
- if (!checkRenderCallback(member, memberPath, allowsRender)) context.report({ node: member, messageId: "action", data: { path: memberPath } })
141
- } else if (member.type === "TSPropertySignature") {
142
- checkData(member.typeAnnotation?.typeAnnotation, memberPath, seen, allowsRender, renderParameter)
143
- }
144
- }
145
-
146
- /** Walk a data type: report trees and functions anywhere inside it. */
147
- const checkData = (node, path, seen, renderProp = false, renderParameter = false) => {
148
- if (!node) return
149
- switch (node.type) {
150
- case "TSFunctionType":
151
- if (checkRenderCallback(node, path, renderProp)) return
152
- context.report({ node, messageId: "action", data: { path } })
153
- return
154
- case "TSTypeReference":
155
- checkDataReference(node, path, seen, renderProp, renderParameter)
156
- return
157
- case "TSUnionType":
158
- case "TSIntersectionType":
159
- for (const part of node.types) checkData(part, path, seen, renderProp, renderParameter)
160
- return
161
- case "TSArrayType":
162
- checkData(node.elementType, path, seen, false, renderParameter)
163
- return
164
- case "TSTypeOperator":
165
- checkData(node.typeAnnotation, path, seen, false, renderParameter)
166
- return
167
- case "TSTypeLiteral":
168
- case "TSInterfaceBody":
169
- for (const member of node.members ?? node.body) checkDataMember(member, path, seen, renderParameter)
170
- return
171
- default:
172
- }
173
- }
174
-
175
- /** Walk `on`: every member is a function whose parameters are data. */
176
- const checkActions = (node) => {
177
- for (const member of membersOf(node, types)) {
178
- const key = keyOf(member)
179
- const type = member.type === "TSMethodSignature" ? member : member.typeAnnotation?.typeAnnotation
180
- if (type?.type !== "TSFunctionType" && type?.type !== "TSMethodSignature") {
181
- context.report({ node: member, messageId: "onData", data: { key } })
182
- continue
183
- }
184
- for (const parameter of type.params ?? type.parameters ?? []) {
185
- checkData(parameter.typeAnnotation?.typeAnnotation, `on.${key}(${parameter.name ?? "arg"})`, new Set())
186
- }
187
- }
188
- }
189
-
190
- return {
191
- Program(node) {
192
- types = collectLocalTypes(node)
193
- },
194
- VariableDeclarator(node) {
195
- if (node.id?.type !== "Identifier" || !/^[A-Z][A-Za-z0-9]*Base$/.test(node.id.name)) return
196
- const fn = node.init
197
- if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression") return
198
- const annotation = fn.params?.[0]?.typeAnnotation?.typeAnnotation
199
- for (const member of membersOf(annotation, types)) {
200
- const key = keyOf(member)
201
- const type = member.typeAnnotation?.typeAnnotation
202
- if (key === "shape") continue
203
- if (key === "state") {
204
- context.report({ node: member, messageId: "renamed" })
205
- continue
206
- }
207
- if (key === "props") checkData(type, "props", new Set())
208
- else if (key === "on") checkActions(type)
209
- else if (key === "children" && tier === "layouts") continue
210
- else {
211
- context.report({
212
- node: member,
213
- messageId: "member",
214
- data: { key, children: tier === "layouts" ? ", plus the router's `children`" : "" },
215
- })
216
- }
217
- }
218
- },
219
- }
220
- },
221
- }
222
-
223
- // -- DRAWING-2 ----------------------------------------------------------------------------------
224
-
225
- /** Only the sibling `index.tsx` (and its specs) may reach `component.tsx`; only `X` leaves the folder. */
226
- export const baseImportPair = {
227
- meta: {
228
- type: "problem",
229
- docs: { description: "A pure `component.tsx` is imported only by its sibling `index.tsx`, which never re-exports `XBase`." },
230
- schema: [],
231
- messages: {
232
- reach:
233
- "`{{source}}` reaches into another surface's pure half. Import that surface's connected export from its folder; the pure half is a private drawing surface.",
234
- reexport:
235
- "`index.tsx` re-exports `{{name}}`. The folder exports only its connected `X`; the pure `XBase` stays behind it so no other tier can bypass the owner.",
236
- },
237
- },
238
- create(context) {
239
- const isOwnIndex = roleOfFile(context) === "entry"
240
- const inTier = inSplitTier(context)
241
- // An import reaches a pure half when the file it resolves to has the `drawing` role of its slot.
242
- const reachesPure = (source) => classifyImport(context, source)?.role === "drawing"
243
-
244
- const check = (node, source) => {
245
- if (typeof source !== "string" || !reachesPure(source)) return
246
- const isSibling = /^\.\/[^/]+$/.test(source)
247
- if (isSibling && isOwnIndex) return
248
- if (isSibling && !inTier) return
249
- context.report({ node, messageId: "reach", data: { source } })
250
- }
251
-
252
- return {
253
- ImportDeclaration(node) {
254
- check(node, node.source?.value)
255
- },
256
- ExportAllDeclaration(node) {
257
- check(node, node.source?.value)
258
- },
259
- ExportNamedDeclaration(node) {
260
- const source = node.source?.value
261
- if (!source) return
262
- check(node, source)
263
- if (!isOwnIndex || !inTier || node.exportKind === "type") return
264
- for (const specifier of node.specifiers ?? []) {
265
- const name = specifier.exported?.name ?? specifier.local?.name
266
- if (specifier.exportKind !== "type" && /^[A-Z][A-Za-z0-9]*Base$/.test(name)) {
267
- context.report({ node: specifier, messageId: "reexport", data: { name } })
268
- }
269
- }
270
- },
271
- }
272
- },
273
- }
274
-
275
- // -- DRAWING-3 ----------------------------------------------------------------------------------
276
-
277
- /** Words that name a data status or an open/closed lifecycle, never a drawn shape. */
278
- const DATA_STATUS = new Set([
279
- "pending", "loading", "failed", "error", "errored", "empty", "forbidden", "unavailable",
280
- "skeleton", "closed", "idle", "refreshing", "retrying",
281
- ])
282
-
283
- /** A surface's `XShape` names shapes only. */
284
- export const noDataStatusShape = {
285
- meta: {
286
- type: "problem",
287
- docs: { description: "`XShape` of a pure half names drawn shapes, never a data status or open/closed." },
288
- schema: [],
289
- messages: {
290
- status:
291
- "`{{name}}` lists \"{{value}}\" as a shape. Loading, forbidden, error and empty describe resource status: pass them as data to the owned status renderer. Open/closed is a boolean atom. A shape is a layout the owner approves as one drawing.",
292
- },
293
- },
294
- create(context) {
295
- if (!pureHalf(context)) return {}
296
- const check = (name, node) => {
297
- const members = node?.type === "TSUnionType" ? node.types : [node]
298
- for (const member of members) {
299
- const value = member?.type === "TSLiteralType" ? member.literal?.value : undefined
300
- if (typeof value === "string" && DATA_STATUS.has(value)) {
301
- context.report({ node: member, messageId: "status", data: { name, value } })
302
- }
303
- }
304
- }
305
- return {
306
- TSTypeAliasDeclaration(node) {
307
- if (/^[A-Z][A-Za-z0-9]*(?:Shape|State)$/.test(node.id.name)) check(node.id.name, node.typeAnnotation)
308
- },
309
- }
310
- },
311
- }
312
-
313
- /** The rules this law contributes to the plugin. */
314
- export const rules = {
315
- "base-props-atom": basePropsAtom,
316
- "base-import-pair": baseImportPair,
317
- "no-data-status-shape": noDataStatusShape,
318
- }
319
-
320
- /** Every FE canon rule is an error; existing debt is fixed before adoption. */
321
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))