@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.
Files changed (178) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -1
  3. package/dist/brand-values.d.ts +28 -0
  4. package/dist/brand-values.js +172 -0
  5. package/dist/button-intent.d.ts +11 -0
  6. package/dist/button-intent.js +361 -0
  7. package/dist/callback-shape.d.ts +7 -0
  8. package/dist/callback-shape.js +396 -0
  9. package/dist/class-names.d.ts +19 -0
  10. package/dist/class-names.js +170 -0
  11. package/dist/client-boundary.d.ts +42 -0
  12. package/dist/client-boundary.js +404 -0
  13. package/dist/comments.d.ts +47 -0
  14. package/dist/comments.js +156 -0
  15. package/dist/control-height.d.ts +7 -0
  16. package/dist/control-height.js +83 -0
  17. package/dist/drawing.d.ts +31 -0
  18. package/dist/drawing.js +376 -0
  19. package/dist/env-owner.d.ts +22 -0
  20. package/dist/env-owner.js +291 -0
  21. package/dist/fe-stack.d.ts +10 -0
  22. package/dist/fe-stack.js +73 -0
  23. package/dist/file-layout.d.ts +71 -0
  24. package/dist/file-layout.js +465 -0
  25. package/dist/formatting.d.ts +25 -0
  26. package/dist/formatting.js +104 -0
  27. package/dist/function-budget.d.ts +9 -0
  28. package/dist/function-budget.js +48 -0
  29. package/dist/grammar-shim.d.ts +5 -0
  30. package/dist/grammar-shim.js +126 -0
  31. package/dist/hooks-folder.d.ts +34 -0
  32. package/dist/hooks-folder.js +320 -0
  33. package/dist/hygiene.d.ts +39 -0
  34. package/dist/hygiene.js +540 -0
  35. package/dist/index.d.ts +105 -0
  36. package/dist/index.js +310 -0
  37. package/{landmark.mjs → dist/landmark.d.ts} +4 -6
  38. package/dist/landmark.js +6 -0
  39. package/dist/lib/ast.d.ts +36 -0
  40. package/dist/lib/ast.js +94 -0
  41. package/dist/lib/bindings.d.ts +84 -0
  42. package/dist/lib/bindings.js +168 -0
  43. package/dist/lib/class-text.d.ts +3 -0
  44. package/dist/lib/class-text.js +30 -0
  45. package/dist/lib/config.d.ts +81 -0
  46. package/dist/lib/config.js +141 -0
  47. package/dist/lib/drawing-scope.d.ts +5 -0
  48. package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
  49. package/dist/lib/eslint-boundary.d.ts +23 -0
  50. package/dist/lib/eslint-boundary.js +6 -0
  51. package/dist/lib/fe-stack.d.ts +49 -0
  52. package/dist/lib/fe-stack.js +98 -0
  53. package/dist/lib/hfs-view.d.ts +74 -0
  54. package/dist/lib/hfs-view.js +6 -0
  55. package/dist/lib/hfs.d.ts +34 -0
  56. package/dist/lib/hfs.js +72 -0
  57. package/dist/lib/next.d.ts +4 -0
  58. package/dist/lib/next.js +10 -0
  59. package/dist/lib/own-hook-origin.d.ts +17 -0
  60. package/dist/lib/own-hook-origin.js +52 -0
  61. package/dist/lib/package-root.d.ts +4 -0
  62. package/dist/lib/package-root.js +10 -0
  63. package/dist/lib/path.d.ts +2 -0
  64. package/{lib/path.mjs → dist/lib/path.js} +1 -1
  65. package/dist/lib/platform.d.ts +26 -0
  66. package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
  67. package/dist/lib/program-for.d.ts +5 -0
  68. package/dist/lib/program-for.js +23 -0
  69. package/dist/lib/pure-owner-helper.d.ts +10 -0
  70. package/dist/lib/pure-owner-helper.js +156 -0
  71. package/dist/lib/react.d.ts +16 -0
  72. package/dist/lib/react.js +22 -0
  73. package/dist/lib/redux-hook-origin.d.ts +4 -0
  74. package/dist/lib/redux-hook-origin.js +50 -0
  75. package/dist/lib/rule.d.ts +28 -0
  76. package/dist/lib/rule.js +5 -0
  77. package/dist/lib/scope.d.ts +88 -0
  78. package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
  79. package/dist/lib/typed-class-values.d.ts +13 -0
  80. package/dist/lib/typed-class-values.js +92 -0
  81. package/dist/lib/types.d.ts +44 -0
  82. package/dist/lib/types.js +64 -0
  83. package/dist/lib/why.d.ts +350 -0
  84. package/dist/lib/why.js +350 -0
  85. package/dist/lint-escape-hatch.d.ts +24 -0
  86. package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
  87. package/dist/lists.d.ts +31 -0
  88. package/dist/lists.js +202 -0
  89. package/dist/loading.d.ts +34 -0
  90. package/dist/loading.js +157 -0
  91. package/dist/naming.d.ts +33 -0
  92. package/dist/naming.js +176 -0
  93. package/dist/native-controls.d.ts +24 -0
  94. package/dist/native-controls.js +105 -0
  95. package/dist/next-conventions.d.ts +48 -0
  96. package/dist/next-conventions.js +452 -0
  97. package/dist/project-graph.d.ts +5 -0
  98. package/{project-graph.mjs → dist/project-graph.js} +8 -12
  99. package/dist/props-and-slots.d.ts +19 -0
  100. package/dist/props-and-slots.js +320 -0
  101. package/dist/served-locale.d.ts +38 -0
  102. package/dist/served-locale.js +263 -0
  103. package/dist/shape-slot.d.ts +18 -0
  104. package/dist/shape-slot.js +324 -0
  105. package/dist/size-and-state-budget.d.ts +46 -0
  106. package/dist/size-and-state-budget.js +424 -0
  107. package/dist/size-growth.d.ts +24 -0
  108. package/dist/size-growth.js +64 -0
  109. package/dist/sonar-parity.d.ts +5 -0
  110. package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
  111. package/dist/surface-rules.d.ts +18 -0
  112. package/dist/surface-rules.js +491 -0
  113. package/dist/swr-family.d.ts +15 -0
  114. package/dist/swr-family.js +419 -0
  115. package/dist/the-split.d.ts +27 -0
  116. package/dist/the-split.js +157 -0
  117. package/dist/tokens.d.ts +9 -0
  118. package/dist/tokens.js +35 -0
  119. package/dist/translation.d.ts +51 -0
  120. package/dist/translation.js +403 -0
  121. package/dist/transport.d.ts +73 -0
  122. package/dist/transport.js +885 -0
  123. package/dist/type-safety.d.ts +43 -0
  124. package/dist/type-safety.js +139 -0
  125. package/dist/ui-rulings.d.ts +11 -0
  126. package/dist/ui-rulings.js +121 -0
  127. package/package.json +31 -9
  128. package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
  129. package/runtime/scripts/lib/package-at.mjs +5 -5
  130. package/brand-values.mjs +0 -172
  131. package/button-intent.mjs +0 -320
  132. package/callback-shape.mjs +0 -332
  133. package/class-names.mjs +0 -149
  134. package/client-boundary.mjs +0 -400
  135. package/comments.mjs +0 -159
  136. package/control-height.mjs +0 -77
  137. package/drawing.mjs +0 -315
  138. package/env-owner.mjs +0 -244
  139. package/fe-stack.mjs +0 -86
  140. package/file-layout.mjs +0 -472
  141. package/formatting.mjs +0 -110
  142. package/function-budget.mjs +0 -49
  143. package/grammar-shim.mjs +0 -123
  144. package/hooks-folder.mjs +0 -300
  145. package/hygiene.mjs +0 -513
  146. package/index.mjs +0 -317
  147. package/lib/ast.mjs +0 -97
  148. package/lib/bindings.mjs +0 -159
  149. package/lib/class-text.mjs +0 -29
  150. package/lib/config.mjs +0 -142
  151. package/lib/fe-stack.mjs +0 -77
  152. package/lib/hfs.mjs +0 -49
  153. package/lib/next.mjs +0 -6
  154. package/lib/own-hook-origin.mjs +0 -53
  155. package/lib/program-for.mjs +0 -22
  156. package/lib/pure-owner-helper.mjs +0 -148
  157. package/lib/react.mjs +0 -28
  158. package/lib/redux-hook-origin.mjs +0 -44
  159. package/lib/typed-class-values.mjs +0 -81
  160. package/lib/types.mjs +0 -63
  161. package/lib/why.mjs +0 -352
  162. package/lists.mjs +0 -193
  163. package/loading.mjs +0 -156
  164. package/naming.mjs +0 -185
  165. package/native-controls.mjs +0 -108
  166. package/next-conventions.mjs +0 -447
  167. package/props-and-slots.mjs +0 -276
  168. package/served-locale.mjs +0 -243
  169. package/size-and-state-budget.mjs +0 -376
  170. package/size-growth.mjs +0 -62
  171. package/surface-rules.mjs +0 -443
  172. package/swr-family.mjs +0 -326
  173. package/the-split.mjs +0 -155
  174. package/tokens.mjs +0 -39
  175. package/translation.mjs +0 -392
  176. package/transport.mjs +0 -830
  177. package/type-safety.mjs +0 -147
  178. package/ui-rulings.mjs +0 -134
