@starci/eslint-canon-fe 3.0.2 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +41 -16
  3. package/brand-values.mjs +174 -0
  4. package/class-names.mjs +1 -1
  5. package/client-boundary.mjs +199 -0
  6. package/comments.mjs +144 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +99 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/hooks-folder.md +29 -0
  13. package/docs/hygiene.md +111 -0
  14. package/docs/lint-escape-hatch.md +32 -0
  15. package/docs/lists.md +74 -0
  16. package/docs/native-controls.md +73 -0
  17. package/docs/next-conventions.md +206 -0
  18. package/docs/size-and-state-budget.md +73 -0
  19. package/docs/spec-quality.md +139 -0
  20. package/docs/translation.md +50 -0
  21. package/docs/transport.md +168 -0
  22. package/docs/type-safety.md +95 -0
  23. package/e2e-shape.mjs +348 -0
  24. package/env-owner.mjs +134 -0
  25. package/file-layout.mjs +100 -7
  26. package/formatting.mjs +106 -0
  27. package/hooks-folder.mjs +119 -0
  28. package/hygiene.mjs +235 -0
  29. package/icon.mjs +210 -211
  30. package/index.mjs +292 -131
  31. package/lib/ast.mjs +94 -0
  32. package/lib/path.mjs +2 -0
  33. package/lib/scope.mjs +42 -0
  34. package/lib/why.mjs +304 -0
  35. package/lint-adoption.mjs +15 -52
  36. package/lint-escape-hatch.mjs +91 -56
  37. package/lists.mjs +197 -0
  38. package/loading.mjs +163 -164
  39. package/naming.mjs +143 -143
  40. package/native-controls.mjs +112 -0
  41. package/next-conventions.mjs +346 -0
  42. package/package.json +7 -3
  43. package/props-and-slots.mjs +4 -2
  44. package/served-locale.mjs +160 -160
  45. package/shape-slot.mjs +339 -0
  46. package/size-and-state-budget.mjs +196 -0
  47. package/spec-quality.mjs +239 -0
  48. package/the-split.mjs +133 -134
  49. package/tokens.mjs +267 -268
  50. package/translation.mjs +262 -122
  51. package/transport.mjs +404 -0
  52. package/type-safety.mjs +156 -74
  53. package/typography.mjs +82 -83
