@starci/eslint-canon-fe 8.2.18 → 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.
- package/CHANGELOG.md +15 -0
- package/README.md +19 -1
- package/callback-shape.mjs +344 -0
- package/client-boundary.mjs +1 -1
- package/docs/hooks-folder.md +1 -1
- package/docs/size-and-state-budget.md +6 -6
- package/drawing.mjs +65 -28
- package/env-owner.mjs +120 -9
- package/function-budget.mjs +49 -0
- package/hooks-folder.mjs +149 -18
- package/icon.mjs +67 -0
- package/index.mjs +47 -0
- package/lib/own-hook-origin.mjs +53 -0
- package/lib/program-for.mjs +22 -0
- package/lib/pure-owner-helper.mjs +148 -0
- package/lib/redux-hook-origin.mjs +44 -0
- package/next-conventions.mjs +38 -35
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/slots.yaml +430 -92
- package/runtime/knowledge/patterns/fe/folder.yaml +4 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/git/diff.mjs +5 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +103 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +22 -10
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +23 -50
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +49 -25
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +15 -7
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +214 -17
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +11 -4
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +9 -13
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +91 -6
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +26 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -3
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +23 -0
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/served-locale.mjs +80 -9
- package/size-and-state-budget.mjs +11 -8
- package/size-growth.mjs +10 -6
- package/swr-family.mjs +197 -0
- package/translation.mjs +135 -7
- package/transport.mjs +126 -59
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
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
|
+
|
|
9
|
+
## 8.3.0 - 2026-10-15
|
|
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).
|
|
11
|
+
|
|
12
|
+
## 8.2.17 - 2026-10-14
|
|
13
|
+
- Changed: `component-line-budget` gives a pure re-export `index.ts` the slot's `barrel` budget (400); `basePropsAtom` admits named `render*` callbacks in `props` of non-block pure halves for feature composition; the bundled machine carries the kind-scoped feature tier edge, canon-shape clone exclusions and in-segment i18n template reads.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
- Changed: carry the runtime mirror of HFS manifest 3.1.0 (the back-end app composition standard, R245 and R246) and the canon pins of eslint-canon-be 3.3.0, HFS 4.3.0 and CLI 1.2.16. ESLint rule implementations are unchanged.
|
|
17
|
+
|
|
3
18
|
## 8.2.18 - 2026-10-14
|
|
4
19
|
|
|
5
20
|
- Changed: carry the canon pins of eslint-canon-be 3.2.19, HFS 4.2.17 and CLI 1.2.17 in the runtime mirror. ESLint rule implementations are unchanged.
|
package/README.md
CHANGED
|
@@ -45,13 +45,31 @@ class-name ownership. Reusable class names belong in a colocated `classNames.ts`
|
|
|
45
45
|
be composed with HeroUI `cn` using one utility token per argument.
|
|
46
46
|
|
|
47
47
|
The `drawing` law holds the split surfaces (blocks, pages, layouts, overlays under `components/`
|
|
48
|
-
or `features/`): the pure `XBase` takes `{ state, props, on }` of atoms
|
|
48
|
+
or `features/`): the pure `XBase` takes `{ state, props, on }` of atoms, with named `render`/`renderX`
|
|
49
|
+
callbacks in feature `props` for block composition (`base-props-atom`), only its
|
|
49
50
|
sibling `index.tsx` imports it and never re-exports it (`base-import-pair`), `XState` names drawn
|
|
50
51
|
shapes and never a data status (`no-data-status-shape`), and a block renders each slot's
|
|
51
52
|
loading/forbidden/error/empty through `SlotView` (`slot-status-through-slotview`). The law's name is
|
|
52
53
|
the `drawing` slot role of `component.tsx`; a split-surface reference tree lives under
|
|
53
54
|
`.claude/examples/`.
|
|
54
55
|
|
|
56
|
+
Page and layout owners in `features/{pages,layouts}/<Name>/index.tsx` may compose overlay and block public entries. A server
|
|
57
|
+
page may import and render a client overlay entry while remaining a server component: `FE_CLIENT_BOUNDARY` accepts the
|
|
58
|
+
overlay entry, and `FE_CLIENT_SERVER_IMPORT` / `FE_CLIENT_REACHES_SERVER` reject server-only imports from its client closure.
|
|
59
|
+
The only feature-to-feature edge is pages or layouts to overlays; layouts never import pages, overlays never import other
|
|
60
|
+
overlays, components never import overlays, and blocks never import another block. For a client list, a server page mounts an overlay whose connected entry reads list
|
|
61
|
+
identities and supplies a block renderer to its pure Base. A pure component or branch may render that keyed list by
|
|
62
|
+
calling the supplied render prop; it imports no block and fetches no data. The supplied renderer composes blocks only,
|
|
63
|
+
with no intrinsic markup or hook calls, and each connected item block still renders its exact sibling `XBase`.
|
|
64
|
+
|
|
65
|
+
A layout-level overlay host passes rendered overlay candidates into a client `features/overlays/OverlayRouteHost` entry.
|
|
66
|
+
Its hook under `hooks/overlays` reads `usePathname()` and `useSearchParams()` and supplies scalar selection to the pure
|
|
67
|
+
`components/branches/OverlayHost` branch. The layout imports the candidate overlay entries; the host and branch only
|
|
68
|
+
receive rendered nodes. No callback crosses the server-to-client boundary, and pages stay server components. This
|
|
69
|
+
composition-only host renders an existing public pure branch without a forwarding private Base; connected blocks still
|
|
70
|
+
require their exact sibling Base. The worked tree is in
|
|
71
|
+
[`knowledge/patterns/fe/boundaries.yaml`](../../../knowledge/patterns/fe/boundaries.yaml), `FE-BOUNDARY-5`.
|
|
72
|
+
|
|
55
73
|
The one locale pattern everywhere is `next-intl`, a `[locale]` segment, default `vi`,
|
|
56
74
|
`localePrefix: "as-needed"`, and `proxy.ts` instead of `middleware.ts`.
|
|
57
75
|
|
|
@@ -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" }
|
package/client-boundary.mjs
CHANGED
|
@@ -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.
|
|
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
|
},
|
package/docs/hooks-folder.md
CHANGED
|
@@ -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
|
|
|
@@ -2,31 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
Law module: `size-and-state-budget.mjs`. Catalogue: R65 FE_SIZE_AND_STATE_BUDGET.
|
|
4
4
|
|
|
5
|
-
A
|
|
5
|
+
A drawing file is at most 300 lines, a connected `index.tsx` at most 200, and a hook implementation at most 200. A pure re-export `index.ts` has a separate 400-line budget. A connected unit holds at most 6 `useState` and reads at most 6 data hooks; there is no hand-written polling loop. `HFS_SIZE_GROWTH` (no growth against the parent commit) is the architecture machine's; these are the absolute file-local limits.
|
|
6
6
|
|
|
7
7
|
Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
|
|
8
8
|
|
|
9
9
|
## `starci-fe/component-line-budget`
|
|
10
10
|
|
|
11
|
-
A
|
|
11
|
+
A file stays within the budget of its HFS slot: `component.tsx` 300 lines, connected `index.tsx` 200, hook implementation 200, module 400. Where the slot states `budget.barrel`, an `index.ts` containing only `export ... from` statements uses that separate 400-line limit. Named, type-only, star and namespace re-exports qualify, including multiline statements and comments. Imports, declarations, local exports, directives and empty statements keep the ordinary limit, as does an empty statement body. Another basename or `index.tsx` keeps its ordinary limit. The rule reads every limit from `knowledge/hfs/slots.yaml`; a trailing newline does not add a line.
|
|
12
12
|
|
|
13
13
|
**Invalid** (`src/components/blocks/Feed/index.tsx`)
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
|
-
// a
|
|
16
|
+
// a 201-line connected entry
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
**Valid** (`src/components/blocks/Feed/index.tsx`)
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
|
-
//
|
|
22
|
+
// 200 lines or fewer
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
**Finding code:** `FE_SIZE_AND_STATE_BUDGET`
|
|
26
26
|
|
|
27
|
-
**Why:** `<file>` is `<n>` lines long, over the
|
|
27
|
+
**Why:** `<file>` is `<n>` lines long, over the limit its slot states for the file's role or its pure re-export barrel.
|
|
28
28
|
|
|
29
|
-
**Fix:**
|
|
29
|
+
**Fix:** Split an oversized implementation by responsibility; narrow a barrel's public surface to the exports its owner needs.
|
|
30
30
|
|
|
31
31
|
## `starci-fe/unit-hook-budget`
|
|
32
32
|
|
package/drawing.mjs
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* THE PURE HALF TAKES ATOMS. `component.tsx` receives `{ state, props, on }`: `props` is data
|
|
10
10
|
* (strings, numbers, booleans, arrays, plain objects), `on` is actions whose arguments are data,
|
|
11
|
-
* and
|
|
12
|
-
* `children`, because the router puts the page there.
|
|
11
|
+
* and feature composition may also take named `render` callbacks inside `props` to render supplied
|
|
12
|
+
* blocks. A layout alone also takes `children`, because the router puts the page there.
|
|
13
13
|
*
|
|
14
14
|
* ONLY ITS OWN INDEX MAY IMPORT IT. Other tiers use the connected export, so the pure half stays a
|
|
15
15
|
* private drawing surface that audit can render from a fixture.
|
|
@@ -38,6 +38,10 @@ const pureHalf = (context) =>
|
|
|
38
38
|
/** Type names that carry a rendered tree rather than data. */
|
|
39
39
|
const NON_ATOM_TYPE = /^(?:ReactNode|ReactElement|ReactPortal|JSX\.Element|Element|ComponentType|FC|FunctionComponent|ElementType|ComponentProps|RenderFunction)$/
|
|
40
40
|
|
|
41
|
+
/** Feature composition's render callbacks return a rendered value and take only data. */
|
|
42
|
+
const RENDER_RESULT_TYPE = /^(?:ReactNode|ReactElement|JSX\.Element)$/
|
|
43
|
+
const RENDER_PROP_NAME = /^render(?:[A-Z][A-Za-z0-9]*)?$/
|
|
44
|
+
|
|
41
45
|
/** The printed name of a type reference, qualified names included. */
|
|
42
46
|
const typeNameOf = (node) => {
|
|
43
47
|
const name = node?.typeName
|
|
@@ -79,17 +83,17 @@ const keyOf = (member) => (member.key?.type === "Identifier" ? member.key.name :
|
|
|
79
83
|
|
|
80
84
|
// -- DRAWING-1 ----------------------------------------------------------------------------------
|
|
81
85
|
|
|
82
|
-
/** The pure half takes
|
|
86
|
+
/** The pure half takes atom data and actions; feature composition may receive named render callbacks. */
|
|
83
87
|
export const basePropsAtom = {
|
|
84
88
|
meta: {
|
|
85
89
|
type: "problem",
|
|
86
|
-
docs: { description: "A pure `XBase` takes `state`, atom `props` and `on` actions;
|
|
90
|
+
docs: { description: "A pure `XBase` takes `state`, atom `props` and `on` actions; feature composition may receive named render callbacks in `props`." },
|
|
87
91
|
schema: [],
|
|
88
92
|
messages: {
|
|
89
93
|
tree:
|
|
90
94
|
"`{{path}}` hands a rendered tree (`{{type}}`) into the pure half. Write the child inside `component.tsx` and pass it atoms (ids, strings, flags); a tree handed in cannot be drawn, audited or reused.",
|
|
91
95
|
action:
|
|
92
|
-
"`{{path}}` is a function inside `props`. Actions live in `on
|
|
96
|
+
"`{{path}}` is a function inside `props`. Actions live in `on`; feature composition may receive named `render` callbacks that take atoms and return rendered blocks.",
|
|
93
97
|
onData:
|
|
94
98
|
"`on.{{key}}` is not an action. `on` holds functions only; data belongs in `props`.",
|
|
95
99
|
member:
|
|
@@ -101,45 +105,78 @@ export const basePropsAtom = {
|
|
|
101
105
|
if (!tier) return {}
|
|
102
106
|
let types = new Map()
|
|
103
107
|
|
|
108
|
+
/** Resolve render results locally; an optional empty result does not make data a render prop. */
|
|
109
|
+
const isRenderResult = (node, seen = new Set()) => {
|
|
110
|
+
if (!node) return false
|
|
111
|
+
if (node.type === "TSUnionType") {
|
|
112
|
+
const empty = (part) => ["TSNullKeyword", "TSUndefinedKeyword"].includes(part.type)
|
|
113
|
+
|| part.type === "TSLiteralType" && part.literal.value === null
|
|
114
|
+
return node.types.some((part) => isRenderResult(part, new Set(seen)))
|
|
115
|
+
&& node.types.every((part) => empty(part) || isRenderResult(part, new Set(seen)))
|
|
116
|
+
}
|
|
117
|
+
if (node.type !== "TSTypeReference") return false
|
|
118
|
+
const name = typeNameOf(node)
|
|
119
|
+
if (RENDER_RESULT_TYPE.test(name.replace(/^React\./, ""))) return true
|
|
120
|
+
if (!types.has(name) || seen.has(name)) return false
|
|
121
|
+
return isRenderResult(types.get(name), new Set(seen).add(name))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const checkRenderCallback = (node, path, allowed) => {
|
|
125
|
+
if (!allowed || !isRenderResult(node.returnType?.typeAnnotation)) return false
|
|
126
|
+
for (const parameter of node.params ?? node.parameters ?? []) {
|
|
127
|
+
checkData(parameter.typeAnnotation?.typeAnnotation, `${path}(${parameter.name ?? "arg"})`, new Set(), false, true)
|
|
128
|
+
}
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const checkDataReference = (node, path, seen, renderProp, renderParameter) => {
|
|
133
|
+
const name = typeNameOf(node)
|
|
134
|
+
if (NON_ATOM_TYPE.test(renderParameter ? name.replace(/^React\./, "") : name)) {
|
|
135
|
+
context.report({ node, messageId: "tree", data: { path, type: name } })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
for (const argument of node.typeArguments?.params ?? node.typeParameters?.params ?? []) checkData(argument, path, seen, false, renderParameter)
|
|
139
|
+
if (types.has(name) && !seen.has(name)) {
|
|
140
|
+
const nextSeen = renderProp || renderParameter ? new Set(seen) : seen
|
|
141
|
+
nextSeen.add(name)
|
|
142
|
+
checkData(types.get(name), path, nextSeen, renderProp, renderParameter)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const checkDataMember = (member, path, seen, renderParameter) => {
|
|
147
|
+
const memberPath = `${path}.${keyOf(member)}`
|
|
148
|
+
const allowsRender = tier !== "blocks" && path === "props" && RENDER_PROP_NAME.test(keyOf(member))
|
|
149
|
+
if (member.type === "TSMethodSignature") {
|
|
150
|
+
if (!checkRenderCallback(member, memberPath, allowsRender)) context.report({ node: member, messageId: "action", data: { path: memberPath } })
|
|
151
|
+
} else if (member.type === "TSPropertySignature") {
|
|
152
|
+
checkData(member.typeAnnotation?.typeAnnotation, memberPath, seen, allowsRender, renderParameter)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
104
156
|
/** Walk a data type: report trees and functions anywhere inside it. */
|
|
105
|
-
const checkData = (node, path, seen) => {
|
|
157
|
+
const checkData = (node, path, seen, renderProp = false, renderParameter = false) => {
|
|
106
158
|
if (!node) return
|
|
107
159
|
switch (node.type) {
|
|
108
160
|
case "TSFunctionType":
|
|
161
|
+
if (checkRenderCallback(node, path, renderProp)) return
|
|
109
162
|
context.report({ node, messageId: "action", data: { path } })
|
|
110
163
|
return
|
|
111
|
-
case "TSTypeReference":
|
|
112
|
-
|
|
113
|
-
if (NON_ATOM_TYPE.test(name)) {
|
|
114
|
-
context.report({ node, messageId: "tree", data: { path, type: name } })
|
|
115
|
-
return
|
|
116
|
-
}
|
|
117
|
-
for (const argument of node.typeArguments?.params ?? node.typeParameters?.params ?? []) checkData(argument, path, seen)
|
|
118
|
-
if (types.has(name) && !seen.has(name)) {
|
|
119
|
-
seen.add(name)
|
|
120
|
-
checkData(types.get(name), path, seen)
|
|
121
|
-
}
|
|
164
|
+
case "TSTypeReference":
|
|
165
|
+
checkDataReference(node, path, seen, renderProp, renderParameter)
|
|
122
166
|
return
|
|
123
|
-
}
|
|
124
167
|
case "TSUnionType":
|
|
125
168
|
case "TSIntersectionType":
|
|
126
|
-
for (const part of node.types) checkData(part, path, seen)
|
|
169
|
+
for (const part of node.types) checkData(part, path, seen, renderProp, renderParameter)
|
|
127
170
|
return
|
|
128
171
|
case "TSArrayType":
|
|
129
|
-
checkData(node.elementType, path, seen)
|
|
172
|
+
checkData(node.elementType, path, seen, false, renderParameter)
|
|
130
173
|
return
|
|
131
174
|
case "TSTypeOperator":
|
|
132
|
-
checkData(node.typeAnnotation, path, seen)
|
|
175
|
+
checkData(node.typeAnnotation, path, seen, false, renderParameter)
|
|
133
176
|
return
|
|
134
177
|
case "TSTypeLiteral":
|
|
135
178
|
case "TSInterfaceBody":
|
|
136
|
-
for (const member of node.members ?? node.body)
|
|
137
|
-
if (member.type === "TSMethodSignature") {
|
|
138
|
-
context.report({ node: member, messageId: "action", data: { path: `${path}.${keyOf(member)}` } })
|
|
139
|
-
} else if (member.type === "TSPropertySignature") {
|
|
140
|
-
checkData(member.typeAnnotation?.typeAnnotation, `${path}.${keyOf(member)}`, seen)
|
|
141
|
-
}
|
|
142
|
-
}
|
|
179
|
+
for (const member of node.members ?? node.body) checkDataMember(member, path, seen, renderParameter)
|
|
143
180
|
return
|
|
144
181
|
default:
|
|
145
182
|
}
|