@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
@@ -0,0 +1,424 @@
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
+ import { defineRule } from "./lib/rule.js";
24
+ import { isNode, walk } from "./lib/ast.js";
25
+ import { functionOf, isFn, unwrap, variableOf } from "./lib/bindings.js";
26
+ import { hfsOf } from "./lib/hfs.js";
27
+ import { isPromiseValued } from "./lib/types.js";
28
+ import { baseName, fileOf, isProductSource, roleOfFile, slotOfFile } from "./lib/scope.js";
29
+ /** True for an object whose fields can be read by name. */
30
+ const isRecord = (value) => typeof value === "object" && value !== null;
31
+ /** A field of the manifest's slot definition, as the manifest states it (`budget`, `roles`), or undefined for an unknown slot. */
32
+ const slotField = (context, id, field) => {
33
+ const slot = hfsOf(context).slot(id);
34
+ return slot === null ? undefined : Reflect.get(slot, field);
35
+ };
36
+ /**
37
+ * 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
38
+ * or the slot states none. The numbers live in the manifest only: a rule that kept its own copy would drift from it.
39
+ */
40
+ const budgetOf = (context) => {
41
+ const id = slotOfFile(context);
42
+ if (id === null)
43
+ return {};
44
+ const budget = slotField(context, id, "budget");
45
+ return isRecord(budget) ? budget : {};
46
+ };
47
+ /**
48
+ * 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,
49
+ * `index.tsx` for the entry), else the slot's `file` budget (hooks 200, modules 400), else null. A pure re-export `index.ts`
50
+ * uses the slot's separate `barrel` budget when stated: every statement must be an `export ... from` declaration.
51
+ */
52
+ const lineBudgetOf = (context, node) => {
53
+ const role = roleOfFile(context);
54
+ const id = slotOfFile(context);
55
+ const roles = role === null || id === null ? undefined : slotField(context, id, "roles");
56
+ const fileName = role === null || !isRecord(roles) ? undefined : roles[role];
57
+ const budget = budgetOf(context);
58
+ const pureBarrel = baseName(fileOf(context)) === "index.ts" && node.body.length > 0 && node.body.every((statement) => (statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") && Boolean(statement.source));
59
+ if (pureBarrel && typeof budget.barrel === "number")
60
+ return budget.barrel;
61
+ const limit = fileName !== undefined && typeof budget[String(fileName)] === "number" ? budget[String(fileName)] : budget.file;
62
+ return typeof limit === "number" ? limit : null;
63
+ };
64
+ /** A hook that reads remote data. */
65
+ const DATA_HOOK = /^use\w*(?:Swr|SWR|Query|Mutation|Fetch|Infinite|Subscription)\w*$/;
66
+ /** A state hook. */
67
+ const STATE_HOOK = /^(?:useState|useReducer)$/;
68
+ /** The name a call is made through: `useState` or `React.useState`. */
69
+ const calleeName = (callee) => {
70
+ if (callee.type === "Identifier")
71
+ return callee.name;
72
+ if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.object.name === "React") {
73
+ return callee.property.name;
74
+ }
75
+ return null;
76
+ };
77
+ /** The name a function is bound to, when it has one. */
78
+ const functionName = (node) => {
79
+ if (node.id)
80
+ return node.id.name;
81
+ const parent = node.parent;
82
+ if (parent && parent.type === "VariableDeclarator" && parent.id.type === "Identifier")
83
+ return parent.id.name;
84
+ return null;
85
+ };
86
+ /** A component or hook, by the naming convention React itself relies on. */
87
+ const isUnitName = (name) => name !== null && name !== "" && (/^[A-Z]/.test(name) || /^use[A-Z0-9]/.test(name));
88
+ // -- BUDGET-1 --------------------------------------------------------------------------------------
89
+ /** A file is within the line budget its slot states for its role, pure re-export barrel (`barrel`) or every file (`file`). */
90
+ export const componentLineBudget = defineRule({
91
+ meta: {
92
+ type: "suggestion",
93
+ docs: { description: "A file stays within the line budget its slot states (`component.tsx`, `index.tsx`, `barrel`, `file`)." },
94
+ schema: [],
95
+ messages: {
96
+ lines: "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.",
97
+ },
98
+ },
99
+ create(context) {
100
+ const source = context.sourceCode || context.getSourceCode();
101
+ return {
102
+ Program(node) {
103
+ const max = lineBudgetOf(context, node);
104
+ if (max === null)
105
+ return;
106
+ // A trailing newline is not a line of code.
107
+ const count = source.lines.length - (source.lines[source.lines.length - 1] === "" ? 1 : 0);
108
+ if (count > max)
109
+ context.report({ node, messageId: "lines", data: { count, max } });
110
+ },
111
+ };
112
+ },
113
+ });
114
+ // -- BUDGET-2 --------------------------------------------------------------------------------------
115
+ /** A connected unit holds at most the slot's `useState` budget of state hooks and reads at most its `dataHooks` budget of data hooks. */
116
+ export const unitHookBudget = defineRule({
117
+ meta: {
118
+ type: "suggestion",
119
+ docs: { description: "A unit stays within the state-hook and data-hook budget its slot states (`useState`, `dataHooks`)." },
120
+ schema: [],
121
+ messages: {
122
+ state: "`{{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.",
123
+ data: "`{{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.",
124
+ },
125
+ },
126
+ create(context) {
127
+ // A slot that states no `useState` / `dataHooks` budget puts no bound on that count: the rule refuses to invent one.
128
+ const { useState: maxState, dataHooks: maxData } = budgetOf(context);
129
+ if (typeof maxState !== "number" && typeof maxData !== "number")
130
+ return {};
131
+ /** One frame per function being walked; only a component or hook frame accumulates. */
132
+ const frames = [];
133
+ const enter = (node) => frames.push({ node, name: functionName(node), state: 0, data: 0 });
134
+ const exit = () => {
135
+ const frame = frames.pop();
136
+ if (!frame || !isUnitName(frame.name))
137
+ return;
138
+ const at = frame.node.id || frame.node;
139
+ if (typeof maxState === "number" && frame.state > maxState) {
140
+ context.report({ node: at, messageId: "state", data: { name: frame.name, count: frame.state, max: maxState } });
141
+ }
142
+ if (typeof maxData === "number" && frame.data > maxData) {
143
+ context.report({ node: at, messageId: "data", data: { name: frame.name, count: frame.data, max: maxData } });
144
+ }
145
+ };
146
+ return {
147
+ FunctionDeclaration: enter,
148
+ FunctionExpression: enter,
149
+ ArrowFunctionExpression: enter,
150
+ "FunctionDeclaration:exit": exit,
151
+ "FunctionExpression:exit": exit,
152
+ "ArrowFunctionExpression:exit": exit,
153
+ CallExpression(node) {
154
+ const name = calleeName(node.callee);
155
+ if (!name)
156
+ return;
157
+ // Attribute the call to the nearest enclosing component or hook.
158
+ const owner = [...frames].reverse().find((frame) => isUnitName(frame.name));
159
+ if (!owner)
160
+ return;
161
+ if (STATE_HOOK.test(name))
162
+ owner.state += 1;
163
+ else if (DATA_HOOK.test(name))
164
+ owner.data += 1;
165
+ },
166
+ };
167
+ },
168
+ });
169
+ // -- BUDGET-3 --------------------------------------------------------------------------------------
170
+ /** `setInterval`, or `window.setInterval`. */
171
+ const isIntervalCall = (node) => {
172
+ const callee = node.callee;
173
+ if (callee.type === "Identifier")
174
+ return callee.name === "setInterval";
175
+ return (callee.type === "MemberExpression" &&
176
+ !callee.computed &&
177
+ callee.property.name === "setInterval" &&
178
+ callee.object.type === "Identifier" &&
179
+ ["window", "globalThis", "self"].includes(callee.object.name));
180
+ };
181
+ /** The function node a timer runs: the inline callback, or the same-file function a name stands for. */
182
+ const timerCallback = (context, node) => functionOf(context, node.arguments[0]);
183
+ /**
184
+ * True when running `fn` reads data: a call in its body (outside nested functions) whose value is a promise - `fetch`, the
185
+ * transport client, a reader, SWR's `mutate()`/revalidation, anything awaited - or an `await`. A tick that only reads the time or
186
+ * sets state (a shared clock, a countdown, an animation step) is not a refresh mechanism.
187
+ */
188
+ const readsData = (context, fn, seen = new Set()) => {
189
+ if (!fn || seen.has(fn))
190
+ return false;
191
+ seen.add(fn);
192
+ let found = false;
193
+ const visit = (current) => {
194
+ if (found || !isNode(current))
195
+ return;
196
+ if (current !== fn && isFn(current))
197
+ return;
198
+ if (current.type === "AwaitExpression") {
199
+ found = true;
200
+ return;
201
+ }
202
+ if (current.type === "CallExpression") {
203
+ if (isPromiseValued(context, current)) {
204
+ found = true;
205
+ return;
206
+ }
207
+ const local = functionOf(context, current.callee);
208
+ if (local && readsData(context, local, seen)) {
209
+ found = true;
210
+ return;
211
+ }
212
+ }
213
+ const entries = Object.entries(current);
214
+ for (const [key, child] of entries) {
215
+ if (key === "parent")
216
+ continue;
217
+ if (Array.isArray(child))
218
+ child.forEach(visit);
219
+ else if (isNode(child))
220
+ visit(child);
221
+ }
222
+ };
223
+ visit(fn.body);
224
+ return found;
225
+ };
226
+ /**
227
+ * No hand-written polling loop in a component or hook.
228
+ *
229
+ * A timer is polling when what it runs READS DATA (see `readsData`, decided by the type of each call, not by a name): a
230
+ * `setInterval`, or a `setTimeout` whose callback schedules the enclosing function again. A timer that only ticks time or state
231
+ * is not a data-refresh mechanism. Grammar's live-time ownership is independently enforced by `no-app-relative-clock` below.
232
+ */
233
+ export const noHandRolledPolling = defineRule({
234
+ meta: {
235
+ type: "problem",
236
+ docs: { description: "No `setInterval` and no self-scheduling `setTimeout` that reads data: one refresh mechanism per resource." },
237
+ schema: [],
238
+ messages: {
239
+ interval: "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.",
240
+ timeout: "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.",
241
+ },
242
+ },
243
+ create(context) {
244
+ const source = context.sourceCode || context.getSourceCode();
245
+ return {
246
+ CallExpression(node) {
247
+ if (isIntervalCall(node)) {
248
+ if (readsData(context, timerCallback(context, node)))
249
+ context.report({ node, messageId: "interval" });
250
+ return;
251
+ }
252
+ const callee = node.callee;
253
+ if (callee.type !== "Identifier" || callee.name !== "setTimeout")
254
+ return;
255
+ const callback = node.arguments[0];
256
+ if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression"))
257
+ return;
258
+ // The enclosing function: does the timer's own callback call it again (the same binding, not the same spelling)?
259
+ const owner = source.getAncestors(node).reverse().find(isFn);
260
+ if (!owner)
261
+ return;
262
+ let again = false;
263
+ const visit = (current) => {
264
+ if (again || !isNode(current))
265
+ return;
266
+ if (current.type === "CallExpression" && functionOf(context, current.callee) === owner) {
267
+ again = true;
268
+ return;
269
+ }
270
+ const entries = Object.entries(current);
271
+ for (const [key, child] of entries) {
272
+ if (key === "parent")
273
+ continue;
274
+ if (Array.isArray(child))
275
+ child.forEach(visit);
276
+ else if (isNode(child))
277
+ visit(child);
278
+ }
279
+ };
280
+ visit(callback.body);
281
+ if (again && (readsData(context, callback) || readsData(context, owner)))
282
+ context.report({ node, messageId: "timeout" });
283
+ },
284
+ };
285
+ },
286
+ });
287
+ /** The static property name of an access or destructured property. */
288
+ const propertyName = (node) => {
289
+ const key = node.type === "MemberExpression" ? node.property : node.key;
290
+ if (!node.computed && key?.type === "Identifier")
291
+ return key.name;
292
+ return key?.type === "Literal" && typeof key.value === "string" ? key.value : null;
293
+ };
294
+ /** The platform member a local destructured binding selects. */
295
+ const destructuredPath = (context, declarator, local, seen) => {
296
+ if (declarator.id.type !== "ObjectPattern")
297
+ return null;
298
+ const property = declarator.id.properties.find((entry) => entry.type === "Property" && entry.value.type === "Identifier" && entry.value.name === local);
299
+ const name = property && propertyName(property);
300
+ const object = platformPath(context, declarator.init, seen);
301
+ return object && name ? [...object, name] : null;
302
+ };
303
+ /** A platform path, following local aliases and destructuring while refusing shadowed globals. */
304
+ const platformPath = (context, node, seen = new Set()) => {
305
+ const inner = unwrap(node);
306
+ if (!inner || seen.has(inner))
307
+ return null;
308
+ seen.add(inner);
309
+ if (inner.type === "MemberExpression") {
310
+ const object = platformPath(context, inner.object, seen);
311
+ const name = propertyName(inner);
312
+ return object && name ? [...object, name] : null;
313
+ }
314
+ if (inner.type !== "Identifier")
315
+ return null;
316
+ const variable = variableOf(context, inner);
317
+ if (!variable || variable.defs.length === 0)
318
+ return [inner.name];
319
+ for (const definition of variable.defs) {
320
+ if (definition.type !== "Variable" || !definition.node.init)
321
+ continue;
322
+ const declarator = definition.node;
323
+ if (declarator.id.type === "Identifier")
324
+ return platformPath(context, declarator.init, seen);
325
+ return destructuredPath(context, declarator, inner.name, seen);
326
+ }
327
+ return null;
328
+ };
329
+ /** A platform API spelling with the optional global-object prefix removed. */
330
+ const platformName = (context, node) => {
331
+ const parts = platformPath(context, node);
332
+ if (!parts)
333
+ return null;
334
+ const head = parts[0];
335
+ if (head !== undefined && ["window", "globalThis", "self"].includes(head))
336
+ parts.shift();
337
+ return parts.join(".");
338
+ };
339
+ /** A wall-clock read: Date.now(), Date(), zero-argument new Date(), or performance.now(). */
340
+ /** The calls that read the wall clock, by platform name. */
341
+ const CLOCK_CALLS = new Set(["Date", "Date.now", "performance.now"]);
342
+ const readsClock = (context, root, seen = new Set()) => {
343
+ if (!root || seen.has(root))
344
+ return false;
345
+ seen.add(root);
346
+ let found = false;
347
+ walk(context.sourceCode, root, (node) => {
348
+ if (found)
349
+ return false;
350
+ if (node.type === "NewExpression" && node.arguments.length === 0 && platformName(context, node.callee) === "Date")
351
+ found = true;
352
+ if (node.type !== "CallExpression")
353
+ return undefined;
354
+ if (CLOCK_CALLS.has(platformName(context, node.callee)))
355
+ found = true;
356
+ const helper = functionOf(context, node.callee);
357
+ if (helper && readsClock(context, helper.body, seen))
358
+ found = true;
359
+ return undefined;
360
+ });
361
+ return found;
362
+ };
363
+ /** Whether a timer callback runs its enclosing function again, directly or through a same-file helper. */
364
+ const callsFunction = (context, root, target, seen = new Set()) => {
365
+ if (!root || seen.has(root))
366
+ return false;
367
+ if (root === target)
368
+ return true;
369
+ seen.add(root);
370
+ let found = false;
371
+ walk(context.sourceCode, root.body ?? root, (node) => {
372
+ if (found)
373
+ return false;
374
+ if (node.type !== "CallExpression")
375
+ return undefined;
376
+ const called = functionOf(context, node.callee);
377
+ if (called === target || called && callsFunction(context, called, target, seen))
378
+ found = true;
379
+ return undefined;
380
+ });
381
+ return found;
382
+ };
383
+ /** Grammar owns the shared clock for live time labels; product components and hooks only pass instants. */
384
+ export const noAppRelativeClock = defineRule({
385
+ meta: {
386
+ type: "problem",
387
+ docs: { description: "Live relative time and countdowns use Grammar's shared clock rather than a product-owned ticker." },
388
+ schema: [],
389
+ messages: {
390
+ 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.",
391
+ },
392
+ },
393
+ create(context) {
394
+ if (!isProductSource(context))
395
+ return {};
396
+ return {
397
+ CallExpression(node) {
398
+ const timer = platformName(context, node.callee);
399
+ if (timer !== "setInterval" && timer !== "setTimeout")
400
+ return;
401
+ const callback = functionOf(context, node.arguments[0]);
402
+ if (!callback)
403
+ return;
404
+ let owner = node.parent;
405
+ while (owner && !isFn(owner))
406
+ owner = owner.parent;
407
+ const ownerFn = owner && isFn(owner) ? owner : undefined;
408
+ const repeats = timer === "setInterval" || ownerFn && callsFunction(context, callback, ownerFn);
409
+ if (repeats && (readsClock(context, callback.body) || timer === "setTimeout" && readsClock(context, ownerFn?.body))) {
410
+ context.report({ node, messageId: "clock" });
411
+ }
412
+ },
413
+ };
414
+ },
415
+ });
416
+ /** The rules this law contributes to the plugin. */
417
+ export const rules = {
418
+ "component-line-budget": componentLineBudget,
419
+ "unit-hook-budget": unitHookBudget,
420
+ "no-hand-rolled-polling": noHandRolledPolling,
421
+ "no-app-relative-clock": noAppRelativeClock,
422
+ };
423
+ /** Every rule is an error: the budget is the same for every app, and there is no baseline. */
424
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,24 @@
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 { type Recommended } from "./lib/rule.js";
17
+ /** The ratchet: a file over the budget must not be new and must not grow. */
18
+ export declare const fileSizeGrowth: import("./lib/rule.js").Rule<"unavailable" | "born" | "grew", []>;
19
+ /** The rules this law contributes to the plugin. */
20
+ export declare const rules: {
21
+ "file-size-growth": import("./lib/rule.js").Rule<"unavailable" | "born" | "grew", []>;
22
+ };
23
+ /** The ratchet is an error. */
24
+ export declare const recommended: Recommended;
@@ -0,0 +1,64 @@
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 { defineRule } from "./lib/rule.js";
17
+ import { hfsOf } from "./lib/hfs.js";
18
+ import { recordedLines } from "#runtime/scripts/api/git/recorded-lines.mjs";
19
+ import { hardGrowthLines, lineCount } from "#runtime/scripts/lib/line-count.mjs";
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
+ /** The ratchet: a file over the budget must not be new and must not grow. */
23
+ export const fileSizeGrowth = defineRule({
24
+ meta: {
25
+ type: "problem",
26
+ docs: { description: "A source file over the budget is neither new nor longer than its recorded size." },
27
+ schema: [],
28
+ messages: {
29
+ born: "This new file has {{lines}} lines, over the budget of {{max}}. A new file starts within the budget: split it by responsibility.",
30
+ 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.",
31
+ 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.",
32
+ },
33
+ },
34
+ create(context) {
35
+ const filename = context.filename || context.getFilename();
36
+ if (!governed(filename))
37
+ 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)
44
+ return;
45
+ const recorded = recordedLines(filename, { source });
46
+ const loc = { line: 1, column: 0 };
47
+ if (recorded === "unavailable")
48
+ context.report({ node, loc, messageId: "unavailable" });
49
+ else if (recorded === null)
50
+ context.report({ node, loc, messageId: "born", data: { lines, max } });
51
+ else if (lines > recorded)
52
+ context.report({ node, loc, messageId: "grew", data: { lines, max, recorded } });
53
+ },
54
+ };
55
+ },
56
+ });
57
+ /** The rules this law contributes to the plugin. */
58
+ export const rules = {
59
+ "file-size-growth": fileSizeGrowth,
60
+ };
61
+ /** The ratchet is an error. */
62
+ export const recommended = {
63
+ "starci-fe/file-size-growth": "error",
64
+ };
@@ -0,0 +1,5 @@
1
+ import type { Recommended, Rules } from "./lib/rule.js";
2
+ /** The rules this law contributes to the plugin. */
3
+ export declare const rules: Rules;
4
+ /** Starts at error: a repository's fix lane clears the debt before the rule ships. */
5
+ export declare const recommended: Recommended;
@@ -9,12 +9,16 @@
9
9
  * API), S6551 (an object turned into text) and S7503 (an `async` function that never awaits), are the typescript-eslint rules
