@starci/eslint-canon-fe 9.15.1 → 9.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.16.1 - 2026-11-07
4
+
5
+ - A patch: `starci-fe/control-height` no longer names `SegmentedControl` (removed from `@starci/grammar` 0.24.0); the controls it holds to the one 40px height are Button, IconButton, Switch, Input, SearchField, Select, Tabs, Textarea, NumberField and ComboBox. Its docs follow.
6
+
7
+ ## 9.16.0 - 2026-11-06
8
+
9
+ - Added: `starci-fe/renderer-presentation-only` (R384, `FE_RENDERER_LOGIC`, RENDERER-PURITY-1) at error: in a client renderer of a root workspace package (`packages/<pkg>/src/client/**/*.tsx`) data computation (arithmetic between two data values, `+=` accumulation), aggregation (`reduce`, a chain of `filter`/`sort`/`flatMap`, `Math.max/min(...spread)`), date math (`Date.now()`/`getTime()` arithmetic, Date setters, date-fns add/sub/diff/compare/round), a relational comparison of two data values and a mutation outside a declared action (a write to a prop or an outer value, an in-place array mutator on data, `fetch`, storage, a beacon) are refused; they belong in a measured `.ts` module the renderer calls. Mapping, display filters, ternaries that pick a view, comparisons and arithmetic with a literal and `handle.run(...)` stay allowed. A front-end package (`fe/packages/*`) is not judged by it.
10
+
3
11
  ## 9.15.1 - 2026-11-05
4
12
 
5
13
  - Fixed (FE-PACKAGES-2): the package config (`starciFePackagesConfig` and `scopeToPackages`, which the Grammar family's `...PackagesConfig` reads) covered only `packages/*/src/workbench/**` and `src/public/**`, and the back-end package config takes no `.tsx`, so a `.tsx` file anywhere else in a root package (a `src/client/` renderer of a block library) matched no config and was never linted. `PACKAGE_SOURCE_FILES` now also lists `packages/*/src/**/*.tsx`: every React file of a package source gets the front-end canon and the Grammar family rules, as the files of `fe/packages` do. The workbench contract stays back end; a plain `.ts` file outside the two subpaths stays the back-end canon's.
@@ -3,7 +3,7 @@ import { attributeValue, stringOf } from "./lib/ast.js";
3
3
  import { importOf } from "./lib/bindings.js";
4
4
  import { defineRule } from "./lib/rule.js";
5
5
  const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|offset-pop))?$/;
6
- const CONTROLS = new Set(["Button", "IconButton", "Switch", "Input", "SearchField", "Select", "Tabs", "SegmentedControl", "Textarea", "NumberField", "ComboBox"]);
6
+ const CONTROLS = new Set(["Button", "IconButton", "Switch", "Input", "SearchField", "Select", "Tabs", "Textarea", "NumberField", "ComboBox"]);
7
7
  /** A marketing hero call to action on a landing page is the one place the 48px step is allowed. */
8
8
  const LANDING_PATH = /[\\/](?:landing|hero|marketing)[\\/]|(?:Landing|Hero)[^\\/]*[\\/]/;
9
9
  /** The default size and its two spellings: the vendor `md` and Grammar's `control` are the same 40px height. */
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ import { recommended as landmarkRecommended, rules as landmarkRules } from "./la
29
29
  import { recommended as listsRecommended, rules as listsRules } from "./lists.js";
30
30
  import { recommended as loadingRecommended, rules as loadingRules } from "./loading.js";
31
31
  import { recommended as directiveFirstRecommended, rules as directiveFirstRules } from "./directive-first.js";
32
+ import { recommended as rendererPurityRecommended, rules as rendererPurityRules } from "./renderer-purity.js";
32
33
  import { recommended as explicitSurfaceRecommended, rules as explicitSurfaceRules } from "./explicit-surface.js";
33
34
  import { recommended as skeletonStatesRecommended, rules as skeletonStatesRules } from "./skeleton-states.js";
34
35
  import { recommended as lintEscapeRecommended, rules as lintEscapeRules } from "./lint-escape-hatch.js";
