@starci/eslint-canon-fe 8.3.0 → 8.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 (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/callback-shape.mjs +344 -0
  3. package/client-boundary.mjs +1 -1
  4. package/docs/hooks-folder.md +1 -1
  5. package/env-owner.mjs +120 -9
  6. package/function-budget.mjs +49 -0
  7. package/hooks-folder.mjs +149 -18
  8. package/icon.mjs +67 -0
  9. package/index.mjs +47 -0
  10. package/lib/own-hook-origin.mjs +53 -0
  11. package/lib/program-for.mjs +22 -0
  12. package/lib/pure-owner-helper.mjs +148 -0
  13. package/lib/redux-hook-origin.mjs +44 -0
  14. package/next-conventions.mjs +38 -35
  15. package/package.json +1 -1
  16. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  17. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  18. package/runtime/knowledge/hfs/slots.yaml +344 -62
  19. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  20. package/runtime/knowledge/sonar-gate.yaml +1 -1
  21. package/runtime/modules/models/runtimes.yaml +1 -1
  22. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  23. package/runtime/scripts/api/git/lib.mjs +3 -0
  24. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  25. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  26. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  27. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  28. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  29. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  30. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  31. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  32. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  33. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  34. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  35. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  36. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  37. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  38. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  39. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  40. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  41. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  42. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  43. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  44. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  45. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  46. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  47. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  48. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  49. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  50. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  51. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  52. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  53. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  54. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  55. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  56. package/runtime/scripts/hfs/slots.mjs +1 -0
  57. package/runtime/scripts/lib/env.mjs +20 -0
  58. package/runtime/scripts/lib/event-contract.mjs +107 -0
  59. package/runtime/scripts/lib/json-clone.mjs +61 -0
  60. package/runtime/scripts/lib/list.mjs +4 -0
  61. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  62. package/runtime/scripts/lib/stack-services.mjs +1 -1
  63. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  64. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  65. package/served-locale.mjs +80 -9
  66. package/size-growth.mjs +10 -6
  67. package/swr-family.mjs +197 -0
  68. package/translation.mjs +135 -7
  69. package/transport.mjs +126 -59
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Changed (R287 `FE_CALLBACK_SHAPE`, a18.1 NIVO request F2, owner ruling `auth-callback-pure-helpers`): the auth callback may call pure text helpers of the app's `modules/routes` or `modules/auth` (`requestOrigin`, `safeNextPath`; resolved by TypeScript symbol: synchronous, no await, no I/O global, only owner helpers and `modules/config` readers), use static route tables (`AUTH_ROUTES`), and read the exchange outcome's `success` or `ok` flag. An impure, async or non-text helper, a helper outside those owners and a credential passed to a helper are still refused.
6
+ - Changed (R287 `FE_CALLBACK_SHAPE`, a18.1 NIVO request F, owner ruling `auth-callback-email-link`): the auth callback may await one `verifyOtp({ token_hash, type })` of the auth owner (`auth.verifyOtp` or `verifyOtp` from `modules/auth/server`), built from exactly the query values `token_hash` and `type`, beside the one `exchangeAuthCode(code)`; the token hash never reaches a redirect.
7
+ - Fixed (R55 `FE_CLIENT_BOUNDARY`, a18.1 NIVO request I): the finding on a page or layout owner now says the owner stays a server component that renders the client block or overlay carrying the directive. The rule is unchanged.
8
+
3
9
  ## 8.3.0 - 2026-10-15
4
10
  - Changed: layouts may import overlays beside pages (tier edge `feature -> feature` from pages and layouts to overlays); a layout hosts the route-selected overlay through `components/branches/OverlayHost` while pages stay server components (pattern FE-BOUNDARY-5). Carries the runtime mirror of the manifest and pins, rebased onto alpha.13 (eslint-canon-fe 8.2.18).
5
11
 
@@ -0,0 +1,344 @@
1
+ /**
2
+ * R287 FE_CALLBACK_SHAPE: the Supabase callback only reads the PKCE code (or the email-link `token_hash` and `type`), exchanges it
3
+ * through the auth owner with exactly one `exchangeAuthCode(code)` or one `verifyOtp({ token_hash, type })`, and redirects. It may read
4
+ * the exchange result's `success` or `ok` flag and call PURE text helpers of `modules/routes` or `modules/auth` (`requestOrigin`,
5
+ * `safeNextPath`), found by symbol resolution in lib/pure-owner-helper.mjs, so open-redirect protection stays in the app's owner modules.
6
+ * Cookie configuration and writes belong in the Supabase helper in fe.modules.auth; application data belongs in the back end.
7
+ * This syntax check follows lexical bindings and local aliases rather than trusting a method's spelling.
8
+ */
9
+ import { stringOf, walk } from "./lib/ast.mjs"
10
+ import { normalizePath } from "./lib/path.mjs"
11
+ import { ownerExportKind } from "./lib/pure-owner-helper.mjs"
12
+ import { classifyImport, inSlot, resolveImport } from "./lib/scope.mjs"
13
+
14
+ /** Runtime expression wrappers that preserve the value's source. */
15
+ const unwrap = (node) => {
16
+ while (node && ["AwaitExpression", "ChainExpression", "TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSInstantiationExpression"].includes(node.type)) node = node.expression ?? node.argument
17
+ return node
18
+ }
19
+
20
+ /** A member's static key, including a bracketed string spelling. */
21
+ const memberName = (node) => node.computed ? stringOf(node.property) : node.property.name
22
+
23
+ /** The lexical binding of an identifier, including shadowing inside the handler. */
24
+ const bindingOf = (sourceCode, node) => {
25
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
26
+ const variable = scope.set.get(node.name)
27
+ if (variable) return variable
28
+ }
29
+ return null
30
+ }
31
+
32
+ /** Whether an identifier refers to the actual unshadowed platform global. */
33
+ const isGlobal = (sourceCode, node, name) => node?.type === "Identifier" && node.name === name && !(bindingOf(sourceCode, node)?.defs.length)
34
+
35
+ /** The property path that a destructured binding selects from its initializer. */
36
+ const patternPath = (pattern, name) => {
37
+ if (pattern.type === "Identifier") return pattern.name === name ? [] : null
38
+ if (pattern.type === "AssignmentPattern") return patternPath(pattern.left, name)
39
+ if (pattern.type !== "ObjectPattern") return null
40
+ for (const property of pattern.properties) {
41
+ if (property.type !== "Property") continue
42
+ const nested = patternPath(property.value, name)
43
+ if (nested !== null) return [property.computed ? stringOf(property.key) : property.key.name ?? property.key.value, ...nested]
44
+ }
45
+ return null
46
+ }
47
+
48
+ /** Route configuration is inert; a second HTTP door or an exported helper is not. */
49
+ const CONFIG_EXPORTS = new Set(["runtime", "dynamic", "revalidate", "fetchCache", "preferredRegion", "maxDuration"])
50
+ const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"])
51
+ const FORBIDDEN = new Set(["AssignmentExpression", "UpdateExpression", "ImportExpression", "TaggedTemplateExpression", "ClassDeclaration", "ClassExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "ThrowStatement", "YieldExpression", "SpreadElement", "ArrayPattern", "JSXElement", "JSXFragment"])
52
+ const TEXT = new Set(["text", "code", "otp-type"])
53
+ const CALLS = new Set(["factory", "exchange", "verify-otp", "pure", "redirect", "params-get", "params-has", "text-guard"])
54
+ /** What a pure owner helper may be given: query text, the page URL or request, and plain values; never the credential itself. */
55
+ const PURE_ARGUMENTS = new Set(["text", "otp-type", "scalar", "url", "request"])
56
+ const URL_TEXT = new Set(["origin", "href", "pathname", "search", "hash", "host", "hostname", "protocol"])
57
+ 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"]])
58
+ const MEMBERS = new Map([
59
+ ["request", new Map([["nextUrl", "url"], ["url", "text"]])],
60
+ ["url", new Map([["searchParams", "params"], ...[...URL_TEXT].map((key) => [key, "text"])])],
61
+ ["params", new Map([["get", "params-get"], ["has", "params-has"]])],
62
+ ["client", new Map([["auth", "auth"]])],
63
+ ["auth", new Map([["exchangeCodeForSession", "exchange"], ["verifyOtp", "verify-otp"]])],
64
+ ["next-response", new Map([["redirect", "redirect"]])],
65
+ ["exchange-result", new Map([["error", "exchange-error"], ["success", "scalar"], ["ok", "scalar"]])],
66
+ ])
67
+
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
+
71
+ /** A locally exported binding's implementation. */
72
+ const exportValue = (sourceCode, entry) => {
73
+ const definition = bindingOf(sourceCode, entry.local)?.defs[0]?.node
74
+ return unwrap(definition?.type === "VariableDeclarator" ? definition.init : definition)
75
+ }
76
+
77
+ /** The runtime names introduced by one local named export. */
78
+ const namedExports = (statement, sourceCode) => {
79
+ const declaration = statement.declaration
80
+ if (declaration?.type === "FunctionDeclaration") return [{ name: declaration.id.name, value: declaration }]
81
+ if (declaration?.type === "VariableDeclaration") {
82
+ if (declaration.declarations.some((entry) => entry.id.type !== "Identifier")) return null
83
+ return declaration.declarations.map((entry) => ({ name: entry.id.name, value: unwrap(entry.init) }))
84
+ }
85
+ if (declaration) return []
86
+ return statement.specifiers.filter((entry) => entry.exportKind !== "type")
87
+ .map((entry) => ({ name: entry.exported.name ?? entry.exported.value, value: exportValue(sourceCode, entry) }))
88
+ }
89
+
90
+ /** Collect runtime exports, refusing reexports and default exports. */
91
+ const exportsOf = (program, sourceCode) => {
92
+ const exported = []
93
+ for (const statement of program.body) {
94
+ if (statement.type === "ExportDefaultDeclaration" || statement.type === "ExportAllDeclaration") return null
95
+ if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type") continue
96
+ if (statement.source) return null
97
+ const entries = namedExports(statement, sourceCode)
98
+ if (!entries) return null
99
+ exported.push(...entries)
100
+ }
101
+ return exported
102
+ }
103
+
104
+ /** Find the one locally implemented GET export and its single request parameter. */
105
+ const handlerOf = (program, sourceCode) => {
106
+ const exported = exportsOf(program, sourceCode)
107
+ if (!exported || exported.some((entry) => entry.name !== "GET" && !CONFIG_EXPORTS.has(entry.name))) return null
108
+ const handlers = exported.filter((entry) => entry.name === "GET")
109
+ if (handlers.length !== 1) return null
110
+ const handler = handlers[0].value
111
+ if (!handler || !FUNCTIONS.has(handler.type) || !handler.async || handler.generator) return null
112
+ return handler.params.length === 1 && handler.params[0].type === "Identifier" ? handler : null
113
+ }
114
+
115
+ /** Framework imports retain their actual owner even under a local alias. */
116
+ const frameworkImportKind = (source, name) => {
117
+ if (source === "next/server" && name === "NextResponse") return "next-response"
118
+ if (source === "next/server" && name === "NextRequest") return "request-type"
119
+ if (source === "next/navigation" && name === "redirect") return "redirect"
120
+ return null
121
+ }
122
+
123
+ /** Only the server auth entry exposes the cookie-owning exchangeAuthCode helper. */
124
+ const isServerAuthImport = (context, source) => {
125
+ const target = normalizePath(resolveImport(context, source))
126
+ return target.endsWith("/modules/auth/server") || target.endsWith("/modules/auth/server.ts")
127
+ }
128
+
129
+ /** A directory import resolves to its index, before the generic modules slot's hypothetical routes.ts. */
130
+ const isRoutesImport = (context, source, slot) => slot === "fe.modules.routes" || classifyImport(context, `${source}/index`)?.slot === "fe.modules.routes"
131
+
132
+ /** The exchange, client and factory names of the auth owner. */
133
+ const authImportKind = (context, source, name) => {
134
+ if (name === "exchangeAuthCode" && isServerAuthImport(context, source)) return "exchange"
135
+ if (name === "verifyOtp" && isServerAuthImport(context, source)) return "verify-otp"
136
+ if (name === "exchangeCodeForSession") return "exchange"
137
+ if (/^(?:create|get)\w*Client$/.test(name ?? "")) return "factory"
138
+ if (["supabase", "supabaseClient", "authClient"].includes(name)) return "client"
139
+ return null
140
+ }
141
+
142
+ /** 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. */
143
+ const ownerImportKind = (context, source, name) => {
144
+ const slot = classifyImport(context, source)?.slot
145
+ if (name === "APP_ROUTES" && isRoutesImport(context, source, slot)) return "routes"
146
+ const authKind = slot === "fe.modules.auth" ? authImportKind(context, source, name) : null
147
+ if (authKind || !(slot === "fe.modules.auth" || isRoutesImport(context, source, slot))) return authKind
148
+ return ownerExportKind(context, source, name)
149
+ }
150
+
151
+ /** Register the allowed bindings in a single import statement. */
152
+ const addImportBindings = (context, sourceCode, statement, imports) => {
153
+ const source = String(statement.source.value)
154
+ if (source === "server-only" && statement.specifiers.length === 0) return true
155
+ if (statement.specifiers.length === 0) return false
156
+ const variables = sourceCode.getDeclaredVariables(statement)
157
+ for (const specifier of statement.specifiers) {
158
+ if (specifier.importKind === "type") continue
159
+ const name = specifier.imported?.name ?? specifier.imported?.value
160
+ const kind = frameworkImportKind(source, name) ?? ownerImportKind(context, source, name)
161
+ if (!kind) return false
162
+ imports.set(variables.find((entry) => entry.name === specifier.local.name), kind)
163
+ }
164
+ return true
165
+ }
166
+
167
+ /** Every runtime import is restricted, even if it is unused in the handler. */
168
+ const importsOf = (context, program, sourceCode) => {
169
+ const imports = new Map()
170
+ for (const statement of program.body) {
171
+ if (statement.type !== "ImportDeclaration" || statement.importKind === "type") continue
172
+ if (!addImportBindings(context, sourceCode, statement, imports)) return null
173
+ }
174
+ return imports
175
+ }
176
+
177
+ /** Select a known property without granting arbitrary methods on the selected value. */
178
+ const memberKind = (kind, key) => {
179
+ if (kind === "routes" && typeof key === "string" && !["__proto__", "constructor", "prototype"].includes(key)) return "text"
180
+ if (TEXT.has(kind) && ["startsWith", "endsWith", "includes"].includes(key)) return "text-guard"
181
+ return MEMBERS.get(kind)?.get(key) ?? null
182
+ }
183
+
184
+ /** An identifier keeps the provenance of its one initializer or the GET request parameter. */
185
+ const identifierOrigin = (node, seen, env, origin) => {
186
+ const variable = bindingOf(env.sourceCode, node)
187
+ if (env.imports.has(variable)) return env.imports.get(variable)
188
+ if (!variable || seen.has(variable) || variable.defs.length !== 1) return null
189
+ const definition = variable.defs[0]
190
+ if (definition.type === "Parameter" && definition.node === env.handler) return "request"
191
+ if (definition.node.type !== "VariableDeclarator") return null
192
+ const path = patternPath(definition.node.id, node.name)
193
+ const nextSeen = new Set([...seen, variable])
194
+ return path?.reduce((kind, key) => memberKind(kind, key), origin(definition.node.init, nextSeen)) ?? null
195
+ }
196
+
197
+ /** Combining known primitive values cannot introduce user-defined coercion. */
198
+ const combinedOrigin = (kinds, conditional = false) => {
199
+ if (conditional && kinds[0] === kinds[1]) return kinds[0]
200
+ if (kinds.every((kind) => TEXT.has(kind))) return "text"
201
+ if (!conditional && kinds.every((kind) => TEXT.has(kind) || kind === "scalar")) return "scalar"
202
+ return null
203
+ }
204
+
205
+ /** The source of a call's return value, independent of argument validation. */
206
+ const callOrigin = (node, seen, origin) => {
207
+ const kind = origin(node.callee, seen)
208
+ if (kind === "params-get") return PARAM_ORIGINS.get(stringOf(node.arguments[0])) ?? "text"
209
+ return CALL_RESULTS.get(kind) ?? null
210
+ }
211
+
212
+ /** Composite expressions retain provenance only for supported primitive operations. */
213
+ const expressionOrigin = (node, seen, env, origin) => {
214
+ if (node.type === "TemplateLiteral") return node.expressions.every((expression) => TEXT.has(origin(expression, seen)) || origin(expression, seen) === "scalar") ? "text" : null
215
+ if (node.type === "MemberExpression") return memberKind(origin(node.object, seen), memberName(node))
216
+ if (node.type === "NewExpression" && isGlobal(env.sourceCode, node.callee, "URL")) return "url"
217
+ if (node.type === "LogicalExpression" || node.type === "BinaryExpression") return combinedOrigin([origin(node.left, seen), origin(node.right, seen)])
218
+ if (node.type === "ConditionalExpression") return combinedOrigin([origin(node.consequent, seen), origin(node.alternate, seen)], true)
219
+ if (node.type === "CallExpression") return callOrigin(node, seen, origin)
220
+ return null
221
+ }
222
+
223
+ /** Follow lexical aliases rather than trusting the spelling of an identifier. */
224
+ const originOf = (env) => {
225
+ const origin = (input, seen = new Set()) => {
226
+ const node = unwrap(input)
227
+ if (!node) return null
228
+ if (node.type === "Identifier") return identifierOrigin(node, seen, env, origin)
229
+ if (node.type === "Literal") return typeof node.value === "string" || node.value === null ? "text" : "scalar"
230
+ return expressionOrigin(node, seen, env, origin)
231
+ }
232
+ return origin
233
+ }
234
+
235
+ /** The closest enclosing function must be the GET handler. */
236
+ const insideHandler = (node, handler) => {
237
+ for (let parent = node.parent; parent; parent = parent.parent) if (FUNCTIONS.has(parent.type)) return parent === handler
238
+ return false
239
+ }
240
+
241
+ /** Await must wrap the actual exchange call before its enclosing statement ends. */
242
+ const isAwaited = (node, handler) => {
243
+ for (let parent = node.parent; parent && parent !== handler; parent = parent.parent) {
244
+ if (parent.type === "AwaitExpression") return true
245
+ if (parent.type.endsWith("Statement")) return false
246
+ }
247
+ return false
248
+ }
249
+
250
+ /** Only a literal redirect status is allowed in an options object. */
251
+ const isRedirectStatus = (property) => {
252
+ if (property.type !== "Property" || property.computed || property.method || property.kind !== "init") return false
253
+ return (property.key.name ?? property.key.value) === "status" && property.value.type === "Literal" && [301, 302, 303, 307, 308].includes(property.value.value)
254
+ }
255
+
256
+ /** Arbitrary objects can invoke getters or toString during coercion; only redirect options belong here. */
257
+ const isRedirectOptions = (node, origin) => node.parent.type === "CallExpression" && origin(node.parent.callee) === "redirect" && node.parent.arguments[1] === node && node.properties.every(isRedirectStatus)
258
+
259
+ /** The one object `verifyOtp` takes: exactly `token_hash` read from the query `token_hash` and `type` read from the query `type`. */
260
+ const isOtpArguments = (node, origin) => {
261
+ if (node.parent.type !== "CallExpression" || origin(node.parent.callee) !== "verify-otp" || node.parent.arguments[0] !== node) return false
262
+ const keyOf = (property) => property.type === "Property" && !property.computed && !property.method && property.kind === "init" ? property.key.name ?? property.key.value : null
263
+ const expected = new Map([["token_hash", "token-hash"], ["type", "otp-type"]])
264
+ return node.properties.length === expected.size && node.properties.every((property) => expected.get(keyOf(property)) === origin(property.value))
265
+ && new Set(node.properties.map(keyOf)).size === expected.size
266
+ }
267
+
268
+ /** Non-call expressions must preserve the callback's narrow set of values and effects. */
269
+ const validExpression = (node, env, origin) => {
270
+ if (node.type === "MemberExpression") return Boolean(origin(node))
271
+ if (node.type === "ObjectExpression") return isRedirectOptions(node, origin) || isOtpArguments(node, origin)
272
+ if (node.type === "VariableDeclarator" && node.id.type === "ObjectPattern") return env.sourceCode.getDeclaredVariables(node).every((variable) => origin(variable.identifiers[0]))
273
+ if (node.type === "NewExpression") return isGlobal(env.sourceCode, node.callee, "URL") && node.arguments.length >= 1 && node.arguments.length <= 2 && node.arguments.every((argument) => TEXT.has(origin(argument)))
274
+ if (node.type === "ReturnStatement") return insideHandler(node, env.handler) && origin(node.argument) === "redirect-result"
275
+ if (node === env.handler && node.body.type !== "BlockStatement") return origin(node.body) === "redirect-result"
276
+ return true
277
+ }
278
+
279
+ /** Validate arguments to each permitted operation. */
280
+ const validCall = (node, kind, env, origin) => {
281
+ if (!insideHandler(node, env.handler) || !CALLS.has(kind) || node.arguments.some((argument) => argument.type === "SpreadElement")) return false
282
+ if (kind === "exchange") return isAwaited(node, env.handler) && node.arguments.length === 1 && origin(node.arguments[0]) === "code"
283
+ if (kind === "verify-otp") return isAwaited(node, env.handler) && node.arguments.length === 1 && node.arguments[0].type === "ObjectExpression"
284
+ if (kind === "pure") return node.arguments.length <= 4 && node.arguments.every((argument) => PURE_ARGUMENTS.has(origin(argument)))
285
+ if (kind === "redirect") return node.arguments.length >= 1 && node.arguments.length <= 2 && ["text", "url"].includes(origin(node.arguments[0]))
286
+ if (kind === "factory") return node.arguments.length <= 1 && node.arguments.every((argument) => origin(argument) === "request")
287
+ return node.arguments.length === 1 && TEXT.has(origin(node.arguments[0]))
288
+ }
289
+
290
+ /** Syntactic forms that add work beyond the one handler are refused outright. */
291
+ const forbiddenNode = (node, handler) => FORBIDDEN.has(node.type) || (FUNCTIONS.has(node.type) && node !== handler) || (node.type === "UnaryExpression" && node.operator === "delete")
292
+
293
+ /** Walk all runtime syntax, counting the single exchange and permitted redirects. */
294
+ const validOperations = (program, env) => {
295
+ const origin = originOf(env)
296
+ const counts = new Map()
297
+ let valid = true
298
+ walk(env.sourceCode, program, (node) => {
299
+ if (!valid) return false
300
+ valid = !forbiddenNode(node, env.handler) && validExpression(node, env, origin)
301
+ if (!valid || node.type !== "CallExpression") return undefined
302
+ const kind = origin(node.callee)
303
+ valid = validCall(node, kind, env, origin)
304
+ counts.set(kind, (counts.get(kind) ?? 0) + 1)
305
+ return undefined
306
+ })
307
+ const exchanges = (counts.get("exchange") ?? 0) + (counts.get("verify-otp") ?? 0)
308
+ return valid && exchanges >= 1 && (counts.get("exchange") ?? 0) <= 1 && (counts.get("verify-otp") ?? 0) <= 1 && (counts.get("factory") ?? 0) <= 1 && counts.get("redirect") > 0
309
+ }
310
+
311
+ /** The callback's request, imports and operations must all have known owners. */
312
+ const callbackVerdict = (context, program) => {
313
+ const sourceCode = context.sourceCode ?? context.getSourceCode()
314
+ const handler = handlerOf(program, sourceCode)
315
+ if (!handler) return false
316
+ const imports = importsOf(context, program, sourceCode)
317
+ return imports !== null && validOperations(program, { sourceCode, handler, imports })
318
+ }
319
+
320
+ /** A callback stays a PKCE exchange and redirect, with every side effect owned by the auth helper. */
321
+ export const callbackShape = {
322
+ meta: {
323
+ type: "problem",
324
+ 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." },
325
+ schema: [],
326
+ messages: {
327
+ 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.",
328
+ },
329
+ },
330
+ create(context) {
331
+ if (!inSlot(context, "fe.route.callback")) return {}
332
+ return {
333
+ "Program:exit"(program) {
334
+ if (!callbackVerdict(context, program)) context.report({ node: program, messageId: "shape" })
335
+ },
336
+ }
337
+ },
338
+ }
339
+
340
+ /** The rule R287 contributes to the front-end plugin. */
341
+ export const rules = { "callback-shape": callbackShape }
342
+
343
+ /** Every callback shape violation is an error. */
344
+ export const recommended = { "starci-fe/callback-shape": "error" }
@@ -75,7 +75,7 @@ export const useClientOnlyAtBoundary = {
75
75
  schema: [],
76
76
  messages: {
77
77
  slot:
78
- "`\"use client\"` on a {{slot}}. Routes, pages and layouts are server components: making one a client component ships its whole subtree, including the message catalog, to every reader and stops it reading data on the server. Move the interaction into a block below it and put the directive on that block's `index.tsx`; context providers go in the route tree's `providers.tsx`.",
78
+ "`\"use client\"` on a {{slot}}. Routes, pages and layouts are server components: making one a client component ships its whole subtree, including the message catalog, to every reader and stops it reading data on the server. Keep the {{slot}} a server component that renders a client component: move the interaction into a block (or overlay) below it, put the directive on that block's `index.tsx`, and import that entry from the {{slot}}; context providers go in the route tree's `providers.tsx`.",
79
79
  elsewhere:
80
80
  "`\"use client\"` here is not at an interaction boundary. The directive belongs on the `index.tsx` of the interactive block (or overlay), on a leaf whose interaction is intrinsic, on `error.tsx`/`global-error.tsx`, on the route tree's `providers.tsx` (the context providers a layout renders around its children), or on the client state of the stack (`components/providers/<name>/index.tsx`, `hooks/`, `redux/`, `modules/toast/`). Below a client block the file is already client; above one it must stay a server component.",
81
81
  },
@@ -8,7 +8,7 @@ Every rule below is an error in `starciFeConfig`; none can be switched off or su
8
8
 
9
9
  ## `starci-fe/hooks-folder-holds-hooks-only`
10
10
 
11
- `hooks/` holds `use*.ts` hooks under the names their family slot gives and, per domain, one `<domain>.shared.ts`; nothing else. A hook file exports one hook; `server-only` and `next/headers` are never imported there.
11
+ `hooks/` holds `use*.ts` hooks under the names their family slot gives and, per domain, one `<domain>.shared.ts`; nothing else. A hook file exports one hook; `server-only` and `next/headers` are never imported there. A hook is a function that calls a hook: a React, SWR or Redux hook, a `use*` imported from the app's own `hooks/` folders (a `hooks/swr` wrapper or a sibling domain hook, resolved by TypeScript symbol resolution through barrels; that hook's own file is judged when it is linted), or a library hook from an explicit list (`next-intl` `useTranslations`/`useLocale`/`useFormatter`/`useNow`/`useTimeZone`/`useMessages`, `next/navigation`, `react-hook-form`, `zustand`). A `use*` from outside every `hooks/` folder or an unlisted library is not a hook by its spelling, and a file that exports a helper, a constant or a component still fails (owner ruling 2026-10-16).
12
12
 
13
13
  **Invalid** (`src/hooks/course/readCourse.ts`)
14
14
 
package/env-owner.mjs CHANGED
@@ -52,6 +52,101 @@ const URL_TEXT = /^(?:https?:)?\/\/\S+/i
52
52
  /** Variable names whose value is a secret or an address: a default for one is a hole in a deployment. */
53
53
  const SENSITIVE_NAME = /(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|URL|URI|HOST|ENDPOINT|ORIGIN)$/i
54
54
 
55
+ /** Type assertions and optional chains preserve the identity of the wrapped value. */
56
+ const unwrapped = (node) => {
57
+ while (node && ["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSSatisfiesExpression", "ChainExpression"].includes(node.type)) node = node.expression
58
+ return node
59
+ }
60
+
61
+ /** The binding of this identifier, rather than every variable with the same spelling. */
62
+ const bindingOf = (sourceCode, identifier) => {
63
+ for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
64
+ const variable = scope.set.get(identifier.name)
65
+ if (variable) return variable
66
+ }
67
+ return null
68
+ }
69
+
70
+ /** Prior writes in the use's execution scope or its parents; a closure can capture a later initializer. */
71
+ const writeBefore = (sourceCode, variable, node) => {
72
+ const scopes = new Set()
73
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) scopes.add(scope.variableScope)
74
+ const writes = variable.references.filter((reference) => reference.isWrite()
75
+ && scopes.has(reference.from.variableScope)
76
+ && !(reference.writeExpr && reference.writeExpr.range[0] <= node.range[0] && node.range[1] <= reference.writeExpr.range[1]))
77
+ return writes
78
+ .filter((reference) => reference.identifier.range[0] < node.range[0])
79
+ .sort((left, right) => right.identifier.range[0] - left.identifier.range[0])[0]
80
+ ?? writes.find((reference) => reference.init)
81
+ }
82
+
83
+ /** Property extraction between a write's source and its destructured binding. */
84
+ const propertyPath = (identifier) => {
85
+ const properties = []
86
+ for (let node = identifier; node.parent; node = node.parent) {
87
+ const parent = node.parent
88
+ if (parent.type === "Property" && parent.value === node) properties.unshift(parent)
89
+ else if (parent.type !== "ObjectPattern" && !(parent.type === "AssignmentPattern" && parent.left === node)) break
90
+ }
91
+ return properties
92
+ }
93
+
94
+ /** Known environment containers and constant computed keys, followed through local aliases. */
95
+ const environmentIdentity = (sourceCode) => {
96
+ const textOf = (node, seen = new Set()) => {
97
+ node = unwrapped(node)
98
+ if (!node || seen.has(node)) return null
99
+ if (node.type === "Literal" && typeof node.value === "string") return node.value
100
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked
101
+ if (node.type !== "Identifier") return null
102
+ const variable = bindingOf(sourceCode, node)
103
+ if (!variable) return null
104
+ const write = writeBefore(sourceCode, variable, node)
105
+ if (!write?.writeExpr || propertyPath(write.identifier).length > 0) return null
106
+ return textOf(write.writeExpr, new Set([...seen, node]))
107
+ }
108
+ const keyOf = (node) => node.computed ? textOf(node.key ?? node.property) : (node.key ?? node.property).name ?? (node.key ?? node.property).value
109
+ const propertyKind = (kind, key) => {
110
+ if (kind === "global" && key === "process") return "process"
111
+ if ((kind === "process" || kind === "meta") && key === "env") return "env"
112
+ return null
113
+ }
114
+ const kindOf = (node, seen = new Set()) => {
115
+ node = unwrapped(node)
116
+ if (!node || seen.has(node)) return null
117
+ if (node.type === "MetaProperty" && node.meta.name === "import" && node.property.name === "meta") return "meta"
118
+ if (node.type === "MemberExpression") return propertyKind(kindOf(node.object, new Set([...seen, node])), keyOf(node))
119
+ if (node.type !== "Identifier") return null
120
+ const variable = bindingOf(sourceCode, node)
121
+ if (!variable || variable.defs.length === 0) {
122
+ if (node.name === "process") return "process"
123
+ return ["globalThis", "global"].includes(node.name) ? "global" : null
124
+ }
125
+ const imported = variable.defs.find((definition) => definition.type === "ImportBinding" && ["process", "node:process"].includes(definition.parent.source.value))
126
+ if (imported) {
127
+ if (imported.parent.importKind === "type" || imported.node.importKind === "type") return null
128
+ if (imported.node.type !== "ImportSpecifier") return "process"
129
+ const name = imported.node.imported.name ?? imported.node.imported.value
130
+ return name === "default" ? "process" : name === "env" ? "env" : null
131
+ }
132
+ const write = writeBefore(sourceCode, variable, node)
133
+ if (!write?.writeExpr) return null
134
+ let kind = kindOf(write.writeExpr, new Set([...seen, node]))
135
+ for (const property of propertyPath(write.identifier)) kind = propertyKind(kind, keyOf(property))
136
+ return kind
137
+ }
138
+ const readsEnv = (pattern, kind) => {
139
+ if (pattern.type === "AssignmentPattern") return readsEnv(pattern.left, kind)
140
+ if (pattern.type !== "ObjectPattern") return false
141
+ return pattern.properties.some((property) => {
142
+ if (property.type !== "Property") return false
143
+ const valueKind = propertyKind(kind, keyOf(property))
144
+ return valueKind === "env" || readsEnv(property.value, valueKind)
145
+ })
146
+ }
147
+ return { kindOf, readsEnv }
148
+ }
149
+
55
150
  // -- FE-ENV-1 --------------------------------------------------------------------------------------
