@starci/eslint-canon-fe 9.3.0 → 9.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +5 -1
- package/dist/brand-values.d.ts +28 -0
- package/dist/brand-values.js +172 -0
- package/dist/button-intent.d.ts +11 -0
- package/dist/button-intent.js +361 -0
- package/dist/callback-shape.d.ts +7 -0
- package/dist/callback-shape.js +396 -0
- package/dist/class-names.d.ts +19 -0
- package/dist/class-names.js +170 -0
- package/dist/client-boundary.d.ts +42 -0
- package/dist/client-boundary.js +404 -0
- package/dist/comments.d.ts +47 -0
- package/dist/comments.js +156 -0
- package/dist/control-height.d.ts +7 -0
- package/dist/control-height.js +83 -0
- package/dist/drawing.d.ts +31 -0
- package/dist/drawing.js +376 -0
- package/dist/env-owner.d.ts +22 -0
- package/dist/env-owner.js +291 -0
- package/dist/fe-stack.d.ts +10 -0
- package/dist/fe-stack.js +73 -0
- package/dist/file-layout.d.ts +71 -0
- package/dist/file-layout.js +465 -0
- package/dist/formatting.d.ts +25 -0
- package/dist/formatting.js +104 -0
- package/dist/function-budget.d.ts +9 -0
- package/dist/function-budget.js +48 -0
- package/dist/grammar-shim.d.ts +5 -0
- package/dist/grammar-shim.js +126 -0
- package/dist/hooks-folder.d.ts +34 -0
- package/dist/hooks-folder.js +320 -0
- package/dist/hygiene.d.ts +39 -0
- package/dist/hygiene.js +540 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +310 -0
- package/{landmark.mjs → dist/landmark.d.ts} +4 -6
- package/dist/landmark.js +6 -0
- package/dist/lib/ast.d.ts +36 -0
- package/dist/lib/ast.js +94 -0
- package/dist/lib/bindings.d.ts +84 -0
- package/dist/lib/bindings.js +168 -0
- package/dist/lib/class-text.d.ts +3 -0
- package/dist/lib/class-text.js +30 -0
- package/dist/lib/config.d.ts +81 -0
- package/dist/lib/config.js +141 -0
- package/dist/lib/drawing-scope.d.ts +5 -0
- package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
- package/dist/lib/eslint-boundary.d.ts +23 -0
- package/dist/lib/eslint-boundary.js +6 -0
- package/dist/lib/fe-stack.d.ts +49 -0
- package/dist/lib/fe-stack.js +98 -0
- package/dist/lib/hfs-view.d.ts +74 -0
- package/dist/lib/hfs-view.js +6 -0
- package/dist/lib/hfs.d.ts +34 -0
- package/dist/lib/hfs.js +72 -0
- package/dist/lib/next.d.ts +4 -0
- package/dist/lib/next.js +10 -0
- package/dist/lib/own-hook-origin.d.ts +17 -0
- package/dist/lib/own-hook-origin.js +52 -0
- package/dist/lib/package-root.d.ts +4 -0
- package/dist/lib/package-root.js +10 -0
- package/dist/lib/path.d.ts +2 -0
- package/{lib/path.mjs → dist/lib/path.js} +1 -1
- package/dist/lib/platform.d.ts +26 -0
- package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
- package/dist/lib/program-for.d.ts +5 -0
- package/dist/lib/program-for.js +23 -0
- package/dist/lib/pure-owner-helper.d.ts +10 -0
- package/dist/lib/pure-owner-helper.js +156 -0
- package/dist/lib/react.d.ts +16 -0
- package/dist/lib/react.js +22 -0
- package/dist/lib/redux-hook-origin.d.ts +4 -0
- package/dist/lib/redux-hook-origin.js +50 -0
- package/dist/lib/rule.d.ts +28 -0
- package/dist/lib/rule.js +5 -0
- package/dist/lib/scope.d.ts +88 -0
- package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
- package/dist/lib/typed-class-values.d.ts +13 -0
- package/dist/lib/typed-class-values.js +92 -0
- package/dist/lib/types.d.ts +44 -0
- package/dist/lib/types.js +64 -0
- package/dist/lib/why.d.ts +350 -0
- package/dist/lib/why.js +350 -0
- package/dist/lint-escape-hatch.d.ts +24 -0
- package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
- package/dist/lists.d.ts +31 -0
- package/dist/lists.js +202 -0
- package/dist/loading.d.ts +34 -0
- package/dist/loading.js +157 -0
- package/dist/naming.d.ts +33 -0
- package/dist/naming.js +176 -0
- package/dist/native-controls.d.ts +24 -0
- package/dist/native-controls.js +105 -0
- package/dist/next-conventions.d.ts +48 -0
- package/dist/next-conventions.js +452 -0
- package/dist/project-graph.d.ts +5 -0
- package/{project-graph.mjs → dist/project-graph.js} +8 -12
- package/dist/props-and-slots.d.ts +19 -0
- package/dist/props-and-slots.js +320 -0
- package/dist/served-locale.d.ts +38 -0
- package/dist/served-locale.js +263 -0
- package/dist/shape-slot.d.ts +18 -0
- package/dist/shape-slot.js +324 -0
- package/dist/size-and-state-budget.d.ts +46 -0
- package/dist/size-and-state-budget.js +424 -0
- package/dist/size-growth.d.ts +24 -0
- package/dist/size-growth.js +64 -0
- package/dist/sonar-parity.d.ts +5 -0
- package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
- package/dist/surface-rules.d.ts +18 -0
- package/dist/surface-rules.js +491 -0
- package/dist/swr-family.d.ts +15 -0
- package/dist/swr-family.js +419 -0
- package/dist/the-split.d.ts +27 -0
- package/dist/the-split.js +157 -0
- package/dist/tokens.d.ts +9 -0
- package/dist/tokens.js +35 -0
- package/dist/translation.d.ts +51 -0
- package/dist/translation.js +403 -0
- package/dist/transport.d.ts +73 -0
- package/dist/transport.js +885 -0
- package/dist/type-safety.d.ts +43 -0
- package/dist/type-safety.js +139 -0
- package/dist/ui-rulings.d.ts +11 -0
- package/dist/ui-rulings.js +121 -0
- package/package.json +31 -9
- package/runtime/knowledge/hfs/canon-pins.yaml +17 -10
- package/brand-values.mjs +0 -172
- package/button-intent.mjs +0 -320
- package/callback-shape.mjs +0 -332
- package/class-names.mjs +0 -149
- package/client-boundary.mjs +0 -400
- package/comments.mjs +0 -159
- package/control-height.mjs +0 -77
- package/drawing.mjs +0 -321
- package/env-owner.mjs +0 -244
- package/fe-stack.mjs +0 -86
- package/file-layout.mjs +0 -472
- package/formatting.mjs +0 -110
- package/function-budget.mjs +0 -49
- package/grammar-shim.mjs +0 -123
- package/hooks-folder.mjs +0 -300
- package/hygiene.mjs +0 -513
- package/index.mjs +0 -317
- package/lib/ast.mjs +0 -97
- package/lib/bindings.mjs +0 -159
- package/lib/class-text.mjs +0 -29
- package/lib/config.mjs +0 -142
- package/lib/fe-stack.mjs +0 -77
- package/lib/hfs.mjs +0 -49
- package/lib/next.mjs +0 -6
- package/lib/own-hook-origin.mjs +0 -53
- package/lib/program-for.mjs +0 -22
- package/lib/pure-owner-helper.mjs +0 -148
- package/lib/react.mjs +0 -28
- package/lib/redux-hook-origin.mjs +0 -44
- package/lib/typed-class-values.mjs +0 -81
- package/lib/types.mjs +0 -63
- package/lib/why.mjs +0 -352
- package/lists.mjs +0 -193
- package/loading.mjs +0 -156
- package/naming.mjs +0 -185
- package/native-controls.mjs +0 -108
- package/next-conventions.mjs +0 -447
- package/props-and-slots.mjs +0 -276
- package/served-locale.mjs +0 -243
- package/size-and-state-budget.mjs +0 -376
- package/size-growth.mjs +0 -62
- package/surface-rules.mjs +0 -443
- package/swr-family.mjs +0 -326
- package/the-split.mjs +0 -155
- package/tokens.mjs +0 -39
- package/translation.mjs +0 -392
- package/transport.mjs +0 -830
- package/type-safety.mjs +0 -147
- package/ui-rulings.mjs +0 -134
package/button-intent.mjs
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
/** CTA-6: app buttons declare consequences; Grammar owns their visual variants. */
|
|
2
|
-
import { attributeValue, stringOf, walk } from "./lib/ast.mjs"
|
|
3
|
-
import { functionOf, importOf, isFn, patternPath, unwrap, variableOf } from "./lib/bindings.mjs"
|
|
4
|
-
import { typedPropertyStrings } from "./lib/typed-class-values.mjs"
|
|
5
|
-
import ts from "typescript"
|
|
6
|
-
|
|
7
|
-
const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|offset-pop))?$/
|
|
8
|
-
const BUTTONS = new Set(["Button", "IconButton"])
|
|
9
|
-
const DIALOGS = new Set(["AlertDialog", "ConfirmDialog"])
|
|
10
|
-
const ITEMS = new Set(["CollectionItem", "ItemActions", "ListItem", "ListRow", "ListBox.Item", "GridList.Item", "Table.Row", "TableRow"])
|
|
11
|
-
|
|
12
|
-
/** The consuming TypeScript program exposes literal intents and spread properties without executing app code. */
|
|
13
|
-
const typedInfo = (context, node, key) => {
|
|
14
|
-
const services = context.sourceCode.parserServices
|
|
15
|
-
if (!services?.program || !services.esTreeNodeToTSNodeMap || !node) return null
|
|
16
|
-
const checker = services.program.getTypeChecker()
|
|
17
|
-
const source = services.esTreeNodeToTSNodeMap.get(node)
|
|
18
|
-
const owner = checker.getTypeAtLocation(source)
|
|
19
|
-
const symbol = key === undefined ? null : owner.getProperty(key)
|
|
20
|
-
if (key !== undefined && !symbol) return null
|
|
21
|
-
const type = symbol ? checker.getTypeOfSymbolAtLocation(symbol, source) : owner
|
|
22
|
-
const members = type.isUnion() ? type.types : [type]
|
|
23
|
-
return { values: members.filter((member) => member.isStringLiteral()).map((member) => member.value), optional: Boolean(symbol && (symbol.flags & ts.SymbolFlags.Optional) !== 0) }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Resolve the imported Grammar role without confusing a shadowed local component with its namesake. */
|
|
27
|
-
const roleOf = (context, name) => {
|
|
28
|
-
const parts = []
|
|
29
|
-
let root = name
|
|
30
|
-
while (root.type === "JSXMemberExpression") {
|
|
31
|
-
parts.unshift(root.property.name)
|
|
32
|
-
root = root.object
|
|
33
|
-
}
|
|
34
|
-
if (root.type !== "JSXIdentifier") return null
|
|
35
|
-
const imported = importOf(context, root)
|
|
36
|
-
if (!imported || !GRAMMAR_ENTRY.test(imported.source)) return null
|
|
37
|
-
if (imported.imported) parts.unshift(imported.imported)
|
|
38
|
-
else if (!variableOf(context, root)?.defs.some((definition) => definition.node.type === "ImportNamespaceSpecifier")) return null
|
|
39
|
-
return parts.join(".")
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Static object properties, including aliases and spreads, retain their button obligations. */
|
|
43
|
-
const objectProperty = (context, input, key, seen = new Set()) => {
|
|
44
|
-
const node = unwrap(input)
|
|
45
|
-
if (!node || seen.has(node)) return []
|
|
46
|
-
const next = new Set(seen).add(node)
|
|
47
|
-
if (node.type === "ObjectExpression") return node.properties.flatMap((property) => {
|
|
48
|
-
if (property.type === "SpreadElement") return objectProperty(context, property.argument, key, next)
|
|
49
|
-
const name = property.computed ? stringOf(property.key) : property.key.name ?? property.key.value
|
|
50
|
-
return name === key ? [property.value] : []
|
|
51
|
-
})
|
|
52
|
-
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch) => objectProperty(context, branch, key, next))
|
|
53
|
-
if (node.type === "MemberExpression") {
|
|
54
|
-
const member = node.computed ? stringOf(node.property) : node.property.name
|
|
55
|
-
return objectProperty(context, node.object, member, next).flatMap((value) => objectProperty(context, value, key, next))
|
|
56
|
-
}
|
|
57
|
-
if (node.type !== "Identifier") return []
|
|
58
|
-
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
59
|
-
if (!definition) return []
|
|
60
|
-
const path = patternPath(definition.node.id, node.name)
|
|
61
|
-
if (path === null) return []
|
|
62
|
-
const values = path.reduce((entries, name) => entries.flatMap((entry) => objectProperty(context, entry, name, next)), [definition.node.init])
|
|
63
|
-
return values.flatMap((value) => objectProperty(context, value, key, next))
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Possible statically authored values of an intent or step; dynamic values remain TypeScript's responsibility. */
|
|
67
|
-
const valuesOf = (context, input, seen = new Set()) => {
|
|
68
|
-
const node = unwrap(input)
|
|
69
|
-
if (!node || seen.has(node)) return []
|
|
70
|
-
const next = new Set(seen).add(node)
|
|
71
|
-
const text = stringOf(node)
|
|
72
|
-
if (text !== null) return [text]
|
|
73
|
-
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch) => valuesOf(context, branch, next))
|
|
74
|
-
if (node.type === "LogicalExpression") return valuesOf(context, node.right, next)
|
|
75
|
-
if (node.type === "MemberExpression") {
|
|
76
|
-
const name = node.computed ? stringOf(node.property) : node.property.name
|
|
77
|
-
const values = objectProperty(context, node.object, name, next).flatMap((value) => valuesOf(context, value, next))
|
|
78
|
-
return values.length ? values : typedInfo(context, node)?.values ?? []
|
|
79
|
-
}
|
|
80
|
-
if (node.type !== "Identifier") return typedInfo(context, node)?.values ?? []
|
|
81
|
-
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
82
|
-
if (!definition) return typedInfo(context, node)?.values ?? []
|
|
83
|
-
const path = patternPath(definition.node.id, node.name)
|
|
84
|
-
if (path === null) return []
|
|
85
|
-
const values = path.reduce((entries, name) => entries.flatMap((entry) => objectProperty(context, entry, name, next)), [definition.node.init])
|
|
86
|
-
return values.flatMap((value) => valuesOf(context, value, next))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** An explicit attribute or statically resolved spread exposing a prop. */
|
|
90
|
-
const propsOf = (context, opening, key) => opening.attributes.flatMap((entry) => {
|
|
91
|
-
if (entry.type === "JSXAttribute") return entry.name.name === key ? [{ node: entry, value: attributeValue(entry) }] : []
|
|
92
|
-
const values = objectProperty(context, entry.argument, key)
|
|
93
|
-
if (values.length) return values.map((value) => ({ node: entry, value }))
|
|
94
|
-
const authored = typedPropertyStrings(context, entry.argument, key, "variant")
|
|
95
|
-
if (authored.length) return authored.map((value) => ({ node: entry, text: value.text }))
|
|
96
|
-
const typed = typedInfo(context, entry.argument, key)
|
|
97
|
-
return typed ? [{ node: entry, texts: typed.values }] : []
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
const hasValue = (context, opening, key, value) => propsOf(context, opening, key).some((prop) => prop.text === value || prop.texts?.includes(value) || valuesOf(context, prop.value).includes(value))
|
|
101
|
-
const isButton = (context, opening) => BUTTONS.has(roleOf(context, opening.name))
|
|
102
|
-
|
|
103
|
-
/** A conditional props spread supplies intent only when every branch supplies it. */
|
|
104
|
-
const suppliesIntent = (context, input, seen = new Set()) => {
|
|
105
|
-
const node = unwrap(input)
|
|
106
|
-
if (!node || seen.has(node)) return false
|
|
107
|
-
const next = new Set(seen).add(node)
|
|
108
|
-
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].every((branch) => suppliesIntent(context, branch, next))
|
|
109
|
-
if (node.type === "Identifier") {
|
|
110
|
-
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
111
|
-
if (definition?.node.id.type === "Identifier") return suppliesIntent(context, definition.node.init, next)
|
|
112
|
-
const typed = typedInfo(context, node, "intent")
|
|
113
|
-
return Boolean(typed && !typed.optional)
|
|
114
|
-
}
|
|
115
|
-
return objectProperty(context, node, "intent").length > 0 || typedPropertyStrings(context, node, "intent", "variant").length > 0
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const hasIntent = (context, opening) => opening.attributes.some((entry) => {
|
|
119
|
-
if (entry.type === "JSXSpreadAttribute") return suppliesIntent(context, entry.argument)
|
|
120
|
-
if (entry.name.name !== "intent") return false
|
|
121
|
-
const value = unwrap(attributeValue(entry))
|
|
122
|
-
return Boolean(value) && !(value.type === "Literal" && value.value === null) && !(value.type === "Identifier" && value.name === "undefined")
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
/** JSX child render callbacks contribute content to their enclosing component render. */
|
|
126
|
-
const inlineRenderCallback = (node) => node.parent?.type === "JSXExpressionContainer" && node.parent.parent?.type !== "JSXAttribute"
|
|
127
|
-
|
|
128
|
-
const lexicalFunction = (node) => {
|
|
129
|
-
for (let current = node.parent; current; current = current.parent) if (isFn(current)) return current
|
|
130
|
-
return null
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** The nearest function owns a render; a component defined in a dialog is still its own subtree. */
|
|
134
|
-
const functionOwner = (node) => {
|
|
135
|
-
for (let current = node.parent; current; current = current.parent) if (isFn(current) && !inlineRenderCallback(current)) return current
|
|
136
|
-
return null
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** A trigger opens a dialog and is outside its confirmation subtree. */
|
|
140
|
-
const insideDialog = (context, opening) => {
|
|
141
|
-
const triggers = new Set()
|
|
142
|
-
for (let current = opening.parent; current; current = current.parent) {
|
|
143
|
-
if (isFn(current) && !inlineRenderCallback(current)) return false
|
|
144
|
-
if (current.type === "JSXAttribute" && current.name.name === "trigger") triggers.add(current.parent)
|
|
145
|
-
if (current.type === "JSXElement" && !triggers.has(current.openingElement) && DIALOGS.has(roleOf(context, current.openingElement.name))) return true
|
|
146
|
-
}
|
|
147
|
-
return false
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Normalize a condition so `ready` and `!ready` select opposite render paths. */
|
|
151
|
-
const guardOf = (context, input, positive) => {
|
|
152
|
-
const node = unwrap(input)
|
|
153
|
-
if (node.type === "UnaryExpression" && node.operator === "!") return guardOf(context, node.argument, !positive)
|
|
154
|
-
if (node.type === "BinaryExpression" && ["===", "==", "!==", "!="].includes(node.operator)) {
|
|
155
|
-
const literal = node.right.type === "Literal" ? node.right : node.left.type === "Literal" ? node.left : null
|
|
156
|
-
if (literal) {
|
|
157
|
-
const selector = context.sourceCode.getText(literal === node.right ? node.left : node.right).replace(/\s+/g, "")
|
|
158
|
-
const choice = JSON.stringify(literal.value)
|
|
159
|
-
const value = node.operator.startsWith("!") ? !positive : positive
|
|
160
|
-
return { key: `${selector}===${choice}`, value, selector, choice }
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return { key: context.sourceCode.getText(node).replace(/\s+/g, ""), value: positive }
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const constrainPath = (context, path, test, positive) => {
|
|
167
|
-
const guard = guardOf(context, test, positive)
|
|
168
|
-
path.guards.set(guard.key, guard.value)
|
|
169
|
-
if (guard.selector && guard.value) path.choices.set(guard.selector, guard.choice)
|
|
170
|
-
return path
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Conditional intent values retain the guards selecting commit instead of flattening both alternatives. */
|
|
174
|
-
const valuePaths = (context, input, seen = new Set()) => {
|
|
175
|
-
const node = unwrap(input)
|
|
176
|
-
if (!node || seen.has(node)) return []
|
|
177
|
-
const next = new Set(seen).add(node)
|
|
178
|
-
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch, index) => valuePaths(context, branch, next).map((path) => constrainPath(context, path, node.test, index === 0)))
|
|
179
|
-
if (node.type === "Identifier") {
|
|
180
|
-
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
181
|
-
if (definition?.node.id.type === "Identifier") return valuePaths(context, definition.node.init, next)
|
|
182
|
-
}
|
|
183
|
-
return valuesOf(context, node).includes("commit") ? [{ guards: new Map(), choices: new Map() }] : []
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Branch guards and return ownership identify mutually exclusive render paths. */
|
|
187
|
-
const renderPath = (context, opening, owner) => {
|
|
188
|
-
const guards = new Map()
|
|
189
|
-
const choices = new Map()
|
|
190
|
-
const path = { guards, choices }
|
|
191
|
-
let returned = null
|
|
192
|
-
let child = opening
|
|
193
|
-
for (let current = opening.parent; current && current !== owner; current = current.parent) {
|
|
194
|
-
if (current.type === "ReturnStatement" && lexicalFunction(current) === owner) returned = current
|
|
195
|
-
if (current.type === "ConditionalExpression" && child !== current.test) constrainPath(context, path, current.test, child === current.consequent)
|
|
196
|
-
if (current.type === "IfStatement" && child !== current.test) constrainPath(context, path, current.test, child === current.consequent)
|
|
197
|
-
if (current.type === "LogicalExpression" && child === current.right && current.operator !== "??") constrainPath(context, path, current.left, current.operator === "&&")
|
|
198
|
-
child = current
|
|
199
|
-
}
|
|
200
|
-
return { guards, choices, returned }
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const canCoexist = (left, right) => {
|
|
204
|
-
if (left.returned && right.returned && left.returned !== right.returned) return false
|
|
205
|
-
if ([...left.choices].some(([selector, choice]) => right.choices.has(selector) && right.choices.get(selector) !== choice)) return false
|
|
206
|
-
return [...left.guards].every(([condition, value]) => !right.guards.has(condition) || right.guards.get(condition) === value)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** No explicit visual prop: any authored variant, including a known props spread, is an app bypass. */
|
|
210
|
-
const buttonNoVariant = {
|
|
211
|
-
meta: { type: "problem", docs: { description: "CTA-6: app buttons never choose a variant." }, schema: [], messages: {
|
|
212
|
-
FE_BUTTON_NO_VARIANT: "FE_BUTTON_NO_VARIANT: declare Button intent instead of variant; Grammar resolves the variant from intent and placement.",
|
|
213
|
-
} },
|
|
214
|
-
create(context) {
|
|
215
|
-
return { JSXOpeningElement(opening) {
|
|
216
|
-
if (!isButton(context, opening)) return
|
|
217
|
-
for (const node of new Set(propsOf(context, opening, "variant").map((prop) => prop.node))) context.report({ node, messageId: "FE_BUTTON_NO_VARIANT" })
|
|
218
|
-
} }
|
|
219
|
-
},
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Every app button states its command's intent explicitly. */
|
|
223
|
-
const buttonIntentRequired = {
|
|
224
|
-
meta: { type: "problem", docs: { description: "CTA-6: every app Button and IconButton declares intent." }, schema: [], messages: {
|
|
225
|
-
FE_BUTTON_INTENT_REQUIRED: "FE_BUTTON_INTENT_REQUIRED: Button and IconButton require intent (commit, alternative, dismiss, neutral or destructive). Choose it from the command's consequence and rank.",
|
|
226
|
-
} },
|
|
227
|
-
create(context) {
|
|
228
|
-
return { JSXOpeningElement(opening) {
|
|
229
|
-
if (isButton(context, opening) && !hasIntent(context, opening)) context.report({ node: opening, messageId: "FE_BUTTON_INTENT_REQUIRED" })
|
|
230
|
-
} }
|
|
231
|
-
},
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** A destructive final press is contained by its same-file confirmation dialog. */
|
|
235
|
-
const destructiveConfirmInDialog = {
|
|
236
|
-
meta: { type: "problem", docs: { description: "CTA-6: destructive confirm buttons stay inside AlertDialog or ConfirmDialog." }, schema: [], messages: {
|
|
237
|
-
FE_DESTRUCTIVE_CONFIRM_IN_DIALOG: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG: a destructive confirm belongs inside the same-file AlertDialog or ConfirmDialog subtree. The trigger uses step=\"entry\".",
|
|
238
|
-
} },
|
|
239
|
-
create(context) {
|
|
240
|
-
return { JSXOpeningElement(opening) {
|
|
241
|
-
if (!isButton(context, opening) || !hasValue(context, opening, "intent", "destructive") || !hasValue(context, opening, "step", "confirm")) return
|
|
242
|
-
if (!insideDialog(context, opening)) context.report({ node: opening, messageId: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG" })
|
|
243
|
-
} }
|
|
244
|
-
},
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** A component render exposes one next action, with exclusive branches counted separately. */
|
|
248
|
-
const singleCommitPerRender = {
|
|
249
|
-
meta: { type: "problem", docs: { description: "CTA-6: at most one commit is visible in a component render." }, schema: [], messages: {
|
|
250
|
-
FE_SINGLE_COMMIT_PER_RENDER: "FE_SINGLE_COMMIT_PER_RENDER: this render contains another commit action. Keep the one next action as commit and classify its alternatives by consequence.",
|
|
251
|
-
} },
|
|
252
|
-
create(context) {
|
|
253
|
-
const owners = new Map()
|
|
254
|
-
return { JSXOpeningElement(opening) {
|
|
255
|
-
if (!isButton(context, opening) || !hasValue(context, opening, "intent", "commit")) return
|
|
256
|
-
const owner = functionOwner(opening) ?? context.sourceCode.ast
|
|
257
|
-
const base = renderPath(context, opening, owner)
|
|
258
|
-
const paths = propsOf(context, opening, "intent").flatMap((prop) => prop.value ? valuePaths(context, prop.value) : prop.text === "commit" || prop.texts?.includes("commit") ? [{ guards: new Map(), choices: new Map() }] : [])
|
|
259
|
-
.map((path) => ({ returned: base.returned, guards: new Map([...base.guards, ...path.guards]), choices: new Map([...base.choices, ...path.choices]) }))
|
|
260
|
-
const prior = owners.get(owner) ?? []
|
|
261
|
-
if (paths.some((path) => prior.some((entry) => canCoexist(entry, path)))) context.report({ node: opening, messageId: "FE_SINGLE_COMMIT_PER_RENDER" })
|
|
262
|
-
prior.push(...paths)
|
|
263
|
-
owners.set(owner, prior)
|
|
264
|
-
} }
|
|
265
|
-
},
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** A repeated row's next action is alternative; the enclosing view keeps the commit. */
|
|
269
|
-
const noCommitInCollectionItem = {
|
|
270
|
-
meta: { type: "problem", docs: { description: "CTA-6: collection item renders cannot expose commit actions." }, schema: [], messages: {
|
|
271
|
-
FE_NO_COMMIT_IN_COLLECTION_ITEM: "FE_NO_COMMIT_IN_COLLECTION_ITEM: collection items cannot use commit. Use alternative for an item's next action and reserve commit for the view's decision.",
|
|
272
|
-
} },
|
|
273
|
-
create(context) {
|
|
274
|
-
const reported = new Set()
|
|
275
|
-
const report = (opening) => {
|
|
276
|
-
if (!reported.has(opening) && isButton(context, opening) && hasValue(context, opening, "intent", "commit")) {
|
|
277
|
-
reported.add(opening)
|
|
278
|
-
context.report({ node: opening, messageId: "FE_NO_COMMIT_IN_COLLECTION_ITEM" })
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
const scanCallback = (callback) => {
|
|
282
|
-
if (!callback) return
|
|
283
|
-
walk(context.sourceCode, callback.body, (node) => {
|
|
284
|
-
if (isFn(node) && !inlineRenderCallback(node)) return false
|
|
285
|
-
if (node.type === "JSXOpeningElement") report(node)
|
|
286
|
-
return undefined
|
|
287
|
-
})
|
|
288
|
-
}
|
|
289
|
-
return {
|
|
290
|
-
JSXAttribute(attribute) {
|
|
291
|
-
if (attribute.name.name === "renderCell" && roleOf(context, attribute.parent.name) === "DataTable") scanCallback(functionOf(context, attributeValue(attribute)))
|
|
292
|
-
},
|
|
293
|
-
JSXOpeningElement(opening) {
|
|
294
|
-
for (let current = opening.parent; current; current = current.parent) {
|
|
295
|
-
if (isFn(current) && !inlineRenderCallback(current)) return
|
|
296
|
-
if (current.type === "JSXElement" && ITEMS.has(roleOf(context, current.openingElement.name))) return report(opening)
|
|
297
|
-
}
|
|
298
|
-
},
|
|
299
|
-
CallExpression(call) {
|
|
300
|
-
const callee = unwrap(call.callee)
|
|
301
|
-
if (callee.type !== "MemberExpression") return
|
|
302
|
-
const name = callee.computed ? stringOf(callee.property) : callee.property.name
|
|
303
|
-
if (name !== "map") return
|
|
304
|
-
scanCallback(functionOf(context, call.arguments[0]))
|
|
305
|
-
},
|
|
306
|
-
}
|
|
307
|
-
},
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** Every intent law is universal across Grammar families and app editions. */
|
|
311
|
-
export const rules = {
|
|
312
|
-
"button-intent-required": buttonIntentRequired,
|
|
313
|
-
"button-no-variant": buttonNoVariant,
|
|
314
|
-
"destructive-confirm-in-dialog": destructiveConfirmInDialog,
|
|
315
|
-
"single-commit-per-render": singleCommitPerRender,
|
|
316
|
-
"no-commit-in-collection-item": noCommitInCollectionItem,
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** All five laws reach consumers at error. */
|
|
320
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/callback-shape.mjs
DELETED
|
@@ -1,332 +0,0 @@
|
|
|
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 { patternPath } from "./lib/bindings.mjs"
|
|
11
|
-
import { normalizePath } from "./lib/path.mjs"
|
|
12
|
-
import { ownerExportKind } from "./lib/pure-owner-helper.mjs"
|
|
13
|
-
import { classifyImport, inSlot, resolveImport } from "./lib/scope.mjs"
|
|
14
|
-
|
|
15
|
-
/** Runtime expression wrappers that preserve the value's source. */
|
|
16
|
-
const unwrap = (node) => {
|
|
17
|
-
while (node && ["AwaitExpression", "ChainExpression", "TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSInstantiationExpression"].includes(node.type)) node = node.expression ?? node.argument
|
|
18
|
-
return node
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** A member's static key, including a bracketed string spelling. */
|
|
22
|
-
const memberName = (node) => node.computed ? stringOf(node.property) : node.property.name
|
|
23
|
-
|
|
24
|
-
/** The lexical binding of an identifier, including shadowing inside the handler. */
|
|
25
|
-
const bindingOf = (sourceCode, node) => {
|
|
26
|
-
for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
|
|
27
|
-
const variable = scope.set.get(node.name)
|
|
28
|
-
if (variable) return variable
|
|
29
|
-
}
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Whether an identifier refers to the actual unshadowed platform global. */
|
|
34
|
-
const isGlobal = (sourceCode, node, name) => node?.type === "Identifier" && node.name === name && !(bindingOf(sourceCode, node)?.defs.length)
|
|
35
|
-
|
|
36
|
-
/** Route configuration is inert; a second HTTP door or an exported helper is not. */
|
|
37
|
-
const CONFIG_EXPORTS = new Set(["runtime", "dynamic", "revalidate", "fetchCache", "preferredRegion", "maxDuration"])
|
|
38
|
-
const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"])
|
|
39
|
-
const FORBIDDEN = new Set(["AssignmentExpression", "UpdateExpression", "ImportExpression", "TaggedTemplateExpression", "ClassDeclaration", "ClassExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "ThrowStatement", "YieldExpression", "SpreadElement", "ArrayPattern", "JSXElement", "JSXFragment"])
|
|
40
|
-
const TEXT = new Set(["text", "code", "otp-type"])
|
|
41
|
-
const CALLS = new Set(["factory", "exchange", "verify-otp", "pure", "redirect", "params-get", "params-has", "text-guard"])
|
|
42
|
-
/** What a pure owner helper may be given: query text, the page URL or request, and plain values; never the credential itself. */
|
|
43
|
-
const PURE_ARGUMENTS = new Set(["text", "otp-type", "scalar", "url", "request"])
|
|
44
|
-
const URL_TEXT = new Set(["origin", "href", "pathname", "search", "hash", "host", "hostname", "protocol"])
|
|
45
|
-
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"]])
|
|
46
|
-
const MEMBERS = new Map([
|
|
47
|
-
["request", new Map([["nextUrl", "url"], ["url", "text"]])],
|
|
48
|
-
["url", new Map([["searchParams", "params"], ...[...URL_TEXT].map((key) => [key, "text"])])],
|
|
49
|
-
["params", new Map([["get", "params-get"], ["has", "params-has"]])],
|
|
50
|
-
["client", new Map([["auth", "auth"]])],
|
|
51
|
-
["auth", new Map([["exchangeCodeForSession", "exchange"], ["verifyOtp", "verify-otp"]])],
|
|
52
|
-
["next-response", new Map([["redirect", "redirect"]])],
|
|
53
|
-
["exchange-result", new Map([["error", "exchange-error"], ["success", "scalar"], ["ok", "scalar"]])],
|
|
54
|
-
])
|
|
55
|
-
|
|
56
|
-
/** The query parameters whose provenance an exchange requires: the PKCE code, and the email link's token hash and type. */
|
|
57
|
-
const PARAM_ORIGINS = new Map([["code", "code"], ["token_hash", "token-hash"], ["type", "otp-type"]])
|
|
58
|
-
|
|
59
|
-
/** A locally exported binding's implementation. */
|
|
60
|
-
const exportValue = (sourceCode, entry) => {
|
|
61
|
-
const definition = bindingOf(sourceCode, entry.local)?.defs[0]?.node
|
|
62
|
-
return unwrap(definition?.type === "VariableDeclarator" ? definition.init : definition)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The runtime names introduced by one local named export. */
|
|
66
|
-
const namedExports = (statement, sourceCode) => {
|
|
67
|
-
const declaration = statement.declaration
|
|
68
|
-
if (declaration?.type === "FunctionDeclaration") return [{ name: declaration.id.name, value: declaration }]
|
|
69
|
-
if (declaration?.type === "VariableDeclaration") {
|
|
70
|
-
if (declaration.declarations.some((entry) => entry.id.type !== "Identifier")) return null
|
|
71
|
-
return declaration.declarations.map((entry) => ({ name: entry.id.name, value: unwrap(entry.init) }))
|
|
72
|
-
}
|
|
73
|
-
if (declaration) return []
|
|
74
|
-
return statement.specifiers.filter((entry) => entry.exportKind !== "type")
|
|
75
|
-
.map((entry) => ({ name: entry.exported.name ?? entry.exported.value, value: exportValue(sourceCode, entry) }))
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Collect runtime exports, refusing reexports and default exports. */
|
|
79
|
-
const exportsOf = (program, sourceCode) => {
|
|
80
|
-
const exported = []
|
|
81
|
-
for (const statement of program.body) {
|
|
82
|
-
if (statement.type === "ExportDefaultDeclaration" || statement.type === "ExportAllDeclaration") return null
|
|
83
|
-
if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type") continue
|
|
84
|
-
if (statement.source) return null
|
|
85
|
-
const entries = namedExports(statement, sourceCode)
|
|
86
|
-
if (!entries) return null
|
|
87
|
-
exported.push(...entries)
|
|
88
|
-
}
|
|
89
|
-
return exported
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Find the one locally implemented GET export and its single request parameter. */
|
|
93
|
-
const handlerOf = (program, sourceCode) => {
|
|
94
|
-
const exported = exportsOf(program, sourceCode)
|
|
95
|
-
if (!exported || exported.some((entry) => entry.name !== "GET" && !CONFIG_EXPORTS.has(entry.name))) return null
|
|
96
|
-
const handlers = exported.filter((entry) => entry.name === "GET")
|
|
97
|
-
if (handlers.length !== 1) return null
|
|
98
|
-
const handler = handlers[0].value
|
|
99
|
-
if (!handler || !FUNCTIONS.has(handler.type) || !handler.async || handler.generator) return null
|
|
100
|
-
return handler.params.length === 1 && handler.params[0].type === "Identifier" ? handler : null
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Framework imports retain their actual owner even under a local alias. */
|
|
104
|
-
const frameworkImportKind = (source, name) => {
|
|
105
|
-
if (source === "next/server" && name === "NextResponse") return "next-response"
|
|
106
|
-
if (source === "next/server" && name === "NextRequest") return "request-type"
|
|
107
|
-
if (source === "next/navigation" && name === "redirect") return "redirect"
|
|
108
|
-
return null
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Only the server auth entry exposes the cookie-owning exchangeAuthCode helper. */
|
|
112
|
-
const isServerAuthImport = (context, source) => {
|
|
113
|
-
const target = normalizePath(resolveImport(context, source))
|
|
114
|
-
return target.endsWith("/modules/auth/server") || target.endsWith("/modules/auth/server.ts")
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** A directory import resolves to its index, before the generic modules slot's hypothetical routes.ts. */
|
|
118
|
-
const isRoutesImport = (context, source, slot) => slot === "fe.modules.routes" || classifyImport(context, `${source}/index`)?.slot === "fe.modules.routes"
|
|
119
|
-
|
|
120
|
-
/** The exchange, client and factory names of the auth owner. */
|
|
121
|
-
const authImportKind = (context, source, name) => {
|
|
122
|
-
if (name === "exchangeAuthCode" && isServerAuthImport(context, source)) return "exchange"
|
|
123
|
-
if (name === "verifyOtp" && isServerAuthImport(context, source)) return "verify-otp"
|
|
124
|
-
if (name === "exchangeCodeForSession") return "exchange"
|
|
125
|
-
if (/^(?:create|get)\w*Client$/.test(name ?? "")) return "factory"
|
|
126
|
-
if (["supabase", "supabaseClient", "authClient"].includes(name)) return "client"
|
|
127
|
-
return null
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** 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. */
|
|
131
|
-
const ownerImportKind = (context, source, name) => {
|
|
132
|
-
const slot = classifyImport(context, source)?.slot
|
|
133
|
-
if (name === "APP_ROUTES" && isRoutesImport(context, source, slot)) return "routes"
|
|
134
|
-
const authKind = slot === "fe.modules.auth" ? authImportKind(context, source, name) : null
|
|
135
|
-
if (authKind || !(slot === "fe.modules.auth" || isRoutesImport(context, source, slot))) return authKind
|
|
136
|
-
return ownerExportKind(context, source, name)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Register the allowed bindings in a single import statement. */
|
|
140
|
-
const addImportBindings = (context, sourceCode, statement, imports) => {
|
|
141
|
-
const source = String(statement.source.value)
|
|
142
|
-
if (source === "server-only" && statement.specifiers.length === 0) return true
|
|
143
|
-
if (statement.specifiers.length === 0) return false
|
|
144
|
-
const variables = sourceCode.getDeclaredVariables(statement)
|
|
145
|
-
for (const specifier of statement.specifiers) {
|
|
146
|
-
if (specifier.importKind === "type") continue
|
|
147
|
-
const name = specifier.imported?.name ?? specifier.imported?.value
|
|
148
|
-
const kind = frameworkImportKind(source, name) ?? ownerImportKind(context, source, name)
|
|
149
|
-
if (!kind) return false
|
|
150
|
-
imports.set(variables.find((entry) => entry.name === specifier.local.name), kind)
|
|
151
|
-
}
|
|
152
|
-
return true
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Every runtime import is restricted, even if it is unused in the handler. */
|
|
156
|
-
const importsOf = (context, program, sourceCode) => {
|
|
157
|
-
const imports = new Map()
|
|
158
|
-
for (const statement of program.body) {
|
|
159
|
-
if (statement.type !== "ImportDeclaration" || statement.importKind === "type") continue
|
|
160
|
-
if (!addImportBindings(context, sourceCode, statement, imports)) return null
|
|
161
|
-
}
|
|
162
|
-
return imports
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Select a known property without granting arbitrary methods on the selected value. */
|
|
166
|
-
const memberKind = (kind, key) => {
|
|
167
|
-
if (kind === "routes" && typeof key === "string" && !["__proto__", "constructor", "prototype"].includes(key)) return "text"
|
|
168
|
-
if (TEXT.has(kind) && ["startsWith", "endsWith", "includes"].includes(key)) return "text-guard"
|
|
169
|
-
return MEMBERS.get(kind)?.get(key) ?? null
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** An identifier keeps the provenance of its one initializer or the GET request parameter. */
|
|
173
|
-
const identifierOrigin = (node, seen, env, origin) => {
|
|
174
|
-
const variable = bindingOf(env.sourceCode, node)
|
|
175
|
-
if (env.imports.has(variable)) return env.imports.get(variable)
|
|
176
|
-
if (!variable || seen.has(variable) || variable.defs.length !== 1) return null
|
|
177
|
-
const definition = variable.defs[0]
|
|
178
|
-
if (definition.type === "Parameter" && definition.node === env.handler) return "request"
|
|
179
|
-
if (definition.node.type !== "VariableDeclarator") return null
|
|
180
|
-
const path = patternPath(definition.node.id, node.name)
|
|
181
|
-
const nextSeen = new Set([...seen, variable])
|
|
182
|
-
return path?.reduce((kind, key) => memberKind(kind, key), origin(definition.node.init, nextSeen)) ?? null
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Combining known primitive values cannot introduce user-defined coercion. */
|
|
186
|
-
const combinedOrigin = (kinds, conditional = false) => {
|
|
187
|
-
if (conditional && kinds[0] === kinds[1]) return kinds[0]
|
|
188
|
-
if (kinds.every((kind) => TEXT.has(kind))) return "text"
|
|
189
|
-
if (!conditional && kinds.every((kind) => TEXT.has(kind) || kind === "scalar")) return "scalar"
|
|
190
|
-
return null
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** The source of a call's return value, independent of argument validation. */
|
|
194
|
-
const callOrigin = (node, seen, origin) => {
|
|
195
|
-
const kind = origin(node.callee, seen)
|
|
196
|
-
if (kind === "params-get") return PARAM_ORIGINS.get(stringOf(node.arguments[0])) ?? "text"
|
|
197
|
-
return CALL_RESULTS.get(kind) ?? null
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Composite expressions retain provenance only for supported primitive operations. */
|
|
201
|
-
const expressionOrigin = (node, seen, env, origin) => {
|
|
202
|
-
if (node.type === "TemplateLiteral") return node.expressions.every((expression) => TEXT.has(origin(expression, seen)) || origin(expression, seen) === "scalar") ? "text" : null
|
|
203
|
-
if (node.type === "MemberExpression") return memberKind(origin(node.object, seen), memberName(node))
|
|
204
|
-
if (node.type === "NewExpression" && isGlobal(env.sourceCode, node.callee, "URL")) return "url"
|
|
205
|
-
if (node.type === "LogicalExpression" || node.type === "BinaryExpression") return combinedOrigin([origin(node.left, seen), origin(node.right, seen)])
|
|
206
|
-
if (node.type === "ConditionalExpression") return combinedOrigin([origin(node.consequent, seen), origin(node.alternate, seen)], true)
|
|
207
|
-
if (node.type === "CallExpression") return callOrigin(node, seen, origin)
|
|
208
|
-
return null
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** Follow lexical aliases rather than trusting the spelling of an identifier. */
|
|
212
|
-
const originOf = (env) => {
|
|
213
|
-
const origin = (input, seen = new Set()) => {
|
|
214
|
-
const node = unwrap(input)
|
|
215
|
-
if (!node) return null
|
|
216
|
-
if (node.type === "Identifier") return identifierOrigin(node, seen, env, origin)
|
|
217
|
-
if (node.type === "Literal") return typeof node.value === "string" || node.value === null ? "text" : "scalar"
|
|
218
|
-
return expressionOrigin(node, seen, env, origin)
|
|
219
|
-
}
|
|
220
|
-
return origin
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** The closest enclosing function must be the GET handler. */
|
|
224
|
-
const insideHandler = (node, handler) => {
|
|
225
|
-
for (let parent = node.parent; parent; parent = parent.parent) if (FUNCTIONS.has(parent.type)) return parent === handler
|
|
226
|
-
return false
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Await must wrap the actual exchange call before its enclosing statement ends. */
|
|
230
|
-
const isAwaited = (node, handler) => {
|
|
231
|
-
for (let parent = node.parent; parent && parent !== handler; parent = parent.parent) {
|
|
232
|
-
if (parent.type === "AwaitExpression") return true
|
|
233
|
-
if (parent.type.endsWith("Statement")) return false
|
|
234
|
-
}
|
|
235
|
-
return false
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Only a literal redirect status is allowed in an options object. */
|
|
239
|
-
const isRedirectStatus = (property) => {
|
|
240
|
-
if (property.type !== "Property" || property.computed || property.method || property.kind !== "init") return false
|
|
241
|
-
return (property.key.name ?? property.key.value) === "status" && property.value.type === "Literal" && [301, 302, 303, 307, 308].includes(property.value.value)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Arbitrary objects can invoke getters or toString during coercion; only redirect options belong here. */
|
|
245
|
-
const isRedirectOptions = (node, origin) => node.parent.type === "CallExpression" && origin(node.parent.callee) === "redirect" && node.parent.arguments[1] === node && node.properties.every(isRedirectStatus)
|
|
246
|
-
|
|
247
|
-
/** The one object `verifyOtp` takes: exactly `token_hash` read from the query `token_hash` and `type` read from the query `type`. */
|
|
248
|
-
const isOtpArguments = (node, origin) => {
|
|
249
|
-
if (node.parent.type !== "CallExpression" || origin(node.parent.callee) !== "verify-otp" || node.parent.arguments[0] !== node) return false
|
|
250
|
-
const keyOf = (property) => property.type === "Property" && !property.computed && !property.method && property.kind === "init" ? property.key.name ?? property.key.value : null
|
|
251
|
-
const expected = new Map([["token_hash", "token-hash"], ["type", "otp-type"]])
|
|
252
|
-
return node.properties.length === expected.size && node.properties.every((property) => expected.get(keyOf(property)) === origin(property.value))
|
|
253
|
-
&& new Set(node.properties.map(keyOf)).size === expected.size
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Non-call expressions must preserve the callback's narrow set of values and effects. */
|
|
257
|
-
const validExpression = (node, env, origin) => {
|
|
258
|
-
if (node.type === "MemberExpression") return Boolean(origin(node))
|
|
259
|
-
if (node.type === "ObjectExpression") return isRedirectOptions(node, origin) || isOtpArguments(node, origin)
|
|
260
|
-
if (node.type === "VariableDeclarator" && node.id.type === "ObjectPattern") return env.sourceCode.getDeclaredVariables(node).every((variable) => origin(variable.identifiers[0]))
|
|
261
|
-
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)))
|
|
262
|
-
if (node.type === "ReturnStatement") return insideHandler(node, env.handler) && origin(node.argument) === "redirect-result"
|
|
263
|
-
if (node === env.handler && node.body.type !== "BlockStatement") return origin(node.body) === "redirect-result"
|
|
264
|
-
return true
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Validate arguments to each permitted operation. */
|
|
268
|
-
const validCall = (node, kind, env, origin) => {
|
|
269
|
-
if (!insideHandler(node, env.handler) || !CALLS.has(kind) || node.arguments.some((argument) => argument.type === "SpreadElement")) return false
|
|
270
|
-
if (kind === "exchange") return isAwaited(node, env.handler) && node.arguments.length === 1 && origin(node.arguments[0]) === "code"
|
|
271
|
-
if (kind === "verify-otp") return isAwaited(node, env.handler) && node.arguments.length === 1 && node.arguments[0].type === "ObjectExpression"
|
|
272
|
-
if (kind === "pure") return node.arguments.length <= 4 && node.arguments.every((argument) => PURE_ARGUMENTS.has(origin(argument)))
|
|
273
|
-
if (kind === "redirect") return node.arguments.length >= 1 && node.arguments.length <= 2 && ["text", "url"].includes(origin(node.arguments[0]))
|
|
274
|
-
if (kind === "factory") return node.arguments.length <= 1 && node.arguments.every((argument) => origin(argument) === "request")
|
|
275
|
-
return node.arguments.length === 1 && TEXT.has(origin(node.arguments[0]))
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** Syntactic forms that add work beyond the one handler are refused outright. */
|
|
279
|
-
const forbiddenNode = (node, handler) => FORBIDDEN.has(node.type) || (FUNCTIONS.has(node.type) && node !== handler) || (node.type === "UnaryExpression" && node.operator === "delete")
|
|
280
|
-
|
|
281
|
-
/** Walk all runtime syntax, counting the single exchange and permitted redirects. */
|
|
282
|
-
const validOperations = (program, env) => {
|
|
283
|
-
const origin = originOf(env)
|
|
284
|
-
const counts = new Map()
|
|
285
|
-
let valid = true
|
|
286
|
-
walk(env.sourceCode, program, (node) => {
|
|
287
|
-
if (!valid) return false
|
|
288
|
-
valid = !forbiddenNode(node, env.handler) && validExpression(node, env, origin)
|
|
289
|
-
if (!valid || node.type !== "CallExpression") return undefined
|
|
290
|
-
const kind = origin(node.callee)
|
|
291
|
-
valid = validCall(node, kind, env, origin)
|
|
292
|
-
counts.set(kind, (counts.get(kind) ?? 0) + 1)
|
|
293
|
-
return undefined
|
|
294
|
-
})
|
|
295
|
-
const exchanges = (counts.get("exchange") ?? 0) + (counts.get("verify-otp") ?? 0)
|
|
296
|
-
return valid && exchanges >= 1 && (counts.get("exchange") ?? 0) <= 1 && (counts.get("verify-otp") ?? 0) <= 1 && (counts.get("factory") ?? 0) <= 1 && counts.get("redirect") > 0
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** The callback's request, imports and operations must all have known owners. */
|
|
300
|
-
const callbackVerdict = (context, program) => {
|
|
301
|
-
const sourceCode = context.sourceCode ?? context.getSourceCode()
|
|
302
|
-
const handler = handlerOf(program, sourceCode)
|
|
303
|
-
if (!handler) return false
|
|
304
|
-
const imports = importsOf(context, program, sourceCode)
|
|
305
|
-
return imports !== null && validOperations(program, { sourceCode, handler, imports })
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** A callback stays a PKCE exchange and redirect, with every side effect owned by the auth helper. */
|
|
309
|
-
export const callbackShape = {
|
|
310
|
-
meta: {
|
|
311
|
-
type: "problem",
|
|
312
|
-
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." },
|
|
313
|
-
schema: [],
|
|
314
|
-
messages: {
|
|
315
|
-
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.",
|
|
316
|
-
},
|
|
317
|
-
},
|
|
318
|
-
create(context) {
|
|
319
|
-
if (!inSlot(context, "fe.route.callback")) return {}
|
|
320
|
-
return {
|
|
321
|
-
"Program:exit"(program) {
|
|
322
|
-
if (!callbackVerdict(context, program)) context.report({ node: program, messageId: "shape" })
|
|
323
|
-
},
|
|
324
|
-
}
|
|
325
|
-
},
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/** The rule R287 contributes to the front-end plugin. */
|
|
329
|
-
export const rules = { "callback-shape": callbackShape }
|
|
330
|
-
|
|
331
|
-
/** Every callback shape violation is an error. */
|
|
332
|
-
export const recommended = { "starci-fe/callback-shape": "error" }
|