@@ -78,6 +79,7 @@ const CONTRIBUTIONS = [
78
79
  { law: "lists", rules: listsRules, recommended: listsRecommended },
79
80
  { law: "loading", rules: loadingRules, recommended: loadingRecommended },
80
81
  { law: "directive-first", rules: directiveFirstRules, recommended: directiveFirstRecommended },
82
+ { law: "renderer-purity", rules: rendererPurityRules, recommended: rendererPurityRecommended },
81
83
  { law: "explicit-surface", rules: explicitSurfaceRules, recommended: explicitSurfaceRecommended },
82
84
  { law: "skeleton-states", rules: skeletonStatesRules, recommended: skeletonStatesRecommended },
83
85
  { law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
@@ -105,7 +105,7 @@ export const SLOT_FREE_RULES = Object.freeze([
105
105
  "outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
106
106
  "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
107
107
  "no-list-panel-padding", "no-hand-built-panes", "no-table-in-card", "no-heading-above-card", "no-selectable-tag-group", "no-fill-command-in-header", "no-app-surface-paint", "divider-full-bleed", "control-height", "selection-fill-no-check", "no-grammar-shim",
108
- "no-swr-default-array", "no-foreign-slot-status", "no-validating-as-loading", "no-placeholder-object", "no-hand-skeleton", "explicit-export-surface", "directive-first",
108
+ "no-swr-default-array", "no-foreign-slot-status", "no-validating-as-loading", "no-placeholder-object", "no-hand-skeleton", "explicit-export-surface", "directive-first", "renderer-presentation-only",
109
109
  "button-intent-required", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
110
110
  ]);
111
111
  /** The source of a plain front-end tree: its `src/`. */
package/dist/lib/why.d.ts CHANGED
@@ -102,6 +102,11 @@ export declare const why: {
102
102
  en: string;
103
103
  fix: string;
104
104
  };
105
+ "renderer-presentation-only": {
106
+ code: string;
107
+ en: string;
108
+ fix: string;
109
+ };
105
110
  "directive-first": {
106
111
  code: string;
107
112
  en: string;
package/dist/lib/why.js CHANGED
@@ -102,6 +102,11 @@ export const why = {
102
102
  en: "`<file>` draws a skeleton by hand.",
103
103
  fix: "Pass isLoading to the Grammar component and let it draw its loading structure; the block hands it the same-shape placeholder (FE-ERROR-1).",
104
104
  },
105
+ "renderer-presentation-only": {
106
+ code: "FE_RENDERER_LOGIC",
107
+ en: "`<file>` is a client renderer of a root package that holds `<what>`.",
108
+ fix: "Move the data computation, aggregation, date math, domain comparison or mutation into a measured .ts module (rules/ or a hook beside it) that the renderer calls; the renderer maps, chooses what to show and formats, and runs only declared actions.",
109
+ },
105
110
  "directive-first": {
106
111
  code: "FE_DIRECTIVE_FIRST",
107
112
  en: "`<file>` has a `<what>` directive below another statement.",
@@ -0,0 +1,11 @@
1
+ import { type Recommended } from "./lib/rule.js";
2
+ /** True for a client renderer of a root workspace package; a front-end package (`fe/packages/*`) is judged by the front-end canon alone. */
3
+ export declare const isRootPackageRenderer: (filename: string) => boolean;
4
+ type Id = "arithmetic" | "accumulation" | "aggregation" | "chain" | "dateMath" | "dateFns" | "domainRule" | "mutation" | "network";
5
+ /** The one rule of the law. */
6
+ export declare const rules: {
7
+ "renderer-presentation-only": import("./lib/rule.js").Rule<Id, []>;
8
+ };
9
+ /** The rule reaches consumers at error. */
10
+ export declare const recommended: Recommended;
11
+ export {};
@@ -0,0 +1,238 @@
1
+ /**
2
+ * RENDERER-PURITY-1: a client renderer of a root workspace package (`packages/<pkg>/src/client/**\/*.tsx`) holds presentation only: mapping rows to
3
+ * components, choosing what to show, formatting. The business logic it would otherwise hold lives in a measured `.ts` module the renderer calls,
4
+ * where the unit run holds it at 100 per file (BE-TEST-13); the renderer itself is outside coverage (SHARED-TEST-1).
5
+ *
6
+ * Refused in such a file, each as `FE_RENDERER_LOGIC`:
7
+ * - data computation: arithmetic (`- * / % **`) between two non-literal values, `+=`/`-=` accumulation of a data value;
8
+ * - aggregation: `.reduce`/`.reduceRight`, two or more of `.filter`/`.sort`/`.toSorted`/`.flatMap` in one chain, `Math.max/min(...spread)`;
9
+ * - date math: arithmetic on `Date.now()`, `new Date()` or `.getTime()`, the `Date` setters, the date-fns functions that add, subtract, compare or round;
10
+ * - a domain rule: a relational comparison (`< <= > >=`) between two data values (a size or an index against a collection length is display);
11
+ * - a mutation outside a declared action: a write to a prop or an outer value, an in-place array mutator on data, `fetch`, storage, a beacon.
12
+ * Allowed: `.map`, one `.filter` (a display filter), ternaries and `&&` that pick a view, comparisons with a literal (`rows.length === 0`),
13
+ * arithmetic with a literal (`index + 1`, `ratio * 100`), a template, and a call of a declared action (`handle.run(...)`).
14
+ */
15
+ import { TSESLint } from "@typescript-eslint/utils";
16
+ import { defineRule } from "./lib/rule.js";
17
+ const RENDERER = /(?:^|\/)packages\/[^/]+\/src\/client\/.+\.tsx$/u;
18
+ const FRONT_END_PACKAGE = /(?:^|\/)fe\/packages\//u;
19
+ /** True for a client renderer of a root workspace package; a front-end package (`fe/packages/*`) is judged by the front-end canon alone. */
20
+ export const isRootPackageRenderer = (filename) => {
21
+ const path = filename.replaceAll("\\", "/");
22
+ return RENDERER.test(path) && !FRONT_END_PACKAGE.test(path);
23
+ };
24
+ const ARITHMETIC = new Set(["-", "*", "/", "%", "**"]);
25
+ const RELATIONAL = new Set(["<", "<=", ">", ">="]);
26
+ const COMPOUND = new Set(["+=", "-=", "*=", "/=", "%=", "**="]);
27
+ const CHAINED = new Set(["filter", "sort", "toSorted", "flatMap"]);
28
+ const IN_PLACE = new Set(["push", "pop", "shift", "unshift", "splice", "fill", "copyWithin", "reverse", "sort"]);
29
+ const STORES = new Set(["localStorage", "sessionStorage", "indexedDB", "caches"]);
30
+ const NETWORK = new Set(["fetch", "XMLHttpRequest", "WebSocket", "EventSource"]);
31
+ const DATE_SETTER = /^set(?:UTC)?(?:Date|FullYear|Month|Hours|Minutes|Seconds|Milliseconds|Time)$/u;
32
+ const DATE_FNS = /^(?:add|sub|differenceIn|diff|isBefore|isAfter|isSame|startOf|endOf|eachDay|intervalToDuration|closestTo|set[A-Z])/u;
33
+ const COUNT_NAMES = new Set(["index", "i", "idx", "position", "length", "count", "size"]);
34
+ const MESSAGES = {
35
+ arithmetic: "arithmetic on two data values ({{what}}) is data computation",
36
+ accumulation: "accumulating a data value with `{{what}}` is data computation",
37
+ aggregation: "`{{what}}` aggregates data",
38
+ chain: "a chain of {{what}} shapes data beyond a display filter",
39
+ dateMath: "{{what}} is date math",
40
+ dateFns: "`{{what}}` from date-fns is date math",
41
+ domainRule: "comparing two data values ({{what}}) is a domain rule, not a choice of view",
42
+ mutation: "{{what}} changes data outside a declared action",
43
+ network: "`{{what}}` reaches data outside a declared action",
44
+ };
45
+ const unwrap = (node) => {
46
+ if (node.type === "ChainExpression")
47
+ return unwrap(node.expression);
48
+ if (node.type === "TSNonNullExpression" || node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression")
49
+ return unwrap(node.expression);
50
+ return node;
51
+ };
52
+ const isLiteral = (node) => {
53
+ const inner = unwrap(node);
54
+ if (inner.type === "Literal")
55
+ return true;
56
+ if (inner.type === "TemplateLiteral")
57
+ return inner.expressions.length === 0;
58
+ return inner.type === "UnaryExpression" && isLiteral(inner.argument);
59
+ };
60
+ /** A value read from data: a member access or a call. A bare name may be a local the renderer derived, so it is not judged alone. */
61
+ const isDataRead = (node) => {
62
+ const inner = unwrap(node);
63
+ return inner.type === "MemberExpression" || inner.type === "CallExpression";
64
+ };
65
+ const callee = (node) => {
66
+ const target = unwrap(node.callee);
67
+ if (target.type === "Identifier")
68
+ return { object: null, name: target.name };
69
+ if (target.type !== "MemberExpression")
70
+ return { object: null, name: null };
71
+ const name = target.computed ? (target.property.type === "Literal" ? String(target.property.value) : null) : target.property.type === "Identifier" ? target.property.name : null;
72
+ return { object: unwrap(target.object), name };
73
+ };
74
+ const text = (context, node) => {
75
+ const source = context.sourceCode.getText(node).replaceAll(/\s+/gu, " ");
76
+ return source.length > 48 ? `${source.slice(0, 45)}...` : source;
77
+ };
78
+ /** True when the expression reads a size or an index (display), so a comparison with it chooses a view. */
79
+ const readsCount = (node) => {
80
+ const inner = unwrap(node);
81
+ if (inner.type === "Identifier")
82
+ return COUNT_NAMES.has(inner.name);
83
+ if (inner.type === "MemberExpression" && !inner.computed && inner.property.type === "Identifier")
84
+ return COUNT_NAMES.has(inner.property.name);
85
+ if (inner.type === "BinaryExpression")
86
+ return readsCount(inner.left) || readsCount(inner.right);
87
+ return false;
88
+ };
89
+ /** True for a value built in this renderer (a literal, a call's result, a `new`), which it may fill, as against a prop, an import or an alias of data. */
90
+ const isFresh = (context, node) => {
91
+ const inner = unwrap(node);
92
+ if (inner.type === "ArrayExpression" || inner.type === "ObjectExpression" || inner.type === "NewExpression" || inner.type === "CallExpression")
93
+ return true;
94
+ if (inner.type === "MemberExpression")
95
+ return false;
96
+ if (inner.type !== "Identifier")
97
+ return false;
98
+ let scope = context.sourceCode.getScope(inner);
99
+ while (scope !== null) {
100
+ const variable = scope.set.get(inner.name);
101
+ if (variable !== undefined) {
102
+ const definition = variable.defs[0];
103
+ if (definition === undefined || definition.type !== TSESLint.Scope.DefinitionType.Variable)
104
+ return false;
105
+ const init = definition.node.init;
106
+ return init !== null && init !== undefined && init.type !== "Identifier" && isFresh(context, init);
107
+ }
108
+ scope = scope.upper;
109
+ }
110
+ return false;
111
+ };
112
+ const rootOf = (node) => {
113
+ const inner = unwrap(node);
114
+ return inner.type === "MemberExpression" ? rootOf(inner.object) : inner;
115
+ };
116
+ const dateOperand = (node) => {
117
+ const inner = unwrap(node);
118
+ if (inner.type === "NewExpression")
119
+ return inner.callee.type === "Identifier" && inner.callee.name === "Date";
120
+ if (inner.type !== "CallExpression")
121
+ return false;
122
+ const { object, name } = callee(inner);
123
+ return name === "getTime" || (name === "now" && object?.type === "Identifier" && object.name === "Date");
124
+ };
125
+ /** True inside the comparator of `.sort`/`.toSorted`: ordering rows by a field is display, whatever the subtraction inside it. */
126
+ const inComparator = (context, node) => context.sourceCode.getAncestors(node).some((ancestor) => {
127
+ if (ancestor.type !== "CallExpression")
128
+ return false;
129
+ const { name } = callee(ancestor);
130
+ return (name === "sort" || name === "toSorted") && ancestor.arguments.some((argument) => argument.range[0] <= node.range[0] && node.range[1] <= argument.range[1]);
131
+ });
132
+ const chainLength = (node) => {
133
+ const links = [];
134
+ let current = node;
135
+ while (current.type === "CallExpression") {
136
+ const { object, name } = callee(current);
137
+ if (name !== null && CHAINED.has(name))
138
+ links.push(current);
139
+ if (object === null)
140
+ break;
141
+ current = object;
142
+ }
143
+ return links;
144
+ };
145
+ const rendererPurity = defineRule({
146
+ meta: {
147
+ type: "problem",
148
+ docs: { description: "RENDERER-PURITY-1: a client renderer of a root package holds presentation only; data computation, aggregation, date math, domain rules and mutations live in measured .ts." },
149
+ schema: [],
150
+ messages: Object.fromEntries(Object.entries(MESSAGES).map(([id, message]) => [id, `FE_RENDERER_LOGIC: ${message}; move it into a measured .ts module the renderer calls (BE-TEST-13, FE-PACKAGES-2) and let the renderer map, choose and format.`])),
151
+ },
152
+ create(context) {
153
+ if (!isRootPackageRenderer(context.filename))
154
+ return {};
155
+ const seen = new Set();
156
+ const report = (node, messageId, what) => {
157
+ if (seen.has(node))
158
+ return;
159
+ seen.add(node);
160
+ context.report({ node, messageId, data: { what } });
161
+ };
162
+ const checkCall = (node) => {
163
+ const { object, name } = callee(node);
164
+ if (name === null)
165
+ return;
166
+ if (object === null && NETWORK.has(name))
167
+ report(node, "network", name);
168
+ if (name === "reduce" || name === "reduceRight")
169
+ report(node, "aggregation", `.${name}(...)`);
170
+ if ((name === "max" || name === "min") && object?.type === "Identifier" && object.name === "Math" && node.arguments.some((argument) => argument.type === "SpreadElement"))
171
+ report(node, "aggregation", `Math.${name}(...)`);
172
+ if (object !== null && DATE_SETTER.test(name))
173
+ report(node, "dateMath", `.${name}(...)`);
174
+ if (object?.type === "Identifier" && STORES.has(object.name) && name !== "getItem")
175
+ report(node, "network", `${object.name}.${name}`);
176
+ if (object?.type === "Identifier" && object.name === "navigator" && name === "sendBeacon")
177
+ report(node, "network", "navigator.sendBeacon");
178
+ if (object !== null && IN_PLACE.has(name) && !isFresh(context, object))
179
+ report(node, "mutation", `\`.${name}(...)\` on ${text(context, object)}`);
180
+ const links = chainLength(node);
181
+ if (links.length >= 2 && links[0] === node) {
182
+ for (const link of links)
183
+ seen.add(link);
184
+ context.report({ node, messageId: "chain", data: { what: links.map((link) => `.${callee(link).name ?? ""}`).reverse().join("") } });
185
+ }
186
+ };
187
+ const checkWrite = (target, node) => {
188
+ const inner = unwrap(target);
189
+ if (inner.type !== "MemberExpression")
190
+ return;
191
+ if (!isFresh(context, rootOf(inner)))
192
+ report(node, "mutation", `writing ${text(context, inner)}`);
193
+ };
194
+ return {
195
+ CallExpression: checkCall,
196
+ BinaryExpression(node) {
197
+ if (ARITHMETIC.has(node.operator) || node.operator === "+") {
198
+ if ((node.operator === "-" || node.operator === "+") && (dateOperand(node.left) || dateOperand(node.right)))
199
+ report(node, "dateMath", text(context, node));
200
+ else if (ARITHMETIC.has(node.operator) && !isLiteral(node.left) && !isLiteral(node.right) && (isDataRead(node.left) || isDataRead(node.right)) && !inComparator(context, node))
201
+ report(node, "arithmetic", text(context, node));
202
+ return;
203
+ }
204
+ if (!RELATIONAL.has(node.operator) || isLiteral(node.left) || isLiteral(node.right))
205
+ return;
206
+ if (!isDataRead(node.left) && !isDataRead(node.right))
207
+ return;
208
+ if (readsCount(node.left) || readsCount(node.right))
209
+ return;
210
+ report(node, "domainRule", text(context, node));
211
+ },
212
+ AssignmentExpression(node) {
213
+ if (COMPOUND.has(node.operator) && !isLiteral(node.right) && isDataRead(node.right))
214
+ report(node, "accumulation", node.operator);
215
+ checkWrite(node.left, node);
216
+ },
217
+ UpdateExpression(node) {
218
+ checkWrite(node.argument, node);
219
+ },
220
+ UnaryExpression(node) {
221
+ if (node.operator === "delete")
222
+ checkWrite(node.argument, node);
223
+ },
224
+ ImportDeclaration(node) {
225
+ if (!/^date-fns(?:\/|$)/u.test(node.source.value))
226
+ return;
227
+ for (const specifier of node.specifiers) {
228
+ if (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && DATE_FNS.test(specifier.imported.name))
229
+ report(specifier, "dateFns", specifier.imported.name);
230
+ }
231
+ },
232
+ };
233
+ },
234
+ });
235
+ /** The one rule of the law. */
236
+ export const rules = { "renderer-presentation-only": rendererPurity };
237
+ /** The rule reaches consumers at error. */
238
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -1,6 +1,6 @@
1
1
  # Control height (CONTROL-HEIGHT-1)
2
2
 
3
- Owner ruling `control-height-40` (2026-10-26). One control height, 40px (HeroUI's own `h-10`), is the default of every standalone Grammar control: Button, IconButton, Input, SearchField, Select, Tabs, SegmentedControl, Switch, the chrome Avatar, Textarea and the composer send button. Grammar draws it; an app never states it.
3
+ Owner ruling `control-height-40` (2026-10-26). One control height, 40px (HeroUI's own `h-10`), is the default of every standalone Grammar control: Button, IconButton, Input, SearchField, Select, Tabs, Switch, the chrome Avatar, Textarea and the composer send button. Grammar draws it; an app never states it.
4
4
 
5
5
  | Rule | Finding code | Required behavior |
6
6
  | --- | --- | --- |
@@ -0,0 +1,17 @@
1
+ # Renderer purity (RENDERER-PURITY-1, R384)
2
+
3
+ A client renderer of a root workspace package (`packages/<pkg>/src/client/**/*.tsx`) holds presentation only: it maps rows to components, chooses what to show and formats. The unit run measures `.ts` only (BE-TEST-13) and the front end has no tests (SHARED-TEST-1), so logic written inside a renderer has no spec and no coverage bar. The logic lives in a measured `.ts` module (`rules/`, a hook beside the renderer) that the renderer calls.
4
+
5
+ | Rule | Finding code | Fix |
6
+ |---|---|---|
7
+ | `starci-fe/renderer-presentation-only` | `FE_RENDERER_LOGIC` | Move the computation, aggregation, date math, domain comparison or mutation into a measured `.ts` module and call it; run an action through its declared `ActionHandle`. |
8
+
9
+ Refused in such a file:
10
+
11
+ - data computation: `-`, `*`, `/`, `%`, `**` between two non-literal values (one side a member access or a call), `+=`/`-=` accumulation of a data value;
12
+ - aggregation: `.reduce`/`.reduceRight`, two or more of `.filter`/`.sort`/`.toSorted`/`.flatMap` in one chain, `Math.max/min(...spread)`;
13
+ - date math: arithmetic on `Date.now()`, `new Date()` or `.getTime()`, the `Date` setters, the date-fns functions `add*`, `sub*`, `differenceIn*`, `diff*`, `isBefore`, `isAfter`, `isSame*`, `startOf*`, `endOf*`, `set*`;
14
+ - a domain rule: `<`, `<=`, `>`, `>=` between two data values; a size or an index against a collection length is display and stays allowed;
15
+ - a mutation outside a declared action: a write to a prop, a parameter or an imported value, an in-place array mutator (`push`, `splice`, `sort`, ...) on data, `fetch`, `localStorage`/`sessionStorage` writes, `navigator.sendBeacon`.
16
+
17
+ Allowed: `.map`, one `.filter` (a display filter), a sort comparator, ternaries and `&&` that pick a view, comparisons and arithmetic with a literal (`rows.length === 0`, `index + 1`, `ratio * 100`), templates, values the renderer builds itself (a local array it fills, `useRef().current`), and `handle.run(...)` of a declared action. A front-end package (`fe/packages/*`) is judged by the front-end canon alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.15.1",
3
+ "version": "9.16.1",
4
4
  "description": "Universal front-end lint laws for TypeScript and Next.js, independent of the selected Grammar family.",
5
5
  "type": "module",
6
6
  "license": "MIT",