@starci/eslint-canon-fe 9.15.0 → 9.16.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.
- package/CHANGELOG.md +8 -0
- package/dist/index.js +2 -0
- package/dist/lib/config.d.ts +1 -1
- package/dist/lib/config.js +3 -3
- package/dist/lib/why.d.ts +5 -0
- package/dist/lib/why.js +5 -0
- package/dist/renderer-purity.d.ts +11 -0
- package/dist/renderer-purity.js +238 -0
- package/docs/renderer-purity.md +17 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.16.0 - 2026-11-06
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
|
|
7
|
+
## 9.15.1 - 2026-11-05
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
3
11
|
## 9.15.0 - 2026-11-05
|
|
4
12
|
|
|
5
13
|
- Changed (decision release-only-what-changed): the canon reads the @starci/hfs runtime from the installed hfs at run time, which it peers by `~5.13.0`, and bundles no copy; `runtime/` keeps only the project graph rule, the jsdoc, sonar and next helpers and the package-scripts template, and the stale forks (slots 3.13.0) are gone.
|
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 },
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -88,7 +88,7 @@ export declare const buildPlainSourceConfig: ({ root, plugin, hooks, reactHooks,
|
|
|
88
88
|
readonly sonar: SonarPlugins;
|
|
89
89
|
readonly canon?: boolean;
|
|
90
90
|
}) => Linter.Config[];
|
|
91
|
-
/** The front-end
|
|
91
|
+
/** The front-end files of a root workspace package: its workbench (drawn with Grammar) and its public face, and every `.tsx` file of the package source wherever it sits (a `src/client/` renderer is drawn too: FE-PACKAGES-2); the React-free workbench contract stays back end. */
|
|
92
92
|
export declare const PACKAGE_SOURCE_FILES: readonly string[];
|
|
93
93
|
/** What a package folder never lints: installed packages, build output, generated wire types. */
|
|
94
94
|
export declare const PACKAGE_IGNORED: readonly string[];
|
package/dist/lib/config.js
CHANGED
|
@@ -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/`. */
|
|
@@ -148,8 +148,8 @@ export const buildPlainSourceConfig = ({ root, plugin, hooks, reactHooks, tsPlug
|
|
|
148
148
|
{ files: [...PLAIN_SOURCE_FILES], ignores: [...OUTSIDE_THE_SCAN_FILES], plugins: { sonarjs: sonar.sonarjs, unicorn: sonar.unicorn }, rules: sonarWayRules(sonar.sonarjs, "fe").rules },
|
|
149
149
|
];
|
|
150
150
|
};
|
|
151
|
-
/** The front-end
|
|
152
|
-
export const PACKAGE_SOURCE_FILES = Object.freeze(["packages/*/src/workbench/**/*.{ts,tsx}", "packages/*/src/public/**/*.{ts,tsx}"]);
|
|
151
|
+
/** The front-end files of a root workspace package: its workbench (drawn with Grammar) and its public face, and every `.tsx` file of the package source wherever it sits (a `src/client/` renderer is drawn too: FE-PACKAGES-2); the React-free workbench contract stays back end. */
|
|
152
|
+
export const PACKAGE_SOURCE_FILES = Object.freeze(["packages/*/src/workbench/**/*.{ts,tsx}", "packages/*/src/public/**/*.{ts,tsx}", "packages/*/src/**/*.tsx"]);
|
|
153
153
|
const PACKAGE_CONTRACT = Object.freeze(["packages/*/src/workbench/contract/**"]);
|
|
154
154
|
/** What a package folder never lints: installed packages, build output, generated wire types. */
|
|
155
155
|
export const PACKAGE_IGNORED = Object.freeze(["**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/__generated__/**"]);
|
package/dist/lib/why.d.ts
CHANGED
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"]));
|
|
@@ -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