@starci/eslint-canon-fe 9.2.0 → 9.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -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 +22 -14
- package/runtime/scripts/lib/package-at.mjs +5 -5
- 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 -315
- 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/swr-family.mjs
DELETED
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
/** R285: API operations are wrapped by the SWR family before product code calls them. */
|
|
2
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
3
|
-
import { readFileSync, readdirSync } from "node:fs"
|
|
4
|
-
import { dirname, join, resolve } from "node:path"
|
|
5
|
-
import { stringOf, walk } from "./lib/ast.mjs"
|
|
6
|
-
import { functionOf } from "./lib/bindings.mjs"
|
|
7
|
-
import { classOf, fileOf, globalReferences, isApiLayer, isProductSource, resolveImport } from "./lib/scope.mjs"
|
|
8
|
-
import { why } from "./lib/why.mjs"
|
|
9
|
-
|
|
10
|
-
/** The SWR family names its wrapper files; entries and side-effect components are not wrappers. */
|
|
11
|
-
const isSwrHook = (context) => {
|
|
12
|
-
const found = classOf(context)
|
|
13
|
-
return found.slot === "fe.hooks.swr" && !found.role && hfsOf(context).allows(fileOf(context))?.allowed === true
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Cache providers and revalidation may be used by consumers; only request hooks own operations. */
|
|
17
|
-
const isRequestHook = (name) => /^useSWR(?:Immutable|Infinite|Mutation|Subscription)?$/.test(name) || name === "default"
|
|
18
|
-
const swrSource = (value) => value === "swr" || value.startsWith("swr/")
|
|
19
|
-
const propertyName = (node) => node.computed ? node.property.value : node.property.name
|
|
20
|
-
|
|
21
|
-
/** Runtime imports of the request hooks (including aliases) are confined to named SWR wrappers. */
|
|
22
|
-
export const swrOnlyInFamily = {
|
|
23
|
-
meta: {
|
|
24
|
-
type: "problem",
|
|
25
|
-
docs: { description: "FE_SWR_OUTSIDE_FAMILY: request hooks belong to fe.hooks.swr." },
|
|
26
|
-
schema: [],
|
|
27
|
-
messages: { outside: "FE_SWR_OUTSIDE_FAMILY: SWR request hooks belong in hooks/swr/. Call the operation's wrapper hook here; cache providers and revalidation remain available to consumers." },
|
|
28
|
-
},
|
|
29
|
-
create(context) {
|
|
30
|
-
if (!isProductSource(context) || isSwrHook(context)) return {}
|
|
31
|
-
const namespaces = new Set()
|
|
32
|
-
const report = (node) => context.report({ node, messageId: "outside" })
|
|
33
|
-
return {
|
|
34
|
-
ImportDeclaration(node) {
|
|
35
|
-
if (node.importKind === "type" || !swrSource(String(node.source.value))) return
|
|
36
|
-
const runtime = node.specifiers.filter((s) => s.importKind !== "type")
|
|
37
|
-
if (runtime.some((s) => s.type === "ImportDefaultSpecifier" ||
|
|
38
|
-
(s.type === "ImportSpecifier" && isRequestHook(s.imported.name ?? s.imported.value)))) report(node)
|
|
39
|
-
for (const specifier of runtime) if (specifier.type === "ImportNamespaceSpecifier") namespaces.add(specifier)
|
|
40
|
-
},
|
|
41
|
-
ExportNamedDeclaration(node) {
|
|
42
|
-
if (!node.source || node.exportKind === "type" || !swrSource(String(node.source.value))) return
|
|
43
|
-
if (node.specifiers.some((s) => s.exportKind !== "type" && isRequestHook(s.local.name ?? s.local.value))) report(node)
|
|
44
|
-
},
|
|
45
|
-
ExportAllDeclaration(node) {
|
|
46
|
-
if (node.exportKind !== "type" && swrSource(String(node.source.value))) report(node)
|
|
47
|
-
},
|
|
48
|
-
ImportExpression(node) {
|
|
49
|
-
if (node.source.type === "Literal" && swrSource(String(node.source.value))) report(node)
|
|
50
|
-
},
|
|
51
|
-
CallExpression(node) {
|
|
52
|
-
if (node.callee.type === "Identifier" && node.callee.name === "require" && node.arguments[0]?.type === "Literal" &&
|
|
53
|
-
swrSource(String(node.arguments[0].value)) && globalReferences(context, new Set(["require"])).includes(node.callee)) report(node)
|
|
54
|
-
},
|
|
55
|
-
"Program:exit"() {
|
|
56
|
-
const source = context.sourceCode ?? context.getSourceCode()
|
|
57
|
-
for (const specifier of namespaces) {
|
|
58
|
-
const variable = source.getDeclaredVariables(specifier.parent).find((v) => v.name === specifier.local.name)
|
|
59
|
-
for (const reference of variable?.references ?? []) {
|
|
60
|
-
const id = reference.identifier
|
|
61
|
-
const member = id.parent
|
|
62
|
-
// Passing or destructuring the namespace also makes its request hooks available outside the family.
|
|
63
|
-
if (member.type !== "MemberExpression" || member.object !== id || member.computed && member.property.type !== "Literal" ||
|
|
64
|
-
isRequestHook(propertyName(member))) report(id)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
},
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** The slot of the stream reader: only a stream operation of the API layer imports it, never an SWR hook, even inside a fetcher. */
|
|
73
|
-
const STREAM_READER_SLOT = "fe.transport.stream"
|
|
74
|
-
|
|
75
|
-
/** Type imports stay available; the API layer may compose its own operations and helpers. */
|
|
76
|
-
const isOperationSource = (context, value, only) => {
|
|
77
|
-
if (typeof value !== "string") return false
|
|
78
|
-
const hfs = hfsOf(context)
|
|
79
|
-
let target = resolveImport(context, value)
|
|
80
|
-
if (target === null) {
|
|
81
|
-
// Workspace package names are read from their actual manifests, never inferred from a suffix.
|
|
82
|
-
let folders
|
|
83
|
-
try { folders = readdirSync(join(hfs.repoRoot, "packages"), { withFileTypes: true }) } catch { return false }
|
|
84
|
-
for (const folder of folders) {
|
|
85
|
-
if (!folder.isDirectory()) continue
|
|
86
|
-
const root = join(hfs.repoRoot, "packages", folder.name)
|
|
87
|
-
if (hfs.slotOf(join(root, "src", "index.ts")) !== "fe.package.api") continue
|
|
88
|
-
let name
|
|
89
|
-
try { name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name } catch { continue }
|
|
90
|
-
if (typeof name !== "string" || !(value === name || value.startsWith(`${name}/`))) continue
|
|
91
|
-
target = value === name ? join(root, "src", "index.ts") : join(root, "src", value.slice(name.length + 1))
|
|
92
|
-
break
|
|
93
|
-
}
|
|
94
|
-
if (target === null) return false
|
|
95
|
-
}
|
|
96
|
-
// Prefer the actual file before a hypothetical barrel: outcome types have a more specific slot.
|
|
97
|
-
const candidates = /\.[cm]?[jt]sx?$/.test(target) ? [target] : [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`, target]
|
|
98
|
-
const found = candidates.map((file) => hfs.classify(file)).filter((entry) => entry.status === "owned")
|
|
99
|
-
if (found.some((entry) => ["fe.transport.outcome", "fe.package.api.outcome"].includes(entry.slot))) return false
|
|
100
|
-
if (only !== undefined) return found.some((entry) => entry.slot === only)
|
|
101
|
-
return found.some((entry) => ["fe.modules.api", "fe.package.api", "fe.transport.client", "fe.transport.stream", "fe.package.api.client"].includes(entry.slot))
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** The generated input-bounds module holds constants and a pure reader derived from the contract, never an operation. */
|
|
105
|
-
const BOUNDS_MODULE = /(?:^|\/)__generated__\/input-bounds\.ts$/
|
|
106
|
-
const RE_EXPORT = /export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g
|
|
107
|
-
const ENTRY = /^\s*(type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/
|
|
108
|
-
|
|
109
|
-
/** True when the named export of `target` is, through a chain of relative named re-exports, defined by a generated input-bounds module. */
|
|
110
|
-
const isBoundsExport = (target, name, depth = 0) => {
|
|
111
|
-
if (depth > 4) return false
|
|
112
|
-
for (const file of [`${target}.ts`, `${target}/index.ts`, target]) {
|
|
113
|
-
if (BOUNDS_MODULE.test(file.replaceAll("\\", "/"))) return true
|
|
114
|
-
let text
|
|
115
|
-
try { text = readFileSync(file, "utf8") } catch { continue }
|
|
116
|
-
for (const [, list, source] of text.matchAll(RE_EXPORT)) {
|
|
117
|
-
if (!source.startsWith(".")) continue
|
|
118
|
-
for (const entry of list.split(",")) {
|
|
119
|
-
const [, kind, origin, exported] = ENTRY.exec(entry) ?? []
|
|
120
|
-
if (origin !== undefined && kind === undefined && (exported ?? origin) === name && isBoundsExport(resolve(dirname(file), source), origin, depth + 1)) return true
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return false
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** An import of named values that all come from a generated input-bounds module is data, not an operation. */
|
|
128
|
-
const importsOnlyBounds = (context, node, value) => {
|
|
129
|
-
const specifiers = node.specifiers?.filter((s) => s.importKind !== "type" && s.exportKind !== "type") ?? []
|
|
130
|
-
if (!specifiers.length || typeof value !== "string") return false
|
|
131
|
-
const target = resolveImport(context, value)
|
|
132
|
-
if (target === null) return false
|
|
133
|
-
return specifiers.every((s) => {
|
|
134
|
-
const name = s.imported?.name ?? s.imported?.value ?? s.local?.name ?? s.local?.value
|
|
135
|
-
return (s.type === "ImportSpecifier" || s.type === "ExportSpecifier") && typeof name === "string" && isBoundsExport(target, name)
|
|
136
|
-
})
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const unwrap = (node) => {
|
|
140
|
-
while (node && ["ChainExpression", "TSAsExpression", "TSNonNullExpression", "TSInstantiationExpression"].includes(node.type)) node = node.expression
|
|
141
|
-
return node
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Resolve lexical bindings so renamed imports and constant aliases keep their provenance. */
|
|
145
|
-
const bindingOf = (source, node) => {
|
|
146
|
-
for (let scope = source.getScope(node); scope; scope = scope.upper) {
|
|
147
|
-
if (scope.set.has(node.name)) return scope.set.get(node.name)
|
|
148
|
-
}
|
|
149
|
-
return null
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const requestOrigin = (source, input, seen = new Set()) => {
|
|
153
|
-
const node = unwrap(input)
|
|
154
|
-
if (node?.type === "MemberExpression") return isRequestHook(propertyName(node)) && requestOrigin(source, node.object, seen) === "namespace" ? "request" : null
|
|
155
|
-
if (node?.type !== "Identifier") return null
|
|
156
|
-
const variable = bindingOf(source, node)
|
|
157
|
-
if (!variable || seen.has(variable) || variable.defs.length !== 1) return null
|
|
158
|
-
const definition = variable.defs[0]
|
|
159
|
-
if (definition.type === "ImportBinding" && definition.parent.importKind !== "type" && swrSource(String(definition.parent.source.value))) {
|
|
160
|
-
const specifier = definition.node
|
|
161
|
-
if (specifier.importKind === "type") return null
|
|
162
|
-
if (specifier.type === "ImportNamespaceSpecifier") return "namespace"
|
|
163
|
-
return specifier.type === "ImportDefaultSpecifier" || isRequestHook(specifier.imported?.name ?? specifier.imported?.value ?? "") ? "request" : null
|
|
164
|
-
}
|
|
165
|
-
if (definition.node.type !== "VariableDeclarator" || definition.parent.kind !== "const") return null
|
|
166
|
-
return requestOrigin(source, definition.node.init, new Set([...seen, variable]))
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Every value reference to the operation must belong to an SWR fetcher, including a named local fetcher. */
|
|
170
|
-
const wrappedOperations = (context, program, imports) => {
|
|
171
|
-
const source = context.sourceCode ?? context.getSourceCode()
|
|
172
|
-
const fetchers = []
|
|
173
|
-
walk(source, program, (node) => {
|
|
174
|
-
if (node.type !== "CallExpression" || requestOrigin(source, node.callee) !== "request" || !node.arguments[1]) return
|
|
175
|
-
let fetcher = unwrap(node.arguments[1])
|
|
176
|
-
if (fetcher.type === "Identifier") {
|
|
177
|
-
const definition = bindingOf(source, fetcher)?.defs[0]
|
|
178
|
-
if (definition?.node.type === "VariableDeclarator" && definition.parent.kind === "const") fetcher = unwrap(definition.node.init) ?? fetcher
|
|
179
|
-
else if (definition?.node.type === "FunctionDeclaration") fetcher = definition.node
|
|
180
|
-
}
|
|
181
|
-
fetchers.push(fetcher)
|
|
182
|
-
})
|
|
183
|
-
const inFetcher = (node) => fetchers.some((fetcher) => fetcher.range[0] <= node.range[0] && fetcher.range[1] >= node.range[1])
|
|
184
|
-
return new Set(imports.filter(({ node }) => {
|
|
185
|
-
if (node.type !== "ImportDeclaration") return inFetcher(node)
|
|
186
|
-
const runtimeNames = new Set(node.specifiers.filter((s) => s.importKind !== "type").map((s) => s.local.name))
|
|
187
|
-
const references = source.getDeclaredVariables(node).filter((v) => runtimeNames.has(v.name))
|
|
188
|
-
.flatMap((v) => v.references.filter((r) => r.isValueReference !== false).map((r) => r.identifier))
|
|
189
|
-
return references.length > 0 && references.every(inFetcher)
|
|
190
|
-
}).map(({ node }) => node))
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Direct operation access from domain hooks, features and components skips the request lifecycle. */
|
|
194
|
-
export const operationsOnlyInSwrHooks = {
|
|
195
|
-
meta: {
|
|
196
|
-
type: "problem",
|
|
197
|
-
docs: { description: "FE_OP_OUTSIDE_SWR_HOOK: runtime API operations are consumed only by SWR wrappers." },
|
|
198
|
-
schema: [],
|
|
199
|
-
messages: { outside: "FE_OP_OUTSIDE_SWR_HOOK: `{{source}}` exposes API operations outside an SWR wrapper. Wrap the operation in hooks/swr/ and call that hook; type-only imports remain available." },
|
|
200
|
-
},
|
|
201
|
-
create(context) {
|
|
202
|
-
if (!isProductSource(context) || isApiLayer(context)) return {}
|
|
203
|
-
const imports = []
|
|
204
|
-
const inspect = (node, value) => {
|
|
205
|
-
if (isOperationSource(context, value) && !importsOnlyBounds(context, node, value)) imports.push({ node, value, reader: isOperationSource(context, value, STREAM_READER_SLOT) })
|
|
206
|
-
}
|
|
207
|
-
return {
|
|
208
|
-
ImportDeclaration(node) {
|
|
209
|
-
if (node.importKind !== "type" && (!node.specifiers.length || node.specifiers.some((s) => s.importKind !== "type"))) inspect(node, node.source.value)
|
|
210
|
-
},
|
|
211
|
-
ExportNamedDeclaration(node) {
|
|
212
|
-
if (node.source && node.exportKind !== "type" && node.specifiers.some((s) => s.exportKind !== "type")) inspect(node, node.source.value)
|
|
213
|
-
},
|
|
214
|
-
ExportAllDeclaration(node) { if (node.exportKind !== "type") inspect(node, node.source.value) },
|
|
215
|
-
ImportExpression(node) { inspect(node, stringOf(node.source)) },
|
|
216
|
-
CallExpression(node) {
|
|
217
|
-
if (node.callee.type === "Identifier" && node.callee.name === "require" && node.arguments[0]?.type === "Literal" &&
|
|
218
|
-
globalReferences(context, new Set(["require"])).includes(node.callee)) inspect(node, node.arguments[0].value)
|
|
219
|
-
},
|
|
220
|
-
"Program:exit"(program) {
|
|
221
|
-
if (!imports.length) return
|
|
222
|
-
const wrapped = isSwrHook(context) ? wrappedOperations(context, program, imports) : new Set()
|
|
223
|
-
for (const { node, value, reader } of imports) if (reader || !wrapped.has(node)) context.report({ node, messageId: "outside", data: { source: value } })
|
|
224
|
-
},
|
|
225
|
-
}
|
|
226
|
-
},
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** A stream operation: `modules/api/sse/<area>/stream-<channel>` or `modules/api/graphql/subscriptions/subscription-<name>`. */
|
|
230
|
-
const STREAM_OPERATION = /modules\/api\/(?:sse\/[^/]+\/stream-[^/]+|graphql\/subscriptions\/subscription-[^/]+)(?:\.tsx?)?$/
|
|
231
|
-
|
|
232
|
-
/** A subscription hook file of the SWR family: `useSubscribe<X>Swr.ts`. */
|
|
233
|
-
const isSubscriptionHook = (context) => isSwrHook(context) && /^useSubscribe[A-Z0-9]\w*Swr\.ts$/.test(fileOf(context).replaceAll("\\", "/").split("/").at(-1) ?? "")
|
|
234
|
-
|
|
235
|
-
/** The `onOpen` property of an object literal, else undefined. */
|
|
236
|
-
const onOpenOf = (options) =>
|
|
237
|
-
options?.type === "ObjectExpression"
|
|
238
|
-
? options.properties.find((property) => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "onOpen")
|
|
239
|
-
: undefined
|
|
240
|
-
|
|
241
|
-
/** True when a function calls `mutate` (the bound mutate of a hook or the one of `useSWRConfig`): the revalidation of the snapshot query. */
|
|
242
|
-
const callsMutate = (source, handler) => {
|
|
243
|
-
let found = false
|
|
244
|
-
walk(source, handler.body, (node) => {
|
|
245
|
-
if (found) return false
|
|
246
|
-
if (node.type !== "CallExpression") return undefined
|
|
247
|
-
const callee = unwrap(node.callee)
|
|
248
|
-
if ((callee?.type === "Identifier" && callee.name === "mutate") || (callee?.type === "MemberExpression" && propertyName(callee) === "mutate")) found = true
|
|
249
|
-
return undefined
|
|
250
|
-
})
|
|
251
|
-
return found
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** True when a callee is a stream operation: a named import, a member of a namespace import, or a const alias of either. */
|
|
255
|
-
const isStreamCallee = (source, input, locals, seen = new Set()) => {
|
|
256
|
-
const node = unwrap(input)
|
|
257
|
-
if (node?.type === "MemberExpression") {
|
|
258
|
-
const object = unwrap(node.object)
|
|
259
|
-
return object?.type === "Identifier" && locals.get(object.name) === "namespace" && bindingOf(source, object)?.defs[0]?.type === "ImportBinding"
|
|
260
|
-
}
|
|
261
|
-
if (node?.type !== "Identifier") return false
|
|
262
|
-
const variable = bindingOf(source, node)
|
|
263
|
-
if (!variable || seen.has(variable) || variable.defs.length !== 1) return false
|
|
264
|
-
const definition = variable.defs[0]
|
|
265
|
-
if (definition.type === "ImportBinding") return locals.get(variable.name) === "named"
|
|
266
|
-
if (definition.node.type !== "VariableDeclarator" || definition.parent.kind !== "const") return false
|
|
267
|
-
return isStreamCallee(source, definition.node.init, locals, new Set([...seen, variable]))
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* A stream or subscription has no replay of what it missed (graphql-ws has none, an SSE reconnect replays a bounded buffer), so the
|
|
272
|
-
* subscription hook revalidates the snapshot query each time the stream opens: its call of the stream operation (named, through a
|
|
273
|
-
* namespace or an alias) passes an `onOpen` handler that calls `mutate`, and a subscription hook that calls no stream operation at
|
|
274
|
-
* all is not one (FE-REALTIME-2).
|
|
275
|
-
*/
|
|
276
|
-
export const subscriptionRefetchesSnapshot = {
|
|
277
|
-
meta: {
|
|
278
|
-
type: "problem",
|
|
279
|
-
docs: { description: "FE_SUBSCRIPTION_NO_SNAPSHOT: a subscription hook passes the stream operation an onOpen handler that revalidates the snapshot query." },
|
|
280
|
-
schema: [],
|
|
281
|
-
messages: {
|
|
282
|
-
missing:
|
|
283
|
-
"FE_SUBSCRIPTION_NO_SNAPSHOT: this call of a stream operation has no `onOpen` handler that calls `mutate`. A stream carries changes, not the state: pass `onOpen: () => void mutate(<snapshot query key>)` so the screen reads its snapshot with the query each time the stream (re)opens.",
|
|
284
|
-
noStream:
|
|
285
|
-
"FE_SUBSCRIPTION_NO_SNAPSHOT: a `useSubscribe<X>Swr` hook calls one stream operation (`modules/api/sse/<area>/stream-*.ts` or `modules/api/graphql/subscriptions/subscription-*.ts`) and revalidates its snapshot query in `onOpen`; this file calls none.",
|
|
286
|
-
},
|
|
287
|
-
},
|
|
288
|
-
create(context) {
|
|
289
|
-
if (!isProductSource(context) || !isSubscriptionHook(context)) return {}
|
|
290
|
-
const source = context.sourceCode ?? context.getSourceCode()
|
|
291
|
-
const locals = new Map()
|
|
292
|
-
let calls = 0
|
|
293
|
-
return {
|
|
294
|
-
ImportDeclaration(node) {
|
|
295
|
-
if (node.importKind === "type" || typeof node.source.value !== "string") return
|
|
296
|
-
const target = resolveImport(context, node.source.value)
|
|
297
|
-
if (target === null || !STREAM_OPERATION.test(target.replaceAll("\\", "/"))) return
|
|
298
|
-
for (const specifier of node.specifiers) {
|
|
299
|
-
if (specifier.importKind === "type") continue
|
|
300
|
-
if (specifier.type === "ImportSpecifier") locals.set(specifier.local.name, "named")
|
|
301
|
-
if (specifier.type === "ImportNamespaceSpecifier") locals.set(specifier.local.name, "namespace")
|
|
302
|
-
}
|
|
303
|
-
},
|
|
304
|
-
CallExpression(node) {
|
|
305
|
-
if (!isStreamCallee(source, node.callee, locals)) return
|
|
306
|
-
calls += 1
|
|
307
|
-
const handler = functionOf(context, onOpenOf(node.arguments[0])?.value)
|
|
308
|
-
if (!handler || !callsMutate(source, handler)) context.report({ node, messageId: "missing" })
|
|
309
|
-
},
|
|
310
|
-
"Program:exit"(program) {
|
|
311
|
-
if (calls === 0) context.report({ node: program, messageId: "noStream" })
|
|
312
|
-
},
|
|
313
|
-
}
|
|
314
|
-
},
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** Code metadata is registered on the shared object without changing the shared catalog module. */
|
|
318
|
-
Object.assign(why, {
|
|
319
|
-
"swr-only-in-family": { code: "FE_SWR_OUTSIDE_FAMILY", en: "A request hook is used outside the SWR family.", fix: "Call the operation's SWR wrapper hook." },
|
|
320
|
-
"operations-only-in-swr-hooks": { code: "FE_OP_OUTSIDE_SWR_HOOK", en: "An API operation is consumed outside an SWR wrapper.", fix: "Wrap the operation in hooks/swr/." },
|
|
321
|
-
"subscription-refetches-snapshot": { code: "FE_SUBSCRIPTION_NO_SNAPSHOT", en: "A subscription hook does not revalidate its snapshot query when the stream opens.", fix: "Pass the stream operation an onOpen handler that revalidates the snapshot query key." },
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
/** R285's published lint enforcers. */
|
|
325
|
-
export const rules = { "swr-only-in-family": swrOnlyInFamily, "operations-only-in-swr-hooks": operationsOnlyInSwrHooks, "subscription-refetches-snapshot": subscriptionRefetchesSnapshot }
|
|
326
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/the-split.mjs
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rule that holds `the-split.md`.
|
|
3
|
-
*
|
|
4
|
-
* ONE RULE, AND IT WATCHES ONE HALF. The drawing half's purity is checkable: a file that fetches,
|
|
5
|
-
* reads a store or resolves a word is visible in what it calls. The connected half's discipline is
|
|
6
|
-
* not - a file that draws badly, or settles the wrong situation, looks exactly like one that does
|
|
7
|
-
* neither. So this rule guards the boundary from the side where a machine can see it, and the other
|
|
8
|
-
* side stays a matter for review.
|
|
9
|
-
*
|
|
10
|
-
* The scope is the slot. The role `drawing` of a component owner is the drawing half in every tier
|
|
11
|
-
* that splits, so the slot does the scoping and the rule needs no configuration.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
15
|
-
import { fileOf, inSlot, kindOfFile, roleOfFile } from "./lib/scope.mjs"
|
|
16
|
-
|
|
17
|
-
/** The drawing half: the file the slot gives the role `drawing` (`component.tsx` of a component, feature or shared-package owner). */
|
|
18
|
-
const isDrawingHalf = (context) => roleOfFile(context) === "drawing"
|
|
19
|
-
|
|
20
|
-
/** A connected block entrypoint (the `entry` of a block owner) and the public name its folder fixes. */
|
|
21
|
-
const connectedBlock = (context) => {
|
|
22
|
-
if (!inSlot(context, "fe.components") || kindOfFile(context) !== "blocks" || roleOfFile(context) !== "entry") return null
|
|
23
|
-
const name = hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0]
|
|
24
|
-
return /^[A-Z][A-Za-z0-9]*$/.test(name) ? name : null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Calls that reach for the world instead of receiving it.
|
|
29
|
-
*
|
|
30
|
-
* Six families of the front-end stack: a request (SWR, `useQuery<X>Swr` / `useMutate<X>Swr` and the REST
|
|
31
|
-
* `use{Get,Post,Put,Patch,Delete}<X>Swr` of `hooks/swr`, the Apollo hooks, an operation of `modules/api` called directly), a
|
|
32
|
-
* form (react-hook-form and the `use<X>Form` hooks of `hooks/rhf`), a store (redux, the zustand `use<X>Store`), a socket
|
|
33
|
-
* (`use<X>SocketIo` of `hooks/socketio`), a toast (`use<X>WithToast` of `modules/toast`) and the translation runtime. Each is a
|
|
34
|
-
* dependency the drawing half cannot be rendered from a fixture without.
|
|
35
|
-
*/
|
|
36
|
-
const REACHES_FOR_THE_WORLD =
|
|
37
|
-
/^(?:useSWR|useSWRMutation|useSWRConfig|use[A-Za-z0-9]*Swr|useQuery|useLazyQuery|useMutation|useSubscription|useApolloClient|useForm|useFormContext|useController|useWatch|use[A-Za-z0-9]*Form|useAppSelector|useAppDispatch|useSelector|useDispatch|use[A-Za-z0-9]*Store|use[A-Za-z0-9]*SocketIo|use[A-Za-z0-9]*WithToast|useTranslations|useLocale|useFormatter|query[A-Z][A-Za-z0-9]*|mutation[A-Z][A-Za-z0-9]*)$/
|
|
38
|
-
|
|
39
|
-
// -- SPLIT-1 ---------------------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
/** The drawing half receives everything and asks for nothing. */
|
|
42
|
-
export const presentationalPurity = {
|
|
43
|
-
meta: {
|
|
44
|
-
type: "problem",
|
|
45
|
-
docs: { description: "The drawing half takes resolved props: no request, no store, no translation." },
|
|
46
|
-
schema: [],
|
|
47
|
-
messages: {
|
|
48
|
-
reaches:
|
|
49
|
-
"`{{name}}(...)` reaches for the world from the drawing half. This file must be renderable from a fixture - that is what makes it testable without standing the world up first, and it is the whole reason the split exists. Move the call to `index.tsx` and pass the result down as a settled situation.",
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
create(context) {
|
|
53
|
-
if (!isDrawingHalf(context)) return {}
|
|
54
|
-
return {
|
|
55
|
-
CallExpression(node) {
|
|
56
|
-
const callee = node.callee
|
|
57
|
-
if (!callee || callee.type !== "Identifier") return
|
|
58
|
-
if (!REACHES_FOR_THE_WORLD.test(callee.name)) return
|
|
59
|
-
context.report({ node, messageId: "reaches", data: { name: callee.name } })
|
|
60
|
-
},
|
|
61
|
-
}
|
|
62
|
-
},
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// -- SPLIT-5 ---------------------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
/** A connected block delegates every render path to its exact pure twin. */
|
|
68
|
-
export const connectedBlockHasPresentationalTwin = {
|
|
69
|
-
meta: {
|
|
70
|
-
type: "problem",
|
|
71
|
-
docs: { description: "A connected block index renders only its exact `XBase` twin from component.tsx." },
|
|
72
|
-
schema: [],
|
|
73
|
-
messages: {
|
|
74
|
-
missing:
|
|
75
|
-
"`{{block}}` reads the world, so it must import exact `{{twin}}` from `./component`. A single leaf, one tree, or no local state is not an exception.",
|
|
76
|
-
bypass:
|
|
77
|
-
"Connected `{{block}}` renders `<{{rendered}}>` directly. Resolve the world here, then render only `<{{twin}}>`; component.tsx owns the complete presentation.",
|
|
78
|
-
unused:
|
|
79
|
-
"Connected `{{block}}` imports `{{twin}}` but never renders it. Every connected render path must cross the pure twin.",
|
|
80
|
-
called:
|
|
81
|
-
"Connected `{{block}}` calls `{{twin}}(...)` as an ordinary function. Render `<{{twin}} />` so React owns the twin's hook lifecycle and every render keeps one stable component boundary.",
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
create(context) {
|
|
85
|
-
const block = connectedBlock(context)
|
|
86
|
-
if (!block) return {}
|
|
87
|
-
|
|
88
|
-
const twin = `${block}Base`
|
|
89
|
-
const worldBindings = new Set()
|
|
90
|
-
const rendered = []
|
|
91
|
-
const calledTwin = []
|
|
92
|
-
let importsTwin = false
|
|
93
|
-
let readsWorld = false
|
|
94
|
-
let rendersTwin = false
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
ImportDeclaration(node) {
|
|
98
|
-
const source = typeof node.source?.value === "string" ? node.source.value : ""
|
|
99
|
-
for (const specifier of node.specifiers || []) {
|
|
100
|
-
const imported = specifier.imported?.name
|
|
101
|
-
const local = specifier.local?.name
|
|
102
|
-
if (source === "./component" && imported === twin && local === twin) importsTwin = true
|
|
103
|
-
if (imported && local && REACHES_FOR_THE_WORLD.test(imported)) worldBindings.add(local)
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
CallExpression(node) {
|
|
107
|
-
if (node.callee?.type !== "Identifier") return
|
|
108
|
-
if (node.callee.name === twin) calledTwin.push(node)
|
|
109
|
-
if (worldBindings.has(node.callee.name) || REACHES_FOR_THE_WORLD.test(node.callee.name)) {
|
|
110
|
-
readsWorld = true
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
JSXOpeningElement(node) {
|
|
114
|
-
if (node.name?.type !== "JSXIdentifier") return
|
|
115
|
-
rendered.push({ name: node.name.name, node })
|
|
116
|
-
if (node.name.name === twin) rendersTwin = true
|
|
117
|
-
},
|
|
118
|
-
"Program:exit"(node) {
|
|
119
|
-
if (!readsWorld) return
|
|
120
|
-
if (!importsTwin) {
|
|
121
|
-
context.report({ node, messageId: "missing", data: { block, twin } })
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
for (const entry of rendered) {
|
|
125
|
-
if (entry.name !== twin) {
|
|
126
|
-
context.report({
|
|
127
|
-
node: entry.node,
|
|
128
|
-
messageId: "bypass",
|
|
129
|
-
data: { block, rendered: entry.name, twin },
|
|
130
|
-
})
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
for (const call of calledTwin) {
|
|
134
|
-
context.report({ node: call, messageId: "called", data: { block, twin } })
|
|
135
|
-
}
|
|
136
|
-
if (!rendersTwin) context.report({ node, messageId: "unused", data: { block, twin } })
|
|
137
|
-
},
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** The rules this law contributes to the plugin. */
|
|
143
|
-
export const rules = {
|
|
144
|
-
"presentational-purity": presentationalPurity,
|
|
145
|
-
"connected-block-has-presentational-twin": connectedBlockHasPresentationalTwin,
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* The level this law asks for, as the plugin's own opinion.
|
|
150
|
-
*
|
|
151
|
-
* Exact: it fires on a call by name, in one file by name. A repository adopting it with history
|
|
152
|
-
* should expect each report to be a real move rather than a deletion - the call has to go
|
|
153
|
-
* somewhere, and where it goes is the other half.
|
|
154
|
-
*/
|
|
155
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/tokens.mjs
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/** Universal closed-token discipline, independent of a Grammar scale. */
|
|
2
|
-
import { classTextVisitors } from "./lib/class-text.mjs"
|
|
3
|
-
|
|
4
|
-
/** A bracketed length: a value chosen once, for one screen, findable by nobody. */
|
|
5
|
-
const ARBITRARY_LENGTH =
|
|
6
|
-
/\b(?:gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|space-x|space-y|size|w|h|min-w|min-h|max-w|max-h)-\[[^\]]+\]/
|
|
7
|
-
|
|
8
|
-
/** A raw colour, which escapes the semantic palette even when it matches it today. */
|
|
9
|
-
const RAW_COLOUR = /\b(?:text|bg|border|ring|from|to|via|fill|stroke|shadow|decoration)-\[#[0-9a-fA-F]/
|
|
10
|
-
|
|
11
|
-
/** An arbitrary value escapes the system, whatever it happens to evaluate to. */
|
|
12
|
-
export const noArbitraryValue = {
|
|
13
|
-
meta: {
|
|
14
|
-
type: "problem",
|
|
15
|
-
docs: { description: "Bracketed lengths and raw colours escape the closed vocabulary." },
|
|
16
|
-
schema: [],
|
|
17
|
-
messages: {
|
|
18
|
-
length:
|
|
19
|
-
"`{{cls}}` is a value chosen once, for one screen. Even where it happens to equal a rung, nobody searching the scale can find it and it does not move when the scale moves. Use a member of the vocabulary, or add one deliberately.",
|
|
20
|
-
colour:
|
|
21
|
-
"`{{cls}}` is a raw colour, outside the semantic palette. A colour named for what it MEANS follows the theme; one named for what it is stays the same in a theme where it is wrong.",
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
create(context) {
|
|
25
|
-
return classTextVisitors(context, (node, text) => {
|
|
26
|
-
if (!text) return
|
|
27
|
-
const length = text.match(ARBITRARY_LENGTH)
|
|
28
|
-
if (length) context.report({ node, messageId: "length", data: { cls: length[0] } })
|
|
29
|
-
const colour = text.match(RAW_COLOUR)
|
|
30
|
-
if (colour) context.report({ node, messageId: "colour", data: { cls: colour[0] } })
|
|
31
|
-
})
|
|
32
|
-
},
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Rules of universal token discipline. */
|
|
36
|
-
export const rules = { "no-arbitrary-value": noArbitraryValue }
|
|
37
|
-
|
|
38
|
-
/** Every token-discipline rule is mandatory. */
|
|
39
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|