@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,19 @@
1
+ /** Rules for named React props, ordinary children, and local visual ownership. */
2
+ import { type Rules, type Recommended } from "./lib/rule.js";
3
+ /** Reject anonymous object types on named component parameters. */
4
+ export declare const noInlineParameterType: import("./lib/rule.js").Rule<"inline", []>;
5
+ /** Require exported React components to take no parameter, or one named props parameter. */
6
+ export declare const publicComponentSignature: import("./lib/rule.js").Rule<"type" | "parameter", []>;
7
+ /** Prevent callers from styling internal component parts through named CSS props. */
8
+ export declare const noPerPartClassNameProp: import("./lib/rule.js").Rule<"perPart", []>;
9
+ /** Keep component appearance local instead of exposing className placement doors. */
10
+ export declare const noPublicClassNameProp: import("./lib/rule.js").Rule<"declaration" | "usage", []>;
11
+ /** Keep layout APIs semantic rather than exposing raw CSS decisions above leaves. */
12
+ export declare const noPublicFrameCssProps: import("./lib/rule.js").Rule<"css", []>;
13
+ /** Prevent utility types from reintroducing public styling doors. */
14
+ export declare const noCssDoorTypeLaundering: import("./lib/rule.js").Rule<"utility", []>;
15
+ /** Props are readonly all the way down: every field, index signature, nested object and collection (FE-TYPING-2). */
16
+ export declare const propsFieldsReadonly: import("./lib/rule.js").Rule<"index" | "field" | "collection", []>;
17
+ export declare const rules: Rules;
18
+ /** All rules in this law are mechanical errors once adopted. */
19
+ export declare const recommended: Recommended;
@@ -0,0 +1,320 @@
1
+ /** Rules for named React props, ordinary children, and local visual ownership. */
2
+ import { defineRule } from "./lib/rule.js";
3
+ import { declarationsOf, fileOf as declarationFile } from "./lib/types.js";
4
+ import { isComponentFile, isComponentPath, isProductSource, kindOfFile } from "./lib/scope.js";
5
+ /** The key of a node that names a member (`key`), or the property of a member access. */
6
+ const memberKey = (node) => {
7
+ if (!node)
8
+ return undefined;
9
+ if ("key" in node && node.key)
10
+ return node.key;
11
+ return "property" in node ? node.property : undefined;
12
+ };
13
+ const propertyName = (node) => {
14
+ const key = memberKey(node);
15
+ if (!key)
16
+ return null;
17
+ if (key.type === "Identifier")
18
+ return key.name;
19
+ if (key.type === "Literal" && typeof key.value === "string")
20
+ return key.value;
21
+ return null;
22
+ };
23
+ /** Reject anonymous object types on named component parameters. */
24
+ export const noInlineParameterType = defineRule({
25
+ meta: {
26
+ type: "suggestion",
27
+ docs: { description: "A parameter uses a named props type, not an inline object shape." },
28
+ schema: [],
29
+ messages: { inline: "Name this parameter type in the module so the component API can be reused." },
30
+ },
31
+ create(context) {
32
+ const check = (node) => {
33
+ for (const param of node.params || []) {
34
+ const annotation = "typeAnnotation" in param ? param.typeAnnotation : undefined;
35
+ const type = annotation?.typeAnnotation;
36
+ if (type?.type === "TSTypeLiteral" || type?.type === "TSIntersectionType" && type.types.some((item) => item.type === "TSTypeLiteral")) {
37
+ context.report({ node: annotation || param, messageId: "inline" });
38
+ }
39
+ }
40
+ };
41
+ return { ArrowFunctionExpression: check, FunctionExpression: check, FunctionDeclaration: check };
42
+ },
43
+ });
44
+ const isExported = (node) => node.parent?.type === "ExportNamedDeclaration" || node.parent?.parent?.type === "ExportNamedDeclaration";
45
+ const containsJsx = (node, seen = new Set()) => {
46
+ if (!node || typeof node !== "object" || seen.has(node))
47
+ return false;
48
+ seen.add(node);
49
+ if ("type" in node && (node.type === "JSXElement" || node.type === "JSXFragment"))
50
+ return true;
51
+ return Object.entries(node).some(([key, value]) => key !== "parent" && (Array.isArray(value) ? value.some((item) => containsJsx(item, seen)) : containsJsx(value, seen)));
52
+ };
53
+ const componentNameOf = (node) => {
54
+ if (node.type !== "VariableDeclarator" || node.id?.type !== "Identifier" || !/^[A-Z]/.test(node.id.name) || !isExported(node))
55
+ return null;
56
+ return node.id.name;
57
+ };
58
+ /** Require exported React components to take no parameter, or one named props parameter. */
59
+ export const publicComponentSignature = defineRule({
60
+ meta: {
61
+ type: "problem",
62
+ docs: { description: "Exported React components take no parameter, or one parameter named props with a matching XProps type (XBase may also take XBaseProps)." },
63
+ schema: [],
64
+ messages: {
65
+ parameter: "Exported component {{name}} must be an arrow const with no parameter, or exactly one parameter named props.",
66
+ type: "Exported component {{name}} must type props as {{expected}}.",
67
+ },
68
+ },
69
+ create(context) {
70
+ const check = (name, fn) => {
71
+ if (!name || !fn)
72
+ return;
73
+ const params = fn.params || [];
74
+ if (params.length === 0)
75
+ return;
76
+ const [param] = params;
77
+ if (params.length !== 1 || param?.type !== "Identifier" || param.name !== "props") {
78
+ context.report({ node: fn, messageId: "parameter", data: { name } });
79
+ return;
80
+ }
81
+ const type = param.typeAnnotation?.typeAnnotation;
82
+ // XBase pairs with XBaseProps so the connected X keeps XProps for itself; XProps stays accepted for
83
+ // existing twins.
84
+ const expected = name.endsWith("Base") ? [`${name}Props`, `${name.slice(0, -4)}Props`] : [`${name}Props`];
85
+ const actual = type?.type === "TSTypeReference" && type.typeName?.type === "Identifier" ? type.typeName.name : null;
86
+ if (!actual || !expected.includes(actual))
87
+ context.report({ node: param, messageId: "type", data: { name, expected: expected.join(" or ") } });
88
+ };
89
+ if (!isComponentFile(context))
90
+ return {};
91
+ return {
92
+ VariableDeclarator(node) {
93
+ const name = componentNameOf(node);
94
+ if (name && node.init?.type === "ArrowFunctionExpression" && containsJsx(node.init))
95
+ check(name, node.init);
96
+ },
97
+ FunctionDeclaration(node) {
98
+ if (!isExported(node) || !containsJsx(node))
99
+ return;
100
+ context.report({ node, messageId: "parameter", data: { name: node.id?.name || "component" } });
101
+ },
102
+ };
103
+ },
104
+ });
105
+ /** Prevent callers from styling internal component parts through named CSS props. */
106
+ export const noPerPartClassNameProp = defineRule({
107
+ meta: {
108
+ type: "problem",
109
+ docs: { description: "Components do not expose per-part class name props." },
110
+ schema: [],
111
+ messages: { perPart: "Keep internal part styling owned by the component." },
112
+ },
113
+ create(context) {
114
+ if (!isComponentFile(context))
115
+ return {};
116
+ return { TSPropertySignature(node) { const name = propertyName(node); if (name && name !== "className" && /^[a-z][A-Za-z0-9]*ClassName$/.test(name))
117
+ context.report({ node, messageId: "perPart" }); } };
118
+ },
119
+ });
120
+ /** Keep component appearance local instead of exposing className placement doors. */
121
+ export const noPublicClassNameProp = defineRule({
122
+ meta: {
123
+ type: "problem",
124
+ docs: { description: "House components do not expose className or classNames props." },
125
+ schema: [],
126
+ messages: { declaration: "Component props must not expose {{prop}}.", usage: "Do not pass {{prop}} to house component {{component}}." },
127
+ },
128
+ create(context) {
129
+ const declared = isComponentFile(context);
130
+ /** A house component: the identifier resolves (through its import) to a file of a component layer. */
131
+ const isHouseComponent = (identifier) => declarationsOf(context, identifier).some((declaration) => isComponentPath(context, declarationFile(declaration)));
132
+ return {
133
+ TSPropertySignature(node) { if (declared && ["className", "classNames"].includes(propertyName(node) ?? ""))
134
+ context.report({ node, messageId: "declaration", data: { prop: propertyName(node) } }); },
135
+ JSXOpeningElement(node) {
136
+ const identifier = node.name?.type === "JSXIdentifier" ? node.name : null;
137
+ if (!identifier)
138
+ return;
139
+ const styled = (node.attributes || []).filter((attribute) => attribute.type === "JSXAttribute" && attribute.name.type === "JSXIdentifier" && ["className", "classNames"].includes(attribute.name.name));
140
+ if (styled.length === 0 || !isHouseComponent(identifier))
141
+ return;
142
+ for (const attribute of styled)
143
+ context.report({ node: attribute, messageId: "usage", data: { prop: attribute.name.name, component: identifier.name } });
144
+ },
145
+ };
146
+ },
147
+ });
148
+ /** Keep layout APIs semantic rather than exposing raw CSS decisions above leaves. */
149
+ export const noPublicFrameCssProps = defineRule({
150
+ meta: { type: "problem", docs: { description: "Non-leaf component props do not expose CSS-shaped frame decisions." }, schema: [], messages: { css: "Move {{prop}} into component-owned layout behavior." } },
151
+ create(context) {
152
+ if (!isComponentFile(context) || kindOfFile(context) === "leaves")
153
+ return {};
154
+ const cssProps = new Set(["gap", "padding", "align", "justify", "className", "classNames", "style", "inline", "nested"]);
155
+ return { TSPropertySignature(node) { const name = propertyName(node); if (name !== null && cssProps.has(name))
156
+ context.report({ node, messageId: "css", data: { prop: name } }); } };
157
+ },
158
+ });
159
+ /** Prevent utility types from reintroducing public styling doors. */
160
+ export const noCssDoorTypeLaundering = defineRule({
161
+ meta: { type: "problem", docs: { description: "Omit, Pick, and Exclude cannot hide styling props." }, schema: [], messages: { utility: "Remove {{prop}} from the owning public type instead of hiding it with {{utility}}." } },
162
+ create(context) {
163
+ if (!isProductSource(context))
164
+ return {};
165
+ return { TSTypeReference(node) { const utility = node.typeName.type === "Identifier" ? node.typeName.name : null; const params = node.typeArguments?.params || []; if (!utility || !["Omit", "Pick", "Exclude"].includes(utility))
166
+ return; const second = params[1]; const keys = second?.type === "TSLiteralType" ? [literalValue(second)] : second !== undefined && "types" in second ? second.types.map((item) => (item.type === "TSLiteralType" ? literalValue(item) : undefined)) : []; const prop = keys.find((key) => typeof key === "string" && ["className", "classNames", "style"].includes(key)); if (prop)
167
+ context.report({ node, messageId: "utility", data: { utility, prop } }); } };
168
+ },
169
+ });
170
+ /** The top-level declaration of a type name in this file (`interface X` or `type X = ...`), or null. */
171
+ /** The value a literal type spells (`"a"`, `1`, `true`); undefined for a template or unary literal type. */
172
+ const literalValue = (type) => ("value" in type.literal ? type.literal.value : undefined);
173
+ /** The node a finding on a props type is reported on. */
174
+ const reportedNode = (type) => ("heritage" in type ? type.heritage : type);
175
+ const localTypeDeclaration = (program, name) => {
176
+ for (const statement of program.body) {
177
+ const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
178
+ if ((declaration?.type === "TSInterfaceDeclaration" || declaration?.type === "TSTypeAliasDeclaration") && declaration.id.name === name)
179
+ return declaration;
180
+ }
181
+ return null;
182
+ };
183
+ const firstParameterType = (fn) => {
184
+ const param = fn.params[0];
185
+ const annotated = param?.type === "AssignmentPattern" ? param.left : param;
186
+ return annotated !== undefined && "typeAnnotation" in annotated ? annotated.typeAnnotation?.typeAnnotation ?? null : null;
187
+ };
188
+ /** The exported functions of a file that render (contain JSX): a component, whatever it is called. */
189
+ const exportedRenderers = (program) => program.body.flatMap((statement) => {
190
+ const declaration = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : null;
191
+ if (!declaration)
192
+ return [];
193
+ const functions = declaration.type === "VariableDeclaration"
194
+ ? declaration.declarations.map((item) => item.init).filter((init) => init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression")
195
+ : declaration.type === "FunctionDeclaration" || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionExpression" ? [declaration] : [];
196
+ return functions.filter((fn) => fn.params.length === 1 && containsJsx(fn.body));
197
+ });
198
+ /** Props are readonly all the way down: every field, index signature, nested object and collection (FE-TYPING-2). */
199
+ export const propsFieldsReadonly = defineRule({
200
+ meta: {
201
+ type: "problem",
202
+ docs: { description: "Every field of a component's props type, nested objects and collections included, is readonly." },
203
+ schema: [],
204
+ messages: {
205
+ field: "Props field `{{name}}` is not readonly. A component reads its props and never writes them: mark it `readonly` so a write is a type error.",
206
+ index: "This props index signature is not readonly. Mark it `readonly [key: ...]` so no entry can be written through props.",
207
+ collection: "This props collection is mutable. Write `readonly T[]`, `readonly [A, B]` or `ReadonlyArray<T>` so the component cannot change what it was given.",
208
+ },
209
+ },
210
+ create(context) {
211
+ if (!isProductSource(context))
212
+ return {};
213
+ const sourceCode = context.sourceCode || context.getSourceCode();
214
+ return {
215
+ "Program:exit"(program) {
216
+ const seen = new Set();
217
+ /** Checks one type: object members, collections and the local declarations it names. */
218
+ const checkType = (type) => {
219
+ if (!type || seen.has(type))
220
+ return;
221
+ seen.add(type);
222
+ if (type.type === "TSTypeOperator" && type.operator === "readonly") {
223
+ checkElements(type.typeAnnotation);
224
+ return;
225
+ }
226
+ if (type.type === "TSArrayType" || type.type === "TSTupleType") {
227
+ context.report({ node: type, messageId: "collection" });
228
+ checkElements(type);
229
+ return;
230
+ }
231
+ if (type.type === "TSUnionType" || type.type === "TSIntersectionType") {
232
+ for (const part of type.types)
233
+ checkType(part);
234
+ return;
235
+ }
236
+ if (type.type === "TSTypeLiteral") {
237
+ checkMembers(type.members);
238
+ return;
239
+ }
240
+ if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier")
241
+ return;
242
+ const name = type.typeName.name;
243
+ const args = type.typeArguments?.params ?? [];
244
+ // `Readonly<T>` makes the top level readonly; what T nests is still judged
245
+ if (name === "Readonly" && args.length === 1 && !isShadowed(type.typeName)) {
246
+ checkNested(args[0]);
247
+ return;
248
+ }
249
+ if (name === "Array" && !isShadowed(type.typeName))
250
+ context.report({ node: reportedNode(type), messageId: "collection" });
251
+ if ((name === "Array" || name === "ReadonlyArray") && !isShadowed(type.typeName)) {
252
+ for (const arg of args)
253
+ checkType(arg);
254
+ return;
255
+ }
256
+ const declaration = localTypeDeclaration(program, name);
257
+ if (!declaration || seen.has(declaration))
258
+ return;
259
+ seen.add(declaration);
260
+ if (declaration.type === "TSTypeAliasDeclaration")
261
+ checkType(declaration.typeAnnotation);
262
+ else {
263
+ checkMembers(declaration.body.body);
264
+ for (const heritage of declaration.extends ?? []) {
265
+ if (heritage.expression.type === "Identifier")
266
+ checkType({ type: "TSTypeReference", typeName: heritage.expression, typeArguments: heritage.typeArguments, heritage });
267
+ }
268
+ }
269
+ };
270
+ /** The element types of a collection, judged like any other props type. */
271
+ const checkElements = (collection) => {
272
+ if (collection?.type === "TSArrayType")
273
+ checkType(collection.elementType);
274
+ else if (collection?.type === "TSTupleType")
275
+ for (const element of collection.elementTypes)
276
+ checkType(element.type === "TSNamedTupleMember" ? element.elementType : element);
277
+ else
278
+ checkType(collection);
279
+ };
280
+ /** A `Readonly<T>` argument: its own fields are readonly, its nested values are judged. */
281
+ const checkNested = (type) => {
282
+ if (type?.type === "TSTypeLiteral")
283
+ for (const member of type.members)
284
+ checkType("typeAnnotation" in member ? member.typeAnnotation?.typeAnnotation : undefined);
285
+ else
286
+ checkType(type);
287
+ };
288
+ const checkMembers = (members) => {
289
+ for (const member of members) {
290
+ if (member.type === "TSPropertySignature") {
291
+ if (!member.readonly)
292
+ context.report({ node: member, messageId: "field", data: { name: sourceCode.getText(member.key) } });
293
+ checkType(member.typeAnnotation?.typeAnnotation);
294
+ }
295
+ else if (member.type === "TSIndexSignature") {
296
+ if (!member.readonly)
297
+ context.report({ node: member, messageId: "index" });
298
+ checkType(member.typeAnnotation?.typeAnnotation);
299
+ }
300
+ }
301
+ };
302
+ /** True when a built-in name is redeclared in this file, so it is not the built-in. */
303
+ const isShadowed = (identifier) => localTypeDeclaration(program, identifier.name) !== null;
304
+ for (const fn of exportedRenderers(program))
305
+ checkType(firstParameterType(fn));
306
+ },
307
+ };
308
+ },
309
+ });
310
+ export const rules = {
311
+ "no-inline-parameter-type": noInlineParameterType,
312
+ "props-fields-readonly": propsFieldsReadonly,
313
+ "public-component-signature": publicComponentSignature,
314
+ "no-per-part-classname-prop": noPerPartClassNameProp,
315
+ "no-public-classname-prop": noPublicClassNameProp,
316
+ "no-public-frame-css-props": noPublicFrameCssProps,
317
+ "no-css-door-type-laundering": noCssDoorTypeLaundering,
318
+ };
319
+ /** All rules in this law are mechanical errors once adopted. */
320
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Rules for the SERVED LOCALE law - `patterns/fe/served-locale/INDEX.md`.
3
+ *
4
+ * WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
5
+ * connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
6
+ * an English course on a `/vi/` URL. The chrome came from the dictionary and the content came from an
7
+ * API that was never told which language to serve, so the failure looked like a translation gap and
8
+ * was looked for in the dictionary, where there was nothing wrong. The header was missing, and no
9
+ * gate had anything to say about a header nobody wrote.
10
+ *
11
+ * WHAT THEY HOLD. That the transport chain declares a locale, and that exactly one place writes it.
12
+ * Both are file-at-a-time facts: the chain is assembled in one file, and a header is written where it
13
+ * is written.
14
+ *
15
+ * WHAT THEY CANNOT HOLD, stated rather than implied. Neither rule can tell whether the value the link
16
+ * computes is CORRECT - whether it reads the address, the cookie or a constant - because that is
17
+ * inside a function these rules only see the name of. A link that attaches `Accept-Language: "en"`
18
+ * unconditionally satisfies both and is wrong. That case is a review question and LOCALE-2 is where
19
+ * it is argued; pretending otherwise would be the rule claiming a guarantee it does not have.
20
+ *
21
+ * WHY THE FIRST RULE KEYS ON THE TERMINAL LINK RATHER THAN ON A FILE NAME. The clients folder (`modules/api/graphql/clients/`,
22
+ * slot `fe.transport.client`) holds the clients, their options and every link, and a file-name pattern that guessed which of
23
+ * them assembles the chain would either miss the real chain or fire on every helper beside it. The chain is identifiable by what it does: it builds the link
24
+ * that talks to the network.
25
+ *
26
+ * WITH ONE EXEMPTION THAT WAS FOUND BY RUNNING IT, NOT BY THINKING ABOUT IT. "Constructs the
27
+ * terminal link" is also true of the file that DEFINES that link, so the first version reported
28
+ * `links/http.ts` and its spec on a repository that had done everything right. There was no correct
29
+ * way to satisfy it there - a locale link inside the http link is a chain hiding in a link - and a
30
+ * rule with no correct way to satisfy it is a finding about the rule. A file under `links/` is one
31
+ * link; a chain is assembled elsewhere, which is where the locale belongs.
32
+ */
33
+ import { type Recommended, type Rules } from "./lib/rule.js";
34
+ /** The header a request declares its language in; HTTP header names are case-insensitive. */
35
+ export declare const LOCALE_HEADER = "Accept-Language";
36
+ export declare const rules: Rules;
37
+ /** Every FE canon rule is an error; see `sources/fe/index.mjs` for why there is no warn tier. */
38
+ export declare const recommended: Recommended;
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Rules for the SERVED LOCALE law - `patterns/fe/served-locale/INDEX.md`.
3
+ *
4
+ * WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
5
+ * connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
6
+ * an English course on a `/vi/` URL. The chrome came from the dictionary and the content came from an
7
+ * API that was never told which language to serve, so the failure looked like a translation gap and
8
+ * was looked for in the dictionary, where there was nothing wrong. The header was missing, and no
9
+ * gate had anything to say about a header nobody wrote.
10
+ *
11
+ * WHAT THEY HOLD. That the transport chain declares a locale, and that exactly one place writes it.
12
+ * Both are file-at-a-time facts: the chain is assembled in one file, and a header is written where it
13
+ * is written.
14
+ *
15
+ * WHAT THEY CANNOT HOLD, stated rather than implied. Neither rule can tell whether the value the link
16
+ * computes is CORRECT - whether it reads the address, the cookie or a constant - because that is
17
+ * inside a function these rules only see the name of. A link that attaches `Accept-Language: "en"`
18
+ * unconditionally satisfies both and is wrong. That case is a review question and LOCALE-2 is where
19
+ * it is argued; pretending otherwise would be the rule claiming a guarantee it does not have.
20
+ *
21
+ * WHY THE FIRST RULE KEYS ON THE TERMINAL LINK RATHER THAN ON A FILE NAME. The clients folder (`modules/api/graphql/clients/`,
22
+ * slot `fe.transport.client`) holds the clients, their options and every link, and a file-name pattern that guessed which of
23
+ * them assembles the chain would either miss the real chain or fire on every helper beside it. The chain is identifiable by what it does: it builds the link
24
+ * that talks to the network.
25
+ *
26
+ * WITH ONE EXEMPTION THAT WAS FOUND BY RUNNING IT, NOT BY THINKING ABOUT IT. "Constructs the
27
+ * terminal link" is also true of the file that DEFINES that link, so the first version reported
28
+ * `links/http.ts` and its spec on a repository that had done everything right. There was no correct
29
+ * way to satisfy it there - a locale link inside the http link is a chain hiding in a link - and a
30
+ * rule with no correct way to satisfy it is a finding about the rule. A file under `links/` is one
31
+ * link; a chain is assembled elsewhere, which is where the locale belongs.
32
+ */
33
+ import { isNode } from "./lib/ast.js";
34
+ import { hfsOf } from "./lib/hfs.js";
35
+ import { defineRule } from "./lib/rule.js";
36
+ import { fileOf, isApiLayer, stem } from "./lib/scope.js";
37
+ /**
38
+ * The name of the file when it sits directly in a `links/` folder of the API layer, else null.
39
+ *
40
+ * NO SLOT NAMES A LINK. The clients slots (`fe.transport.client`, `fe.package.api.client`) own their whole folder, so the API
41
+ * layer decides WHERE a link may be and the folder called `links` is the one fact left to the rule; the manifest would
42
+ * carry it as a role (`locale-link: links/locale.ts`) and this function would ask for that role instead.
43
+ */
44
+ const linkFileName = (context) => {
45
+ if (!isApiLayer(context))
46
+ return null;
47
+ const parts = hfsOf(context).relative(fileOf(context)).split("/");
48
+ return parts.at(-2) === "links" ? parts.at(-1) ?? null : null;
49
+ };
50
+ /**
51
+ * The link file that owns the locale header.
52
+ *
53
+ * This is the ONE file allowed to write it: the `locale` link of a transport slot. The point of the second rule is that
54
+ * the header appears in one place, so that place has to be named somewhere, and a name is what the law and the rule can
55
+ * both refer to.
56
+ */
57
+ const isLocaleLinkFile = (context) => {
58
+ const name = linkFileName(context);
59
+ return name !== null && stem(name) === "locale";
60
+ };
61
+ /**
62
+ * One link's own implementation, rather than a chain of them.
63
+ *
64
+ * WHY THIS EXEMPTION EXISTS, AND IT WAS MEASURED. The first version of the chain rule keyed only on
65
+ * "constructs the terminal link", which is true of the file that DEFINES that link - `links/http.ts`
66
+ * builds `new HttpLink(...)`, because that is its whole job. Run against real source it reported
67
+ * that file and its spec, and there was no correct way to satisfy it there: attaching a locale link
68
+ * inside the http link would be a chain hiding in a link.
69
+ *
70
+ * A file under `links/` is one link. A chain is assembled somewhere else, which is exactly where the
71
+ * locale belongs.
72
+ */
73
+ const isLinkImplementationFile = (context) => linkFileName(context) !== null;
74
+ /** The header a request declares its language in; HTTP header names are case-insensitive. */
75
+ export const LOCALE_HEADER = "Accept-Language";
76
+ /** The retired locale header is refused everywhere, including the locale link. */
77
+ const RETIRED_LOCALE_HEADER = "x-locale";
78
+ /** Callee names that construct the terminal link - the one that actually reaches the network. */
79
+ const TERMINAL_LINK_CALLEES = new Set(["createHttpLink", "HttpLink", "createUploadLink", "BatchHttpLink"]);
80
+ /** Callee names that attach the reader's locale to an operation. */
81
+ const LOCALE_LINK_CALLEES = new Set(["createAttachLocaleLink", "createLocaleLink"]);
82
+ /** The callee name of a call or `new` expression, when it has a plain one. */
83
+ const calleeName = (node) => {
84
+ const callee = node.callee;
85
+ if (!callee)
86
+ return null;
87
+ if (callee.type === "Identifier")
88
+ return callee.name;
89
+ if (callee.type === "MemberExpression" && callee.property && callee.property.type === "Identifier") {
90
+ return callee.property.name;
91
+ }
92
+ return null;
93
+ };
94
+ /** Literal and expression-free template spellings identify the same header. */
95
+ const stringOf = (node) => {
96
+ if (node?.type === "Literal" && typeof node.value === "string")
97
+ return node.value;
98
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0)
99
+ return firstQuasi(node).value.cooked;
100
+ return null;
101
+ };
102
+ /** The first quasi of a template literal: every template has one, so its absence stops the run as reading it always has. */
103
+ const firstQuasi = (node) => {
104
+ const [first] = node.quasis;
105
+ if (first === undefined)
106
+ throw new TypeError("a template literal without a quasi");
107
+ return first;
108
+ };
109
+ /** Every static property spelling, without treating a computed variable as its identifier's name. */
110
+ const keyOf = (node) => node.property.type === "Identifier" && !node.computed ? node.property.name : stringOf(node.property);
111
+ /** Every string-ish key a property can carry, so `"Accept-Language"` and `["Accept-Language"]` read the same. */
112
+ const propertyKeyOf = (property) => {
113
+ if (!property || property.type !== "Property")
114
+ return null;
115
+ const key = property.key;
116
+ if (!key)
117
+ return null;
118
+ if (key.type === "Identifier" && !property.computed)
119
+ return key.name;
120
+ return stringOf(key);
121
+ };
122
+ /** Resolve a receiver's binding, so an unrelated local `Headers` or `set` method stays unrelated. */
123
+ const variableOf = (node, sourceCode) => {
124
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
125
+ const variable = scope.set.get(node.name);
126
+ if (variable)
127
+ return variable;
128
+ }
129
+ return null;
130
+ };
131
+ /** Global browser constructors and globals may be qualified, but cannot be shadowed. */
132
+ const isNativeName = (node, name, sourceCode) => {
133
+ if (node?.type === "Identifier")
134
+ return node.name === name && !variableOf(node, sourceCode)?.defs.length;
135
+ return node?.type === "MemberExpression" && keyOf(node) === name &&
136
+ ["globalThis", "window"].some((global) => isNativeName(node.object, global, sourceCode));
137
+ };
138
+ /** Syntax establishes native Headers values and stable aliases; unknown or reassigned receivers are left alone. */
139
+ /** The type a node is annotated with (`x: Headers`, `x as Headers`): the inside of a `: T` annotation, else the node's own `typeAnnotation`. */
140
+ const annotationOf = (node) => {
141
+ const outer = Reflect.get(node, "typeAnnotation");
142
+ if (!isNode(outer))
143
+ return undefined;
144
+ const inner = Reflect.get(outer, "typeAnnotation");
145
+ return isNode(inner) ? inner : outer;
146
+ };
147
+ const isNativeInstance = (node, name, sourceCode, seen = new Set()) => {
148
+ if (!node || seen.has(node))
149
+ return false;
150
+ seen.add(node);
151
+ const annotation = annotationOf(node);
152
+ if (annotation?.type === "TSTypeReference" && isNativeName(annotation.typeName, name, sourceCode))
153
+ return true;
154
+ if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "ChainExpression")
155
+ return isNativeInstance(node.expression, name, sourceCode, seen);
156
+ if (node.type === "NewExpression")
157
+ return isNativeName(node.callee, name, sourceCode);
158
+ if (name === "Headers" && node.type === "MemberExpression" && keyOf(node) === "headers") {
159
+ return ["Request", "Response"].some((owner) => isNativeInstance(node.object, owner, sourceCode, new Set(seen)));
160
+ }
161
+ if (node.type !== "Identifier")
162
+ return false;
163
+ const variable = variableOf(node, sourceCode);
164
+ if (variable?.references.some((reference) => reference.isWrite() && !reference.init))
165
+ return false;
166
+ return (variable?.identifiers ?? []).some((identifier) => isNativeInstance(identifier, name, sourceCode, seen)) ||
167
+ (variable?.defs ?? []).some((definition) => definition.type === "Variable" && isNativeInstance(definition.node.init, name, sourceCode, seen));
168
+ };
169
+ /** The header-writing methods of `Headers`. */
170
+ const HEADER_WRITES = ["set", "append"];
171
+ export const rules = {
172
+ "api-client-attaches-the-locale": defineRule({
173
+ meta: {
174
+ type: "problem",
175
+ docs: {
176
+ description: "A transport chain that builds the terminal HTTP link must also attach the reader's locale, so every call declares the language it wants served.",
177
+ },
178
+ schema: [],
179
+ messages: {
180
+ missing: "This assembles the API transport but never attaches the reader's locale, so every call it makes is served the server's default language. Add the locale link to the chain, beside the auth link and unconditionally - a guest reads in a language too. A surface that is bilingual in its chrome and monolingual in its content reads as a translation gap, which is why it gets looked for in the dictionary rather than here.",
181
+ },
182
+ },
183
+ create(context) {
184
+ // A single link's implementation is not a chain - `links/http.ts` constructs the terminal link
185
+ // because defining it IS its job, and there is no correct way to attach a locale there. A
186
+ // spec asserts about a chain rather than being one.
187
+ if (isLinkImplementationFile(context))
188
+ return {};
189
+ let terminalNode = null;
190
+ let attachesLocale = false;
191
+ const note = (node) => {
192
+ const name = calleeName(node);
193
+ if (name === null)
194
+ return;
195
+ if (TERMINAL_LINK_CALLEES.has(name))
196
+ terminalNode = terminalNode || node;
197
+ if (LOCALE_LINK_CALLEES.has(name))
198
+ attachesLocale = true;
199
+ };
200
+ return {
201
+ CallExpression: note,
202
+ NewExpression: note,
203
+ "Program:exit"() {
204
+ if (terminalNode && !attachesLocale) {
205
+ context.report({ node: terminalNode, messageId: "missing" });
206
+ }
207
+ },
208
+ };
209
+ },
210
+ }),
211
+ "locale-header-belongs-to-the-link": defineRule({
212
+ meta: {
213
+ type: "problem",
214
+ docs: {
215
+ description: "The locale header is written by the locale link and nowhere else, so one place answers which language a request is in.",
216
+ },
217
+ schema: [],
218
+ messages: {
219
+ elsewhere: "Only the locale link may write `Accept-Language`. Setting it here is a second answer to which language this request is in, and the two diverge the first time one of them is updated - usually leaving a single hook correct and the rest of the surface on the server's default. Let the transport attach it.",
220
+ retired: "The `x-locale` header is retired. Declare the reader's language with `Accept-Language` in the locale link, the one place that owns it.",
221
+ },
222
+ },
223
+ create(context) {
224
+ const ownsLocale = isLocaleLinkFile(context);
225
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
226
+ const checkHeader = (node, key) => {
227
+ const header = key?.toLowerCase();
228
+ if (header === RETIRED_LOCALE_HEADER) {
229
+ context.report({ node, messageId: "retired" });
230
+ }
231
+ else if (header === LOCALE_HEADER.toLowerCase() && !ownsLocale) {
232
+ context.report({ node, messageId: "elsewhere" });
233
+ }
234
+ };
235
+ return {
236
+ Property(node) {
237
+ checkHeader(node, propertyKeyOf(node));
238
+ },
239
+ AssignmentExpression(node) {
240
+ if (node.left.type === "MemberExpression")
241
+ checkHeader(node, keyOf(node.left));
242
+ },
243
+ NewExpression(node) {
244
+ const initial = node.arguments[0];
245
+ if (!isNativeName(node.callee, "Headers", sourceCode) || initial?.type !== "ArrayExpression")
246
+ return;
247
+ for (const entry of initial.elements) {
248
+ if (entry?.type === "ArrayExpression")
249
+ checkHeader(entry, stringOf(entry.elements[0]));
250
+ }
251
+ },
252
+ CallExpression(node) {
253
+ const callee = node.callee;
254
+ if (callee.type === "MemberExpression" && HEADER_WRITES.includes(keyOf(callee)) && isNativeInstance(callee.object, "Headers", sourceCode)) {
255
+ checkHeader(node, stringOf(node.arguments[0]));
256
+ }
257
+ },
258
+ };
259
+ },
260
+ }),
261
+ };
262
+ /** Every FE canon rule is an error; see `sources/fe/index.mjs` for why there is no warn tier. */
263
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));