56
151
 
57
152
  /** Only `modules/config` reads the environment. */
@@ -66,20 +161,36 @@ export const noEnvOutsideConfig = {
66
161
  },
67
162
  },
68
163
  create(context) {
69
- const filename = context.filename || context.getFilename()
70
164
  if (isConfigModule(context)) return {}
165
+ const members = []
166
+ const destructures = []
71
167
  return {
168
+ ImportDeclaration(node) {
169
+ if (node.importKind === "type" || !["process", "node:process"].includes(node.source.value)) return
170
+ for (const specifier of node.specifiers) {
171
+ if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type" && (specifier.imported.name ?? specifier.imported.value) === "env") context.report({ node: specifier, messageId: "env" })
172
+ }
173
+ },
72
174
  MemberExpression(node) {
73
- if (isEnvObject(node)) context.report({ node, messageId: "env" })
175
+ members.push(node)
74
176
  },
75
177
  VariableDeclarator(node) {
76
- // `const { env } = process` reads the environment without the member expression.
77
- if (node.id.type !== "ObjectPattern" || !node.init) return
78
- if (node.init.type !== "Identifier" || node.init.name !== "process") return
79
- const reads = node.id.properties.some(
80
- (property) => property.type === "Property" && property.key.type === "Identifier" && property.key.name === "env",
81
- )
82
- if (reads) context.report({ node, messageId: "env" })
178
+ if (node.id.type === "ObjectPattern" && node.init) destructures.push({ node, pattern: node.id, value: node.init })
179
+ },
180
+ AssignmentExpression(node) {
181
+ if (node.operator === "=" && node.left.type === "ObjectPattern") destructures.push({ node, pattern: node.left, value: node.right })
182
+ },
183
+ AssignmentPattern(node) {
184
+ if (node.left.type === "ObjectPattern") destructures.push({ node, pattern: node.left, value: node.right })
185
+ },
186
+ "Program:exit"() {
187
+ const { kindOf, readsEnv } = environmentIdentity(context.sourceCode ?? context.getSourceCode())
188
+ for (const node of members) {
189
+ if (kindOf(node) === "env") context.report({ node, messageId: "env" })
190
+ }
191
+ for (const { node, pattern, value } of destructures) {
192
+ if (readsEnv(pattern, kindOf(value))) context.report({ node, messageId: "env" })
193
+ }
83
194
  },
84
195
  }
85
196
  },
