@starci/eslint-canon-fe 9.3.0 → 9.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +15 -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 +403 -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 +18 -11
  129. package/runtime/scripts/hfs/repo-identity.mjs +5 -4
  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 -321
  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,396 @@
1
+ import { stringOf, walk } from "./lib/ast.js";
2
+ import { patternPath } from "./lib/bindings.js";
3
+ import { normalizePath } from "./lib/path.js";
4
+ import { ownerExportKind } from "./lib/pure-owner-helper.js";
5
+ import { defineRule } from "./lib/rule.js";
6
+ import { classifyImport, inSlot, resolveImport } from "./lib/scope.js";
7
+ /** A value a well-formed tree always has; its absence stops the run as reading it always has. */
8
+ const must = (value, what) => {
9
+ if (value === null || value === undefined)
10
+ throw new TypeError(`missing ${what}`);
11
+ return value;
12
+ };
13
+ /** The node a runtime wrapper wraps, or undefined when the node wraps nothing. */
14
+ const wrapped = (node) => {
15
+ if (node.type === "AwaitExpression")
16
+ return node.argument;
17
+ if (node.type === "ChainExpression" || node.type === "TSAsExpression" || node.type === "TSTypeAssertion" || node.type === "TSNonNullExpression" || node.type === "TSInstantiationExpression")
18
+ return node.expression;
19
+ return undefined;
20
+ };
21
+ const WRAPPERS = new Set(["AwaitExpression", "ChainExpression", "TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSInstantiationExpression"]);
22
+ /** Runtime expression wrappers that preserve the value's source. */
23
+ const unwrap = (input) => {
24
+ let node = input;
25
+ while (node && WRAPPERS.has(node.type))
26
+ node = wrapped(node);
27
+ return node;
28
+ };
29
+ /** A member's static key, including a bracketed string spelling. */
30
+ const memberName = (node) => node.computed ? stringOf(node.property) : node.property.name;
31
+ /** The name an identifier-like node spells, or undefined. */
32
+ const nameOf = (node) => ("name" in node && typeof node.name === "string" ? node.name : undefined);
33
+ /** The lexical binding of an identifier, including shadowing inside the handler. */
34
+ const bindingOf = (sourceCode, node) => {
35
+ const name = nameOf(node);
36
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
37
+ const variable = name === undefined ? undefined : scope.set.get(name);
38
+ if (variable)
39
+ return variable;
40
+ }
41
+ return null;
42
+ };
43
+ /** Whether an identifier refers to the actual unshadowed platform global. */
44
+ const isGlobal = (sourceCode, node, name) => node?.type === "Identifier" && node.name === name && !(bindingOf(sourceCode, node)?.defs.length);
45
+ /** Route configuration is inert; a second HTTP door or an exported helper is not. */
46
+ const CONFIG_EXPORTS = new Set(["runtime", "dynamic", "revalidate", "fetchCache", "preferredRegion", "maxDuration"]);
47
+ const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
48
+ const FORBIDDEN = new Set(["AssignmentExpression", "UpdateExpression", "ImportExpression", "TaggedTemplateExpression", "ClassDeclaration", "ClassExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "ThrowStatement", "YieldExpression", "SpreadElement", "ArrayPattern", "JSXElement", "JSXFragment"]);
49
+ const TEXT = new Set(["text", "code", "otp-type"]);
50
+ const CALLS = new Set(["factory", "exchange", "verify-otp", "pure", "redirect", "params-get", "params-has", "text-guard"]);
51
+ /** What a pure owner helper may be given: query text, the page URL or request, and plain values; never the credential itself. */
52
+ const PURE_ARGUMENTS = new Set(["text", "otp-type", "scalar", "url", "request"]);
53
+ const URL_TEXT = new Set(["origin", "href", "pathname", "search", "hash", "host", "hostname", "protocol"]);
54
+ const CALL_RESULTS = new Map([["factory", "client"], ["exchange", "exchange-result"], ["verify-otp", "exchange-result"], ["pure", "text"], ["redirect", "redirect-result"], ["params-has", "scalar"], ["text-guard", "scalar"]]);
55
+ const MEMBERS = new Map([
56
+ ["request", new Map([["nextUrl", "url"], ["url", "text"]])],
57
+ ["url", new Map([["searchParams", "params"], ...[...URL_TEXT].map((key) => [key, "text"])])],
58
+ ["params", new Map([["get", "params-get"], ["has", "params-has"]])],
59
+ ["client", new Map([["auth", "auth"]])],
60
+ ["auth", new Map([["exchangeCodeForSession", "exchange"], ["verifyOtp", "verify-otp"]])],
61
+ ["next-response", new Map([["redirect", "redirect"]])],
62
+ ["exchange-result", new Map([["error", "exchange-error"], ["success", "scalar"], ["ok", "scalar"]])],
63
+ ]);
64
+ const TEXT_GUARDS = ["startsWith", "endsWith", "includes"];
65
+ const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];
66
+ const REDIRECT_TARGETS = ["text", "url"];
67
+ const REDIRECT_STATUSES = [301, 302, 303, 307, 308];
68
+ /** The query parameters whose provenance an exchange requires: the PKCE code, and the email link's token hash and type. */
69
+ const PARAM_ORIGINS = new Map([["code", "code"], ["token_hash", "token-hash"], ["type", "otp-type"]]);
70
+ /** True for a function node. */
71
+ const isFunctionNode = (node) => FUNCTIONS.has(node.type);
72
+ /** A locally exported binding's implementation. */
73
+ const exportValue = (sourceCode, entry) => {
74
+ const definition = bindingOf(sourceCode, entry.local)?.defs[0]?.node;
75
+ return unwrap(definition?.type === "VariableDeclarator" ? definition.init : definition);
76
+ };
77
+ /** The spelling of an export or import name: an identifier's name or a string literal's value. */
78
+ const moduleExportName = (node) => node.type === "Identifier" ? node.name : node.value;
79
+ /** The runtime names introduced by one local named export. */
80
+ const namedExports = (statement, sourceCode) => {
81
+ const declaration = statement.declaration;
82
+ if (declaration?.type === "FunctionDeclaration")
83
+ return [{ name: must(declaration.id, "function name").name, value: declaration }];
84
+ if (declaration?.type === "VariableDeclaration") {
85
+ if (declaration.declarations.some((entry) => entry.id.type !== "Identifier"))
86
+ return null;
87
+ return declaration.declarations.map((entry) => ({ name: must(nameOf(entry.id), "variable name"), value: unwrap(entry.init) }));
88
+ }
89
+ if (declaration)
90
+ return [];
91
+ return statement.specifiers.filter((entry) => entry.exportKind !== "type")
92
+ .map((entry) => ({ name: moduleExportName(entry.exported), value: exportValue(sourceCode, entry) }));
93
+ };
94
+ /** Collect runtime exports, refusing reexports and default exports. */
95
+ const exportsOf = (program, sourceCode) => {
96
+ const exported = [];
97
+ for (const statement of program.body) {
98
+ if (statement.type === "ExportDefaultDeclaration" || statement.type === "ExportAllDeclaration")
99
+ return null;
100
+ if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type")
101
+ continue;
102
+ if (statement.source)
103
+ return null;
104
+ const entries = namedExports(statement, sourceCode);
105
+ if (!entries)
106
+ return null;
107
+ exported.push(...entries);
108
+ }
109
+ return exported;
110
+ };
111
+ /** Find the one locally implemented GET export and its single request parameter. */
112
+ const handlerOf = (program, sourceCode) => {
113
+ const exported = exportsOf(program, sourceCode);
114
+ if (!exported || exported.some((entry) => entry.name !== "GET" && !CONFIG_EXPORTS.has(entry.name)))
115
+ return null;
116
+ const handlers = exported.filter((entry) => entry.name === "GET");
117
+ if (handlers.length !== 1)
118
+ return null;
119
+ const handler = must(handlers[0], "GET export").value;
120
+ if (!handler || !isFunctionNode(handler) || !handler.async || handler.generator)
121
+ return null;
122
+ return handler.params.length === 1 && handler.params[0]?.type === "Identifier" ? handler : null;
123
+ };
124
+ /** Framework imports retain their actual owner even under a local alias. */
125
+ const frameworkImportKind = (source, name) => {
126
+ if (source === "next/server" && name === "NextResponse")
127
+ return "next-response";
128
+ if (source === "next/server" && name === "NextRequest")
129
+ return "request-type";
130
+ if (source === "next/navigation" && name === "redirect")
131
+ return "redirect";
132
+ return null;
133
+ };
134
+ /** Only the server auth entry exposes the cookie-owning exchangeAuthCode helper. */
135
+ const isServerAuthImport = (context, source) => {
136
+ const target = normalizePath(resolveImport(context, source));
137
+ return target.endsWith("/modules/auth/server") || target.endsWith("/modules/auth/server.ts");
138
+ };
139
+ /** A directory import resolves to its index, before the generic modules slot's hypothetical routes.ts. */
140
+ const isRoutesImport = (context, source, slot) => slot === "fe.modules.routes" || classifyImport(context, `${source}/index`)?.slot === "fe.modules.routes";
141
+ const CLIENT_NAMES = ["supabase", "supabaseClient", "authClient"];
142
+ /** The exchange, client and factory names of the auth owner. */
143
+ const authImportKind = (context, source, name) => {
144
+ if (name === "exchangeAuthCode" && isServerAuthImport(context, source))
145
+ return "exchange";
146
+ if (name === "verifyOtp" && isServerAuthImport(context, source))
147
+ return "verify-otp";
148
+ if (name === "exchangeCodeForSession")
149
+ return "exchange";
150
+ if (/^(?:create|get)\w*Client$/.test(name ?? ""))
151
+ return "factory";
152
+ if (CLIENT_NAMES.includes(name))
153
+ return "client";
154
+ return null;
155
+ };
156
+ /** Auth helpers and static route values must come from their declared HFS owners; any other export of `modules/routes` or `modules/auth` counts only as a pure helper or a static table. */
157
+ const ownerImportKind = (context, source, name) => {
158
+ const slot = classifyImport(context, source)?.slot;
159
+ if (name === "APP_ROUTES" && isRoutesImport(context, source, slot))
160
+ return "routes";
161
+ const authKind = slot === "fe.modules.auth" ? authImportKind(context, source, name) : null;
162
+ if (authKind || !(slot === "fe.modules.auth" || isRoutesImport(context, source, slot)))
163
+ return authKind;
164
+ // A default or namespace import names no export, so no export of the owner can match it.
165
+ return name === undefined ? null : ownerExportKind(context, source, name);
166
+ };
167
+ /** The imported name of a specifier; undefined for a default or namespace import. */
168
+ const importedName = (specifier) => specifier.type === "ImportSpecifier" ? moduleExportName(specifier.imported) : undefined;
169
+ /** Register the allowed bindings in a single import statement. */
170
+ const addImportBindings = (context, sourceCode, statement, imports) => {
171
+ const source = String(statement.source.value);
172
+ if (source === "server-only" && statement.specifiers.length === 0)
173
+ return true;
174
+ if (statement.specifiers.length === 0)
175
+ return false;
176
+ const variables = sourceCode.getDeclaredVariables(statement);
177
+ for (const specifier of statement.specifiers) {
178
+ if (specifier.type === "ImportSpecifier" && specifier.importKind === "type")
179
+ continue;
180
+ const name = importedName(specifier);
181
+ const kind = frameworkImportKind(source, name) ?? ownerImportKind(context, source, name);
182
+ if (!kind)
183
+ return false;
184
+ imports.set(variables.find((entry) => entry.name === specifier.local.name), kind);
185
+ }
186
+ return true;
187
+ };
188
+ /** Every runtime import is restricted, even if it is unused in the handler. */
189
+ const importsOf = (context, program, sourceCode) => {
190
+ const imports = new Map();
191
+ for (const statement of program.body) {
192
+ if (statement.type !== "ImportDeclaration" || statement.importKind === "type")
193
+ continue;
194
+ if (!addImportBindings(context, sourceCode, statement, imports))
195
+ return null;
196
+ }
197
+ return imports;
198
+ };
199
+ /** Select a known property without granting arbitrary methods on the selected value. */
200
+ const memberKind = (kind, key) => {
201
+ if (kind === "routes" && typeof key === "string" && !UNSAFE_KEYS.includes(key))
202
+ return "text";
203
+ if (TEXT.has(kind) && TEXT_GUARDS.includes(key))
204
+ return "text-guard";
205
+ return MEMBERS.get(kind)?.get(key) ?? null;
206
+ };
207
+ /** An identifier keeps the provenance of its one initializer or the GET request parameter. */
208
+ const identifierOrigin = (node, seen, env, origin) => {
209
+ const variable = bindingOf(env.sourceCode, node);
210
+ if (env.imports.has(variable))
211
+ return env.imports.get(variable) ?? null;
212
+ if (!variable || seen.has(variable) || variable.defs.length !== 1)
213
+ return null;
214
+ const definition = must(variable.defs[0], "definition");
215
+ if (definition.type === "Parameter" && definition.node === env.handler)
216
+ return "request";
217
+ if (definition.node.type !== "VariableDeclarator")
218
+ return null;
219
+ const path = patternPath(definition.node.id, node.name);
220
+ const nextSeen = new Set([...seen, variable]);
221
+ return path?.reduce((kind, key) => memberKind(kind, key), origin(definition.node.init, nextSeen)) ?? null;
222
+ };
223
+ /** Combining known primitive values cannot introduce user-defined coercion. */
224
+ const combinedOrigin = (kinds, conditional = false) => {
225
+ if (conditional && kinds[0] === kinds[1])
226
+ return kinds[0];
227
+ if (kinds.every((kind) => TEXT.has(kind)))
228
+ return "text";
229
+ if (!conditional && kinds.every((kind) => TEXT.has(kind) || kind === "scalar"))
230
+ return "scalar";
231
+ return null;
232
+ };
233
+ /** The source of a call's return value, independent of argument validation. */
234
+ const callOrigin = (node, seen, origin) => {
235
+ const kind = origin(node.callee, seen);
236
+ if (kind === "params-get")
237
+ return PARAM_ORIGINS.get(stringOf(node.arguments[0])) ?? "text";
238
+ return CALL_RESULTS.get(kind) ?? null;
239
+ };
240
+ /** Composite expressions retain provenance only for supported primitive operations. */
241
+ const expressionOrigin = (node, seen, env, origin) => {
242
+ if (node.type === "TemplateLiteral")
243
+ return node.expressions.every((expression) => TEXT.has(origin(expression, seen)) || origin(expression, seen) === "scalar") ? "text" : null;
244
+ if (node.type === "MemberExpression")
245
+ return memberKind(origin(node.object, seen), memberName(node));
246
+ if (node.type === "NewExpression" && isGlobal(env.sourceCode, node.callee, "URL"))
247
+ return "url";
248
+ if (node.type === "LogicalExpression" || node.type === "BinaryExpression")
249
+ return combinedOrigin([origin(node.left, seen), origin(node.right, seen)]);
250
+ if (node.type === "ConditionalExpression")
251
+ return combinedOrigin([origin(node.consequent, seen), origin(node.alternate, seen)], true);
252
+ if (node.type === "CallExpression")
253
+ return callOrigin(node, seen, origin);
254
+ return null;
255
+ };
256
+ /** Follow lexical aliases rather than trusting the spelling of an identifier. */
257
+ const originOf = (env) => {
258
+ const origin = (input, seen = new Set()) => {
259
+ const node = unwrap(input);
260
+ if (!node)
261
+ return null;
262
+ if (node.type === "Identifier")
263
+ return identifierOrigin(node, seen, env, origin);
264
+ if (node.type === "Literal")
265
+ return typeof node.value === "string" || node.value === null ? "text" : "scalar";
266
+ return expressionOrigin(node, seen, env, origin);
267
+ };
268
+ return origin;
269
+ };
270
+ /** The closest enclosing function must be the GET handler. */
271
+ const insideHandler = (node, handler) => {
272
+ for (let parent = node.parent; parent; parent = parent.parent)
273
+ if (FUNCTIONS.has(parent.type))
274
+ return parent === handler;
275
+ return false;
276
+ };
277
+ /** Await must wrap the actual exchange call before its enclosing statement ends. */
278
+ const isAwaited = (node, handler) => {
279
+ for (let parent = node.parent; parent && parent !== handler; parent = parent.parent) {
280
+ if (parent.type === "AwaitExpression")
281
+ return true;
282
+ if (parent.type.endsWith("Statement"))
283
+ return false;
284
+ }
285
+ return false;
286
+ };
287
+ /** The static key of a plain `init` property, or null for a computed, method or accessor property and for a spread. */
288
+ const plainKey = (property) => {
289
+ if (property.type !== "Property" || property.computed || property.method || property.kind !== "init")
290
+ return null;
291
+ const key = property.key;
292
+ return key.type === "Identifier" ? key.name : key.value;
293
+ };
294
+ /** Only a literal redirect status is allowed in an options object. */
295
+ const isRedirectStatus = (property) => {
296
+ if (property.type !== "Property" || property.computed || property.method || property.kind !== "init")
297
+ return false;
298
+ return plainKey(property) === "status" && property.value.type === "Literal" && REDIRECT_STATUSES.includes(property.value.value);
299
+ };
300
+ /** Arbitrary objects can invoke getters or toString during coercion; only redirect options belong here. */
301
+ const isRedirectOptions = (node, origin) => node.parent.type === "CallExpression" && origin(node.parent.callee) === "redirect" && node.parent.arguments[1] === node && node.properties.every(isRedirectStatus);
302
+ /** The one object `verifyOtp` takes: exactly `token_hash` read from the query `token_hash` and `type` read from the query `type`. */
303
+ const isOtpArguments = (node, origin) => {
304
+ if (node.parent.type !== "CallExpression" || origin(node.parent.callee) !== "verify-otp" || node.parent.arguments[0] !== node)
305
+ return false;
306
+ const expected = new Map([["token_hash", "token-hash"], ["type", "otp-type"]]);
307
+ return node.properties.length === expected.size && node.properties.every((property) => property.type === "Property" && expected.get(plainKey(property)) === origin(property.value))
308
+ && new Set(node.properties.map(plainKey)).size === expected.size;
309
+ };
310
+ /** Non-call expressions must preserve the callback's narrow set of values and effects. */
311
+ const validExpression = (node, env, origin) => {
312
+ if (node.type === "MemberExpression")
313
+ return Boolean(origin(node));
314
+ if (node.type === "ObjectExpression")
315
+ return isRedirectOptions(node, origin) || isOtpArguments(node, origin);
316
+ if (node.type === "VariableDeclarator" && node.id.type === "ObjectPattern")
317
+ return env.sourceCode.getDeclaredVariables(node).every((variable) => origin(variable.identifiers[0]));
318
+ if (node.type === "NewExpression")
319
+ return isGlobal(env.sourceCode, node.callee, "URL") && node.arguments.length >= 1 && node.arguments.length <= 2 && node.arguments.every((argument) => TEXT.has(origin(argument)));
320
+ if (node.type === "ReturnStatement")
321
+ return insideHandler(node, env.handler) && origin(node.argument) === "redirect-result";
322
+ if (node === env.handler && env.handler.body.type !== "BlockStatement")
323
+ return origin(env.handler.body) === "redirect-result";
324
+ return true;
325
+ };
326
+ /** Validate arguments to each permitted operation. */
327
+ const validCall = (node, kind, env, origin) => {
328
+ if (!insideHandler(node, env.handler) || !CALLS.has(kind) || node.arguments.some((argument) => argument.type === "SpreadElement"))
329
+ return false;
330
+ if (kind === "exchange")
331
+ return isAwaited(node, env.handler) && node.arguments.length === 1 && origin(node.arguments[0]) === "code";
332
+ if (kind === "verify-otp")
333
+ return isAwaited(node, env.handler) && node.arguments.length === 1 && node.arguments[0]?.type === "ObjectExpression";
334
+ if (kind === "pure")
335
+ return node.arguments.length <= 4 && node.arguments.every((argument) => PURE_ARGUMENTS.has(origin(argument)));
336
+ if (kind === "redirect")
337
+ return node.arguments.length >= 1 && node.arguments.length <= 2 && REDIRECT_TARGETS.includes(origin(node.arguments[0]));
338
+ if (kind === "factory")
339
+ return node.arguments.length <= 1 && node.arguments.every((argument) => origin(argument) === "request");
340
+ return node.arguments.length === 1 && TEXT.has(origin(node.arguments[0]));
341
+ };
342
+ /** Syntactic forms that add work beyond the one handler are refused outright. */
343
+ const forbiddenNode = (node, handler) => FORBIDDEN.has(node.type) || (FUNCTIONS.has(node.type) && node !== handler) || (node.type === "UnaryExpression" && node.operator === "delete");
344
+ /** Walk all runtime syntax, counting the single exchange and permitted redirects. */
345
+ const validOperations = (program, env) => {
346
+ const origin = originOf(env);
347
+ const counts = new Map();
348
+ let valid = true;
349
+ walk(env.sourceCode, program, (node) => {
350
+ if (!valid)
351
+ return false;
352
+ valid = !forbiddenNode(node, env.handler) && validExpression(node, env, origin);
353
+ if (!valid || node.type !== "CallExpression")
354
+ return undefined;
355
+ const kind = origin(node.callee);
356
+ valid = validCall(node, kind, env, origin);
357
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
358
+ return undefined;
359
+ });
360
+ const exchanges = (counts.get("exchange") ?? 0) + (counts.get("verify-otp") ?? 0);
361
+ return valid && exchanges >= 1 && (counts.get("exchange") ?? 0) <= 1 && (counts.get("verify-otp") ?? 0) <= 1 && (counts.get("factory") ?? 0) <= 1 && (counts.get("redirect") ?? 0) > 0;
362
+ };
363
+ /** The callback's request, imports and operations must all have known owners. */
364
+ const callbackVerdict = (context, program) => {
365
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
366
+ const handler = handlerOf(program, sourceCode);
367
+ if (!handler)
368
+ return false;
369
+ const imports = importsOf(context, program, sourceCode);
370
+ return imports !== null && validOperations(program, { sourceCode, handler, imports });
371
+ };
372
+ /** A callback stays a PKCE exchange and redirect, with every side effect owned by the auth helper. */
373
+ export const callbackShape = defineRule({
374
+ meta: {
375
+ type: "problem",
376
+ docs: { description: "R287 FE_CALLBACK_SHAPE: the Supabase callback reads the PKCE code or the email-link token_hash and type, exchanges it through modules/auth (exchangeAuthCode or verifyOtp), and redirects only." },
377
+ schema: [],
378
+ messages: {
379
+ shape: "FE_CALLBACK_SHAPE: the auth callback must export an async GET that reads the query code (or the email-link token_hash and type), awaits one exchangeAuthCode(code) from modules/auth/server or auth.exchangeCodeForSession(code) and, for an email link, one verifyOtp({ token_hash, type }) built from exactly those two query values, may call pure text helpers of modules/routes or modules/auth (no I/O, no await) to compute the target and validate next, and redirects with NextResponse.redirect or next/navigation redirect. Create the client in modules/auth; keep data access, cookie writes, other calls and additional HTTP handlers outside the callback.",
380
+ },
381
+ },
382
+ create(context) {
383
+ if (!inSlot(context, "fe.route.callback"))
384
+ return {};
385
+ return {
386
+ "Program:exit"(program) {
387
+ if (!callbackVerdict(context, program))
388
+ context.report({ node: program, messageId: "shape" });
389
+ },
390
+ };
391
+ },
392
+ });
393
+ /** The rule R287 contributes to the front-end plugin. */
394
+ export const rules = { "callback-shape": callbackShape };
395
+ /** Every callback shape violation is an error. */
396
+ export const recommended = { "starci-fe/callback-shape": "error" };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The classNames law: component markup consumes named styling declarations, while colocated
3
+ * classNames files own the utility-token composition.
4
+ */
5
+ import { type Recommended } from "./lib/rule.js";
6
+ /** Report className values written directly in component JSX. */
7
+ export declare const noInlineClassName: import("./lib/rule.js").Rule<"inline", []>;
8
+ /** Require every classNames declaration to use the colocated classNames.ts convention. */
9
+ export declare const classNamesInColocatedFile: import("./lib/rule.js").Rule<"misplaced", []>;
10
+ /** Require variadic cn utility tokens: no arrays, whitespace-packed values, or fractional commas. */
11
+ export declare const cnArgumentsAreSingleTokens: import("./lib/rule.js").Rule<"array" | "token" | "unexported", []>;
12
+ /** Every rule this law publishes. */
13
+ export declare const rules: {
14
+ "no-inline-class-name": import("./lib/rule.js").Rule<"inline", []>;
15
+ "class-names-in-colocated-file": import("./lib/rule.js").Rule<"misplaced", []>;
16
+ "cn-arguments-are-single-tokens": import("./lib/rule.js").Rule<"array" | "token" | "unexported", []>;
17
+ };
18
+ /** Recommended severity for the classNames law. */
19
+ export declare const recommended: Recommended;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The classNames law: component markup consumes named styling declarations, while colocated
3
+ * classNames files own the utility-token composition.
4
+ */
5
+ import { defineRule } from "./lib/rule.js";
6
+ import { isComponentFile, roleOfFile } from "./lib/scope.js";
7
+ /** The owner a cn call may sit in, and the exports that publish one. */
8
+ const FUNCTION_OWNERS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
9
+ const EXPORTS = new Set(["ExportNamedDeclaration", "ExportDefaultDeclaration"]);
10
+ /** The styling declarations of a component owner: the file its slot gives the role `styles`. */
11
+ const isClassNamesFile = (context) => isComponentFile(context) && roleOfFile(context) === "styles";
12
+ const isClassAttribute = (node) => {
13
+ const name = node.name.name;
14
+ return typeof name === "string" && ["className", "class"].includes(name);
15
+ };
16
+ const isCn = (node) => node?.type === "Identifier" && node.name === "cn";
17
+ const importedClassNames = (context) => {
18
+ const imports = new Set();
19
+ const sourceCode = context.sourceCode || context.getSourceCode();
20
+ for (const statement of sourceCode.ast.body) {
21
+ if (statement.type !== "ImportDeclaration" || statement.source.value !== "./classNames")
22
+ continue;
23
+ for (const specifier of statement.specifiers)
24
+ imports.add(specifier.local.name);
25
+ }
26
+ return imports;
27
+ };
28
+ const isExportedOwner = (node) => {
29
+ let current = node;
30
+ while (current) {
31
+ if (FUNCTION_OWNERS.has(current.type)) {
32
+ current = current.parent;
33
+ if (current?.type === "VariableDeclarator")
34
+ current = current.parent;
35
+ if (current?.type === "VariableDeclaration")
36
+ current = current.parent;
37
+ return EXPORTS.has(current?.type);
38
+ }
39
+ if (current.type === "VariableDeclarator") {
40
+ current = current.parent?.parent;
41
+ return EXPORTS.has(current?.type);
42
+ }
43
+ current = current.parent;
44
+ }
45
+ return false;
46
+ };
47
+ const visitTokenLeaves = (node, report) => {
48
+ if (!node)
49
+ return;
50
+ if (node.type === "ArrayExpression") {
51
+ report(node, "array");
52
+ return;
53
+ }
54
+ if (node.type === "Literal" && typeof node.value === "string") {
55
+ if (/\s/.test(node.value) || /\d+,\d+/.test(node.value))
56
+ report(node, "token");
57
+ return;
58
+ }
59
+ if (node.type === "ConditionalExpression") {
60
+ visitTokenLeaves(node.consequent, report);
61
+ visitTokenLeaves(node.alternate, report);
62
+ }
63
+ else if (node.type === "LogicalExpression") {
64
+ visitTokenLeaves(node.left, report);
65
+ visitTokenLeaves(node.right, report);
66
+ }
67
+ };
68
+ const isEmptyClassName = (node) => node?.type === "Literal" && node.value === null || node?.type === "Identifier" && node.name === "undefined";
69
+ const usesImportedClassName = (node, imported) => {
70
+ if (!node)
71
+ return false;
72
+ if (node.type === "Identifier")
73
+ return imported.has(node.name) || node.name === "undefined";
74
+ if (isEmptyClassName(node))
75
+ return true;
76
+ if (node.type === "CallExpression") {
77
+ if (node.callee.type === "Identifier")
78
+ return imported.has(node.callee.name);
79
+ return node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && imported.has(node.callee.object.name);
80
+ }
81
+ if (node.type === "MemberExpression")
82
+ return node.object.type === "Identifier" && imported.has(node.object.name);
83
+ if (node.type === "ConditionalExpression")
84
+ return usesImportedClassName(node.consequent, imported) && usesImportedClassName(node.alternate, imported);
85
+ if (node.type === "LogicalExpression") {
86
+ if (node.operator === "&&")
87
+ return usesImportedClassName(node.right, imported);
88
+ return usesImportedClassName(node.left, imported) && usesImportedClassName(node.right, imported);
89
+ }
90
+ return false;
91
+ };
92
+ /** Report className values written directly in component JSX. */
93
+ export const noInlineClassName = defineRule({
94
+ meta: {
95
+ type: "problem",
96
+ docs: { description: "Component JSX must import a named className declaration." },
97
+ schema: [],
98
+ messages: { inline: "Move this className value to an imported colocated classNames.ts export." },
99
+ },
100
+ create(context) {
101
+ if (!isComponentFile(context))
102
+ return {};
103
+ const imported = importedClassNames(context);
104
+ return {
105
+ JSXAttribute(node) {
106
+ if (!isClassAttribute(node) || !node.value)
107
+ return;
108
+ const expression = node.value.type === "JSXExpressionContainer" ? node.value.expression : node.value;
109
+ if (!usesImportedClassName(expression, imported))
110
+ context.report({ node, messageId: "inline" });
111
+ },
112
+ };
113
+ },
114
+ });
115
+ /** Require every classNames declaration to use the colocated classNames.ts convention. */
116
+ export const classNamesInColocatedFile = defineRule({
117
+ meta: {
118
+ type: "problem",
119
+ docs: { description: "Reusable className declarations belong in colocated classNames.ts files." },
120
+ schema: [],
121
+ messages: { misplaced: "Put reusable className declarations in a colocated classNames.ts file." },
122
+ },
123
+ create(context) {
124
+ if (!isComponentFile(context))
125
+ return {};
126
+ const styles = isClassNamesFile(context);
127
+ return {
128
+ CallExpression(node) {
129
+ if (!isCn(node.callee) || styles)
130
+ return;
131
+ context.report({ node, messageId: "misplaced" });
132
+ },
133
+ };
134
+ },
135
+ });
136
+ /** Require variadic cn utility tokens: no arrays, whitespace-packed values, or fractional commas. */
137
+ export const cnArgumentsAreSingleTokens = defineRule({
138
+ meta: {
139
+ type: "problem",
140
+ docs: { description: "Each cn argument is one valid utility token." },
141
+ schema: [],
142
+ messages: {
143
+ array: "Pass utility tokens to cn as variadic arguments, not an array.",
144
+ token: "Each cn argument must be one utility token without whitespace or malformed fractional commas.",
145
+ unexported: "Export every className declaration or function that produces a cn value.",
146
+ },
147
+ },
148
+ create(context) {
149
+ if (!isClassNamesFile(context))
150
+ return {};
151
+ return {
152
+ CallExpression(node) {
153
+ if (!isCn(node.callee))
154
+ return;
155
+ if (!isExportedOwner(node))
156
+ context.report({ node, messageId: "unexported" });
157
+ for (const argument of node.arguments)
158
+ visitTokenLeaves(argument, (reportedNode, messageId) => context.report({ node: reportedNode, messageId }));
159
+ },
160
+ };
161
+ },
162
+ });
163
+ /** Every rule this law publishes. */
164
+ export const rules = {
165
+ "no-inline-class-name": noInlineClassName,
166
+ "class-names-in-colocated-file": classNamesInColocatedFile,
167
+ "cn-arguments-are-single-tokens": cnArgumentsAreSingleTokens,
168
+ };
169
+ /** Recommended severity for the classNames law. */
170
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The rule that holds `client-boundary.md` (HFS R55, `FE_CLIENT_BOUNDARY`).
3
+ *
4
+ * SERVER FIRST. A route file is a server component; `"use client"` is a cost the app pays where a
5
+ * reader interacts, and it is paid once, at the top of the interactive subtree, not sprinkled
6
+ * wherever a hook happened to be needed. The measured alternative: 155 client files in one app,
7
+ * a client layout that shipped a 132 KB message catalog to every page, against 3 client files in a
8
+ * sibling app that did the same job server-first.
9
+ *
10
+ * The directive is allowed in exactly the places the framework or the interaction requires:
11
+ * - the `index.tsx` of an interactive block (or overlay), which is the connected half;
12
+ * - a leaf, whose interactivity is intrinsic (a menu, a tooltip trigger);
13
+ * - `error.tsx` and `global-error.tsx`, which Next requires to be client components;
14
+ * - the client state of the front-end stack, which only ever runs in the browser: a provider of
15
+ * `components/providers/<name>/index.tsx`, every file of `hooks/` (the SWR, form, store, socket and effect hooks and the
16
+ * side-effect components), the redux store (`redux/`) and the toast module (`modules/toast/`). A directive there states
17
+ * what the file already is; it ships nothing a server component would have kept.
18
+ * Everything below a client block is already on the client and needs no directive; everything
19
+ * above it is a server component and must not have one.
20
+ */
21
+ import { type Recommended, type Rules } from "./lib/rule.js";
22
+ /** `"use client"` only at an interaction boundary; pages and layouts stay server components. */
23
+ export declare const useClientOnlyAtBoundary: import("./lib/rule.js").Rule<"slot" | "elsewhere", []>;
24
+ /** A client component imports nothing that only exists on the server. */
25
+ export declare const clientNoServerImport: import("./lib/rule.js").Rule<"server", []>;
26
+ export declare const serverModuleMarksServerOnly: import("./lib/rule.js").Rule<"mark", []>;
27
+ /**
28
+ * The front end serves no API: no `"use server"` directive, at the top of a module or as the first statement of a function.
29
+ * Reads and writes reach the back end through the operations of `modules/api` and the SWR hooks; a write that must run on a
30
+ * server is a GraphQL mutation of the back end.
31
+ */
32
+ export declare const noServerAction: import("./lib/rule.js").Rule<"action", []>;
33
+ /** Web storage is read and written only inside `modules/`. */
34
+ export declare const webStorageOnlyInModules: import("./lib/rule.js").Rule<"storage", []>;
35
+ /** Raw HTML is set only on a `<script>`, where the framework has no other way to inline JSON-LD or the theme bootstrap. */
36
+ export declare const noDangerousHtml: import("./lib/rule.js").Rule<"html", []>;
37
+ /** A cookie the app writes states its script reach, is sent only over TLS and is kept off cross-site requests. */
38
+ export declare const responseCookieAttributes: import("./lib/rule.js").Rule<"attributes", []>;
39
+ /** The rules this law contributes to the plugin. */
40
+ export declare const rules: Rules;
41
+ /** Every rule is an error. */
42
+ export declare const recommended: Recommended;