@starci/eslint-canon-fe 4.0.0 → 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 (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +34 -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 +1 -2
  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 +290 -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 +8 -4
  43. package/served-locale.mjs +160 -160
  44. package/shape-slot.mjs +1 -2
  45. package/size-and-state-budget.mjs +196 -0
  46. package/spec-quality.mjs +239 -0
  47. package/the-split.mjs +133 -134
  48. package/tokens.mjs +267 -268
  49. package/translation.mjs +262 -122
  50. package/transport.mjs +404 -0
  51. package/type-safety.mjs +156 -74
  52. package/typography.mjs +82 -83
package/served-locale.mjs CHANGED
@@ -1,161 +1,161 @@
1
- /**
1
+ /**
2
2
  * Rules for the SERVED LOCALE law - `patterns/fe/served-locale/INDEX.md`.
3
- *
4
- * WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
5
- * connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
6
- * an English course on a `/vi/` URL. The chrome came from the dictionary and the content came from an
7
- * API that was never told which language to serve, so the failure looked like a translation gap and
8
- * was looked for in the dictionary, where there was nothing wrong. The header was missing, and no
9
- * gate had anything to say about a header nobody wrote.
10
- *
11
- * WHAT THEY HOLD. That the transport chain declares a locale, and that exactly one place writes it.
12
- * Both are file-at-a-time facts: the chain is assembled in one file, and a header is written where it
13
- * is written.
14
- *
15
- * WHAT THEY CANNOT HOLD, stated rather than implied. Neither rule can tell whether the value the link
16
- * computes is CORRECT - whether it reads the address, the cookie or a constant - because that is
17
- * inside a function these rules only see the name of. A link that attaches `x-locale: "en"`
18
- * unconditionally satisfies both and is wrong. That case is a review question and LOCALE-2 is where
19
- * it is argued; pretending otherwise would be the rule claiming a guarantee it does not have.
20
- *
21
- * WHY THE FIRST RULE KEYS ON THE TERMINAL LINK RATHER THAN ON THE FILE PATH. A repository may name
22
- * its client folder anything, and a path pattern that guessed wrong would either miss the real chain
23
- * or fire on every helper beside it. The chain is identifiable by what it does: it builds the link
24
- * that talks to the network.
25
- *
26
- * WITH ONE EXEMPTION THAT WAS FOUND BY RUNNING IT, NOT BY THINKING ABOUT IT. "Constructs the
27
- * terminal link" is also true of the file that DEFINES that link, so the first version reported
28
- * `links/http.ts` and its spec on a repository that had done everything right. There was no correct
29
- * way to satisfy it there - a locale link inside the http link is a chain hiding in a link - and a
30
- * rule with no correct way to satisfy it is a finding about the rule. A file under `links/` is one
31
- * link; a chain is assembled elsewhere, which is where the locale belongs.
32
- */
33
-
34
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
35
- const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
36
-
37
- /**
38
- * The link file that owns the locale header.
39
- *
40
- * This is the ONE file allowed to write it, and it is recognised by path rather than by content:
41
- * the point of the second rule is that the header appears in one place, so that place has to be
42
- * named somewhere, and a name is what the law and the rule can both refer to.
43
- */
44
- export const isLocaleLinkFile = (filename) => /\/links\/locale\.[cm]?tsx?$/.test(normalize(filename))
45
-
46
- /**
47
- * One link's own implementation, rather than a chain of them.
48
- *
49
- * WHY THIS EXEMPTION EXISTS, AND IT WAS MEASURED. The first version of the chain rule keyed only on
50
- * "constructs the terminal link", which is true of the file that DEFINES that link - `links/http.ts`
51
- * builds `new HttpLink(...)`, because that is its whole job. Run against real source it reported
52
- * that file and its spec, and there was no correct way to satisfy it there: attaching a locale link
53
- * inside the http link would be a chain hiding in a link.
54
- *
55
- * A file under `links/` is one link. A chain is assembled somewhere else, which is exactly where the
56
- * locale belongs.
57
- */
58
- export const isLinkImplementationFile = (filename) => /\/links\/[^/]+$/.test(normalize(filename))
59
-
60
- /** A spec or test file - it asserts about production shapes rather than being one. */
61
- export const isTestFile = (filename) => /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalize(filename))
62
-
63
- /** The header a request declares its language in. */
64
- export const LOCALE_HEADER = "x-locale"
65
-
66
- /** Callee names that construct the terminal link - the one that actually reaches the network. */
67
- const TERMINAL_LINK_CALLEES = new Set(["createHttpLink", "HttpLink", "createUploadLink", "BatchHttpLink"])
68
-
69
- /** Callee names that attach the reader's locale to an operation. */
70
- const LOCALE_LINK_CALLEES = new Set(["createAttachLocaleLink", "createLocaleLink"])
71
-
72
- /** The callee name of a call or `new` expression, when it has a plain one. */
73
- const calleeName = (node) => {
74
- const callee = node && node.callee
75
- if (!callee) return null
76
- if (callee.type === "Identifier") return callee.name
77
- if (callee.type === "MemberExpression" && callee.property && callee.property.type === "Identifier") {
78
- return callee.property.name
79
- }
80
- return null
81
- }
82
-
83
- /** Every string-ish key a property can carry, so `"x-locale"` and `["x-locale"]` read the same. */
84
- const propertyKeyOf = (property) => {
85
- if (!property || property.type !== "Property") return null
86
- const key = property.key
87
- if (!key) return null
88
- if (key.type === "Identifier" && !property.computed) return key.name
89
- if (key.type === "Literal" && typeof key.value === "string") return key.value
90
- return null
91
- }
92
-
93
- export const rules = {
94
- "api-client-attaches-the-locale": {
95
- meta: {
96
- type: "problem",
97
- docs: {
98
- description:
99
- "A transport chain that builds the terminal HTTP link must also attach the reader's locale, so every call declares the language it wants served.",
100
- },
101
- schema: [],
102
- messages: {
103
- missing:
104
- "This assembles the API transport but never attaches the reader's locale, so every call it makes is served the server's default language. Add the locale link to the chain, beside the auth link and unconditionally - a guest reads in a language too. A surface that is bilingual in its chrome and monolingual in its content reads as a translation gap, which is why it gets looked for in the dictionary rather than here.",
105
- },
106
- },
107
- create(context) {
108
- const filename = context.filename || context.getFilename()
109
- // A single link's implementation is not a chain - `links/http.ts` constructs the terminal link
110
- // because defining it IS its job, and there is no correct way to attach a locale there. A
111
- // spec asserts about a chain rather than being one.
112
- if (isLinkImplementationFile(filename) || isTestFile(filename)) return {}
113
- let terminalNode = null
114
- let attachesLocale = false
115
- const note = (node) => {
116
- const name = calleeName(node)
117
- if (name === null) return
118
- if (TERMINAL_LINK_CALLEES.has(name)) terminalNode = terminalNode || node
119
- if (LOCALE_LINK_CALLEES.has(name)) attachesLocale = true
120
- }
121
- return {
122
- CallExpression: note,
123
- NewExpression: note,
124
- "Program:exit"() {
125
- if (terminalNode && !attachesLocale) {
126
- context.report({ node: terminalNode, messageId: "missing" })
127
- }
128
- },
129
- }
130
- },
131
- },
132
-
133
- "locale-header-belongs-to-the-link": {
134
- meta: {
135
- type: "problem",
136
- docs: {
137
- description:
138
- "The locale header is written by the locale link and nowhere else, so one place answers which language a request is in.",
139
- },
140
- schema: [],
141
- messages: {
142
- elsewhere:
143
- "Only the locale link may write `x-locale`. Setting it here is a second answer to which language this request is in, and the two diverge the first time one of them is updated - usually leaving a single hook correct and the rest of the surface on the server's default. Let the transport attach it.",
144
- },
145
- },
146
- create(context) {
147
- const filename = context.filename || context.getFilename()
148
- if (isLocaleLinkFile(filename)) return {}
149
- return {
150
- Property(node) {
151
- if (propertyKeyOf(node) === LOCALE_HEADER) {
152
- context.report({ node, messageId: "elsewhere" })
153
- }
154
- },
155
- }
156
- },
157
- },
158
- }
159
-
160
- /** Every FE canon rule is an error; see `sources/fe/index.mjs` for why there is no warn tier. */
161
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
3
+ *
4
+ * WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
5
+ * connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
6
+ * an English course on a `/vi/` URL. The chrome came from the dictionary and the content came from an
7
+ * API that was never told which language to serve, so the failure looked like a translation gap and
8
+ * was looked for in the dictionary, where there was nothing wrong. The header was missing, and no
9
+ * gate had anything to say about a header nobody wrote.
10
+ *
11
+ * WHAT THEY HOLD. That the transport chain declares a locale, and that exactly one place writes it.
12
+ * Both are file-at-a-time facts: the chain is assembled in one file, and a header is written where it
13
+ * is written.
14
+ *
15
+ * WHAT THEY CANNOT HOLD, stated rather than implied. Neither rule can tell whether the value the link
16
+ * computes is CORRECT - whether it reads the address, the cookie or a constant - because that is
17
+ * inside a function these rules only see the name of. A link that attaches `x-locale: "en"`
18
+ * unconditionally satisfies both and is wrong. That case is a review question and LOCALE-2 is where
19
+ * it is argued; pretending otherwise would be the rule claiming a guarantee it does not have.
20
+ *
21
+ * WHY THE FIRST RULE KEYS ON THE TERMINAL LINK RATHER THAN ON THE FILE PATH. A repository may name
22
+ * its client folder anything, and a path pattern that guessed wrong would either miss the real chain
23
+ * or fire on every helper beside it. The chain is identifiable by what it does: it builds the link
24
+ * that talks to the network.
25
+ *
26
+ * WITH ONE EXEMPTION THAT WAS FOUND BY RUNNING IT, NOT BY THINKING ABOUT IT. "Constructs the
27
+ * terminal link" is also true of the file that DEFINES that link, so the first version reported
28
+ * `links/http.ts` and its spec on a repository that had done everything right. There was no correct
29
+ * way to satisfy it there - a locale link inside the http link is a chain hiding in a link - and a
30
+ * rule with no correct way to satisfy it is a finding about the rule. A file under `links/` is one
31
+ * link; a chain is assembled elsewhere, which is where the locale belongs.
32
+ */
33
+
34
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
35
+ const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
36
+
37
+ /**
38
+ * The link file that owns the locale header.
39
+ *
40
+ * This is the ONE file allowed to write it, and it is recognised by path rather than by content:
41
+ * the point of the second rule is that the header appears in one place, so that place has to be
42
+ * named somewhere, and a name is what the law and the rule can both refer to.
43
+ */
44
+ export const isLocaleLinkFile = (filename) => /\/links\/locale\.[cm]?tsx?$/.test(normalize(filename))
45
+
46
+ /**
47
+ * One link's own implementation, rather than a chain of them.
48
+ *
49
+ * WHY THIS EXEMPTION EXISTS, AND IT WAS MEASURED. The first version of the chain rule keyed only on
50
+ * "constructs the terminal link", which is true of the file that DEFINES that link - `links/http.ts`
51
+ * builds `new HttpLink(...)`, because that is its whole job. Run against real source it reported
52
+ * that file and its spec, and there was no correct way to satisfy it there: attaching a locale link
53
+ * inside the http link would be a chain hiding in a link.
54
+ *
55
+ * A file under `links/` is one link. A chain is assembled somewhere else, which is exactly where the
56
+ * locale belongs.
57
+ */
58
+ export const isLinkImplementationFile = (filename) => /\/links\/[^/]+$/.test(normalize(filename))
59
+
60
+ /** A spec or test file - it asserts about production shapes rather than being one. */
61
+ export const isTestFile = (filename) => /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalize(filename))
62
+
63
+ /** The header a request declares its language in. */
64
+ export const LOCALE_HEADER = "x-locale"
65
+
66
+ /** Callee names that construct the terminal link - the one that actually reaches the network. */
67
+ const TERMINAL_LINK_CALLEES = new Set(["createHttpLink", "HttpLink", "createUploadLink", "BatchHttpLink"])
68
+
69
+ /** Callee names that attach the reader's locale to an operation. */
70
+ const LOCALE_LINK_CALLEES = new Set(["createAttachLocaleLink", "createLocaleLink"])
71
+
72
+ /** The callee name of a call or `new` expression, when it has a plain one. */
73
+ const calleeName = (node) => {
74
+ const callee = node && node.callee
75
+ if (!callee) return null
76
+ if (callee.type === "Identifier") return callee.name
77
+ if (callee.type === "MemberExpression" && callee.property && callee.property.type === "Identifier") {
78
+ return callee.property.name
79
+ }
80
+ return null
81
+ }
82
+
83
+ /** Every string-ish key a property can carry, so `"x-locale"` and `["x-locale"]` read the same. */
84
+ const propertyKeyOf = (property) => {
85
+ if (!property || property.type !== "Property") return null
86
+ const key = property.key
87
+ if (!key) return null
88
+ if (key.type === "Identifier" && !property.computed) return key.name
89
+ if (key.type === "Literal" && typeof key.value === "string") return key.value
90
+ return null
91
+ }
92
+
93
+ export const rules = {
94
+ "api-client-attaches-the-locale": {
95
+ meta: {
96
+ type: "problem",
97
+ docs: {
98
+ description:
99
+ "A transport chain that builds the terminal HTTP link must also attach the reader's locale, so every call declares the language it wants served.",
100
+ },
101
+ schema: [],
102
+ messages: {
103
+ missing:
104
+ "This assembles the API transport but never attaches the reader's locale, so every call it makes is served the server's default language. Add the locale link to the chain, beside the auth link and unconditionally - a guest reads in a language too. A surface that is bilingual in its chrome and monolingual in its content reads as a translation gap, which is why it gets looked for in the dictionary rather than here.",
105
+ },
106
+ },
107
+ create(context) {
108
+ const filename = context.filename || context.getFilename()
109
+ // A single link's implementation is not a chain - `links/http.ts` constructs the terminal link
110
+ // because defining it IS its job, and there is no correct way to attach a locale there. A
111
+ // spec asserts about a chain rather than being one.
112
+ if (isLinkImplementationFile(filename) || isTestFile(filename)) return {}
113
+ let terminalNode = null
114
+ let attachesLocale = false
115
+ const note = (node) => {
116
+ const name = calleeName(node)
117
+ if (name === null) return
118
+ if (TERMINAL_LINK_CALLEES.has(name)) terminalNode = terminalNode || node
119
+ if (LOCALE_LINK_CALLEES.has(name)) attachesLocale = true
120
+ }
121
+ return {
122
+ CallExpression: note,
123
+ NewExpression: note,
124
+ "Program:exit"() {
125
+ if (terminalNode && !attachesLocale) {
126
+ context.report({ node: terminalNode, messageId: "missing" })
127
+ }
128
+ },
129
+ }
130
+ },
131
+ },
132
+
133
+ "locale-header-belongs-to-the-link": {
134
+ meta: {
135
+ type: "problem",
136
+ docs: {
137
+ description:
138
+ "The locale header is written by the locale link and nowhere else, so one place answers which language a request is in.",
139
+ },
140
+ schema: [],
141
+ messages: {
142
+ elsewhere:
143
+ "Only the locale link may write `x-locale`. Setting it here is a second answer to which language this request is in, and the two diverge the first time one of them is updated - usually leaving a single hook correct and the rest of the surface on the server's default. Let the transport attach it.",
144
+ },
145
+ },
146
+ create(context) {
147
+ const filename = context.filename || context.getFilename()
148
+ if (isLocaleLinkFile(filename)) return {}
149
+ return {
150
+ Property(node) {
151
+ if (propertyKeyOf(node) === LOCALE_HEADER) {
152
+ context.report({ node, messageId: "elsewhere" })
153
+ }
154
+ },
155
+ }
156
+ },
157
+ },
158
+ }
159
+
160
+ /** Every FE canon rule is an error; see `sources/fe/index.mjs` for why there is no warn tier. */
161
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/shape-slot.mjs CHANGED
@@ -19,8 +19,7 @@
19
19
  * never wrong, beyond it.