@@ -0,0 +1,49 @@
1
+ /**
2
+ * R291 HFS_FUNCTION_BUDGET: every function body stays within the common line budget.
3
+ *
4
+ * Count the physical span of the body, including braces, comments, blank lines and nested functions.
5
+ * Signatures and parameters are outside the body. An expression arrow has an expression body and
6
+ * counts the expression and its wrapping parentheses, including comments and blanks inside them.
7
+ */
8
+ import { hfsOf } from "./lib/hfs.mjs"
9
+
10
+ /** Functions, methods, constructors, accessors and callbacks all receive the same body budget. */
11
+ export const functionLineBudget = {
12
+ meta: {
13
+ type: "problem",
14
+ docs: { description: "A function body stays within ruleParams.functionLines, counting comments and blank lines." },
15
+ schema: [],
16
+ messages: {
17
+ lines: "HFS_FUNCTION_BUDGET: this function body has {{lines}} lines; the budget is {{max}}. Split the work into functions with one responsibility each.",
18
+ },
19
+ },
20
+ create(context) {
21
+ // C08 adds this common parameter to the shipped manifest; keep the owner-decided cap while it lands.
22
+ const max = hfsOf(context).ruleParams.functionLines ?? 80
23
+ if (!Number.isInteger(max) || max < 1) throw new RangeError("ruleParams.functionLines must be a positive integer")
24
+ const source = context.sourceCode || context.getSourceCode()
25
+ const check = (node) => {
26
+ if (!node.body) return
27
+ let start = node.body.loc.start.line
28
+ let end = node.body.loc.end.line
29
+ if (node.type === "ArrowFunctionExpression" && node.body.type !== "BlockStatement") {
30
+ const arrow = source.getTokenBefore(node.body, { filter: (token) => token.value === "=>" })
31
+ start = source.getTokenAfter(arrow, { includeComments: true }).loc.start.line
32
+ end = node.loc.end.line
33
+ }
34
+ const lines = end - start + 1
35
+ if (lines > max) context.report({ node: node.body, messageId: "lines", data: { lines, max } })
36
+ }
37
+ return {
38
+ FunctionDeclaration: check,
39
+ FunctionExpression: check,
40
+ ArrowFunctionExpression: check,
41
+ }
42
+ },
43
+ }
44
+
45
+ /** The rule this law contributes to the plugin. */
46
+ export const rules = { "function-line-budget": functionLineBudget }
47
+
48
+ /** The function budget is an error in both editions. */
49
+ export const recommended = { "starci-fe/function-line-budget": "error" }