@starci/eslint-canon-fe 9.2.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 (178) hide show
  1. package/CHANGELOG.md +14 -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 +22 -14
  129. package/runtime/scripts/lib/package-at.mjs +5 -5
  130. package/brand-values.mjs +0 -172
  131. package/button-intent.mjs +0 -320
  132. package/callback-shape.mjs +0 -332
  133. package/class-names.mjs +0 -149
  134. package/client-boundary.mjs +0 -400
  135. package/comments.mjs +0 -159
  136. package/control-height.mjs +0 -77
  137. package/drawing.mjs +0 -315
  138. package/env-owner.mjs +0 -244
  139. package/fe-stack.mjs +0 -86
  140. package/file-layout.mjs +0 -472
  141. package/formatting.mjs +0 -110
  142. package/function-budget.mjs +0 -49
  143. package/grammar-shim.mjs +0 -123
  144. package/hooks-folder.mjs +0 -300
  145. package/hygiene.mjs +0 -513
  146. package/index.mjs +0 -317
  147. package/lib/ast.mjs +0 -97
  148. package/lib/bindings.mjs +0 -159
  149. package/lib/class-text.mjs +0 -29
  150. package/lib/config.mjs +0 -142
  151. package/lib/fe-stack.mjs +0 -77
  152. package/lib/hfs.mjs +0 -49
  153. package/lib/next.mjs +0 -6
  154. package/lib/own-hook-origin.mjs +0 -53
  155. package/lib/program-for.mjs +0 -22
  156. package/lib/pure-owner-helper.mjs +0 -148
  157. package/lib/react.mjs +0 -28
  158. package/lib/redux-hook-origin.mjs +0 -44
  159. package/lib/typed-class-values.mjs +0 -81
  160. package/lib/types.mjs +0 -63
  161. package/lib/why.mjs +0 -352
  162. package/lists.mjs +0 -193
  163. package/loading.mjs +0 -156
  164. package/naming.mjs +0 -185
  165. package/native-controls.mjs +0 -108
  166. package/next-conventions.mjs +0 -447
  167. package/props-and-slots.mjs +0 -276
  168. package/served-locale.mjs +0 -243
  169. package/size-and-state-budget.mjs +0 -376
  170. package/size-growth.mjs +0 -62
  171. package/surface-rules.mjs +0 -443
  172. package/swr-family.mjs +0 -326
  173. package/the-split.mjs +0 -155
  174. package/tokens.mjs +0 -39
  175. package/translation.mjs +0 -392
  176. package/transport.mjs +0 -830
  177. package/type-safety.mjs +0 -147
  178. package/ui-rulings.mjs +0 -134
