@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
@@ -0,0 +1,419 @@
1
+ /** R285: API operations are wrapped by the SWR family before product code calls them. */
2
+ import { hfsOf } from "./lib/hfs.js";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { stringOf, walk } from "./lib/ast.js";
6
+ import { functionOf } from "./lib/bindings.js";
7
+ import { defineRule } from "./lib/rule.js";
8
+ import { classOf, fileOf, globalReferences, isApiLayer, isProductSource, resolveImport } from "./lib/scope.js";
9
+ import { why } from "./lib/why.js";
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
+ /** Cache providers and revalidation may be used by consumers; only request hooks own operations. */
16
+ const isRequestHook = (name) => /^useSWR(?:Immutable|Infinite|Mutation|Subscription)?$/.test(String(name)) || name === "default";
17
+ const swrSource = (value) => value === "swr" || value.startsWith("swr/");
18
+ const propertyName = (node) => node.computed ? (node.property.type === "Literal" ? node.property.value : undefined) : node.property.name;
19
+ /** The name a module export specifier spells: an identifier, or a string such as `"a-b"`. */
20
+ const exportName = (node) => (node.type === "Identifier" ? node.name : node.value);
21
+ /** True for a specifier written `type x` inside a value import or export. */
22
+ const isTypeSpecifier = (specifier) => (specifier.type === "ImportSpecifier" && specifier.importKind === "type") || (specifier.type === "ExportSpecifier" && specifier.exportKind === "type");
23
+ /** The parent of a reference's identifier; a reference always sits inside an expression or a declaration. */
24
+ const parentOfReference = (identifier) => {
25
+ const parent = identifier.parent;
26
+ if (parent === undefined)
27
+ throw new TypeError("Cannot read properties of undefined (reading 'type')");
28
+ return parent;
29
+ };
30
+ /** The module of the declaration holding an import binding; an import-equals declaration has none, which stops the run as it always has. */
31
+ const declarationSource = (parent) => {
32
+ if (parent.type !== "ImportDeclaration")
33
+ throw new TypeError("Cannot read properties of undefined (reading 'value')");
34
+ return parent.source.value;
35
+ };
36
+ /** Runtime imports of the request hooks (including aliases) are confined to named SWR wrappers. */
37
+ export const swrOnlyInFamily = defineRule({
38
+ meta: {
39
+ type: "problem",
40
+ docs: { description: "FE_SWR_OUTSIDE_FAMILY: request hooks belong to fe.hooks.swr." },
41
+ schema: [],
42
+ 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." },
43
+ },
44
+ create(context) {
45
+ if (!isProductSource(context) || isSwrHook(context))
46
+ return {};
47
+ const namespaces = new Set();
48
+ const report = (node) => context.report({ node, messageId: "outside" });
49
+ return {
50
+ ImportDeclaration(node) {
51
+ if (node.importKind === "type" || !swrSource(String(node.source.value)))
52
+ return;
53
+ const runtime = node.specifiers.filter((s) => !isTypeSpecifier(s));
54
+ if (runtime.some((s) => s.type === "ImportDefaultSpecifier" ||
55
+ (s.type === "ImportSpecifier" && isRequestHook(exportName(s.imported)))))
56
+ report(node);
57
+ for (const specifier of runtime)
58
+ if (specifier.type === "ImportNamespaceSpecifier")
59
+ namespaces.add(specifier);
60
+ },
61
+ ExportNamedDeclaration(node) {
62
+ if (!node.source || node.exportKind === "type" || !swrSource(String(node.source.value)))
63
+ return;
64
+ if (node.specifiers.some((s) => s.exportKind !== "type" && isRequestHook(exportName(s.local))))
65
+ report(node);
66
+ },
67
+ ExportAllDeclaration(node) {
68
+ if (node.exportKind !== "type" && swrSource(String(node.source.value)))
69
+ report(node);
70
+ },
71
+ ImportExpression(node) {
72
+ if (node.source.type === "Literal" && swrSource(String(node.source.value)))
73
+ report(node);
74
+ },
75
+ CallExpression(node) {
76
+ const [first] = node.arguments;
77
+ if (node.callee.type === "Identifier" && node.callee.name === "require" && first?.type === "Literal" &&
78
+ swrSource(String(first.value)) && globalReferences(context, new Set(["require"])).includes(node.callee))
79
+ report(node);
80
+ },
81
+ "Program:exit"() {
82
+ const source = context.sourceCode ?? context.getSourceCode();
83
+ for (const specifier of namespaces) {
84
+ const variable = source.getDeclaredVariables(specifier.parent).find((v) => v.name === specifier.local.name);
85
+ for (const reference of variable?.references ?? []) {
86
+ const id = reference.identifier;
87
+ const member = parentOfReference(id);
88
+ // Passing or destructuring the namespace also makes its request hooks available outside the family.
89
+ if (member.type !== "MemberExpression" || member.object !== id || member.computed && member.property.type !== "Literal" ||
90
+ isRequestHook(propertyName(member)))
91
+ report(id);
92
+ }
93
+ }
94
+ },
95
+ };
96
+ },
97
+ });
98
+ /** The slot of the stream reader: only a stream operation of the API layer imports it, never an SWR hook, even inside a fetcher. */
99
+ const STREAM_READER_SLOT = "fe.transport.stream";
100
+ /** The slots of the result vocabulary: types, never operations. */
101
+ const OUTCOME_SLOTS = ["fe.transport.outcome", "fe.package.api.outcome"];
102
+ /** The slots that hold API operations. */
103
+ const OPERATION_SLOTS = ["fe.modules.api", "fe.package.api", "fe.transport.client", "fe.transport.stream", "fe.package.api.client"];
104
+ /** The `name` of a parsed package.json; reading it from `null` throws, as it always has. */
105
+ const manifestName = (manifest) => {
106
+ if (manifest === null || manifest === undefined)
107
+ throw new TypeError("Cannot read properties of null (reading 'name')");
108
+ return typeof manifest === "object" && "name" in manifest ? manifest.name : undefined;
109
+ };
110
+ /** Type imports stay available; the API layer may compose its own operations and helpers. */
111
+ const isOperationSource = (context, value, only) => {
112
+ if (typeof value !== "string")
113
+ return false;
114
+ const hfs = hfsOf(context);
115
+ let target = resolveImport(context, value);
116
+ if (target === null) {
117
+ // Workspace package names are read from their actual manifests, never inferred from a suffix.
118
+ let folders;
119
+ try {
120
+ folders = readdirSync(join(hfs.repoRoot, "packages"), { withFileTypes: true });
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ for (const folder of folders) {
126
+ if (!folder.isDirectory())
127
+ continue;
128
+ const root = join(hfs.repoRoot, "packages", folder.name);
129
+ if (hfs.slotOf(join(root, "src", "index.ts")) !== "fe.package.api")
130
+ continue;
131
+ let name;
132
+ try {
133
+ name = manifestName(JSON.parse(readFileSync(join(root, "package.json"), "utf8")));
134
+ }
135
+ catch {
136
+ continue;
137
+ }
138
+ if (typeof name !== "string" || !(value === name || value.startsWith(`${name}/`)))
139
+ continue;
140
+ target = value === name ? join(root, "src", "index.ts") : join(root, "src", value.slice(name.length + 1));
141
+ break;
142
+ }
143
+ if (target === null)
144
+ return false;
145
+ }
146
+ // Prefer the actual file before a hypothetical barrel: outcome types have a more specific slot.
147
+ const candidates = /\.[cm]?[jt]sx?$/.test(target) ? [target] : [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`, target];
148
+ const found = candidates.map((file) => hfs.classify(file)).filter((entry) => entry.status === "owned");
149
+ if (found.some((entry) => OUTCOME_SLOTS.includes(entry.slot)))
150
+ return false;
151
+ if (only !== undefined)
152
+ return found.some((entry) => entry.slot === only);
153
+ return found.some((entry) => OPERATION_SLOTS.includes(entry.slot));
154
+ };
155
+ /** The generated input-bounds module holds constants and a pure reader derived from the contract, never an operation. */
156
+ const BOUNDS_MODULE = /(?:^|\/)__generated__\/input-bounds\.ts$/;
157
+ const RE_EXPORT = /export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
158
+ const ENTRY = /^\s*(type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/;
159
+ /** True when the named export of `target` is, through a chain of relative named re-exports, defined by a generated input-bounds module. */
160
+ const isBoundsExport = (target, name, depth = 0) => {
161
+ if (depth > 4)
162
+ return false;
163
+ for (const file of [`${target}.ts`, `${target}/index.ts`, target]) {
164
+ if (BOUNDS_MODULE.test(file.replaceAll("\\", "/")))
165
+ return true;
166
+ let text;
167
+ try {
168
+ text = readFileSync(file, "utf8");
169
+ }
170
+ catch {
171
+ continue;
172
+ }
173
+ for (const [, list, source] of text.matchAll(RE_EXPORT)) {
174
+ if (list === undefined || source === undefined || !source.startsWith("."))
175
+ continue;
176
+ for (const entry of list.split(",")) {
177
+ const [, kind, origin, exported] = ENTRY.exec(entry) ?? [];
178
+ if (origin !== undefined && kind === undefined && (exported ?? origin) === name && isBoundsExport(resolve(dirname(file), source), origin, depth + 1))
179
+ return true;
180
+ }
181
+ }
182
+ }
183
+ return false;
184
+ };
185
+ /** The specifiers of an import or a re-export; the other inspected nodes have none. */
186
+ const specifiersOf = (node) => node.type === "ImportDeclaration" || node.type === "ExportNamedDeclaration" ? node.specifiers : undefined;
187
+ /** An import of named values that all come from a generated input-bounds module is data, not an operation. */
188
+ const importsOnlyBounds = (context, node, value) => {
189
+ const specifiers = specifiersOf(node)?.filter((s) => !isTypeSpecifier(s)) ?? [];
190
+ if (!specifiers.length || typeof value !== "string")
191
+ return false;
192
+ const target = resolveImport(context, value);
193
+ if (target === null)
194
+ return false;
195
+ return specifiers.every((s) => {
196
+ const name = s.type === "ImportSpecifier" ? exportName(s.imported) : s.type === "ExportSpecifier" ? exportName(s.local) : undefined;
197
+ return name !== undefined && isBoundsExport(target, name);
198
+ });
199
+ };
200
+ const unwrap = (start) => {
201
+ let node = start;
202
+ while (node && (node.type === "ChainExpression" || node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "TSInstantiationExpression"))
203
+ node = node.expression;
204
+ return node;
205
+ };
206
+ /** Resolve lexical bindings so renamed imports and constant aliases keep their provenance. */
207
+ const bindingOf = (source, node) => {
208
+ for (let scope = source.getScope(node); scope; scope = scope.upper) {
209
+ const variable = scope.set.get(node.name);
210
+ if (variable !== undefined)
211
+ return variable;
212
+ }
213
+ return null;
214
+ };
215
+ const requestOrigin = (source, input, seen = new Set()) => {
216
+ const node = unwrap(input);
217
+ if (node?.type === "MemberExpression")
218
+ return isRequestHook(propertyName(node)) && requestOrigin(source, node.object, seen) === "namespace" ? "request" : null;
219
+ if (node?.type !== "Identifier")
220
+ return null;
221
+ const variable = bindingOf(source, node);
222
+ if (!variable || seen.has(variable) || variable.defs.length !== 1)
223
+ return null;
224
+ const [definition] = variable.defs;
225
+ if (definition === undefined)
226
+ return null;
227
+ if (definition.type === "ImportBinding" && definition.parent.importKind !== "type" && swrSource(String(declarationSource(definition.parent)))) {
228
+ const specifier = definition.node;
229
+ if ((specifier.type === "ImportSpecifier" || specifier.type === "TSImportEqualsDeclaration") && specifier.importKind === "type")
230
+ return null;
231
+ if (specifier.type === "ImportNamespaceSpecifier")
232
+ return "namespace";
233
+ return specifier.type === "ImportDefaultSpecifier" || isRequestHook(specifier.type === "ImportSpecifier" ? exportName(specifier.imported) : "") ? "request" : null;
234
+ }
235
+ if (definition.type !== "Variable" || definition.parent.kind !== "const")
236
+ return null;
237
+ return requestOrigin(source, definition.node.init, new Set([...seen, variable]));
238
+ };
239
+ /** Every value reference to the operation must belong to an SWR fetcher, including a named local fetcher. */
240
+ const wrappedOperations = (context, program, imports) => {
241
+ const source = context.sourceCode ?? context.getSourceCode();
242
+ const fetchers = [];
243
+ walk(source, program, (node) => {
244
+ if (node.type !== "CallExpression" || requestOrigin(source, node.callee) !== "request")
245
+ return;
246
+ const argument = node.arguments[1];
247
+ if (!argument)
248
+ return;
249
+ let fetcher = unwrap(argument) ?? argument;
250
+ if (fetcher.type === "Identifier") {
251
+ const definition = bindingOf(source, fetcher)?.defs[0];
252
+ if (definition?.type === "Variable" && definition.parent.kind === "const")
253
+ fetcher = unwrap(definition.node.init) ?? fetcher;
254
+ else if (definition?.node.type === "FunctionDeclaration")
255
+ fetcher = definition.node;
256
+ }
257
+ fetchers.push(fetcher);
258
+ });
259
+ const inFetcher = (node) => fetchers.some((fetcher) => fetcher.range[0] <= node.range[0] && fetcher.range[1] >= node.range[1]);
260
+ return new Set(imports.filter(({ node }) => {
261
+ if (node.type !== "ImportDeclaration")
262
+ return inFetcher(node);
263
+ const runtimeNames = new Set(node.specifiers.filter((s) => !isTypeSpecifier(s)).map((s) => s.local.name));
264
+ const references = source.getDeclaredVariables(node).filter((v) => runtimeNames.has(v.name))
265
+ .flatMap((v) => v.references.filter((r) => r.isValueReference !== false).map((r) => r.identifier));
266
+ return references.length > 0 && references.every(inFetcher);
267
+ }).map(({ node }) => node));
268
+ };
269
+ /** Direct operation access from domain hooks, features and components skips the request lifecycle. */
270
+ export const operationsOnlyInSwrHooks = defineRule({
271
+ meta: {
272
+ type: "problem",
273
+ docs: { description: "FE_OP_OUTSIDE_SWR_HOOK: runtime API operations are consumed only by SWR wrappers." },
274
+ schema: [],
275
+ 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." },
276
+ },
277
+ create(context) {
278
+ if (!isProductSource(context) || isApiLayer(context))
279
+ return {};
280
+ const imports = [];
281
+ const inspect = (node, value) => {
282
+ if (isOperationSource(context, value) && !importsOnlyBounds(context, node, value))
283
+ imports.push({ node, value, reader: isOperationSource(context, value, STREAM_READER_SLOT) });
284
+ };
285
+ return {
286
+ ImportDeclaration(node) {
287
+ if (node.importKind !== "type" && (!node.specifiers.length || node.specifiers.some((s) => !isTypeSpecifier(s))))
288
+ inspect(node, node.source.value);
289
+ },
290
+ ExportNamedDeclaration(node) {
291
+ if (node.source && node.exportKind !== "type" && node.specifiers.some((s) => s.exportKind !== "type"))
292
+ inspect(node, node.source.value);
293
+ },
294
+ ExportAllDeclaration(node) { if (node.exportKind !== "type")
295
+ inspect(node, node.source.value); },
296
+ ImportExpression(node) { inspect(node, stringOf(node.source)); },
297
+ CallExpression(node) {
298
+ const [first] = node.arguments;
299
+ if (node.callee.type === "Identifier" && node.callee.name === "require" && first?.type === "Literal" &&
300
+ globalReferences(context, new Set(["require"])).includes(node.callee))
301
+ inspect(node, first.value);
302
+ },
303
+ "Program:exit"(program) {
304
+ if (!imports.length)
305
+ return;
306
+ const wrapped = isSwrHook(context) ? wrappedOperations(context, program, imports) : new Set();
307
+ for (const { node, value, reader } of imports)
308
+ if (reader || !wrapped.has(node))
309
+ context.report({ node, messageId: "outside", data: { source: value } });
310
+ },
311
+ };
312
+ },
313
+ });
314
+ /** A stream operation: `modules/api/sse/<area>/stream-<channel>` or `modules/api/graphql/subscriptions/subscription-<name>`. */
315
+ const STREAM_OPERATION = /modules\/api\/(?:sse\/[^/]+\/stream-[^/]+|graphql\/subscriptions\/subscription-[^/]+)(?:\.tsx?)?$/;
316
+ /** A subscription hook file of the SWR family: `useSubscribe<X>Swr.ts`. */
317
+ const isSubscriptionHook = (context) => isSwrHook(context) && /^useSubscribe[A-Z0-9]\w*Swr\.ts$/.test(fileOf(context).replaceAll("\\", "/").split("/").at(-1) ?? "");
318
+ /** The `onOpen` property of an object literal, else undefined. */
319
+ const onOpenOf = (options) => options?.type === "ObjectExpression"
320
+ ? options.properties.find((property) => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "onOpen")
321
+ : undefined;
322
+ /** True when a function calls `mutate` (the bound mutate of a hook or the one of `useSWRConfig`): the revalidation of the snapshot query. */
323
+ const callsMutate = (source, handler) => {
324
+ let found = false;
325
+ walk(source, handler.body, (node) => {
326
+ if (found)
327
+ return false;
328
+ if (node.type !== "CallExpression")
329
+ return undefined;
330
+ const callee = unwrap(node.callee);
331
+ if ((callee?.type === "Identifier" && callee.name === "mutate") || (callee?.type === "MemberExpression" && propertyName(callee) === "mutate"))
332
+ found = true;
333
+ return undefined;
334
+ });
335
+ return found;
336
+ };
337
+ /** True when a callee is a stream operation: a named import, a member of a namespace import, or a const alias of either. */
338
+ const isStreamCallee = (source, input, locals, seen = new Set()) => {
339
+ const node = unwrap(input);
340
+ if (node?.type === "MemberExpression") {
341
+ const object = unwrap(node.object);
342
+ return object?.type === "Identifier" && locals.get(object.name) === "namespace" && bindingOf(source, object)?.defs[0]?.type === "ImportBinding";
343
+ }
344
+ if (node?.type !== "Identifier")
345
+ return false;
346
+ const variable = bindingOf(source, node);
347
+ if (!variable || seen.has(variable) || variable.defs.length !== 1)
348
+ return false;
349
+ const [definition] = variable.defs;
350
+ if (definition === undefined)
351
+ return false;
352
+ if (definition.type === "ImportBinding")
353
+ return locals.get(variable.name) === "named";
354
+ if (definition.type !== "Variable" || definition.parent.kind !== "const")
355
+ return false;
356
+ return isStreamCallee(source, definition.node.init, locals, new Set([...seen, variable]));
357
+ };
358
+ /**
359
+ * A stream or subscription has no replay of what it missed (graphql-ws has none, an SSE reconnect replays a bounded buffer), so the
360
+ * subscription hook revalidates the snapshot query each time the stream opens: its call of the stream operation (named, through a
361
+ * namespace or an alias) passes an `onOpen` handler that calls `mutate`, and a subscription hook that calls no stream operation at
362
+ * all is not one (FE-REALTIME-2).
363
+ */
364
+ export const subscriptionRefetchesSnapshot = defineRule({
365
+ meta: {
366
+ type: "problem",
367
+ docs: { description: "FE_SUBSCRIPTION_NO_SNAPSHOT: a subscription hook passes the stream operation an onOpen handler that revalidates the snapshot query." },
368
+ schema: [],
369
+ messages: {
370
+ missing: "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.",
371
+ noStream: "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.",
372
+ },
373
+ },
374
+ create(context) {
375
+ if (!isProductSource(context) || !isSubscriptionHook(context))
376
+ return {};
377
+ const source = context.sourceCode ?? context.getSourceCode();
378
+ const locals = new Map();
379
+ let calls = 0;
380
+ return {
381
+ ImportDeclaration(node) {
382
+ if (node.importKind === "type" || typeof node.source.value !== "string")
383
+ return;
384
+ const target = resolveImport(context, node.source.value);
385
+ if (target === null || !STREAM_OPERATION.test(target.replaceAll("\\", "/")))
386
+ return;
387
+ for (const specifier of node.specifiers) {
388
+ if (isTypeSpecifier(specifier))
389
+ continue;
390
+ if (specifier.type === "ImportSpecifier")
391
+ locals.set(specifier.local.name, "named");
392
+ if (specifier.type === "ImportNamespaceSpecifier")
393
+ locals.set(specifier.local.name, "namespace");
394
+ }
395
+ },
396
+ CallExpression(node) {
397
+ if (!isStreamCallee(source, node.callee, locals))
398
+ return;
399
+ calls += 1;
400
+ const handler = functionOf(context, onOpenOf(node.arguments[0])?.value);
401
+ if (!handler || !callsMutate(source, handler))
402
+ context.report({ node, messageId: "missing" });
403
+ },
404
+ "Program:exit"(program) {
405
+ if (calls === 0)
406
+ context.report({ node: program, messageId: "noStream" });
407
+ },
408
+ };
409
+ },
410
+ });
411
+ /** Code metadata is registered on the shared object without changing the shared catalog module. */
412
+ Object.assign(why, {
413
+ "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." },
414
+ "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/." },
415
+ "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." },
416
+ });
417
+ /** R285's published lint enforcers. */
418
+ export const rules = { "swr-only-in-family": swrOnlyInFamily, "operations-only-in-swr-hooks": operationsOnlyInSwrHooks, "subscription-refetches-snapshot": subscriptionRefetchesSnapshot };
419
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,27 @@
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
+ import { type Rules, type Recommended } from "./lib/rule.js";
14
+ /** The drawing half receives everything and asks for nothing. */
15
+ export declare const presentationalPurity: import("./lib/rule.js").Rule<"reaches" | "hookType", []>;
16
+ /** A connected block delegates every render path to its exact pure twin. */
17
+ export declare const connectedBlockHasPresentationalTwin: import("./lib/rule.js").Rule<"missing" | "bypass" | "unused" | "called", []>;
18
+ /** The rules this law contributes to the plugin. */
19
+ export declare const rules: Rules;
20
+ /**
21
+ * The level this law asks for, as the plugin's own opinion.
22
+ *
23
+ * Exact: it fires on a call by name, in one file by name. A repository adopting it with history
24
+ * should expect each report to be a real move rather than a deletion - the call has to go
25
+ * somewhere, and where it goes is the other half.
26
+ */
27
+ export declare const recommended: Recommended;
@@ -0,0 +1,157 @@
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
+ import { defineRule } from "./lib/rule.js";
14
+ import { hfsOf } from "./lib/hfs.js";
15
+ import { fileOf, inSlot, kindOfFile, roleOfFile } from "./lib/scope.js";
16
+ /** The drawing half: the file the slot gives the role `drawing` (`component.tsx` of a component, feature or shared-package owner). */
17
+ const isDrawingHalf = (context) => roleOfFile(context) === "drawing";
18
+ /** A connected block entrypoint (the `entry` of a block owner) and the public name its folder fixes. */
19
+ const connectedBlock = (context) => {
20
+ if (!inSlot(context, "fe.components") || kindOfFile(context) !== "blocks" || roleOfFile(context) !== "entry")
21
+ return null;
22
+ const name = hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0];
23
+ return name !== undefined && /^[A-Z][A-Za-z0-9]*$/.test(name) ? name : null;
24
+ };
25
+ /**
26
+ * Calls that reach for the world instead of receiving it.
27
+ *
28
+ * Six families of the front-end stack: a request (SWR, `useQuery<X>Swr` / `useMutate<X>Swr` and the REST
29
+ * `use{Get,Post,Put,Patch,Delete}<X>Swr` of `hooks/swr`, the Apollo hooks, an operation of `modules/api` called directly), a
30
+ * form (react-hook-form and the `use<X>Form` hooks of `hooks/rhf`), a store (redux, the zustand `use<X>Store`), a socket
31
+ * (`use<X>SocketIo` of `hooks/socketio`), a toast (`use<X>WithToast` of `modules/toast`) and the translation runtime. Each is a
32
+ * dependency the drawing half cannot be rendered from a fixture without.
33
+ */
34
+ const REACHES_FOR_THE_WORLD = /^(?: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]*)$/;
35
+ // -- SPLIT-1 ---------------------------------------------------------------------------------------
36
+ /** The drawing half receives everything and asks for nothing. */
37
+ export const presentationalPurity = defineRule({
38
+ meta: {
39
+ type: "problem",
40
+ docs: { description: "The drawing half takes resolved props: no request, no store, no translation, no import (type or value) from `@/hooks`." },
41
+ schema: [],
42
+ messages: {
43
+ reaches: "`{{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.",
44
+ hookType: "`{{source}}` lends the drawing half a type derived from a hook. The pure half declares its own atom view types (strings, numbers, booleans, plain objects, a DataSlot per read); a hook-derived type ties it to the world it must be renderable without.",
45
+ },
46
+ },
47
+ create(context) {
48
+ if (!isDrawingHalf(context))
49
+ return {};
50
+ return {
51
+ ImportDeclaration(node) {
52
+ const source = node.source.value;
53
+ if (source === "@/hooks" || source.startsWith("@/hooks/"))
54
+ context.report({ node, messageId: "hookType", data: { source } });
55
+ },
56
+ CallExpression(node) {
57
+ const callee = node.callee;
58
+ if (!callee || callee.type !== "Identifier")
59
+ return;
60
+ if (!REACHES_FOR_THE_WORLD.test(callee.name))
61
+ return;
62
+ context.report({ node, messageId: "reaches", data: { name: callee.name } });
63
+ },
64
+ };
65
+ },
66
+ });
67
+ // -- SPLIT-5 ---------------------------------------------------------------------------------------
68
+ /** A connected block delegates every render path to its exact pure twin. */
69
+ export const connectedBlockHasPresentationalTwin = defineRule({
70
+ meta: {
71
+ type: "problem",
72
+ docs: { description: "A connected block index renders only its exact `XBase` twin from component.tsx." },
73
+ schema: [],
74
+ messages: {
75
+ missing: "`{{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: "Connected `{{block}}` renders `<{{rendered}}>` directly. Resolve the world here, then render only `<{{twin}}>`; component.tsx owns the complete presentation.",
77
+ unused: "Connected `{{block}}` imports `{{twin}}` but never renders it. Every connected render path must cross the pure twin.",
78
+ called: "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.",
79
+ },
80
+ },
81
+ create(context) {
82
+ const block = connectedBlock(context);
83
+ if (!block)
84
+ return {};
85
+ const twin = `${block}Base`;
86
+ const worldBindings = new Set();
87
+ const rendered = [];
88
+ const calledTwin = [];
89
+ let importsTwin = false;
90
+ let readsWorld = false;
91
+ let rendersTwin = false;
92
+ return {
93
+ ImportDeclaration(node) {
94
+ const source = typeof node.source?.value === "string" ? node.source.value : "";
95
+ for (const specifier of node.specifiers || []) {
96
+ const imported = specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" ? specifier.imported.name : undefined;
97
+ const local = specifier.local.name;
98
+ if (source === "./component" && imported === twin && local === twin)
99
+ importsTwin = true;
100
+ if (imported && local && REACHES_FOR_THE_WORLD.test(imported))
101
+ worldBindings.add(local);
102
+ }
103
+ },
104
+ CallExpression(node) {
105
+ if (node.callee?.type !== "Identifier")
106
+ return;
107
+ if (node.callee.name === twin)
108
+ 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")
115
+ return;
116
+ rendered.push({ name: node.name.name, node });
117
+ if (node.name.name === twin)
118
+ rendersTwin = true;
119
+ },
120
+ "Program:exit"(node) {
121
+ if (!readsWorld)
122
+ return;
123
+ if (!importsTwin) {
124
+ context.report({ node, messageId: "missing", data: { block, twin } });
125
+ return;
126
+ }
127
+ for (const entry of rendered) {
128
+ if (entry.name !== twin) {
129
+ context.report({
130
+ node: entry.node,
131
+ messageId: "bypass",
132
+ data: { block, rendered: entry.name, twin },
133
+ });
134
+ }
135
+ }
136
+ for (const call of calledTwin) {
137
+ context.report({ node: call, messageId: "called", data: { block, twin } });
138
+ }
139
+ if (!rendersTwin)
140
+ context.report({ node, messageId: "unused", data: { block, twin } });
141
+ },
142
+ };
143
+ },
144
+ });
145
+ /** The rules this law contributes to the plugin. */
146
+ export const rules = {
147
+ "presentational-purity": presentationalPurity,
148
+ "connected-block-has-presentational-twin": connectedBlockHasPresentationalTwin,
149
+ };
150
+ /**
151
+ * The level this law asks for, as the plugin's own opinion.
152
+ *
153
+ * Exact: it fires on a call by name, in one file by name. A repository adopting it with history
154
+ * should expect each report to be a real move rather than a deletion - the call has to go
155
+ * somewhere, and where it goes is the other half.
156
+ */
157
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,9 @@
1
+ import { type Recommended } from "./lib/rule.js";
2
+ /** An arbitrary value escapes the system, whatever it happens to evaluate to. */
3
+ export declare const noArbitraryValue: import("./lib/rule.js").Rule<"length" | "colour", []>;
4
+ /** Rules of universal token discipline. */
5
+ export declare const rules: {
6
+ "no-arbitrary-value": import("./lib/rule.js").Rule<"length" | "colour", []>;
7
+ };
8
+ /** Every token-discipline rule is mandatory. */
9
+ export declare const recommended: Recommended;
package/dist/tokens.js ADDED
@@ -0,0 +1,35 @@
1
+ /** Universal closed-token discipline, independent of a Grammar scale. */
2
+ import { classTextVisitors } from "./lib/class-text.js";
3
+ import { defineRule } from "./lib/rule.js";
4
+ /** A bracketed length: a value chosen once, for one screen, findable by nobody. */
5
+ const ARBITRARY_LENGTH = /\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)-\[[^\]]+\]/;
6
+ /** A raw colour, which escapes the semantic palette even when it matches it today. */
7
+ const RAW_COLOUR = /\b(?:text|bg|border|ring|from|to|via|fill|stroke|shadow|decoration)-\[#[0-9a-fA-F]/;
8
+ /** An arbitrary value escapes the system, whatever it happens to evaluate to. */
9
+ export const noArbitraryValue = defineRule({
10
+ meta: {
11
+ type: "problem",
12
+ docs: { description: "Bracketed lengths and raw colours escape the closed vocabulary." },
13
+ schema: [],
14
+ messages: {
15
+ length: "`{{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.",
16
+ colour: "`{{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.",
17
+ },
18
+ },
19
+ create(context) {
20
+ return classTextVisitors(context, (node, text) => {
21
+ if (!text)
22
+ return;
23
+ const length = text.match(ARBITRARY_LENGTH);
24
+ if (length)
25
+ context.report({ node, messageId: "length", data: { cls: length[0] } });
26
+ const colour = text.match(RAW_COLOUR);
27
+ if (colour)
28
+ context.report({ node, messageId: "colour", data: { cls: colour[0] } });
29
+ });
30
+ },
31
+ });
32
+ /** Rules of universal token discipline. */
33
+ export const rules = { "no-arbitrary-value": noArbitraryValue };
34
+ /** Every token-discipline rule is mandatory. */
35
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));