@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/swr-family.mjs DELETED
@@ -1,326 +0,0 @@
1
- /** R285: API operations are wrapped by the SWR family before product code calls them. */
2
- import { hfsOf } from "./lib/hfs.mjs"
3
- import { readFileSync, readdirSync } from "node:fs"
4
- import { dirname, join, resolve } from "node:path"
5
- import { stringOf, walk } from "./lib/ast.mjs"
6
- import { functionOf } from "./lib/bindings.mjs"
7
- import { classOf, fileOf, globalReferences, isApiLayer, isProductSource, resolveImport } from "./lib/scope.mjs"
8
- import { why } from "./lib/why.mjs"
9
-
10
- /** The SWR family names its wrapper files; entries and side-effect components are not wrappers. */
11
- const isSwrHook = (context) => {
12
- const found = classOf(context)
13
- return found.slot === "fe.hooks.swr" && !found.role && hfsOf(context).allows(fileOf(context))?.allowed === true
14
- }
15
-
16
- /** Cache providers and revalidation may be used by consumers; only request hooks own operations. */
17
- const isRequestHook = (name) => /^useSWR(?:Immutable|Infinite|Mutation|Subscription)?$/.test(name) || name === "default"
18
- const swrSource = (value) => value === "swr" || value.startsWith("swr/")
19
- const propertyName = (node) => node.computed ? node.property.value : node.property.name
20
-
21
- /** Runtime imports of the request hooks (including aliases) are confined to named SWR wrappers. */
22
- export const swrOnlyInFamily = {
23
- meta: {
24
- type: "problem",
25
- docs: { description: "FE_SWR_OUTSIDE_FAMILY: request hooks belong to fe.hooks.swr." },
26
- schema: [],
27
- messages: { outside: "FE_SWR_OUTSIDE_FAMILY: SWR request hooks belong in hooks/swr/. Call the operation's wrapper hook here; cache providers and revalidation remain available to consumers." },
28
- },
29
- create(context) {
30
- if (!isProductSource(context) || isSwrHook(context)) return {}
31
- const namespaces = new Set()
32
- const report = (node) => context.report({ node, messageId: "outside" })
33
- return {
34
- ImportDeclaration(node) {
35
- if (node.importKind === "type" || !swrSource(String(node.source.value))) return
36
- const runtime = node.specifiers.filter((s) => s.importKind !== "type")
37
- if (runtime.some((s) => s.type === "ImportDefaultSpecifier" ||
38
- (s.type === "ImportSpecifier" && isRequestHook(s.imported.name ?? s.imported.value)))) report(node)
39
- for (const specifier of runtime) if (specifier.type === "ImportNamespaceSpecifier") namespaces.add(specifier)
40
- },
41
- ExportNamedDeclaration(node) {
42
- if (!node.source || node.exportKind === "type" || !swrSource(String(node.source.value))) return
43
- if (node.specifiers.some((s) => s.exportKind !== "type" && isRequestHook(s.local.name ?? s.local.value))) report(node)
44
- },
45
- ExportAllDeclaration(node) {
46
- if (node.exportKind !== "type" && swrSource(String(node.source.value))) report(node)
47
- },
48
- ImportExpression(node) {
49
- if (node.source.type === "Literal" && swrSource(String(node.source.value))) report(node)
50
- },
51
- CallExpression(node) {
52
- if (node.callee.type === "Identifier" && node.callee.name === "require" && node.arguments[0]?.type === "Literal" &&
53
- swrSource(String(node.arguments[0].value)) && globalReferences(context, new Set(["require"])).includes(node.callee)) report(node)
54
- },
55
- "Program:exit"() {
56
- const source = context.sourceCode ?? context.getSourceCode()
57
- for (const specifier of namespaces) {
58
- const variable = source.getDeclaredVariables(specifier.parent).find((v) => v.name === specifier.local.name)
59
- for (const reference of variable?.references ?? []) {
60
- const id = reference.identifier
61
- const member = id.parent
62
- // Passing or destructuring the namespace also makes its request hooks available outside the family.
63
- if (member.type !== "MemberExpression" || member.object !== id || member.computed && member.property.type !== "Literal" ||
64
- isRequestHook(propertyName(member))) report(id)
65
- }
66
- }
67
- },
68
- }
69
- },
70
- }
71
-
72
- /** The slot of the stream reader: only a stream operation of the API layer imports it, never an SWR hook, even inside a fetcher. */
73
- const STREAM_READER_SLOT = "fe.transport.stream"
74
-
75
- /** Type imports stay available; the API layer may compose its own operations and helpers. */
76
- const isOperationSource = (context, value, only) => {
77
- if (typeof value !== "string") return false
78
- const hfs = hfsOf(context)
79
- let target = resolveImport(context, value)
80
- if (target === null) {
81
- // Workspace package names are read from their actual manifests, never inferred from a suffix.
82
- let folders
83
- try { folders = readdirSync(join(hfs.repoRoot, "packages"), { withFileTypes: true }) } catch { return false }
84
- for (const folder of folders) {
85
- if (!folder.isDirectory()) continue
86
- const root = join(hfs.repoRoot, "packages", folder.name)
87
- if (hfs.slotOf(join(root, "src", "index.ts")) !== "fe.package.api") continue
88
- let name
89
- try { name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name } catch { continue }
90
- if (typeof name !== "string" || !(value === name || value.startsWith(`${name}/`))) continue
91
- target = value === name ? join(root, "src", "index.ts") : join(root, "src", value.slice(name.length + 1))
92
- break
93
- }
94
- if (target === null) return false
95
- }
96
- // Prefer the actual file before a hypothetical barrel: outcome types have a more specific slot.
97
- const candidates = /\.[cm]?[jt]sx?$/.test(target) ? [target] : [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`, target]
98
- const found = candidates.map((file) => hfs.classify(file)).filter((entry) => entry.status === "owned")
99
- if (found.some((entry) => ["fe.transport.outcome", "fe.package.api.outcome"].includes(entry.slot))) return false
100
- if (only !== undefined) return found.some((entry) => entry.slot === only)
101
- return found.some((entry) => ["fe.modules.api", "fe.package.api", "fe.transport.client", "fe.transport.stream", "fe.package.api.client"].includes(entry.slot))
102
- }
103
-
104
- /** The generated input-bounds module holds constants and a pure reader derived from the contract, never an operation. */
105
- const BOUNDS_MODULE = /(?:^|\/)__generated__\/input-bounds\.ts$/
106
- const RE_EXPORT = /export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g
107
- const ENTRY = /^\s*(type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/
108
-
109
- /** True when the named export of `target` is, through a chain of relative named re-exports, defined by a generated input-bounds module. */
110
- const isBoundsExport = (target, name, depth = 0) => {
111
- if (depth > 4) return false
112
- for (const file of [`${target}.ts`, `${target}/index.ts`, target]) {
113
- if (BOUNDS_MODULE.test(file.replaceAll("\\", "/"))) return true
114
- let text
115
- try { text = readFileSync(file, "utf8") } catch { continue }
116
- for (const [, list, source] of text.matchAll(RE_EXPORT)) {
117
- if (!source.startsWith(".")) continue
118
- for (const entry of list.split(",")) {
119
- const [, kind, origin, exported] = ENTRY.exec(entry) ?? []
120
- if (origin !== undefined && kind === undefined && (exported ?? origin) === name && isBoundsExport(resolve(dirname(file), source), origin, depth + 1)) return true
121
- }
122
- }
123
- }
124
- return false
125
- }
126
-
127
- /** An import of named values that all come from a generated input-bounds module is data, not an operation. */
128
- const importsOnlyBounds = (context, node, value) => {
129
- const specifiers = node.specifiers?.filter((s) => s.importKind !== "type" && s.exportKind !== "type") ?? []
130
- if (!specifiers.length || typeof value !== "string") return false
131
- const target = resolveImport(context, value)
132
- if (target === null) return false
133
- return specifiers.every((s) => {
134
- const name = s.imported?.name ?? s.imported?.value ?? s.local?.name ?? s.local?.value
135
- return (s.type === "ImportSpecifier" || s.type === "ExportSpecifier") && typeof name === "string" && isBoundsExport(target, name)
136
- })
137
- }
138
-
139
- const unwrap = (node) => {
140
- while (node && ["ChainExpression", "TSAsExpression", "TSNonNullExpression", "TSInstantiationExpression"].includes(node.type)) node = node.expression
141
- return node
142
- }
143
-
144
- /** Resolve lexical bindings so renamed imports and constant aliases keep their provenance. */
145
- const bindingOf = (source, node) => {
146
- for (let scope = source.getScope(node); scope; scope = scope.upper) {
147
- if (scope.set.has(node.name)) return scope.set.get(node.name)
148
- }
149
- return null
150
- }
151
-
152
- const requestOrigin = (source, input, seen = new Set()) => {
153
- const node = unwrap(input)
154
- if (node?.type === "MemberExpression") return isRequestHook(propertyName(node)) && requestOrigin(source, node.object, seen) === "namespace" ? "request" : null
155
- if (node?.type !== "Identifier") return null
156
- const variable = bindingOf(source, node)
157
- if (!variable || seen.has(variable) || variable.defs.length !== 1) return null
158
- const definition = variable.defs[0]
159
- if (definition.type === "ImportBinding" && definition.parent.importKind !== "type" && swrSource(String(definition.parent.source.value))) {
160
- const specifier = definition.node
161
- if (specifier.importKind === "type") return null
162
- if (specifier.type === "ImportNamespaceSpecifier") return "namespace"
163
- return specifier.type === "ImportDefaultSpecifier" || isRequestHook(specifier.imported?.name ?? specifier.imported?.value ?? "") ? "request" : null
164
- }
165
- if (definition.node.type !== "VariableDeclarator" || definition.parent.kind !== "const") return null
166
- return requestOrigin(source, definition.node.init, new Set([...seen, variable]))
167
- }
168
-
169
- /** Every value reference to the operation must belong to an SWR fetcher, including a named local fetcher. */
170
- const wrappedOperations = (context, program, imports) => {
171
- const source = context.sourceCode ?? context.getSourceCode()
172
- const fetchers = []
173
- walk(source, program, (node) => {
174
- if (node.type !== "CallExpression" || requestOrigin(source, node.callee) !== "request" || !node.arguments[1]) return
175
- let fetcher = unwrap(node.arguments[1])
176
- if (fetcher.type === "Identifier") {
177
- const definition = bindingOf(source, fetcher)?.defs[0]
178
- if (definition?.node.type === "VariableDeclarator" && definition.parent.kind === "const") fetcher = unwrap(definition.node.init) ?? fetcher
179
- else if (definition?.node.type === "FunctionDeclaration") fetcher = definition.node
180
- }
181
- fetchers.push(fetcher)
182
- })
183
- const inFetcher = (node) => fetchers.some((fetcher) => fetcher.range[0] <= node.range[0] && fetcher.range[1] >= node.range[1])
184
- return new Set(imports.filter(({ node }) => {
185
- if (node.type !== "ImportDeclaration") return inFetcher(node)
186
- const runtimeNames = new Set(node.specifiers.filter((s) => s.importKind !== "type").map((s) => s.local.name))
187
- const references = source.getDeclaredVariables(node).filter((v) => runtimeNames.has(v.name))
188
- .flatMap((v) => v.references.filter((r) => r.isValueReference !== false).map((r) => r.identifier))
189
- return references.length > 0 && references.every(inFetcher)
190
- }).map(({ node }) => node))
191
- }
192
-
193
- /** Direct operation access from domain hooks, features and components skips the request lifecycle. */
194
- export const operationsOnlyInSwrHooks = {
195
- meta: {
196
- type: "problem",
197
- docs: { description: "FE_OP_OUTSIDE_SWR_HOOK: runtime API operations are consumed only by SWR wrappers." },
198
- schema: [],
199
- messages: { outside: "FE_OP_OUTSIDE_SWR_HOOK: `{{source}}` exposes API operations outside an SWR wrapper. Wrap the operation in hooks/swr/ and call that hook; type-only imports remain available." },
200
- },
201
- create(context) {
202
- if (!isProductSource(context) || isApiLayer(context)) return {}
203
- const imports = []
204
- const inspect = (node, value) => {
205
- if (isOperationSource(context, value) && !importsOnlyBounds(context, node, value)) imports.push({ node, value, reader: isOperationSource(context, value, STREAM_READER_SLOT) })
206
- }
207
- return {
208
- ImportDeclaration(node) {
209
- if (node.importKind !== "type" && (!node.specifiers.length || node.specifiers.some((s) => s.importKind !== "type"))) inspect(node, node.source.value)
210
- },
211
- ExportNamedDeclaration(node) {
212
- if (node.source && node.exportKind !== "type" && node.specifiers.some((s) => s.exportKind !== "type")) inspect(node, node.source.value)
213
- },
214
- ExportAllDeclaration(node) { if (node.exportKind !== "type") inspect(node, node.source.value) },
215
- ImportExpression(node) { inspect(node, stringOf(node.source)) },
216
- CallExpression(node) {
217
- if (node.callee.type === "Identifier" && node.callee.name === "require" && node.arguments[0]?.type === "Literal" &&
218
- globalReferences(context, new Set(["require"])).includes(node.callee)) inspect(node, node.arguments[0].value)
219
- },
220
- "Program:exit"(program) {
221
- if (!imports.length) return
222
- const wrapped = isSwrHook(context) ? wrappedOperations(context, program, imports) : new Set()
223
- for (const { node, value, reader } of imports) if (reader || !wrapped.has(node)) context.report({ node, messageId: "outside", data: { source: value } })
224
- },
225
- }
226
- },
227
- }
228
-
229
- /** A stream operation: `modules/api/sse/<area>/stream-<channel>` or `modules/api/graphql/subscriptions/subscription-<name>`. */
230
- const STREAM_OPERATION = /modules\/api\/(?:sse\/[^/]+\/stream-[^/]+|graphql\/subscriptions\/subscription-[^/]+)(?:\.tsx?)?$/
231
-
232
- /** A subscription hook file of the SWR family: `useSubscribe<X>Swr.ts`. */
233
- const isSubscriptionHook = (context) => isSwrHook(context) && /^useSubscribe[A-Z0-9]\w*Swr\.ts$/.test(fileOf(context).replaceAll("\\", "/").split("/").at(-1) ?? "")
234
-
235
- /** The `onOpen` property of an object literal, else undefined. */
236
- const onOpenOf = (options) =>
237
- options?.type === "ObjectExpression"
238
- ? options.properties.find((property) => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "onOpen")
239
- : undefined
240
-
241
- /** True when a function calls `mutate` (the bound mutate of a hook or the one of `useSWRConfig`): the revalidation of the snapshot query. */
242
- const callsMutate = (source, handler) => {
243
- let found = false
244
- walk(source, handler.body, (node) => {
245
- if (found) return false
246
- if (node.type !== "CallExpression") return undefined
247
- const callee = unwrap(node.callee)
248
- if ((callee?.type === "Identifier" && callee.name === "mutate") || (callee?.type === "MemberExpression" && propertyName(callee) === "mutate")) found = true
249
- return undefined
250
- })
251
- return found
252
- }
253
-
254
- /** True when a callee is a stream operation: a named import, a member of a namespace import, or a const alias of either. */
255
- const isStreamCallee = (source, input, locals, seen = new Set()) => {
256
- const node = unwrap(input)
257
- if (node?.type === "MemberExpression") {
258
- const object = unwrap(node.object)
259
- return object?.type === "Identifier" && locals.get(object.name) === "namespace" && bindingOf(source, object)?.defs[0]?.type === "ImportBinding"
260
- }
261
- if (node?.type !== "Identifier") return false
262
- const variable = bindingOf(source, node)
263
- if (!variable || seen.has(variable) || variable.defs.length !== 1) return false
264
- const definition = variable.defs[0]
265
- if (definition.type === "ImportBinding") return locals.get(variable.name) === "named"
266
- if (definition.node.type !== "VariableDeclarator" || definition.parent.kind !== "const") return false
267
- return isStreamCallee(source, definition.node.init, locals, new Set([...seen, variable]))
268
- }
269
-
270
- /**
271
- * A stream or subscription has no replay of what it missed (graphql-ws has none, an SSE reconnect replays a bounded buffer), so the
272
- * subscription hook revalidates the snapshot query each time the stream opens: its call of the stream operation (named, through a
273
- * namespace or an alias) passes an `onOpen` handler that calls `mutate`, and a subscription hook that calls no stream operation at
274
- * all is not one (FE-REALTIME-2).
275
- */
276
- export const subscriptionRefetchesSnapshot = {
277
- meta: {
278
- type: "problem",
279
- docs: { description: "FE_SUBSCRIPTION_NO_SNAPSHOT: a subscription hook passes the stream operation an onOpen handler that revalidates the snapshot query." },
280
- schema: [],
281
- messages: {
282
- missing:
283
- "FE_SUBSCRIPTION_NO_SNAPSHOT: this call of a stream operation has no `onOpen` handler that calls `mutate`. A stream carries changes, not the state: pass `onOpen: () => void mutate(<snapshot query key>)` so the screen reads its snapshot with the query each time the stream (re)opens.",
284
- noStream:
285
- "FE_SUBSCRIPTION_NO_SNAPSHOT: a `useSubscribe<X>Swr` hook calls one stream operation (`modules/api/sse/<area>/stream-*.ts` or `modules/api/graphql/subscriptions/subscription-*.ts`) and revalidates its snapshot query in `onOpen`; this file calls none.",
286
- },
287
- },
288
- create(context) {
289
- if (!isProductSource(context) || !isSubscriptionHook(context)) return {}
290
- const source = context.sourceCode ?? context.getSourceCode()
291
- const locals = new Map()
292
- let calls = 0
293
- return {
294
- ImportDeclaration(node) {
295
- if (node.importKind === "type" || typeof node.source.value !== "string") return
296
- const target = resolveImport(context, node.source.value)
297
- if (target === null || !STREAM_OPERATION.test(target.replaceAll("\\", "/"))) return
298
- for (const specifier of node.specifiers) {
299
- if (specifier.importKind === "type") continue
300
- if (specifier.type === "ImportSpecifier") locals.set(specifier.local.name, "named")
301
- if (specifier.type === "ImportNamespaceSpecifier") locals.set(specifier.local.name, "namespace")
302
- }
303
- },
304
- CallExpression(node) {
305
- if (!isStreamCallee(source, node.callee, locals)) return
306
- calls += 1
307
- const handler = functionOf(context, onOpenOf(node.arguments[0])?.value)
308
- if (!handler || !callsMutate(source, handler)) context.report({ node, messageId: "missing" })
309
- },
310
- "Program:exit"(program) {
311
- if (calls === 0) context.report({ node: program, messageId: "noStream" })
312
- },
313
- }
314
- },
315
- }
316
-
317
- /** Code metadata is registered on the shared object without changing the shared catalog module. */
318
- Object.assign(why, {
319
- "swr-only-in-family": { code: "FE_SWR_OUTSIDE_FAMILY", en: "A request hook is used outside the SWR family.", fix: "Call the operation's SWR wrapper hook." },
320
- "operations-only-in-swr-hooks": { code: "FE_OP_OUTSIDE_SWR_HOOK", en: "An API operation is consumed outside an SWR wrapper.", fix: "Wrap the operation in hooks/swr/." },
321
- "subscription-refetches-snapshot": { code: "FE_SUBSCRIPTION_NO_SNAPSHOT", en: "A subscription hook does not revalidate its snapshot query when the stream opens.", fix: "Pass the stream operation an onOpen handler that revalidates the snapshot query key." },
322
- })
323
-
324
- /** R285's published lint enforcers. */
325
- export const rules = { "swr-only-in-family": swrOnlyInFamily, "operations-only-in-swr-hooks": operationsOnlyInSwrHooks, "subscription-refetches-snapshot": subscriptionRefetchesSnapshot }
326
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/the-split.mjs DELETED
@@ -1,155 +0,0 @@
1
- /**
2
- * The rule that holds `the-split.md`.
3
- *
4
- * ONE RULE, AND IT WATCHES ONE HALF. The drawing half's purity is checkable: a file that fetches,
5
- * reads a store or resolves a word is visible in what it calls. The connected half's discipline is
6
- * not - a file that draws badly, or settles the wrong situation, looks exactly like one that does
7
- * neither. So this rule guards the boundary from the side where a machine can see it, and the other
8
- * side stays a matter for review.
9
- *
10
- * The scope is the slot. The role `drawing` of a component owner is the drawing half in every tier
11
- * that splits, so the slot does the scoping and the rule needs no configuration.
12
- */
13
-
14
- import { hfsOf } from "./lib/hfs.mjs"
15
- import { fileOf, inSlot, kindOfFile, roleOfFile } from "./lib/scope.mjs"
16
-
17
- /** The drawing half: the file the slot gives the role `drawing` (`component.tsx` of a component, feature or shared-package owner). */
18
- const isDrawingHalf = (context) => roleOfFile(context) === "drawing"
19
-
20
- /** A connected block entrypoint (the `entry` of a block owner) and the public name its folder fixes. */
21
- const connectedBlock = (context) => {
22
- if (!inSlot(context, "fe.components") || kindOfFile(context) !== "blocks" || roleOfFile(context) !== "entry") return null
23
- const name = hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0]
24
- return /^[A-Z][A-Za-z0-9]*$/.test(name) ? name : null
25
- }
26
-
27
- /**
28
- * Calls that reach for the world instead of receiving it.
29
- *
30
- * Six families of the front-end stack: a request (SWR, `useQuery<X>Swr` / `useMutate<X>Swr` and the REST
31
- * `use{Get,Post,Put,Patch,Delete}<X>Swr` of `hooks/swr`, the Apollo hooks, an operation of `modules/api` called directly), a
32
- * form (react-hook-form and the `use<X>Form` hooks of `hooks/rhf`), a store (redux, the zustand `use<X>Store`), a socket
33
- * (`use<X>SocketIo` of `hooks/socketio`), a toast (`use<X>WithToast` of `modules/toast`) and the translation runtime. Each is a
34
- * dependency the drawing half cannot be rendered from a fixture without.
35
- */
36
- const REACHES_FOR_THE_WORLD =
37
- /^(?:useSWR|useSWRMutation|useSWRConfig|use[A-Za-z0-9]*Swr|useQuery|useLazyQuery|useMutation|useSubscription|useApolloClient|useForm|useFormContext|useController|useWatch|use[A-Za-z0-9]*Form|useAppSelector|useAppDispatch|useSelector|useDispatch|use[A-Za-z0-9]*Store|use[A-Za-z0-9]*SocketIo|use[A-Za-z0-9]*WithToast|useTranslations|useLocale|useFormatter|query[A-Z][A-Za-z0-9]*|mutation[A-Z][A-Za-z0-9]*)$/
38
-
39
- // -- SPLIT-1 ---------------------------------------------------------------------------------------
40
-
41
- /** The drawing half receives everything and asks for nothing. */
42
- export const presentationalPurity = {
43
- meta: {
44
- type: "problem",
45
- docs: { description: "The drawing half takes resolved props: no request, no store, no translation." },
46
- schema: [],
47
- messages: {
48
- reaches:
49
- "`{{name}}(...)` reaches for the world from the drawing half. This file must be renderable from a fixture - that is what makes it testable without standing the world up first, and it is the whole reason the split exists. Move the call to `index.tsx` and pass the result down as a settled situation.",
50
- },
51
- },
52
- create(context) {
53
- if (!isDrawingHalf(context)) return {}
54
- return {
55
- CallExpression(node) {
56
- const callee = node.callee
57
- if (!callee || callee.type !== "Identifier") return
58
- if (!REACHES_FOR_THE_WORLD.test(callee.name)) return
59
- context.report({ node, messageId: "reaches", data: { name: callee.name } })
60
- },
61
- }
62
- },
63
- }
64
-
65
- // -- SPLIT-5 ---------------------------------------------------------------------------------------
66
-
67
- /** A connected block delegates every render path to its exact pure twin. */
68
- export const connectedBlockHasPresentationalTwin = {
69
- meta: {
70
- type: "problem",
71
- docs: { description: "A connected block index renders only its exact `XBase` twin from component.tsx." },
72
- schema: [],
73
- messages: {
74
- missing:
75
- "`{{block}}` reads the world, so it must import exact `{{twin}}` from `./component`. A single leaf, one tree, or no local state is not an exception.",
76
- bypass:
77
- "Connected `{{block}}` renders `<{{rendered}}>` directly. Resolve the world here, then render only `<{{twin}}>`; component.tsx owns the complete presentation.",
78
- unused:
79
- "Connected `{{block}}` imports `{{twin}}` but never renders it. Every connected render path must cross the pure twin.",
80
- called:
81
- "Connected `{{block}}` calls `{{twin}}(...)` as an ordinary function. Render `<{{twin}} />` so React owns the twin's hook lifecycle and every render keeps one stable component boundary.",
82
- },
83
- },
84
- create(context) {
85
- const block = connectedBlock(context)
86
- if (!block) return {}
87
-
88
- const twin = `${block}Base`
89
- const worldBindings = new Set()
90
- const rendered = []
91
- const calledTwin = []
92
- let importsTwin = false
93
- let readsWorld = false
94
- let rendersTwin = false
95
-
96
- return {
97
- ImportDeclaration(node) {
98
- const source = typeof node.source?.value === "string" ? node.source.value : ""
99
- for (const specifier of node.specifiers || []) {
100
- const imported = specifier.imported?.name
101
- const local = specifier.local?.name
102
- if (source === "./component" && imported === twin && local === twin) importsTwin = true
103
- if (imported && local && REACHES_FOR_THE_WORLD.test(imported)) worldBindings.add(local)
104
- }
105
- },
106
- CallExpression(node) {
107
- if (node.callee?.type !== "Identifier") return
108
- if (node.callee.name === twin) calledTwin.push(node)
109
- if (worldBindings.has(node.callee.name) || REACHES_FOR_THE_WORLD.test(node.callee.name)) {
110
- readsWorld = true
111
- }
112
- },
113
- JSXOpeningElement(node) {
114
- if (node.name?.type !== "JSXIdentifier") return
115
- rendered.push({ name: node.name.name, node })
116
- if (node.name.name === twin) rendersTwin = true
117
- },
118
- "Program:exit"(node) {
119
- if (!readsWorld) return
120
- if (!importsTwin) {
121
- context.report({ node, messageId: "missing", data: { block, twin } })
122
- return
123
- }
124
- for (const entry of rendered) {
125
- if (entry.name !== twin) {
126
- context.report({
127
- node: entry.node,
128
- messageId: "bypass",
129
- data: { block, rendered: entry.name, twin },
130
- })
131
- }
132
- }
133
- for (const call of calledTwin) {
134
- context.report({ node: call, messageId: "called", data: { block, twin } })
135
- }
136
- if (!rendersTwin) context.report({ node, messageId: "unused", data: { block, twin } })
137
- },
138
- }
139
- },
140
- }
141
-
142
- /** The rules this law contributes to the plugin. */
143
- export const rules = {
144
- "presentational-purity": presentationalPurity,
145
- "connected-block-has-presentational-twin": connectedBlockHasPresentationalTwin,
146
- }
147
-
148
- /**
149
- * The level this law asks for, as the plugin's own opinion.
150
- *
151
- * Exact: it fires on a call by name, in one file by name. A repository adopting it with history
152
- * should expect each report to be a real move rather than a deletion - the call has to go
153
- * somewhere, and where it goes is the other half.
154
- */
155
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/tokens.mjs DELETED
@@ -1,39 +0,0 @@
1
- /** Universal closed-token discipline, independent of a Grammar scale. */
2
- import { classTextVisitors } from "./lib/class-text.mjs"
3
-
4
- /** A bracketed length: a value chosen once, for one screen, findable by nobody. */
5
- const ARBITRARY_LENGTH =
6
- /\b(?:gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|space-x|space-y|size|w|h|min-w|min-h|max-w|max-h)-\[[^\]]+\]/
7
-
8
- /** A raw colour, which escapes the semantic palette even when it matches it today. */
9
- const RAW_COLOUR = /\b(?:text|bg|border|ring|from|to|via|fill|stroke|shadow|decoration)-\[#[0-9a-fA-F]/
10
-
11
- /** An arbitrary value escapes the system, whatever it happens to evaluate to. */
12
- export const noArbitraryValue = {
13
- meta: {
14
- type: "problem",
15
- docs: { description: "Bracketed lengths and raw colours escape the closed vocabulary." },
16
- schema: [],
17
- messages: {
18
- length:
19
- "`{{cls}}` is a value chosen once, for one screen. Even where it happens to equal a rung, nobody searching the scale can find it and it does not move when the scale moves. Use a member of the vocabulary, or add one deliberately.",
20
- colour:
21
- "`{{cls}}` is a raw colour, outside the semantic palette. A colour named for what it MEANS follows the theme; one named for what it is stays the same in a theme where it is wrong.",
22
- },
23
- },
24
- create(context) {
25
- return classTextVisitors(context, (node, text) => {
26
- if (!text) return
27
- const length = text.match(ARBITRARY_LENGTH)
28
- if (length) context.report({ node, messageId: "length", data: { cls: length[0] } })
29
- const colour = text.match(RAW_COLOUR)
30
- if (colour) context.report({ node, messageId: "colour", data: { cls: colour[0] } })
31
- })
32
- },
33
- }
34
-
35
- /** Rules of universal token discipline. */
36
- export const rules = { "no-arbitrary-value": noArbitraryValue }
37
-
38
- /** Every token-discipline rule is mandatory. */
39
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))