package/drawing.mjs DELETED
@@ -1,315 +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 `state`, 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 `state`, `props` and `on`{{children}}.",
89
- },
90
- },
91
- create(context) {
92
- const tier = pureHalf(context)
93
- if (!tier) return {}
94
- let types = new Map()
95
-
96
- /** Resolve render results locally; an optional empty result does not make data a render prop. */
97
- const isRenderResult = (node, seen = new Set()) => {
98
- if (!node) return false
99
- if (node.type === "TSUnionType") {
100
- const empty = (part) => ["TSNullKeyword", "TSUndefinedKeyword"].includes(part.type)
101
- || part.type === "TSLiteralType" && part.literal.value === null
102
- return node.types.some((part) => isRenderResult(part, new Set(seen)))
103
- && node.types.every((part) => empty(part) || isRenderResult(part, new Set(seen)))
104
- }
105
- if (node.type !== "TSTypeReference") return false
106
- const name = typeNameOf(node)
107
- if (RENDER_RESULT_TYPE.test(name.replace(/^React\./, ""))) return true
108
- if (!types.has(name) || seen.has(name)) return false
109
- return isRenderResult(types.get(name), new Set(seen).add(name))
110
- }
111
-
112
- const checkRenderCallback = (node, path, allowed) => {
113
- if (!allowed || !isRenderResult(node.returnType?.typeAnnotation)) return false
114
- for (const parameter of node.params ?? node.parameters ?? []) {
115
- checkData(parameter.typeAnnotation?.typeAnnotation, `${path}(${parameter.name ?? "arg"})`, new Set(), false, true)
116
- }
117
- return true
118
- }
119
-
120
- const checkDataReference = (node, path, seen, renderProp, renderParameter) => {
121
- const name = typeNameOf(node)
122
- if (NON_ATOM_TYPE.test(renderParameter ? name.replace(/^React\./, "") : name)) {
123
- context.report({ node, messageId: "tree", data: { path, type: name } })
124
- return
125
- }
126
- for (const argument of node.typeArguments?.params ?? node.typeParameters?.params ?? []) checkData(argument, path, seen, false, renderParameter)
127
- if (types.has(name) && !seen.has(name)) {
128
- const nextSeen = renderProp || renderParameter ? new Set(seen) : seen
129
- nextSeen.add(name)
130
- checkData(types.get(name), path, nextSeen, renderProp, renderParameter)
131
- }
132
- }
133
-
134
- const checkDataMember = (member, path, seen, renderParameter) => {
135
- const memberPath = `${path}.${keyOf(member)}`
136
- const allowsRender = tier !== "blocks" && path === "props" && RENDER_PROP_NAME.test(keyOf(member))
137
- if (member.type === "TSMethodSignature") {
138
- if (!checkRenderCallback(member, memberPath, allowsRender)) context.report({ node: member, messageId: "action", data: { path: memberPath } })
139
- } else if (member.type === "TSPropertySignature") {
140
- checkData(member.typeAnnotation?.typeAnnotation, memberPath, seen, allowsRender, renderParameter)
141
- }
142
- }
143
-
144
- /** Walk a data type: report trees and functions anywhere inside it. */
145
- const checkData = (node, path, seen, renderProp = false, renderParameter = false) => {
146
- if (!node) return
147
- switch (node.type) {
148
- case "TSFunctionType":
149
- if (checkRenderCallback(node, path, renderProp)) return
150
- context.report({ node, messageId: "action", data: { path } })
151
- return
152
- case "TSTypeReference":
153
- checkDataReference(node, path, seen, renderProp, renderParameter)
154
- return
155
- case "TSUnionType":
156
- case "TSIntersectionType":
157
- for (const part of node.types) checkData(part, path, seen, renderProp, renderParameter)
158
- return
159
- case "TSArrayType":
160
- checkData(node.elementType, path, seen, false, renderParameter)
161
- return
162
- case "TSTypeOperator":
163
- checkData(node.typeAnnotation, path, seen, false, renderParameter)
164
- return
165
- case "TSTypeLiteral":
166
- case "TSInterfaceBody":
167
- for (const member of node.members ?? node.body) checkDataMember(member, path, seen, renderParameter)
168
- return
169
- default:
170
- }
171
- }
172
-
173
- /** Walk `on`: every member is a function whose parameters are data. */
174
- const checkActions = (node) => {
175
- for (const member of membersOf(node, types)) {
176
- const key = keyOf(member)
177
- const type = member.type === "TSMethodSignature" ? member : member.typeAnnotation?.typeAnnotation
178
- if (type?.type !== "TSFunctionType" && type?.type !== "TSMethodSignature") {
179
- context.report({ node: member, messageId: "onData", data: { key } })
180
- continue
181
- }
182
- for (const parameter of type.params ?? type.parameters ?? []) {
183
- checkData(parameter.typeAnnotation?.typeAnnotation, `on.${key}(${parameter.name ?? "arg"})`, new Set())
184
- }
185
- }
186
- }
187
-
188
- return {
189
- Program(node) {
190
- types = collectLocalTypes(node)
191
- },
192
- VariableDeclarator(node) {
193
- if (node.id?.type !== "Identifier" || !/^[A-Z][A-Za-z0-9]*Base$/.test(node.id.name)) return
194
- const fn = node.init
195
- if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression") return
196
- const annotation = fn.params?.[0]?.typeAnnotation?.typeAnnotation
197
- for (const member of membersOf(annotation, types)) {
198
- const key = keyOf(member)
199
- const type = member.typeAnnotation?.typeAnnotation
200
- if (key === "state") continue
201
- if (key === "props") checkData(type, "props", new Set())
202
- else if (key === "on") checkActions(type)
203
- else if (key === "children" && tier === "layouts") continue
204
- else {
205
- context.report({
206
- node: member,
207
- messageId: "member",
208
- data: { key, children: tier === "layouts" ? ", plus the router's `children`" : "" },
209
- })
210
- }
211
- }
212
- },
213
- }
214
- },
215
- }
216
-
217
- // -- DRAWING-2 ----------------------------------------------------------------------------------
218
-
219
- /** Only the sibling `index.tsx` (and its specs) may reach `component.tsx`; only `X` leaves the folder. */
220
- export const baseImportPair = {
221
- meta: {
222
- type: "problem",
223
- docs: { description: "A pure `component.tsx` is imported only by its sibling `index.tsx`, which never re-exports `XBase`." },
224
- schema: [],
225
- messages: {
226
- reach:
227
- "`{{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.",
228
- reexport:
229
- "`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.",
230
- },
231
- },
232
- create(context) {
233
- const isOwnIndex = roleOfFile(context) === "entry"
234
- const inTier = inSplitTier(context)
235
- // An import reaches a pure half when the file it resolves to has the `drawing` role of its slot.
236
- const reachesPure = (source) => classifyImport(context, source)?.role === "drawing"
237
-
238
- const check = (node, source) => {
239
- if (typeof source !== "string" || !reachesPure(source)) return
240
- const isSibling = /^\.\/[^/]+$/.test(source)
241
- if (isSibling && isOwnIndex) return
242
- if (isSibling && !inTier) return
243
- context.report({ node, messageId: "reach", data: { source } })
244
- }
245
-
246
- return {
247
- ImportDeclaration(node) {
248
- check(node, node.source?.value)
249
- },
250
- ExportAllDeclaration(node) {
251
- check(node, node.source?.value)
252
- },
253
- ExportNamedDeclaration(node) {
254
- const source = node.source?.value
255
- if (!source) return
256
- check(node, source)
257
- if (!isOwnIndex || !inTier || node.exportKind === "type") return
258
- for (const specifier of node.specifiers ?? []) {
259
- const name = specifier.exported?.name ?? specifier.local?.name
260
- if (specifier.exportKind !== "type" && /^[A-Z][A-Za-z0-9]*Base$/.test(name)) {
261
- context.report({ node: specifier, messageId: "reexport", data: { name } })
262
- }
263
- }
264
- },
265
- }
266
- },
267
- }
268
-
269
- // -- DRAWING-3 ----------------------------------------------------------------------------------
270
-
271
- /** Words that name a data status or an open/closed lifecycle, never a drawn shape. */
272
- const DATA_STATUS = new Set([
273
- "pending", "loading", "failed", "error", "errored", "empty", "forbidden", "unavailable",
274
- "skeleton", "closed", "idle", "refreshing", "retrying",
275
- ])
276
-
277
- /** A surface's `XState` names shapes only. */
278
- export const noDataStatusShape = {
279
- meta: {
280
- type: "problem",
281
- docs: { description: "`XState` of a pure half names drawn shapes, never a data status or open/closed." },
282
- schema: [],
283
- messages: {
284
- status:
285
- "`{{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.",
286
- },
287
- },
288
- create(context) {
289
- if (!pureHalf(context)) return {}
290
- const check = (name, node) => {
291
- const members = node?.type === "TSUnionType" ? node.types : [node]
292
- for (const member of members) {
293
- const value = member?.type === "TSLiteralType" ? member.literal?.value : undefined
294
- if (typeof value === "string" && DATA_STATUS.has(value)) {
295
- context.report({ node: member, messageId: "status", data: { name, value } })
296
- }
297
- }
298
- }
299
- return {
300
- TSTypeAliasDeclaration(node) {
301
- if (/^[A-Z][A-Za-z0-9]*State$/.test(node.id.name)) check(node.id.name, node.typeAnnotation)
302
- },
303
- }
304
- },
305
- }
306
-
307
- /** The rules this law contributes to the plugin. */
308
- export const rules = {
309
- "base-props-atom": basePropsAtom,
310
- "base-import-pair": baseImportPair,
311
- "no-data-status-shape": noDataStatusShape,
312
- }
313
-
314
- /** Every FE canon rule is an error; existing debt is fixed before adoption. */
315
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/env-owner.mjs DELETED
@@ -1,244 +0,0 @@
1
- /**
2
- * The rules that hold `env-owner.md` (HFS R49, `FE_ENV_OWNER`).
3
- *
4
- * TWO FACTS, ONE OWNER. The environment is read in exactly one place, `modules/config`, and what it
5
- * reads has no default that stands in for a real deployment value. They are one law because they
6
- * fail together: an app with nine copies of `process.env.API_URL ?? "http://localhost:3068"` had
7
- * nine readers and nine silent defaults, so a production build with the variable missing talked to
8
- * a developer's laptop and looked healthy.
9
- *
10
- * The rules cannot see whether `modules/config` throws when a variable is missing in production -
11
- * that is a behaviour, and the module's own spec is where it is proven. What they hold is the
12
- * shape that makes it provable: one reader, and no literal fallback next to it.
13
- */
14
-
15
- import { isConfigModule } from "./lib/scope.mjs"
16
-
17
- /** `process.env`, or `import.meta.env`. */
18
- const isEnvObject = (node) => {
19
- if (!node || node.type !== "MemberExpression" || node.computed) return false
20
- const name = node.property && node.property.name
21
- if (name !== "env") return false
22
- const object = node.object
23
- if (object.type === "Identifier" && object.name === "process") return true
24
- return (
25
- object.type === "MetaProperty" && object.meta.name === "import" && object.property.name === "meta"
26
- )
27
- }
28
-
29
- /** The variable name of `process.env.NAME` / `process.env["NAME"]`, or null. */
30
- const envVariableName = (node) => {
31
- if (!node || node.type !== "MemberExpression" || !isEnvObject(node.object)) return null
32
- if (!node.computed && node.property.type === "Identifier") return node.property.name
33
- if (node.computed && node.property.type === "Literal" && typeof node.property.value === "string") {
34
- return node.property.value
35
- }
36
- return null
37
- }
38
-
39
- /** Static text of a literal or an expression-free template, else null. */
40
- const staticText = (node) => {
41
- if (!node) return null
42
- if (node.type === "Literal" && typeof node.value === "string") return node.value
43
- if (node.type === "TemplateLiteral") {
44
- return node.quasis.map((quasi) => quasi.value.cooked ?? "").join("")
45
- }
46
- return null
47
- }
48
-
49
- /** An absolute or protocol-relative URL: what a fallback must never carry. */
50
- const URL_TEXT = /^(?:https?:)?\/\/\S+/i
51
-
52
- /** Variable names whose value is a secret or an address: a default for one is a hole in a deployment. */
53
- const SENSITIVE_NAME = /(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|URL|URI|HOST|ENDPOINT|ORIGIN)$/i
54
-
55
- /** Type assertions and optional chains preserve the identity of the wrapped value. */
56
- const unwrapped = (node) => {
57
- while (node && ["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSSatisfiesExpression", "ChainExpression"].includes(node.type)) node = node.expression
58
- return node
59
- }
60
-
61
- /** The binding of this identifier, rather than every variable with the same spelling. */
62
- const bindingOf = (sourceCode, identifier) => {
63
- for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
64
- const variable = scope.set.get(identifier.name)
65
- if (variable) return variable
66
- }
67
- return null
68
- }
69
-
70
- /** Prior writes in the use's execution scope or its parents; a closure can capture a later initializer. */
71
- const writeBefore = (sourceCode, variable, node) => {
72
- const scopes = new Set()
73
- for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) scopes.add(scope.variableScope)
74
- const writes = variable.references.filter((reference) => reference.isWrite()
75
- && scopes.has(reference.from.variableScope)
76
- && !(reference.writeExpr && reference.writeExpr.range[0] <= node.range[0] && node.range[1] <= reference.writeExpr.range[1]))
77
- return writes
78
- .filter((reference) => reference.identifier.range[0] < node.range[0])
79
- .sort((left, right) => right.identifier.range[0] - left.identifier.range[0])[0]
80
- ?? writes.find((reference) => reference.init)
81
- }
82
-
83
- /** Property extraction between a write's source and its destructured binding. */
84
- const propertyPath = (identifier) => {
85
- const properties = []
86
- for (let node = identifier; node.parent; node = node.parent) {
87
- const parent = node.parent
88
- if (parent.type === "Property" && parent.value === node) properties.unshift(parent)
89
- else if (parent.type !== "ObjectPattern" && !(parent.type === "AssignmentPattern" && parent.left === node)) break
90
- }
91
- return properties
92
- }
93
-
94
- /** Known environment containers and constant computed keys, followed through local aliases. */
95
- const environmentIdentity = (sourceCode) => {
96
- const textOf = (node, seen = new Set()) => {
97
- node = unwrapped(node)
98
- if (!node || seen.has(node)) return null
99
- if (node.type === "Literal" && typeof node.value === "string") return node.value
100
- if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked
101
- if (node.type !== "Identifier") return null
102
- const variable = bindingOf(sourceCode, node)
103
- if (!variable) return null
104
- const write = writeBefore(sourceCode, variable, node)
105
- if (!write?.writeExpr || propertyPath(write.identifier).length > 0) return null
106
- return textOf(write.writeExpr, new Set([...seen, node]))
107
- }
108
- const keyOf = (node) => node.computed ? textOf(node.key ?? node.property) : (node.key ?? node.property).name ?? (node.key ?? node.property).value
109
- const propertyKind = (kind, key) => {
110
- if (kind === "global" && key === "process") return "process"
111
- if ((kind === "process" || kind === "meta") && key === "env") return "env"
112
- return null
113
- }
114
- const kindOf = (node, seen = new Set()) => {
115
- node = unwrapped(node)
116
- if (!node || seen.has(node)) return null
117
- if (node.type === "MetaProperty" && node.meta.name === "import" && node.property.name === "meta") return "meta"
118
- if (node.type === "MemberExpression") return propertyKind(kindOf(node.object, new Set([...seen, node])), keyOf(node))
119
- if (node.type !== "Identifier") return null
120
- const variable = bindingOf(sourceCode, node)
121
- if (!variable || variable.defs.length === 0) {
122
- if (node.name === "process") return "process"
123
- return ["globalThis", "global"].includes(node.name) ? "global" : null
124
- }
125
- const imported = variable.defs.find((definition) => definition.type === "ImportBinding" && ["process", "node:process"].includes(definition.parent.source.value))
126
- if (imported) {
127
- if (imported.parent.importKind === "type" || imported.node.importKind === "type") return null
128
- if (imported.node.type !== "ImportSpecifier") return "process"
129
- const name = imported.node.imported.name ?? imported.node.imported.value
130
- return name === "default" ? "process" : name === "env" ? "env" : null
131
- }
132
- const write = writeBefore(sourceCode, variable, node)
133
- if (!write?.writeExpr) return null
134
- let kind = kindOf(write.writeExpr, new Set([...seen, node]))
135
- for (const property of propertyPath(write.identifier)) kind = propertyKind(kind, keyOf(property))
136
- return kind
137
- }
138
- const readsEnv = (pattern, kind) => {
139
- if (pattern.type === "AssignmentPattern") return readsEnv(pattern.left, kind)
140
- if (pattern.type !== "ObjectPattern") return false
141
- return pattern.properties.some((property) => {
142
- if (property.type !== "Property") return false
143
- const valueKind = propertyKind(kind, keyOf(property))
144
- return valueKind === "env" || readsEnv(property.value, valueKind)
145
- })
146
- }
147
- return { kindOf, readsEnv }
148
- }
149
-
150
- // -- FE-ENV-1 --------------------------------------------------------------------------------------
151
-
152
- /** Only `modules/config` reads the environment. */
153
- export const noEnvOutsideConfig = {
154
- meta: {
155
- type: "problem",
156
- docs: { description: "`process.env` and `import.meta.env` are read only in `modules/config`." },
157
- schema: [],
158
- messages: {
159
- env:
160
- "This file reads the environment. Only `modules/config` may: it is the one place that can fail loudly when a production variable is missing. Read the value from the config module's typed export instead.",
161
- },
162
- },
163
- create(context) {
164
- if (isConfigModule(context)) return {}
165
- const members = []
166
- const destructures = []
167
- return {
168
- ImportDeclaration(node) {
169
- if (node.importKind === "type" || !["process", "node:process"].includes(node.source.value)) return
170
- for (const specifier of node.specifiers) {
171
- if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type" && (specifier.imported.name ?? specifier.imported.value) === "env") context.report({ node: specifier, messageId: "env" })
172
- }
173
- },
174
- MemberExpression(node) {
175
- members.push(node)
176
- },
177
- VariableDeclarator(node) {
178
- if (node.id.type === "ObjectPattern" && node.init) destructures.push({ node, pattern: node.id, value: node.init })
179
- },
180
- AssignmentExpression(node) {
181
- if (node.operator === "=" && node.left.type === "ObjectPattern") destructures.push({ node, pattern: node.left, value: node.right })
182
- },
183
- AssignmentPattern(node) {
184
- if (node.left.type === "ObjectPattern") destructures.push({ node, pattern: node.left, value: node.right })
185
- },
186
- "Program:exit"() {
187
- const { kindOf, readsEnv } = environmentIdentity(context.sourceCode ?? context.getSourceCode())
188
- for (const node of members) {
189
- if (kindOf(node) === "env") context.report({ node, messageId: "env" })
190
- }
191
- for (const { node, pattern, value } of destructures) {
192
- if (readsEnv(pattern, kindOf(value))) context.report({ node, messageId: "env" })
193
- }
194
- },
195
- }
196
- },
197
- }
198
-
199
- // -- FE-ENV-2 --------------------------------------------------------------------------------------
200
-
201
- /** A fallback never stands in for a deployment address or secret. */
202
- export const noHardcodedEndpointFallback = {
203
- meta: {
204
- type: "problem",
205
- docs: { description: "No URL literal as a fallback, and no literal default for an address or secret variable." },
206
- schema: [],
207
- messages: {
208
- url:
209
- "A URL literal used as a fallback (`?? \"http://...\"`, `|| \"http://...\"` or a parameter default). A missing variable then silently points production at a developer's machine. Read the address from `modules/config`, which throws when it is absent.",
210
- variable:
211
- "`{{name}}` is an address or secret variable with a literal default. A default here makes a misconfigured deployment look healthy. Remove the default and let `modules/config` fail loudly when it is missing.",
212
- },
213
- },
214
- create(context) {
215
- return {
216
- LogicalExpression(node) {
217
- if (node.operator !== "??" && node.operator !== "||") return
218
- const text = staticText(node.right)
219
- if (text === null) return
220
- if (URL_TEXT.test(text)) {
221
- context.report({ node: node.right, messageId: "url" })
222
- return
223
- }
224
- const name = envVariableName(node.left)
225
- if (name && text !== "" && SENSITIVE_NAME.test(name)) {
226
- context.report({ node: node.right, messageId: "variable", data: { name } })
227
- }
228
- },
229
- AssignmentPattern(node) {
230
- const text = staticText(node.right)
231
- if (text !== null && URL_TEXT.test(text)) context.report({ node: node.right, messageId: "url" })
232
- },
233
- }
234
- },
235
- }
236
-
237
- /** The rules this law contributes to the plugin. */
238
- export const rules = {
239
- "no-env-outside-config": noEnvOutsideConfig,
240
- "no-hardcoded-endpoint-fallback": noHardcodedEndpointFallback,
241
- }
242
-
243
- /** Both are exact syntactic shapes, and both are errors. */
244
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/fe-stack.mjs DELETED
@@ -1,86 +0,0 @@
1
- /**
2
- * The rule that holds the front-end stack (`knowledge/hfs/fe-stack.yaml`, failure code `FE_STACK_FORBIDDEN`).
3
- *
4
- * ONE LIBRARY PER CONCERN. Every common front-end concern - GraphQL, REST, data hooks, toast, forms, state, realtime, i18n, UI,
5
- * icons, motion, dates, numbers, storage, charts, markdown, code - has one canonical library, the same for the lite and the full
6
- * edition (owner ruling 2026-10-13). A second library for a concern is a second set of conventions a reader has to know and a
7
- * second dependency a release has to keep current, and it always arrives one import at a time. The catalog names, per concern,
8
- * the alternatives a front end never imports; this rule refuses exactly those, read from the catalog, so the list lives once.
9
- *
10
- * SUPABASE IS AUTH ONLY. With a Supabase connection the front end holds the Supabase Auth client and nothing else of Supabase
11
- * (the data path is GraphQL to the back end): `@supabase/*` is imported only by `modules/auth` (slot `fe.modules.auth`).
12
- *
13
- * WHAT IS LEFT TO SIBLINGS. A canonical library imported in the wrong place (Apollo outside `modules/api`, axios outside its REST
14
- * calls, socket.io-client outside `hooks/socketio`) and `fetch` outside the API layer are `fetch-only-in-api-client`'s findings;
15
- * a glyph library outside the icon leaf is the icon law's. Each import is judged by one rule.
16
- */
17
- import { forbiddingConcern, packageNameOf } from "./lib/fe-stack.mjs"
18
- import { globalReferences, inSlot, isProductSource } from "./lib/scope.mjs"
19
-
20
- /** The package scope only the identity-provider client may import. */
21
- const SUPABASE_SCOPE = "@supabase/"
22
-
23
- /**
24
- * No import (static, re-export, dynamic or `require`) of a package the fe-stack catalog forbids, and no `@supabase/*` outside
25
- * `modules/auth`. Type-only imports count: a type import still makes the package a dependency of the front end.
26
- */
27
- export const feStackImport = {
28
- meta: {
29
- type: "problem",
30
- docs: { description: "A front end imports no library the fe-stack catalog forbids, and `@supabase/*` only in `modules/auth`." },
31
- schema: [],
32
- messages: {
33
- forbidden:
34
- "`{{name}}` is a forbidden alternative for the {{concern}} concern of the front-end stack. The stack has one library per concern, the same in every app and both editions: use {{canonical}} ({{pattern}}). {{why}}",
35
- supabase:
36
- "`{{name}}` is imported outside `modules/auth`. Supabase on the front end is the auth client only, held by `modules/auth` (the session, the access token the Apollo link attaches, sign-in and sign-out); data is read and written through the GraphQL operations of `modules/api`, never through Supabase from the browser.",
37
- },
38
- },
39
- create(context) {
40
- if (!isProductSource(context)) return {}
41
- const authOwner = inSlot(context, "fe.modules.auth")
42
- const judge = (node, source) => {
43
- const name = packageNameOf(source)
44
- if (name === null) return
45
- const concern = forbiddingConcern(source)
46
- if (concern) {
47
- const named = concern.packages?.length > 0 ? concern.packages : concern.home ?? []
48
- const canonical = named.map((entry) => `\`${entry}\``).join(", ")
49
- context.report({ node, messageId: "forbidden", data: { name, concern: concern.id, canonical, pattern: concern.pattern ?? "", why: concern.why ?? "" } })
50
- return
51
- }
52
- if (name.startsWith(SUPABASE_SCOPE) && !authOwner) context.report({ node, messageId: "supabase", data: { name } })
53
- }
54
- return {
55
- ImportDeclaration(node) {
56
- judge(node, node.source.value)
57
- },
58
- ExportNamedDeclaration(node) {
59
- if (node.source) judge(node, node.source.value)
60
- },
61
- ExportAllDeclaration(node) {
62
- judge(node, node.source.value)
63
- },
64
- ImportExpression(node) {
65
- if (node.source.type === "Literal") judge(node, node.source.value)
66
- },
67
- TSImportEqualsDeclaration(node) {
68
- // `import x = require("moment")`
69
- if (node.moduleReference?.type === "TSExternalModuleReference" && node.moduleReference.expression?.type === "Literal") judge(node, node.moduleReference.expression.value)
70
- },
71
- CallExpression(node) {
72
- // `require("moment")` where `require` is the CommonJS global.
73
- if (node.callee.type !== "Identifier" || node.callee.name !== "require" || node.arguments[0]?.type !== "Literal") return
74
- if (globalReferences(context, new Set(["require"])).includes(node.callee)) judge(node, node.arguments[0].value)
75
- },
76
- }
77
- },
78
- }
79
-
80
- /** The rules this law contributes to the plugin. */
81
- export const rules = {
82
- "fe-stack-import": feStackImport,
83
- }
84
-
85
- /** Every rule is an error: a second library for a concern is the defect, not a style. */
86
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))