@starci/eslint-canon-fe 9.3.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 (177) hide show
  1. package/CHANGELOG.md +9 -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 +17 -10
  129. package/brand-values.mjs +0 -172
  130. package/button-intent.mjs +0 -320
  131. package/callback-shape.mjs +0 -332
  132. package/class-names.mjs +0 -149
  133. package/client-boundary.mjs +0 -400
  134. package/comments.mjs +0 -159
  135. package/control-height.mjs +0 -77
  136. package/drawing.mjs +0 -321
  137. package/env-owner.mjs +0 -244
  138. package/fe-stack.mjs +0 -86
  139. package/file-layout.mjs +0 -472
  140. package/formatting.mjs +0 -110
  141. package/function-budget.mjs +0 -49
  142. package/grammar-shim.mjs +0 -123
  143. package/hooks-folder.mjs +0 -300
  144. package/hygiene.mjs +0 -513
  145. package/index.mjs +0 -317
  146. package/lib/ast.mjs +0 -97
  147. package/lib/bindings.mjs +0 -159
  148. package/lib/class-text.mjs +0 -29
  149. package/lib/config.mjs +0 -142
  150. package/lib/fe-stack.mjs +0 -77
  151. package/lib/hfs.mjs +0 -49
  152. package/lib/next.mjs +0 -6
  153. package/lib/own-hook-origin.mjs +0 -53
  154. package/lib/program-for.mjs +0 -22
  155. package/lib/pure-owner-helper.mjs +0 -148
  156. package/lib/react.mjs +0 -28
  157. package/lib/redux-hook-origin.mjs +0 -44
  158. package/lib/typed-class-values.mjs +0 -81
  159. package/lib/types.mjs +0 -63
  160. package/lib/why.mjs +0 -352
  161. package/lists.mjs +0 -193
  162. package/loading.mjs +0 -156
  163. package/naming.mjs +0 -185
  164. package/native-controls.mjs +0 -108
  165. package/next-conventions.mjs +0 -447
  166. package/props-and-slots.mjs +0 -276
  167. package/served-locale.mjs +0 -243
  168. package/size-and-state-budget.mjs +0 -376
  169. package/size-growth.mjs +0 -62
  170. package/surface-rules.mjs +0 -443
  171. package/swr-family.mjs +0 -326
  172. package/the-split.mjs +0 -155
  173. package/tokens.mjs +0 -39
  174. package/translation.mjs +0 -392
  175. package/transport.mjs +0 -830
  176. package/type-safety.mjs +0 -147
  177. package/ui-rulings.mjs +0 -134