@@ -1,376 +0,0 @@
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
- * LIVE TIME. A recurring timer that reads the wall clock to refresh a time label is a second clock beside Grammar's shared
21
- * ticker; product code passes the target instant to `RelativeTime` instead.
22
- */
23
-
24
- import { walk } from "./lib/ast.mjs"
25
- import { functionOf, isFn, unwrap, variableOf } from "./lib/bindings.mjs"
26
- import { hfsOf } from "./lib/hfs.mjs"
27
- import { isPromiseValued } from "./lib/types.mjs"
28
- import { baseName, fileOf, isProductSource, roleOfFile, slotOfFile } from "./lib/scope.mjs"
29
-
30
- /**
31
- * The budget the slot of the linted file states (`budget` in knowledge/hfs/slots.yaml), or an empty one when no slot owns the file
32
- * or the slot states none. The numbers live in the manifest only: a rule that kept its own copy would drift from it.
33
- */
34
- const budgetOf = (context) => {
35
- const id = slotOfFile(context)
36
- return id === null ? {} : hfsOf(context).slot(id)?.budget ?? {}
37
- }
38
-
39
- /**
40
- * The line limit of the linted file: the budget keyed by the file name its role has in the slot (`component.tsx` for the drawing,
41
- * `index.tsx` for the entry), else the slot's `file` budget (hooks 200, modules 400), else null. A pure re-export `index.ts`
42
- * uses the slot's separate `barrel` budget when stated: every statement must be an `export ... from` declaration.
43
- */
44
- const lineBudgetOf = (context, node) => {
45
- const role = roleOfFile(context)
46
- const id = slotOfFile(context)
47
- const fileName = role === null || id === null ? undefined : hfsOf(context).slot(id)?.roles?.[role]
48
- const budget = budgetOf(context)
49
- const pureBarrel = baseName(fileOf(context)) === "index.ts" && node.body.length > 0 && node.body.every((statement) =>
50
- ["ExportNamedDeclaration", "ExportAllDeclaration"].includes(statement.type) && Boolean(statement.source))
51
- if (pureBarrel && typeof budget.barrel === "number") return budget.barrel
52
- const limit = fileName !== undefined && typeof budget[fileName] === "number" ? budget[fileName] : budget.file
53
- return typeof limit === "number" ? limit : null
54
- }
55
-
56
- /** A hook that reads remote data. */
57
- const DATA_HOOK = /^use\w*(?:Swr|SWR|Query|Mutation|Fetch|Infinite|Subscription)\w*$/
58
-
59
- /** A state hook. */
60
- const STATE_HOOK = /^(?:useState|useReducer)$/
61
-
62
- /** The name a call is made through: `useState` or `React.useState`. */
63
- const calleeName = (callee) => {
64
- if (callee.type === "Identifier") return callee.name
65
- if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.object.name === "React") {
66
- return callee.property.name
67
- }
68
- return null
69
- }
70
-
71
- /** The name a function is bound to, when it has one. */
72
- const functionName = (node) => {
73
- if (node.id) return node.id.name
74
- const parent = node.parent
75
- if (parent && parent.type === "VariableDeclarator" && parent.id.type === "Identifier") return parent.id.name
76
- return null
77
- }
78
-
79
- /** A component or hook, by the naming convention React itself relies on. */
80
- const isUnitName = (name) => Boolean(name) && (/^[A-Z]/.test(name) || /^use[A-Z0-9]/.test(name))
81
-
82
- // -- BUDGET-1 --------------------------------------------------------------------------------------
83
-
84
- /** A file is within the line budget its slot states for its role, pure re-export barrel (`barrel`) or every file (`file`). */
85
- export const componentLineBudget = {
86
- meta: {
87
- type: "suggestion",
88
- docs: { description: "A file stays within the line budget its slot states (`component.tsx`, `index.tsx`, `barrel`, `file`)." },
89
- schema: [],
90
- messages: {
91
- lines:
92
- "This 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.",
93
- },
94
- },
95
- create(context) {
96
- const source = context.sourceCode || context.getSourceCode()
97
- return {
98
- Program(node) {
99
- const max = lineBudgetOf(context, node)
100
- if (max === null) return
101
- // A trailing newline is not a line of code.
102
- const count = source.lines.length - (source.lines[source.lines.length - 1] === "" ? 1 : 0)
103
- if (count > max) context.report({ node, messageId: "lines", data: { count, max } })
104
- },
105
- }
106
- },
107
- }
108
-
109
- // -- BUDGET-2 --------------------------------------------------------------------------------------
110
-
111
- /** A connected unit holds at most the slot's `useState` budget of state hooks and reads at most its `dataHooks` budget of data hooks. */
112
- export const unitHookBudget = {
113
- meta: {
114
- type: "suggestion",
115
- docs: { description: "A unit stays within the state-hook and data-hook budget its slot states (`useState`, `dataHooks`)." },
116
- schema: [],
117
- messages: {
118
- state:
119
- "`{{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.",
120
- data:
121
- "`{{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.",
122
- },
123
- },
124
- create(context) {
125
- // A slot that states no `useState` / `dataHooks` budget puts no bound on that count: the rule refuses to invent one.
126
- const { useState: maxState, dataHooks: maxData } = budgetOf(context)
127
- if (typeof maxState !== "number" && typeof maxData !== "number") return {}
128
- /** One frame per function being walked; only a component or hook frame accumulates. */
129
- const frames = []
130
- const enter = (node) => frames.push({ node, name: functionName(node), state: 0, data: 0 })
131
- const exit = () => {
132
- const frame = frames.pop()
133
- if (!frame || !isUnitName(frame.name)) return
134
- const at = frame.node.id || frame.node
135
- if (typeof maxState === "number" && frame.state > maxState) {
136
- context.report({ node: at, messageId: "state", data: { name: frame.name, count: frame.state, max: maxState } })
137
- }
138
- if (typeof maxData === "number" && frame.data > maxData) {
139
- context.report({ node: at, messageId: "data", data: { name: frame.name, count: frame.data, max: maxData } })
140
- }
141
- }
142
- return {
143
- FunctionDeclaration: enter,
144
- FunctionExpression: enter,
145
- ArrowFunctionExpression: enter,
146
- "FunctionDeclaration:exit": exit,
147
- "FunctionExpression:exit": exit,
148
- "ArrowFunctionExpression:exit": exit,
149
- CallExpression(node) {
150
- const name = calleeName(node.callee)
151
- if (!name) return
152
- // Attribute the call to the nearest enclosing component or hook.
153
- const owner = [...frames].reverse().find((frame) => isUnitName(frame.name))
154
- if (!owner) return
155
- if (STATE_HOOK.test(name)) owner.state += 1
156
- else if (DATA_HOOK.test(name)) owner.data += 1
157
- },
158
- }
159
- },
160
- }
161
-
162
- // -- BUDGET-3 --------------------------------------------------------------------------------------
163
-
164
- /** `setInterval`, or `window.setInterval`. */
165
- const isIntervalCall = (node) => {
166
- const callee = node.callee
167
- if (callee.type === "Identifier") return callee.name === "setInterval"
168
- return (
169
- callee.type === "MemberExpression" &&
170
- !callee.computed &&
171
- callee.property.name === "setInterval" &&
172
- callee.object.type === "Identifier" &&
173
- ["window", "globalThis", "self"].includes(callee.object.name)
174
- )
175
- }
176
-
177
- /** The function node a timer runs: the inline callback, or the same-file function a name stands for. */
178
- const timerCallback = (context, node) => functionOf(context, node.arguments[0])
179
-
180
- /**
181
- * True when running `fn` reads data: a call in its body (outside nested functions) whose value is a promise - `fetch`, the
182
- * transport client, a reader, SWR's `mutate()`/revalidation, anything awaited - or an `await`. A tick that only reads the time or
183
- * sets state (a shared clock, a countdown, an animation step) is not a refresh mechanism.
184
- */
185
- const readsData = (context, fn, seen = new Set()) => {
186
- if (!fn || seen.has(fn)) return false
187
- seen.add(fn)
188
- let found = false
189
- const visit = (current) => {
190
- if (found || !current || typeof current.type !== "string") return
191
- if (current !== fn && isFn(current)) return
192
- if (current.type === "AwaitExpression") { found = true; return }
193
- if (current.type === "CallExpression") {
194
- if (isPromiseValued(context, current)) { found = true; return }
195
- const local = functionOf(context, current.callee)
196
- if (local && readsData(context, local, seen)) { found = true; return }
197
- }
198
- for (const [key, child] of Object.entries(current)) {
199
- if (key === "parent") continue
200
- if (Array.isArray(child)) child.forEach(visit)
201
- else if (child && typeof child.type === "string") visit(child)
202
- }
203
- }
204
- visit(fn.body)
205
- return found
206
- }
207
-
208
- /**
209
- * No hand-written polling loop in a component or hook.
210
- *
211
- * A timer is polling when what it runs READS DATA (see `readsData`, decided by the type of each call, not by a name): a
212
- * `setInterval`, or a `setTimeout` whose callback schedules the enclosing function again. A timer that only ticks time or state
213
- * is not a data-refresh mechanism. Grammar's live-time ownership is independently enforced by `no-app-relative-clock` below.
214
- */
215
- export const noHandRolledPolling = {
216
- meta: {
217
- type: "problem",
218
- docs: { description: "No `setInterval` and no self-scheduling `setTimeout` that reads data: one refresh mechanism per resource." },
219
- schema: [],
220
- messages: {
221
- interval:
222
- "A `setInterval` loop that reads data. 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.",
223
- timeout:
224
- "A `setTimeout` that schedules its own next run and reads data is a polling loop written by hand. Refresh through the data hook (`refreshInterval`) or a socket.",
225
- },
226
- },
227
- create(context) {
228
- const source = context.sourceCode || context.getSourceCode()
229
- return {
230
- CallExpression(node) {
231
- if (isIntervalCall(node)) {
232
- if (readsData(context, timerCallback(context, node))) context.report({ node, messageId: "interval" })
233
- return
234
- }
235
- const callee = node.callee
236
- if (callee.type !== "Identifier" || callee.name !== "setTimeout") return
237
- const callback = node.arguments[0]
238
- if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) return
239
- // The enclosing function: does the timer's own callback call it again (the same binding, not the same spelling)?
240
- const owner = source.getAncestors(node).reverse().find((ancestor) => isFn(ancestor))
241
- if (!owner) return
242
- let again = false
243
- const visit = (current) => {
244
- if (again || !current || typeof current.type !== "string") return
245
- if (current.type === "CallExpression" && functionOf(context, current.callee) === owner) { again = true; return }
246
- for (const [key, child] of Object.entries(current)) {
247
- if (key === "parent") continue
248
- if (Array.isArray(child)) child.forEach(visit)
249
- else if (child && typeof child.type === "string") visit(child)
250
- }
251
- }
252
- visit(callback.body)
253
- if (again && (readsData(context, callback) || readsData(context, owner))) context.report({ node, messageId: "timeout" })
254
- },
255
- }
256
- },
257
- }
258
-
259
- /** The static property name of an access or destructured property. */
260
- const propertyName = (node) => {
261
- const key = node.property ?? node.key
262
- if (!node.computed && key?.type === "Identifier") return key.name
263
- return key?.type === "Literal" && typeof key.value === "string" ? key.value : null
264
- }
265
-
266
- /** The platform member a local destructured binding selects. */
267
- const destructuredPath = (context, declarator, local, seen) => {
268
- if (declarator.id.type !== "ObjectPattern") return null
269
- const property = declarator.id.properties.find((entry) => entry.type === "Property" && entry.value.type === "Identifier" && entry.value.name === local)
270
- const name = property && propertyName(property)
271
- const object = platformPath(context, declarator.init, seen)
272
- return object && name ? [...object, name] : null
273
- }
274
-
275
- /** A platform path, following local aliases and destructuring while refusing shadowed globals. */
276
- const platformPath = (context, node, seen = new Set()) => {
277
- const inner = unwrap(node)
278
- if (!inner || seen.has(inner)) return null
279
- seen.add(inner)
280
- if (inner.type === "MemberExpression") {
281
- const object = platformPath(context, inner.object, seen)
282
- const name = propertyName(inner)
283
- return object && name ? [...object, name] : null
284
- }
285
- if (inner.type !== "Identifier") return null
286
- const variable = variableOf(context, inner)
287
- if (!variable || variable.defs.length === 0) return [inner.name]
288
- for (const definition of variable.defs) {
289
- if (definition.type !== "Variable" || !definition.node.init) continue
290
- const declarator = definition.node
291
- if (declarator.id.type === "Identifier") return platformPath(context, declarator.init, seen)
292
- return destructuredPath(context, declarator, inner.name, seen)
293
- }
294
- return null
295
- }
296
-
297
- /** A platform API spelling with the optional global-object prefix removed. */
298
- const platformName = (context, node) => {
299
- const parts = platformPath(context, node)
300
- if (!parts) return null
301
- if (["window", "globalThis", "self"].includes(parts[0])) parts.shift()
302
- return parts.join(".")
303
- }
304
-
305
- /** A wall-clock read: Date.now(), Date(), zero-argument new Date(), or performance.now(). */
306
- const readsClock = (context, root, seen = new Set()) => {
307
- if (!root || seen.has(root)) return false
308
- seen.add(root)
309
- let found = false
310
- walk(context.sourceCode, root, (node) => {
311
- if (found) return false
312
- if (node.type === "NewExpression" && node.arguments.length === 0 && platformName(context, node.callee) === "Date") found = true
313
- if (node.type !== "CallExpression") return undefined
314
- if (["Date", "Date.now", "performance.now"].includes(platformName(context, node.callee))) found = true
315
- const helper = functionOf(context, node.callee)
316
- if (helper && readsClock(context, helper.body, seen)) found = true
317
- return undefined
318
- })
319
- return found
320
- }
321
-
322
- /** Whether a timer callback runs its enclosing function again, directly or through a same-file helper. */
323
- const callsFunction = (context, root, target, seen = new Set()) => {
324
- if (!root || seen.has(root)) return false
325
- if (root === target) return true
326
- seen.add(root)
327
- let found = false
328
- walk(context.sourceCode, root.body ?? root, (node) => {
329
- if (found) return false
330
- if (node.type !== "CallExpression") return undefined
331
- const called = functionOf(context, node.callee)
332
- if (called === target || called && callsFunction(context, called, target, seen)) found = true
333
- return undefined
334
- })
335
- return found
336
- }
337
-
338
- /** Grammar owns the shared clock for live time labels; product components and hooks only pass instants. */
339
- export const noAppRelativeClock = {
340
- meta: {
341
- type: "problem",
342
- docs: { description: "Live relative time and countdowns use Grammar's shared clock rather than a product-owned ticker." },
343
- schema: [],
344
- messages: {
345
- clock: "This recurring timer reads the clock. Render RelativeTime with dateTime, locale and mode; digital countdowns support format=\"digital\", expiredLabel and a serializable template containing {time}. Grammar owns its single shared ticker and server-safe first render. Product components and hooks pass the target instant and never start another clock for time labels.",
346
- },
347
- },
348
- create(context) {
349
- if (!isProductSource(context)) return {}
350
- return {
351
- CallExpression(node) {
352
- const timer = platformName(context, node.callee)
353
- if (timer !== "setInterval" && timer !== "setTimeout") return
354
- const callback = functionOf(context, node.arguments[0])
355
- if (!callback) return
356
- let owner = node.parent
357
- while (owner && !isFn(owner)) owner = owner.parent
358
- const repeats = timer === "setInterval" || owner && callsFunction(context, callback, owner)
359
- if (repeats && (readsClock(context, callback.body) || timer === "setTimeout" && readsClock(context, owner.body))) {
360
- context.report({ node, messageId: "clock" })
361
- }
362
- },
363
- }
364
- },
365
- }
366
-
367
- /** The rules this law contributes to the plugin. */
368
- export const rules = {
369
- "component-line-budget": componentLineBudget,
370
- "unit-hook-budget": unitHookBudget,
371
- "no-hand-rolled-polling": noHandRolledPolling,
372
- "no-app-relative-clock": noAppRelativeClock,
373
- }
374
-
375
- /** Every rule is an error: the budget is the same for every app, and there is no baseline. */
376
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/size-growth.mjs DELETED
@@ -1,62 +0,0 @@
1
- /**
2
- * The rule that holds the file size budget (HFS R20 `HFS_SIZE_GROWTH`, the file-level half).
3
- *
4
- * A source file has a hard-growth budget of `fileLines.soft` lines while `fileLines.hardGrowth` holds
5
- * (`ruleParams.fe.fileLines` of the slot manifest, read through the slot view `hfsOf(context).ruleParams`). `file-size-growth` is the ratchet and
6
- * fails only on a file that must not be this long: a NEW file over the budget, or an existing file over the budget that
7
- * is longer than its recorded size. The recorded size is the line count at `merge-base HEAD <base>`
8
- * (STARCI_BASE_REF, origin/main, then main); only staged lint text in a commit hook uses HEAD. An unavailable baseline
9
- * is reported. A file may stay as large as it is; it may not grow, and it may not be born large. The rule
10
- * takes no option: the budget is the manifest's, the baseline is git's.
11
- *
12
- * There is no baseline file and no allowlist: the record is the repository's own history. A declaration file (`*.d.ts`) carries no behaviour and is not governed. Component,
13
- * hook and state budgets are stricter still where they apply (`size-and-state-budget`); this ratchet is the one that
14
- * needs the previous revision.
15
- */
16
- import { hfsOf } from "./lib/hfs.mjs"
17
- import { recordedLines } from "./runtime/scripts/api/git/recorded-lines.mjs"
18
- import { hardGrowthLines, lineCount } from "./runtime/scripts/lib/line-count.mjs"
19
-
20
- /** Whether a file is a source file this budget governs. */
21
- const governed = (filename) => /\.[cm]?tsx?$/.test(filename) && !/\.d\.[cm]?ts$/.test(filename)
22
-
23
- /** The ratchet: a file over the budget must not be new and must not grow. */
24
- export const fileSizeGrowth = {
25
- meta: {
26
- type: "problem",
27
- docs: { description: "A source file over the budget is neither new nor longer than its recorded size." },
28
- schema: [],
29
- messages: {
30
- born: "This new file has {{lines}} lines, over the budget of {{max}}. A new file starts within the budget: split it by responsibility.",
31
- grew: "This file has {{lines}} lines, over the budget of {{max}}, and it was {{recorded}} at the recorded revision. A file over the budget may not grow: move the new code into its own file.",
32
- unavailable: "The recorded size of this over-budget file is unavailable. Make STARCI_BASE_REF, origin/main or main and its merge-base history available before linting.",
33
- },
34
- },
35
- create(context) {
36
- const filename = context.filename || context.getFilename()
37
- if (!governed(filename)) return {}
38
- const max = hardGrowthLines(hfsOf(context).ruleParams.fileLines)
39
- return {
40
- "Program:exit"(node) {
41
- const source = (context.sourceCode || context.getSourceCode()).text
42
- const lines = lineCount(source)
43
- if (lines <= max) return
44
- const recorded = recordedLines(filename, { source })
45
- const loc = { line: 1, column: 0 }
46
- if (recorded === "unavailable") context.report({ node, loc, messageId: "unavailable" })
47
- else if (recorded === null) context.report({ node, loc, messageId: "born", data: { lines, max } })
48
- else if (lines > recorded) context.report({ node, loc, messageId: "grew", data: { lines, max, recorded } })
49
- },
50
- }
51
- },
52
- }
53
-
54
- /** The rules this law contributes to the plugin. */
55
- export const rules = {
56
- "file-size-growth": fileSizeGrowth,
57
- }
58
-
59
- /** The ratchet is an error. */
60
- export const recommended = {
61
- "starci-fe/file-size-growth": "error",
62
- }