20
20
  */
21
21
 
22
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
23
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
22
+ import { normalizePath } from "./lib/path.mjs"
24
23
 
25
24
  /** The pure half of a split tier, and which tier it is. */
26
25
  const pureHalf = (filename) => {
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The rules that hold `size-and-state-budget.md` (HFS R65, `FE_SIZE_AND_STATE_BUDGET`).
3
+ *
4
+ * A BUDGET IS A SMELL DETECTOR, NOT A STYLE. A component past 300 lines, or one that holds more than
5
+ * six pieces of state or reads six data hooks, is doing the work of several units: one repository's
6
+ * status-flow component held 20 `useState`s and three data sources in one 900-line file, and no
7
+ * reviewer could say what it did. The numbers are the same for every app so that "too big" is a
8
+ * measurement, not an opinion.
9
+ *
10
+ * WHAT THE LINT HOLDS, AND WHAT IT LEAVES. Absolute limits per file and per component are file-local
11
+ * facts and are held here. "Size may not GROW against the parent commit" (`HFS_SIZE_GROWTH`) is a
12
+ * repository fact needing the previous revision, so it belongs to the architecture machine; the two
13
+ * are complementary, not duplicates - this catches the new giant, that catches the old one growing.
14
+ *
15
+ * POLLING. A hand-rolled `setInterval` or self-scheduling `setTimeout` is a second refresh mechanism
16
+ * beside the data hook's own, and two mechanisms per resource means double requests and a spinner
17
+ * that never settles. Each resource refreshes by exactly one mechanism (a SWR `refreshInterval`, or a
18
+ * socket), so the loop in a component is always the wrong one.
19
+ */
20
+
21
+ import { isSpecFile } from "./lib/scope.mjs"
22
+ import { normalizePath } from "./lib/path.mjs"
23
+
24
+ /** The largest a component file may be. */
25
+ export const MAX_COMPONENT_LINES = 300
26
+
27
+ /** The most `useState` calls one connected unit may hold. */
28
+ export const MAX_STATE_HOOKS = 6
29
+
30
+ /** The most data hooks one connected unit may read. */
31
+ export const MAX_DATA_HOOKS = 6
32
+
33
+ /** A hook that reads remote data. */
34
+ const DATA_HOOK = /^use\w*(?:Swr|SWR|Query|Mutation|Fetch|Infinite|Subscription)\w*$/
35
+
36
+ /** A state hook. */
37
+ const STATE_HOOK = /^(?:useState|useReducer)$/
38
+
39
+ /** The name a call is made through: `useState` or `React.useState`. */
40
+ const calleeName = (callee) => {
41
+ if (callee.type === "Identifier") return callee.name
42
+ if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.object.name === "React") {
43
+ return callee.property.name
44
+ }
45
+ return null
46
+ }
47
+
48
+ /** The name a function is bound to, when it has one. */
49
+ const functionName = (node) => {
50
+ if (node.id) return node.id.name
51
+ const parent = node.parent
52
+ if (parent && parent.type === "VariableDeclarator" && parent.id.type === "Identifier") return parent.id.name
53
+ return null
54
+ }
55
+
56
+ /** A component or hook, by the naming convention React itself relies on. */
57
+ const isUnitName = (name) => Boolean(name) && (/^[A-Z]/.test(name) || /^use[A-Z0-9]/.test(name))
58
+
59
+ // -- BUDGET-1 --------------------------------------------------------------------------------------
60
+
61
+ /** A component file is at most 300 lines. */
62
+ export const componentLineBudget = {
63
+ meta: {
64
+ type: "suggestion",
65
+ docs: { description: `A component file has at most ${MAX_COMPONENT_LINES} lines.` },
66
+ schema: [],
67
+ messages: {
68
+ lines:
69
+ "This component file has {{count}} lines; the budget is {{max}}. A file this size is several units in one: split the drawing from the data, and the sections from each other, so each part can be named, tested and reviewed on its own.",
70
+ },
71
+ },
72
+ create(context) {
73
+ const filename = normalizePath(context.filename || context.getFilename())
74
+ if (!filename.endsWith(".tsx") || isSpecFile(filename) || !filename.includes("/src/")) return {}
75
+ const source = context.sourceCode || context.getSourceCode()
76
+ return {
77
+ Program(node) {
78
+ // A trailing newline is not a line of code.
79
+ const count = source.lines.length - (source.lines[source.lines.length - 1] === "" ? 1 : 0)
80
+ if (count > MAX_COMPONENT_LINES) context.report({ node, messageId: "lines", data: { count, max: MAX_COMPONENT_LINES } })
81
+ },
82
+ }
83
+ },
84
+ }
85
+
86
+ // -- BUDGET-2 --------------------------------------------------------------------------------------
87
+
88
+ /** A connected unit holds at most six state hooks and reads at most six data hooks. */
89
+ export const unitHookBudget = {
90
+ meta: {
91
+ type: "suggestion",
92
+ docs: { description: `A unit has at most ${MAX_STATE_HOOKS} state hooks and ${MAX_DATA_HOOKS} data hooks.` },
93
+ schema: [],
94
+ messages: {
95
+ state:
96
+ "`{{name}}` holds {{count}} state hooks; the budget is {{max}}. That much local state is several concerns tangled together: group what changes together into a reducer or a hook of its own, and derive the rest instead of storing it.",
97
+ data:
98
+ "`{{name}}` reads {{count}} data hooks; the budget is {{max}}. A unit that gathers this many resources is a page pretending to be a block: give each slot its own connected block.",
99
+ },
100
+ },
101
+ create(context) {
102
+ if (isSpecFile(context.filename || context.getFilename())) return {}
103
+ /** One frame per function being walked; only a component or hook frame accumulates. */
104
+ const frames = []
105
+ const enter = (node) => frames.push({ node, name: functionName(node), state: 0, data: 0 })
106
+ const exit = () => {
107
+ const frame = frames.pop()
108
+ if (!frame || !isUnitName(frame.name)) return
109
+ const at = frame.node.id || frame.node
110
+ if (frame.state > MAX_STATE_HOOKS) {
111
+ context.report({ node: at, messageId: "state", data: { name: frame.name, count: frame.state, max: MAX_STATE_HOOKS } })
112
+ }
113
+ if (frame.data > MAX_DATA_HOOKS) {
114
+ context.report({ node: at, messageId: "data", data: { name: frame.name, count: frame.data, max: MAX_DATA_HOOKS } })
115
+ }
116
+ }
117
+ return {
118
+ FunctionDeclaration: enter,
119
+ FunctionExpression: enter,
120
+ ArrowFunctionExpression: enter,
121
+ "FunctionDeclaration:exit": exit,
122
+ "FunctionExpression:exit": exit,
123
+ "ArrowFunctionExpression:exit": exit,
124
+ CallExpression(node) {
125
+ const name = calleeName(node.callee)
126
+ if (!name) return
127
+ // Attribute the call to the nearest enclosing component or hook.
128
+ const owner = [...frames].reverse().find((frame) => isUnitName(frame.name))
129
+ if (!owner) return
130
+ if (STATE_HOOK.test(name)) owner.state += 1
131
+ else if (DATA_HOOK.test(name)) owner.data += 1
132
+ },
133
+ }
134
+ },
135
+ }
136
+
137
+ // -- BUDGET-3 --------------------------------------------------------------------------------------
138
+
139
+ /** `setInterval`, or `window.setInterval`. */
140
+ const isIntervalCall = (node) => {
141
+ const callee = node.callee
142
+ if (callee.type === "Identifier") return callee.name === "setInterval"
143
+ return (
144
+ callee.type === "MemberExpression" &&
145
+ !callee.computed &&
146
+ callee.property.name === "setInterval" &&
147
+ callee.object.type === "Identifier" &&
148
+ ["window", "globalThis", "self"].includes(callee.object.name)
149
+ )
150
+ }
151
+
152
+ /** No hand-written polling loop in a component or hook. */
153
+ export const noHandRolledPolling = {
154
+ meta: {
155
+ type: "problem",
156
+ docs: { description: "No `setInterval` and no self-scheduling `setTimeout`: one refresh mechanism per resource." },
157
+ schema: [],
158
+ messages: {
159
+ interval:
160
+ "A `setInterval` loop. This is a second refresh mechanism beside the data hook's own; two mechanisms per resource means double requests and a spinner that never settles. Refresh through the data hook (`refreshInterval`) or a socket, not a timer in the component.",
161
+ timeout:
162
+ "A `setTimeout` that schedules its own next run is a polling loop written by hand. Refresh through the data hook (`refreshInterval`) or a socket.",
163
+ },
164
+ },
165
+ create(context) {
166
+ if (isSpecFile(context.filename || context.getFilename())) return {}
167
+ const source = context.sourceCode || context.getSourceCode()
168
+ return {
169
+ CallExpression(node) {
170
+ if (isIntervalCall(node)) return context.report({ node, messageId: "interval" })
171
+ const callee = node.callee
172
+ if (callee.type !== "Identifier" || callee.name !== "setTimeout") return
173
+ const callback = node.arguments[0]
174
+ if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) return
175
+ // The enclosing named function: does the timer's own callback call it again?
176
+ const owner = source.getAncestors(node).reverse().find((ancestor) =>
177
+ ["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(ancestor.type) && functionName(ancestor),
178
+ )
179
+ const name = owner ? functionName(owner) : null
180
+ if (!name) return
181
+ const calls = new RegExp(`\\b${name}\\s*\\(`).test(source.getText(callback))
182
+ if (calls) context.report({ node, messageId: "timeout" })
183
+ },
184
+ }
185
+ },
186
+ }
187
+
188
+ /** The rules this law contributes to the plugin. */
189
+ export const rules = {
190
+ "component-line-budget": componentLineBudget,
191
+ "unit-hook-budget": unitHookBudget,
192
+ "no-hand-rolled-polling": noHandRolledPolling,
193
+ }
194
+
195
+ /** Every rule is an error: the budget is the same for every app, and there is no baseline. */
196
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))