10
10
  * `no-deprecated`, `no-base-to-string` and `require-await` the factory turns on.
11
11
  */
12
- import { SONAR_SYNTAX_RULES } from "./runtime/scripts/lib/sonar-syntax-rules.mjs"
13
-
14
- const NAMES = ["no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import", "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params"]
15
-
12
+ import { SONAR_SYNTAX_RULES } from "#runtime/scripts/lib/sonar-syntax-rules.mjs";
13
+ /** A rule of the shared Sonar syntax set; one this law names and the set lacks is a broken runtime copy. */
14
+ const syntaxRule = (name) => {
15
+ const rule = SONAR_SYNTAX_RULES[name];
16
+ if (rule === undefined)
17
+ throw new TypeError(`the runtime Sonar syntax rules publish no ${name}`);
18
+ return rule;
19
+ };
20
+ const NAMES = ["no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import", "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params"];
16
21
  /** The rules this law contributes to the plugin. */
17
- export const rules = Object.fromEntries(NAMES.map((name) => [name, SONAR_SYNTAX_RULES[name]]))
18
-
22
+ export const rules = Object.fromEntries(NAMES.map((name) => [name, syntaxRule(name)]));
19
23
  /** Starts at error: a repository's fix lane clears the debt before the rule ships. */
20
- export const recommended = Object.fromEntries(NAMES.map((name) => [`starci-fe/${name}`, "error"]))
24
+ export const recommended = Object.fromEntries(NAMES.map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,18 @@
1
+ import { type Recommended } from "./lib/rule.js";
2
+ /** ANATOMY-2: field variants belong to Grammar's context, including across app component boundaries. */
3
+ export declare const nestedControlVariant: import("./lib/rule.js").Rule<"variant" | "tabs", []>;
4
+ /** App classes carry layout; Grammar owns surface paint. */
5
+ export declare const noAppSurfacePaint: import("./lib/rule.js").Rule<"surface", []>;
6
+ /** PADDING-0: app padding wrappers cannot add a second horizontal inset to Grammar's list or section caption. */
7
+ export declare const noListPanelPadding: import("./lib/rule.js").Rule<"padding", []>;
8
+ /** BOUNDARY-1 case-5: only content bands carry inset; horizontal separators reach the surface edges. */
9
+ export declare const dividerFullBleed: import("./lib/rule.js").Rule<"padding", []>;
10
+ /** The surface laws ship at error in every composing source tree. */
11
+ export declare const rules: {
12
+ "divider-full-bleed": import("./lib/rule.js").Rule<"padding", []>;
13
+ "nested-control-variant": import("./lib/rule.js").Rule<"variant" | "tabs", []>;
14
+ "no-list-panel-padding": import("./lib/rule.js").Rule<"padding", []>;
15
+ "no-app-surface-paint": import("./lib/rule.js").Rule<"surface", []>;
16
+ };
17
+ /** No app can lower or omit a surface law. */
18
+ export declare const recommended: Recommended;