package/formatting.mjs ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The rule that holds `formatting.md` (HFS R59 `FE_I18N_PLACEMENT`, sub-check `FE_I18N_FORMATTER`).
3
+ *
4
+ * THE LOCALE HAS ONE OWNER. `next-intl` knows the reader's locale and time zone and hands them to
5
+ * `useFormatter()` / `getFormatter()`. Every other way of turning a number or a date into text either
6
+ * guesses the locale (`toLocaleString()` with no argument uses the SERVER's on the server and the
7
+ * browser's on the client, which is the hydration mismatch that makes a price flicker), hard-codes
8
+ * one (`"en-US"` in a Vietnamese product), or builds a private formatter that ignores the app's
9
+ * currency and date styles. Money and dates are the two places a wrong format is a wrong fact.
10
+ *
11
+ * WHAT IS REFUSED. `toLocaleString`, `toLocaleDateString`, `toLocaleTimeString`; `new Intl.*Format`;
12
+ * `toFixed` when the result is shown (inside JSX or a template); a currency symbol glued to a
13
+ * substitution in a template; and the date libraries (`moment`, `dayjs`, `date-fns`, `luxon`).
14
+ * `modules/i18n/**` is exempt: it is where the formatters are configured.
15
+ */
16
+
17
+ import { isSpecFile } from "./lib/scope.mjs"
18
+ import { normalizePath } from "./lib/path.mjs"
19
+
20
+ /** The locale-sensitive `Date` and `Number` methods. */
21
+ const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"])
22
+
23
+ /** The `Intl` formatters a product must not build itself. */
24
+ const INTL_FORMATTERS = new Set(["NumberFormat", "DateTimeFormat", "RelativeTimeFormat", "ListFormat", "PluralRules"])
25
+
26
+ /** Date libraries that format on their own locale. */
27
+ const DATE_LIBRARIES = /^(?:moment(?:-timezone)?|dayjs|date-fns(?:\/.+)?|luxon)$/
28
+
29
+ /** A currency mark or code beside a substitution. */
30
+ const CURRENCY = /[₫€£¥]|\b(?:VND|USD|EUR)\b/
31
+
32
+ /** True when `node` is displayed: it sits in a JSX expression or a template literal. */
33
+ const isDisplayed = (node) => {
34
+ for (let current = node.parent; current; current = current.parent) {
35
+ if (current.type === "JSXExpressionContainer" || current.type === "TemplateLiteral") return true
36
+ if (current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression" || current.type === "FunctionDeclaration") return false
37
+ }
38
+ return false
39
+ }
40
+
41
+ /** Numbers, money and dates are formatted by the `next-intl` formatter. */
42
+ export const useIntlFormatter = {
43
+ meta: {
44
+ type: "problem",
45
+ docs: { description: "Format numbers, money and dates with `useFormatter` / `getFormatter` from `next-intl`." },
46
+ schema: [],
47
+ messages: {
48
+ method:
49
+ "`{{name}}` formats with a locale this file does not own: with no argument it is the server's on the server and the browser's in the browser (a price that flickers on hydration), with a string it is one language for every reader. Use `useFormatter().number(...)` / `.dateTime(...)` (or `getFormatter()` on the server).",
50
+ intl:
51
+ "`new Intl.{{name}}` builds a private formatter that ignores the app's locale, time zone and named formats. Use the `next-intl` formatter, and put a shared format in `modules/i18n` once.",
52
+ fixed:
53
+ "`.toFixed()` in displayed text writes a number with a dot decimal separator and no grouping to every reader. Use `useFormatter().number(value, { maximumFractionDigits })`.",
54
+ currency:
55
+ "A currency mark glued to a substitution in a template writes the symbol on the wrong side for half of the locales and skips grouping. Use `useFormatter().number(value, { style: \"currency\", currency })`.",
56
+ library:
57
+ "`{{name}}` formats dates on its own locale data, next to `next-intl`'s. One library decides the language of a date: use the `next-intl` formatter (`dateTime`, `relativeTime`).",
58
+ },
59
+ },
60
+ create(context) {
61
+ const file = normalizePath(context.filename || context.getFilename())
62
+ if (isSpecFile(file) || /\/modules\/i18n\//.test(file)) return {}
63
+ return {
64
+ ImportDeclaration(node) {
65
+ const source = String(node.source.value)
66
+ if (DATE_LIBRARIES.test(source)) context.report({ node, messageId: "library", data: { name: source } })
67
+ },
68
+ CallExpression(node) {
69
+ const callee = node.callee
70
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
71
+ const name = callee.property.name
72
+ if (LOCALE_METHODS.has(name)) context.report({ node, messageId: "method", data: { name } })
73
+ else if (name === "toFixed" && isDisplayed(node)) context.report({ node, messageId: "fixed" })
74
+ },
75
+ NewExpression(node) {
76
+ const callee = node.callee
77
+ if (
78
+ callee.type === "MemberExpression" &&
79
+ callee.object.type === "Identifier" &&
80
+ callee.object.name === "Intl" &&
81
+ callee.property.type === "Identifier" &&
82
+ INTL_FORMATTERS.has(callee.property.name)
83
+ ) {
84
+ context.report({ node, messageId: "intl", data: { name: callee.property.name } })
85
+ }
86
+ },
87
+ TemplateLiteral(node) {
88
+ if (node.expressions.length === 0) return
89
+ const glued = node.quasis.some((quasi, index) => {
90
+ const text = quasi.value.cooked ?? ""
91
+ if (index < node.expressions.length && text.endsWith("$")) return true
92
+ return CURRENCY.test(text)
93
+ })
94
+ if (glued) context.report({ node, messageId: "currency" })
95
+ },
96
+ }
97
+ },
98
+ }
99
+
100
+ /** The rules this law contributes to the plugin. */
101
+ export const rules = {
102
+ "use-intl-formatter": useIntlFormatter,
103
+ }
104
+
105
+ /** Every rule is an error. */
106
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The rule that holds `hooks-folder.md` (HFS R56 `FE_HOOKS_ARE_HOOKS`, lint half).
3
+ *
4
+ * `hooks/` MEANS REACT HOOKS. One app kept its server readers there - fetch, map, no React - and
5
+ * had a single real hook; another had a filename-equals-export rule that gave a helper shared by
6
+ * sixteen hooks nowhere to live, so it was copied sixteen times. Both came from the standard not
7
+ * saying where the non-hook code goes. It now does:
8
+ *
9
+ * hooks/<domain>/use<Name>.ts one hook per file
10
+ * hooks/<domain>/<domain>.shared.ts one file for the domain's non-hook helpers (key builders...)
11
+ * modules/api/<domain>/read-*.ts server readers (wrapped in `cache()`)
12
+ *
13
+ * The rule holds the shape of one file at a time. Whether a folder holds exactly one `.shared.ts`
14
+ * and whether a helper name repeats across files is a repository fact, and belongs to the
15
+ * architecture machine that sees the whole tree.
16
+ */
17
+
18
+ import { baseName, isSpecFile } from "./lib/scope.mjs"
19
+ import { normalizePath } from "./lib/path.mjs"
20
+
21
+ /** The path below the first `hooks/` folder, as segments, or null when the file is not in one. */
22
+ const belowHooks = (file) => {
23
+ const match = /\/src\/hooks\/(.+)$/.exec(file) || /\/hooks\/(.+)$/.exec(file)
24
+ return match ? match[1].split("/") : null
25
+ }
26
+
27
+ /** Names that mark a hook. */
28
+ const isHookName = (name) => /^use[A-Z0-9]/.test(name)
29
+
30
+ /** Modules that only run on the server: a reader, not a hook. */
31
+ const SERVER_ONLY = /^(?:server-only|next\/headers)$/
32
+
33
+ /** The exported names a declaration introduces. */
34
+ const exportedNames = (node) => {
35
+ if (node.type === "ExportDefaultDeclaration") {
36
+ const declaration = node.declaration
37
+ return [declaration && declaration.id ? declaration.id.name : "default"]
38
+ }
39
+ const declaration = node.declaration
40
+ if (!declaration) return node.specifiers.map((specifier) => specifier.exported.name)
41
+ if (declaration.type === "FunctionDeclaration" && declaration.id) return [declaration.id.name]
42
+ if (declaration.type === "VariableDeclaration") {
43
+ return declaration.declarations.filter((d) => d.id.type === "Identifier").map((d) => d.id.name)
44
+ }
45
+ return []
46
+ }
47
+
48
+ /** `hooks/` holds React hooks and one shared helper file per domain. */
49
+ export const hooksFolderHoldsHooksOnly = {
50
+ meta: {
51
+ type: "problem",
52
+ docs: { description: "`hooks/<domain>/` holds `use*.ts` hooks and one `<domain>.shared.ts`; nothing else." },
53
+ schema: [],
54
+ messages: {
55
+ domain:
56
+ "A file directly in `hooks/`. Hooks live in `hooks/<domain>/use<Name>.ts`; the domain folder is what keeps a shared helper next to the hooks that use it.",
57
+ notHook:
58
+ "`{{name}}` is in `hooks/` but is not a React hook. Hooks are `use<Name>.ts`; the domain's non-hook helpers go in `<domain>.shared.ts`; a server reader goes in `modules/api/<domain>/read-*.ts`.",
59
+ export:
60
+ "`{{name}}` is exported from a hook file but is not a hook. A hook file exports the hook (a function named `use...`); a helper belongs in `<domain>.shared.ts`.",
61
+ many:
62
+ "This file exports {{count}} hooks. One hook per file keeps a hook findable by its name and keeps a change to one from re-running the tests of the others.",
63
+ sharedHook:
64
+ "`{{name}}` is a hook exported from the shared helper file. That file holds non-hook helpers; a hook there is a hook nobody will find. Give it its own `use<Name>.ts`.",
65
+ server:
66
+ "`{{source}}` marks server-only code, which is a reader, not a hook. Server readers live in `modules/api/<domain>/read-*.ts`, wrapped in React's `cache()`.",
67
+ },
68
+ },
69
+ create(context) {
70
+ const file = normalizePath(context.filename || context.getFilename())
71
+ const segments = belowHooks(file)
72
+ if (!segments) return {}
73
+ const name = baseName(file)
74
+ const spec = isSpecFile(file)
75
+ const subject = spec ? name.replace(/\.(?:test|spec)\.(?:ts|tsx)$/, ".ts") : name
76
+ const domain = segments.length >= 2 ? segments[segments.length - 2] : null
77
+ const isHookFile = /^use[A-Z0-9]\w*\.ts$/.test(subject)
78
+ const isSharedFile = domain !== null && subject === `${domain}.shared.ts`
79
+
80
+ return {
81
+ Program(program) {
82
+ if (segments.length < 2) return context.report({ node: program, messageId: "domain" })
83
+ if (!isHookFile && !isSharedFile) context.report({ node: program, messageId: "notHook", data: { name } })
84
+ },
85
+ ImportDeclaration(node) {
86
+ if (SERVER_ONLY.test(String(node.source.value)) && !spec) {
87
+ context.report({ node, messageId: "server", data: { source: node.source.value } })
88
+ }
89
+ },
90
+ "Program:exit"(program) {
91
+ if (spec || (!isHookFile && !isSharedFile)) return
92
+ const exported = program.body
93
+ .filter((node) => node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration")
94
+ .flatMap((node) => exportedNames(node).map((exportedName) => ({ node, name: exportedName })))
95
+ if (isSharedFile) {
96
+ for (const item of exported) {
97
+ if (isHookName(item.name)) context.report({ node: item.node, messageId: "sharedHook", data: { name: item.name } })
98
+ }
99
+ return
100
+ }
101
+ const hooks = exported.filter((item) => isHookName(item.name))
102
+ for (const item of exported) {
103
+ if (!isHookName(item.name) && item.name !== "default") {
104
+ context.report({ node: item.node, messageId: "export", data: { name: item.name } })
105
+ }
106
+ }
107
+ if (hooks.length > 1) context.report({ node: hooks[1].node, messageId: "many", data: { count: hooks.length } })
108
+ },
109
+ }
110
+ },
111
+ }
112
+
113
+ /** The rules this law contributes to the plugin. */
114
+ export const rules = {
115
+ "hooks-folder-holds-hooks-only": hooksFolderHoldsHooksOnly,
116
+ }
117
+
118
+ /** Every rule is an error. */
119
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/hygiene.mjs ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The rules that hold `hygiene.md`: the small runtime habits that pass every type check and fail in a
3
+ * reader's browser (HFS R65 `FE_SIZE_AND_STATE_BUDGET` for the timer, catch and console rules, R50
4
+ * `FE_TRANSPORT_OWNER` for the fetch rule).
5
+ *
6
+ * FOUR HABITS, ONE CAUSE: work started by a component that nobody owns the end of.
7
+ * - a timer started outside an effect, or in one that never clears it, fires after the component is
8
+ * gone and sets state on nothing;
9
+ * - a fetch started in an effect has no cache, no dedupe, no error state and no cancel, which is
10
+ * what SWR and the server readers already give for free (R50: client reads use SWR);
11
+ * - an empty `catch` turns a failure into a blank screen with no trace;
12
+ * - `console.*` is a log nobody reads, in a place no log pipeline sees.
13
+ */
14
+
15
+ import { calleeName, effectCallback, isEffectCall, isFunction, walk } from "./lib/ast.mjs"
16
+ import { isSpecFile } from "./lib/scope.mjs"
17
+ import { normalizePath } from "./lib/path.mjs"
18
+
19
+ /** Product source outside specs. */
20
+ const isGoverned = (filename) => {
21
+ const file = normalizePath(filename)
22
+ return file.includes("/src/") && !isSpecFile(file)
23
+ }
24
+
25
+ /** The timer functions, bare or on the global. */
26
+ const TIMERS = new Set(["setTimeout", "setInterval", "window.setTimeout", "window.setInterval", "globalThis.setTimeout", "globalThis.setInterval"])
27
+
28
+ // -- HYGIENE-1 -------------------------------------------------------------------------------------
29
+
30
+ /** The effect callback that encloses `node`, crossing any function boundary, or null. */
31
+ const enclosingEffect = (node) => {
32
+ for (let current = node.parent; current; current = current.parent) {
33
+ if (isFunction(current) && current.parent?.type === "CallExpression" && isEffectCall(current.parent) && effectCallback(current.parent) === current) {
34
+ return current
35
+ }
36
+ }
37
+ return null
38
+ }
39
+
40
+ /** The value an effect callback returns at its own top level (the cleanup), or null. */
41
+ const cleanupOf = (effect) => {
42
+ if (effect.body.type !== "BlockStatement") return null
43
+ const last = [...effect.body.body].reverse().find((statement) => statement.type === "ReturnStatement")
44
+ return last?.argument ?? null
45
+ }
46
+
47
+ const CLEARERS = new Set(["clearTimeout", "clearInterval", "window.clearTimeout", "window.clearInterval", "globalThis.clearTimeout", "globalThis.clearInterval"])
48
+
49
+ /** The identifier a timer call's handle is stored in (`const id = ...` or `id = ...`), or null. */
50
+ const handleOf = (call) => {
51
+ const parent = call.parent
52
+ if (parent?.type === "VariableDeclarator" && parent.init === call && parent.id.type === "Identifier") return parent.id.name
53
+ if (parent?.type === "AssignmentExpression" && parent.right === call && parent.left.type === "Identifier") return parent.left.name
54
+ return null
55
+ }
56
+
57
+ /** The function a function directly returns (arrow expression body, or its last top-level `return`), or null. */
58
+ const returnedFunction = (fn) => {
59
+ if (fn.body.type !== "BlockStatement") return isFunction(fn.body) ? fn.body : null
60
+ const last = [...fn.body.body].reverse().find((statement) => statement.type === "ReturnStatement")
61
+ return last?.argument && isFunction(last.argument) ? last.argument : null
62
+ }
63
+
64
+ /**
65
+ * True when a function enclosing the timer call returns a closure that clears the same handle: the
66
+ * `subscribe` of `useSyncExternalStore`, or any owner that hands back its own unsubscribe.
67
+ */
68
+ const clearedByReturnedCleanup = (call, source) => {
69
+ const handle = handleOf(call)
70
+ if (!handle) return false
71
+ for (let current = call.parent; current; current = current.parent) {
72
+ if (current.type !== "FunctionExpression" && current.type !== "ArrowFunctionExpression" && current.type !== "FunctionDeclaration") continue
73
+ const cleanup = returnedFunction(current)
74
+ if (!cleanup) continue
75
+ let clears = false
76
+ walk(source, cleanup, (node) => {
77
+ if (node.type === "CallExpression" && CLEARERS.has(calleeName(node.callee)) && node.arguments[0]?.type === "Identifier" && node.arguments[0].name === handle) clears = true
78
+ })
79
+ if (clears) return true
80
+ }
81
+ return false
82
+ }
83
+
84
+ /** A timer belongs in an effect that clears it, or in a function whose returned cleanup clears it. */
85
+ export const timerNeedsEffectCleanup = {
86
+ meta: {
87
+ type: "problem",
88
+ docs: { description: "`setTimeout` and `setInterval` live in an effect whose cleanup clears them." },
89
+ schema: [],
90
+ messages: {
91
+ orphan:
92
+ "`{{timer}}` is started outside an effect. Nothing ends it when the component unmounts, so it fires later and sets state on a component that is gone. Start it in a `useEffect` and clear it in the cleanup, or move it into a hook that does.",
93
+ noCleanup:
94
+ "`{{timer}}` is started in an effect that returns no cleanup which clears it. On unmount or on the next run the old timer is still pending. Return `() => clearTimeout(id)` (or `clearInterval`).",
95
+ },
96
+ },
97
+ create(context) {
98
+ const filename = context.filename || context.getFilename()
99
+ // Non-React modules (the API client's timeout, the theme bootstrap) own their timers and clear them in `finally`.
100
+ if (!isGoverned(filename) || /\/modules\//.test(normalizePath(filename))) return {}
101
+ const source = context.sourceCode ?? context.getSourceCode()
102
+ return {
103
+ CallExpression(node) {
104
+ const name = calleeName(node.callee)
105
+ if (!name || !TIMERS.has(name)) return
106
+ if (clearedByReturnedCleanup(node, source)) return
107
+ const effect = enclosingEffect(node)
108
+ if (!effect) return context.report({ node, messageId: "orphan", data: { timer: name } })
109
+ const cleanup = cleanupOf(effect)
110
+ const clears = cleanup && /\bclear(?:Timeout|Interval)\b/.test(source.getText(cleanup))
111
+ if (!clears) context.report({ node, messageId: "noCleanup", data: { timer: name } })
112
+ },
113
+ }
114
+ },
115
+ }
116
+
117
+ // -- HYGIENE-2 -------------------------------------------------------------------------------------
118
+
119
+ /** A fetch in an effect: `await`, `fetch(...)`, `.then(...)`, outside the cleanup function. */
120
+ const fetchesIn = (sourceCode, effect) => {
121
+ const cleanup = cleanupOf(effect)
122
+ let found = null
123
+ walk(sourceCode, effect.body, (node) => {
124
+ if (found) return false
125
+ if (node === cleanup) return false
126
+ if (node.type === "AwaitExpression") found = node
127
+ else if (node.type === "CallExpression") {
128
+ const name = calleeName(node.callee)
129
+ if (name === "fetch" || name === "window.fetch" || (node.callee.type === "MemberExpression" && node.callee.property.type === "Identifier" && node.callee.property.name === "then")) {
130
+ found = node
131
+ }
132
+ }
133
+ return undefined
134
+ })
135
+ return found
136
+ }
137
+
138
+ /** No data loading inside `useEffect`; SWR on the client, a reader on the server. */
139
+ export const noDataFetchInEffect = {
140
+ meta: {
141
+ type: "problem",
142
+ docs: { description: "No `await`, `fetch` or `.then` inside a `useEffect`; read through SWR or a server reader." },
143
+ schema: [],
144
+ messages: {
145
+ fetch:
146
+ "This effect loads data (`await`, `fetch` or `.then`). An effect has no cache, no request dedupe, no error or loading state and no cancel, so two components asking for the same thing fetch twice and a slow first answer overwrites a fast second one. Client reads use SWR calling the app client (`hooks/`); reads a route needs go through a server reader (`modules/api/<domain>/read-*.ts`).",
147
+ },
148
+ },
149
+ create(context) {
150
+ if (!isGoverned(context.filename || context.getFilename())) return {}
151
+ const source = context.sourceCode ?? context.getSourceCode()
152
+ return {
153
+ CallExpression(node) {
154
+ if (!isEffectCall(node)) return
155
+ const effect = effectCallback(node)
156
+ if (!effect) return
157
+ const found = fetchesIn(source, effect)
158
+ if (found) context.report({ node: found, messageId: "fetch" })
159
+ },
160
+ }
161
+ },
162
+ }
163
+
164
+ // -- HYGIENE-3 -------------------------------------------------------------------------------------
165
+
166
+ /** A `catch` block with nothing in it, or a promise `.catch` handler with nothing in it. */
167
+ export const noEmptyCatch = {
168
+ meta: {
169
+ type: "problem",
170
+ docs: { description: "A `catch` that does nothing hides the failure; handle it or return a typed outcome." },
171
+ schema: [],
172
+ messages: {
173
+ empty:
174
+ "This `catch` does nothing. The failure disappears: no message for the reader, no trace for the team. Return a typed outcome that carries the cause, show the error state, or rethrow.",
175
+ promise:
176
+ "This `.catch` handler does nothing, so a rejected promise disappears. Turn it into a typed outcome the caller renders, or let it propagate.",
177
+ },
178
+ },
179
+ create(context) {
180
+ if (!isGoverned(context.filename || context.getFilename())) return {}
181
+ return {
182
+ CatchClause(node) {
183
+ if (node.body.body.length === 0) context.report({ node, messageId: "empty" })
184
+ },
185
+ CallExpression(node) {
186
+ const callee = node.callee
187
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || callee.property.name !== "catch") return
188
+ const handler = node.arguments[0]
189
+ if (!handler || !isFunction(handler)) return
190
+ const empty =
191
+ (handler.body.type === "BlockStatement" && handler.body.body.length === 0) ||
192
+ (handler.body.type === "Identifier" && handler.body.name === "undefined") ||
193
+ (handler.body.type === "Literal" && handler.body.value === null)
194
+ if (empty) context.report({ node: handler, messageId: "promise" })
195
+ },
196
+ }
197
+ },
198
+ }
199
+
200
+ // -- HYGIENE-4 -------------------------------------------------------------------------------------
201
+
202
+ /** No `console.*` in product source. */
203
+ export const noConsole = {
204
+ meta: {
205
+ type: "problem",
206
+ docs: { description: "No `console.*` call in product source." },
207
+ schema: [],
208
+ messages: {
209
+ console:
210
+ "`console.{{method}}` in product source. It reaches no log pipeline, leaks internals into a reader's devtools and hides the real handling. Return a typed outcome, render the error state, or let the error reach `error.tsx`.",
211
+ },
212
+ },
213
+ create(context) {
214
+ if (!isGoverned(context.filename || context.getFilename())) return {}
215
+ return {
216
+ CallExpression(node) {
217
+ const callee = node.callee
218
+ if (callee.type !== "MemberExpression" || callee.object.type !== "Identifier" || callee.object.name !== "console") return
219
+ const method = callee.property.type === "Identifier" ? callee.property.name : "*"
220
+ context.report({ node, messageId: "console", data: { method } })
221
+ },
222
+ }
223
+ },
224
+ }
225
+
226
+ /** The rules this law contributes to the plugin. */
227
+ export const rules = {
228
+ "timer-needs-effect-cleanup": timerNeedsEffectCleanup,
229
+ "no-data-fetch-in-effect": noDataFetchInEffect,
230
+ "no-empty-catch": noEmptyCatch,
231
+ "no-console": noConsole,
232
+ }
233
+
234
+ /** Every rule is an error. */
235
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))