@@ -0,0 +1,83 @@
1
+ /** CONTROL-HEIGHT-1: one control height; the app never picks a larger step or mixes steps in one row. */
2
+ import { attributeValue, stringOf } from "./lib/ast.js";
3
+ import { importOf } from "./lib/bindings.js";
4
+ import { defineRule } from "./lib/rule.js";
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"]);
7
+ /** A marketing hero call to action on a landing page is the one place the 48px step is allowed. */
8
+ const LANDING_PATH = /[\\/](?:landing|hero|marketing)[\\/]|(?:Landing|Hero)[^\\/]*[\\/]/;
9
+ /** The default size and its two spellings: the vendor `md` and Grammar's `control` are the same 40px height. */
10
+ const DEFAULT_SIZE = new Set(["md", "control"]);
11
+ /** The Grammar control a JSX name resolves to; a shadowed or foreign component of the same name is not one. */
12
+ const controlOf = (context, name) => {
13
+ let root = name;
14
+ const parts = [];
15
+ while (root.type === "JSXMemberExpression") {
16
+ parts.unshift(root.property.name);
17
+ root = root.object;
18
+ }
19
+ if (root.type !== "JSXIdentifier")
20
+ return null;
21
+ const imported = importOf(context, root);
22
+ if (!imported || !GRAMMAR_ENTRY.test(imported.source))
23
+ return null;
24
+ const role = imported.imported ?? parts.shift();
25
+ return role !== undefined && parts.length === 0 && CONTROLS.has(role) ? role : null;
26
+ };
27
+ /** The statically authored size of a control: `control` when absent, null when it is dynamic. */
28
+ const sizeOf = (opening) => {
29
+ const attribute = opening.attributes.find((entry) => entry.type === "JSXAttribute" && entry.name.name === "size");
30
+ if (!attribute)
31
+ return "control";
32
+ const text = stringOf(attributeValue(attribute));
33
+ if (text === null)
34
+ return null;
35
+ return DEFAULT_SIZE.has(text) ? "control" : text;
36
+ };
37
+ /** The Grammar controls a row holds directly, looking through fragments. */
38
+ const rowControls = (context, element) => element.children.flatMap((child) => {
39
+ if (child.type === "JSXFragment")
40
+ return rowControls(context, child);
41
+ if (child.type !== "JSXElement")
42
+ return [];
43
+ return controlOf(context, child.openingElement.name) === null ? [] : [child.openingElement];
44
+ });
45
+ const controlHeight = defineRule({
46
+ meta: { type: "problem", docs: { description: "CONTROL-HEIGHT-1: one 40px control height, no larger step outside a landing hero, no mixed sizes in one row." }, schema: [], messages: {
47
+ FE_CONTROL_HEIGHT: "FE_CONTROL_HEIGHT: {{detail}} Grammar draws every standalone control at one 40px height; omit size (sm is for a control nested in an item or a dense toolbar, lg only for a landing hero call to action).",
48
+ } },
49
+ create(context) {
50
+ const landing = LANDING_PATH.test(context.filename);
51
+ const reported = new Set();
52
+ const report = (node, detail) => {
53
+ if (reported.has(node))
54
+ return;
55
+ reported.add(node);
56
+ context.report({ node, messageId: "FE_CONTROL_HEIGHT", data: { detail } });
57
+ };
58
+ return {
59
+ JSXOpeningElement(opening) {
60
+ const role = controlOf(context, opening.name);
61
+ if (role === null)
62
+ return;
63
+ const size = sizeOf(opening);
64
+ if (size === "xl" || (size === "lg" && !landing))
65
+ report(opening, `${role} size="${size}" is a larger control step than the app chrome allows.`);
66
+ },
67
+ "JSXElement, JSXFragment"(element) {
68
+ const controls = rowControls(context, element);
69
+ const sizes = controls.map(sizeOf);
70
+ if (sizes.includes(null) || new Set(sizes).size < 2)
71
+ return;
72
+ const index = sizes.findIndex((size) => size !== sizes[0]);
73
+ const odd = controls[index];
74
+ if (odd)
75
+ report(odd, "this control sits in a row with controls of another size.");
76
+ },
77
+ };
78
+ },
79
+ });
80
+ /** The one rule of the law. */
81
+ export const rules = { "control-height": controlHeight };
82
+ /** The rule reaches consumers at error. */
83
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The rules that hold the drawing law (the `drawing` slot role: a surface's pure `component.tsx`).
3
+ *
4
+ * TWO AXES, AND ONLY ONE IS DRAWN. A surface's `shape` is its SHAPE: a layout the owner approves as
5
+ * one drawing. A slot's data status (loading, forbidden, error, empty) is not a shape; it is a
6
+ * status recipe applied per resource, and it is never a drawn shape. Mixing the two is what made
7
+ * every block ship four hand-drawn variants that disagreed with each other.
8
+ *
9
+ * THE PURE HALF TAKES ATOMS. `component.tsx` receives `{ shape?, props, on? }`: `props` is data
10
+ * (strings, numbers, booleans, arrays, plain objects), `on` is actions whose arguments are data,
11
+ * and feature composition may also take named `render` callbacks inside `props` to render supplied
12
+ * blocks. A layout alone also takes `children`, because the router puts the page there.
13
+ *
14
+ * ONLY ITS OWN INDEX MAY IMPORT IT. Other tiers use the connected export, so the pure half stays a
15
+ * private drawing surface that audit can render from a fixture.
16
+ *
17
+ * Every rule reads one file. Types imported from elsewhere are trusted as data; only names this
18
+ * file declares are followed, which is exact for the tree the convention produces and silent,
19
+ * never wrong, beyond it.
20
+ */
21
+ import { type Rules, type Recommended } from "./lib/rule.js";
22
+ /** The pure half takes atom data and actions; feature composition may receive named render callbacks. */
23
+ export declare const basePropsAtom: import("./lib/rule.js").Rule<"action" | "tree" | "onData" | "member" | "renamed", []>;
24
+ /** Only the sibling `index.tsx` (and its specs) may reach `component.tsx`; only `X` leaves the folder. */
25
+ export declare const baseImportPair: import("./lib/rule.js").Rule<"reach" | "reexport", []>;
26
+ /** A surface's `XShape` names drawn shapes only: no data status, no open/closed, no status flag. */
27
+ export declare const noDataStatusShape: import("./lib/rule.js").Rule<"status" | "flag", []>;
28
+ /** The rules this law contributes to the plugin. */
29
+ export declare const rules: Rules;
30
+ /** Every FE canon rule is an error; existing debt is fixed before adoption. */
31
+ export declare const recommended: Recommended;
@@ -0,0 +1,376 @@
1
+ /**
2
+ * The rules that hold the drawing law (the `drawing` slot role: a surface's pure `component.tsx`).
3
+ *
4
+ * TWO AXES, AND ONLY ONE IS DRAWN. A surface's `shape` is its SHAPE: a layout the owner approves as
5
+ * one drawing. A slot's data status (loading, forbidden, error, empty) is not a shape; it is a
6
+ * status recipe applied per resource, and it is never a drawn shape. Mixing the two is what made
7
+ * every block ship four hand-drawn variants that disagreed with each other.
8
+ *
9
+ * THE PURE HALF TAKES ATOMS. `component.tsx` receives `{ shape?, props, on? }`: `props` is data
10
+ * (strings, numbers, booleans, arrays, plain objects), `on` is actions whose arguments are data,
11
+ * and feature composition may also take named `render` callbacks inside `props` to render supplied
12
+ * blocks. A layout alone also takes `children`, because the router puts the page there.
13
+ *
14
+ * ONLY ITS OWN INDEX MAY IMPORT IT. Other tiers use the connected export, so the pure half stays a
15
+ * private drawing surface that audit can render from a fixture.
16
+ *
17
+ * Every rule reads one file. Types imported from elsewhere are trusted as data; only names this
18
+ * file declares are followed, which is exact for the tree the convention produces and silent,
19
+ * never wrong, beyond it.
20
+ */
21
+ import { defineRule } from "./lib/rule.js";
22
+ import { classifyImport, roleOfFile } from "./lib/scope.js";
23
+ import { inSplitTier, pureHalf } from "./lib/drawing-scope.js";
24
+ /** Type names that carry a rendered tree rather than data. */
25
+ const NON_ATOM_TYPE = /^(?:ReactNode|ReactElement|ReactPortal|JSX\.Element|Element|ComponentType|FC|FunctionComponent|ElementType|ComponentProps|RenderFunction)$/;
26
+ /** Feature composition's render callbacks return a rendered value and take only data. */
27
+ const RENDER_RESULT_TYPE = /^(?:ReactNode|ReactElement|JSX\.Element)$/;
28
+ const RENDER_PROP_NAME = /^render(?:[A-Z][A-Za-z0-9]*)?$/;
29
+ /** The declared type of a parameter, or undefined. */
30
+ const parameterType = (parameter) => parameter !== undefined && "typeAnnotation" in parameter ? parameter.typeAnnotation?.typeAnnotation : undefined;
31
+ /** The name of a parameter that is a plain identifier, or undefined. */
32
+ const parameterName = (parameter) => ("name" in parameter ? parameter.name : undefined);
33
+ /** The declared type of a member of an object type, or undefined. */
34
+ const memberType = (member) => "typeAnnotation" in member ? member.typeAnnotation?.typeAnnotation : undefined;
35
+ const typeNameOf = (node) => {
36
+ const name = node?.typeName;
37
+ if (!name)
38
+ return "";
39
+ if (name.type === "Identifier")
40
+ return name.name;
41
+ if (name.type === "TSQualifiedName")
42
+ return `${typeNameOf({ typeName: name.left })}.${name.right.name}`;
43
+ return "";
44
+ };
45
+ /** Every type this file declares, by name, so references can be followed locally. */
46
+ const collectLocalTypes = (program) => {
47
+ const types = new Map();
48
+ const visit = (statement) => {
49
+ const declaration = statement?.type === "ExportNamedDeclaration" ? statement.declaration : statement;
50
+ if (declaration?.type === "TSTypeAliasDeclaration")
51
+ types.set(declaration.id.name, declaration.typeAnnotation);
52
+ if (declaration?.type === "TSInterfaceDeclaration")
53
+ types.set(declaration.id.name, declaration.body);
54
+ };
55
+ for (const statement of program.body)
56
+ visit(statement);
57
+ return types;
58
+ };
59
+ /** The members of an object-shaped type node, following local names and intersections. */
60
+ const membersOf = (node, types, seen = new Set()) => {
61
+ if (!node)
62
+ return [];
63
+ if (node.type === "TSTypeLiteral")
64
+ return node.members;
65
+ if (node.type === "TSInterfaceBody")
66
+ return node.body;
67
+ if (node.type === "TSIntersectionType")
68
+ return node.types.flatMap((part) => membersOf(part, types, seen));
69
+ if (node.type === "TSTypeReference") {
70
+ const name = typeNameOf(node);
71
+ if (seen.has(name) || !types.has(name))
72
+ return [];
73
+ seen.add(name);
74
+ return membersOf(types.get(name), types, seen);
75
+ }
76
+ return [];
77
+ };
78
+ /** The key a property signature declares. */
79
+ const keyOf = (member) => {
80
+ const key = "key" in member ? member.key : undefined;
81
+ return key?.type === "Identifier" ? key.name : key !== undefined && "value" in key ? key.value : undefined;
82
+ };
83
+ // -- DRAWING-1 ----------------------------------------------------------------------------------
84
+ /** The pure half takes atom data and actions; feature composition may receive named render callbacks. */
85
+ export const basePropsAtom = defineRule({
86
+ meta: {
87
+ type: "problem",
88
+ docs: { description: "A pure `XBase` takes `shape`, atom `props` and `on` actions; feature composition may receive named render callbacks in `props`." },
89
+ fixable: "code",
90
+ schema: [],
91
+ messages: {
92
+ tree: "`{{path}}` hands a rendered tree (`{{type}}`) into the pure half. Write the child inside `component.tsx` and pass it atoms (ids, strings, flags); a tree handed in cannot be drawn, audited or reused.",
93
+ action: "`{{path}}` is a function inside `props`. Actions live in `on`; feature composition may receive named `render` callbacks that take atoms and return rendered blocks.",
94
+ onData: "`on.{{key}}` is not an action. `on` holds functions only; data belongs in `props`.",
95
+ member: "`{{key}}` is not part of the pure contract. A pure half takes `shape`, `props` and `on`{{children}}.",
96
+ renamed: "`state` is the retired name of the drawn shape: rename it `shape` (owner ruling fe-shape-not-state).",
97
+ },
98
+ },
99
+ create(context) {
100
+ const tier = pureHalf(context);
101
+ if (!tier)
102
+ return {};
103
+ let types = new Map();
104
+ /** Resolve render results locally; an optional empty result does not make data a render prop. */
105
+ const isRenderResult = (node, seen = new Set()) => {
106
+ if (!node)
107
+ return false;
108
+ if (node.type === "TSUnionType") {
109
+ const empty = (part) => ["TSNullKeyword", "TSUndefinedKeyword"].includes(part.type)
110
+ || part.type === "TSLiteralType" && "value" in part.literal && part.literal.value === null;
111
+ return node.types.some((part) => isRenderResult(part, new Set(seen)))
112
+ && node.types.every((part) => empty(part) || isRenderResult(part, new Set(seen)));
113
+ }
114
+ if (node.type !== "TSTypeReference")
115
+ return false;
116
+ const name = typeNameOf(node);
117
+ if (RENDER_RESULT_TYPE.test(name.replace(/^React\./, "")))
118
+ return true;
119
+ if (!types.has(name) || seen.has(name))
120
+ return false;
121
+ return isRenderResult(types.get(name), new Set(seen).add(name));
122
+ };
123
+ const checkRenderCallback = (node, path, allowed) => {
124
+ if (!allowed || !isRenderResult(node.returnType?.typeAnnotation))
125
+ return false;
126
+ for (const parameter of node.params) {
127
+ checkData(parameterType(parameter), `${path}(${parameterName(parameter) ?? "arg"})`, new Set(), false, true);
128
+ }
129
+ return true;
130
+ };
131
+ const checkDataReference = (node, path, seen, renderProp, renderParameter) => {
132
+ const name = typeNameOf(node);
133
+ if (NON_ATOM_TYPE.test(renderParameter ? name.replace(/^React\./, "") : name)) {
134
+ context.report({ node, messageId: "tree", data: { path, type: name } });
135
+ return;
136
+ }
137
+ for (const argument of node.typeArguments?.params ?? [])
138
+ checkData(argument, path, seen, false, renderParameter);
139
+ if (types.has(name) && !seen.has(name)) {
140
+ const nextSeen = renderProp || renderParameter ? new Set(seen) : seen;
141
+ nextSeen.add(name);
142
+ checkData(types.get(name), path, nextSeen, renderProp, renderParameter);
143
+ }
144
+ };
145
+ const checkDataMember = (member, path, seen, renderParameter) => {
146
+ const memberPath = `${path}.${String(keyOf(member))}`;
147
+ const allowsRender = tier !== "blocks" && path === "props" && RENDER_PROP_NAME.test(String(keyOf(member)));
148
+ if (member.type === "TSMethodSignature") {
149
+ if (!checkRenderCallback(member, memberPath, allowsRender))
150
+ context.report({ node: member, messageId: "action", data: { path: memberPath } });
151
+ }
152
+ else if (member.type === "TSPropertySignature") {
153
+ checkData(memberType(member), memberPath, seen, allowsRender, renderParameter);
154
+ }
155
+ };
156
+ /** Walk a data type: report trees and functions anywhere inside it. */
157
+ const checkData = (node, path, seen, renderProp = false, renderParameter = false) => {
158
+ if (!node)
159
+ return;
160
+ switch (node.type) {
161
+ case "TSFunctionType":
162
+ if (checkRenderCallback(node, path, renderProp))
163
+ return;
164
+ context.report({ node, messageId: "action", data: { path } });
165
+ return;
166
+ case "TSTypeReference":
167
+ checkDataReference(node, path, seen, renderProp, renderParameter);
168
+ return;
169
+ case "TSUnionType":
170
+ case "TSIntersectionType":
171
+ for (const part of node.types)
172
+ checkData(part, path, seen, renderProp, renderParameter);
173
+ return;
174
+ case "TSArrayType":
175
+ checkData(node.elementType, path, seen, false, renderParameter);
176
+ return;
177
+ case "TSTypeOperator":
178
+ checkData(node.typeAnnotation, path, seen, false, renderParameter);
179
+ return;
180
+ case "TSTypeLiteral":
181
+ case "TSInterfaceBody":
182
+ for (const member of node.type === "TSTypeLiteral" ? node.members : node.body)
183
+ checkDataMember(member, path, seen, renderParameter);
184
+ return;
185
+ default:
186
+ }
187
+ };
188
+ /** Walk `on`: every member is a function whose parameters are data. */
189
+ const checkActions = (node) => {
190
+ for (const member of membersOf(node, types)) {
191
+ const key = keyOf(member);
192
+ const type = member.type === "TSMethodSignature" ? member : memberType(member);
193
+ if (type?.type !== "TSFunctionType" && type?.type !== "TSMethodSignature") {
194
+ context.report({ node: member, messageId: "onData", data: { key } });
195
+ continue;
196
+ }
197
+ for (const parameter of type.params) {
198
+ checkData(parameterType(parameter), `on.${String(key)}(${parameterName(parameter) ?? "arg"})`, new Set());
199
+ }
200
+ }
201
+ };
202
+ return {
203
+ Program(node) {
204
+ types = collectLocalTypes(node);
205
+ },
206
+ VariableDeclarator(node) {
207
+ if (node.id?.type !== "Identifier" || !/^[A-Z][A-Za-z0-9]*Base$/.test(node.id.name))
208
+ return;
209
+ const fn = node.init;
210
+ if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression")
211
+ return;
212
+ const annotation = parameterType(fn.params[0]);
213
+ for (const member of membersOf(annotation, types)) {
214
+ const key = keyOf(member);
215
+ const type = memberType(member);
216
+ if (key === "shape")
217
+ continue;
218
+ if (key === "state") {
219
+ const stateKey = "key" in member ? member.key : undefined;
220
+ context.report({
221
+ node: member,
222
+ messageId: "renamed",
223
+ ...(stateKey?.type === "Identifier" ? { fix: (fixer) => fixer.replaceText(stateKey, "shape") } : {}),
224
+ });
225
+ continue;
226
+ }
227
+ if (key === "props")
228
+ checkData(type, "props", new Set());
229
+ else if (key === "on")
230
+ checkActions(type);
231
+ else if (key === "children" && tier === "layouts")
232
+ continue;
233
+ else {
234
+ context.report({
235
+ node: member,
236
+ messageId: "member",
237
+ data: { key, children: tier === "layouts" ? ", plus the router's `children`" : "" },
238
+ });
239
+ }
240
+ }
241
+ },
242
+ };
243
+ },
244
+ });
245
+ /** The name of an export specifier's identifier; undefined for a string-literal name. */
246
+ const identifierName = (node) => (node.type === "Identifier" ? node.name : undefined);
247
+ // -- DRAWING-2 ----------------------------------------------------------------------------------
248
+ /** Only the sibling `index.tsx` (and its specs) may reach `component.tsx`; only `X` leaves the folder. */
249
+ export const baseImportPair = defineRule({
250
+ meta: {
251
+ type: "problem",
252
+ docs: { description: "A pure `component.tsx` is imported only by its sibling `index.tsx`, which never re-exports `XBase`." },
253
+ schema: [],
254
+ messages: {
255
+ reach: "`{{source}}` reaches into another surface's pure half. Import that surface's connected export from its folder; the pure half is a private drawing surface.",
256
+ reexport: "`index.tsx` re-exports `{{name}}`. The folder exports only its connected `X`; the pure `XBase` stays behind it so no other tier can bypass the owner.",
257
+ },
258
+ },
259
+ create(context) {
260
+ const isOwnIndex = roleOfFile(context) === "entry";
261
+ const inTier = inSplitTier(context);
262
+ // An import reaches a pure half when the file it resolves to has the `drawing` role of its slot.
263
+ const reachesPure = (source) => classifyImport(context, source)?.role === "drawing";
264
+ const check = (node, source) => {
265
+ if (typeof source !== "string" || !reachesPure(source))
266
+ return;
267
+ const isSibling = /^\.\/[^/]+$/.test(source);
268
+ if (isSibling && isOwnIndex)
269
+ return;
270
+ if (isSibling && !inTier)
271
+ return;
272
+ context.report({ node, messageId: "reach", data: { source } });
273
+ };
274
+ return {
275
+ ImportDeclaration(node) {
276
+ check(node, node.source?.value);
277
+ },
278
+ ExportAllDeclaration(node) {
279
+ check(node, node.source?.value);
280
+ },
281
+ ExportNamedDeclaration(node) {
282
+ const source = node.source?.value;
283
+ if (!source)
284
+ return;
285
+ check(node, source);
286
+ if (!isOwnIndex || !inTier || node.exportKind === "type")
287
+ return;
288
+ for (const specifier of node.specifiers ?? []) {
289
+ const name = identifierName(specifier.exported) ?? identifierName(specifier.local);
290
+ if (specifier.exportKind !== "type" && name !== undefined && /^[A-Z][A-Za-z0-9]*Base$/.test(name)) {
291
+ context.report({ node: specifier, messageId: "reexport", data: { name } });
292
+ }
293
+ }
294
+ },
295
+ };
296
+ },
297
+ });
298
+ // -- DRAWING-3 ----------------------------------------------------------------------------------
299
+ /** Words that name a data status or an open/closed lifecycle, never a drawn shape. */
300
+ const DATA_STATUS = new Set([
301
+ "pending", "loading", "failed", "error", "errored", "empty", "forbidden", "unavailable",
302
+ "skeleton", "closed", "idle", "refreshing", "retrying",
303
+ ]);
304
+ /** The name of a pure half's shape union: `XShape`, and still the retired `XState`, so a status word is caught during the rename. */
305
+ const SHAPE_NAME = /^[A-Z][A-Za-z0-9]*(?:Shape|State)$/;
306
+ /** A boolean member that names a status or an open/closed lifecycle: `isOpen`, `isLoading`, `isRenewing`. */
307
+ const STATUS_FLAG = /^is[A-Z]/;
308
+ /** A surface's `XShape` names drawn shapes only: no data status, no open/closed, no status flag. */
309
+ export const noDataStatusShape = defineRule({
310
+ meta: {
311
+ type: "problem",
312
+ docs: { description: "`XShape` of a pure half (a union, an interface or an inline `shape` object) names drawn shapes, never a data status, open/closed or a status flag." },
313
+ schema: [],
314
+ messages: {
315
+ status: "`{{name}}` lists \"{{value}}\" as a shape. Loading, forbidden, error and empty describe resource status: pass them as data to the owned status renderer. Open/closed is a boolean atom. A shape is a layout the owner approves as one drawing.",
316
+ flag: "`{{name}}` carries the flag `{{key}}`. A shape names a drawing, never a status or open/closed flag: a read's status goes through its DataSlot and SlotView, a pending mutation is a prop of its control, and open/closed is a boolean atom in `props`.",
317
+ },
318
+ },
319
+ create(context) {
320
+ if (!pureHalf(context))
321
+ return {};
322
+ const checkMembers = (name, members) => {
323
+ for (const member of members) {
324
+ const key = keyOf(member);
325
+ const type = memberType(member);
326
+ if ((typeof key === "string" && STATUS_FLAG.test(key)) || type?.type === "TSBooleanKeyword") {
327
+ context.report({ node: member, messageId: "flag", data: { name, key: String(key) } });
328
+ }
329
+ else if (type !== undefined) {
330
+ check(name, type);
331
+ }
332
+ }
333
+ };
334
+ const check = (name, node) => {
335
+ if (node.type === "TSTypeLiteral") {
336
+ checkMembers(name, node.members);
337
+ return;
338
+ }
339
+ const members = node.type === "TSUnionType" ? node.types : [node];
340
+ for (const member of members) {
341
+ if (member.type === "TSTypeLiteral") {
342
+ checkMembers(name, member.members);
343
+ continue;
344
+ }
345
+ const value = member.type === "TSLiteralType" && "value" in member.literal ? member.literal.value : undefined;
346
+ if (typeof value === "string" && DATA_STATUS.has(value)) {
347
+ context.report({ node: member, messageId: "status", data: { name, value } });
348
+ }
349
+ }
350
+ };
351
+ return {
352
+ TSTypeAliasDeclaration(node) {
353
+ if (SHAPE_NAME.test(node.id.name))
354
+ check(node.id.name, node.typeAnnotation);
355
+ },
356
+ TSInterfaceDeclaration(node) {
357
+ if (SHAPE_NAME.test(node.id.name))
358
+ checkMembers(node.id.name, node.body.body);
359
+ },
360
+ TSPropertySignature(node) {
361
+ const type = node.typeAnnotation?.typeAnnotation;
362
+ if (keyOf(node) !== "shape" || type === undefined || type.type === "TSTypeReference")
363
+ return;
364
+ check("shape", type);
365
+ },
366
+ };
367
+ },
368
+ });
369
+ /** The rules this law contributes to the plugin. */
370
+ export const rules = {
371
+ "base-props-atom": basePropsAtom,
372
+ "base-import-pair": baseImportPair,
373
+ "no-data-status-shape": noDataStatusShape,
374
+ };
375
+ /** Every FE canon rule is an error; existing debt is fixed before adoption. */
376
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The rules that hold `env-owner.md` (HFS R49, `FE_ENV_OWNER`).
3
+ *
4
+ * TWO FACTS, ONE OWNER. The environment is read in exactly one place, `modules/config`, and what it
5
+ * reads has no default that stands in for a real deployment value. They are one law because they
6
+ * fail together: an app with nine copies of `process.env.API_URL ?? "http://localhost:3068"` had
7
+ * nine readers and nine silent defaults, so a production build with the variable missing talked to
8
+ * a developer's laptop and looked healthy.
9
+ *
10
+ * The rules cannot see whether `modules/config` throws when a variable is missing in production -
11
+ * that is a behaviour, and the module's own spec is where it is proven. What they hold is the
12
+ * shape that makes it provable: one reader, and no literal fallback next to it.
13
+ */
14
+ import { type Recommended, type Rules } from "./lib/rule.js";
15
+ /** Only `modules/config` reads the environment. */
16
+ export declare const noEnvOutsideConfig: import("./lib/rule.js").Rule<"env", []>;
17
+ /** A fallback never stands in for a deployment address or secret. */
18
+ export declare const noHardcodedEndpointFallback: import("./lib/rule.js").Rule<"url" | "variable", []>;
19
+ /** The rules this law contributes to the plugin. */
20
+ export declare const rules: Rules;
21
+ /** Both are exact syntactic shapes, and both are errors. */
22
+ export declare const recommended: Recommended;