@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.4.0 - 2026-10-28
4
+
5
+ - Changed: the package is TypeScript. `src/**/*.ts` (rules, the shared `lib/` readers and every spec) is strict and builds to `dist/` with the `@starci/tsconfig` `package-esm` preset; `main`, `types` and `exports` point at `dist`, keeping `@starci/eslint-canon-fe/lib/*.mjs` (also `lib/*.js`). The fixture tester is `fixtures/typed/tester.ts`. The bundled runtime is read through the package import `#runtime/*`, typed by `src/types/runtime` until the runtime is built from `@starci/hfs`. Rules are typed against typescript-eslint's rule module; `@typescript-eslint/utils` joins the dependencies. No rule changed behaviour in the conversion.
6
+ - Changed: `base-props-atom` fixes the retired `state` key by renaming it `shape`; `no-data-status-shape` also reads an `interface XShape`, an inline `shape: { ... }` object and a boolean status member (`isOpen`, `isRenewing`) in a shape, and still the old `XState` names.
7
+ - Changed: `presentational-purity` also refuses an import from `@/hooks` in a pure half, `import type` included.
8
+ - Added: the shape-slot law (R92): `exhaustive-shape-switch` (a pure half's branch on `props.shape` / `shape` draws every `XShape` member; no default stands in for one), `no-data-status-prop` (no `isLoading`, `isError`, `isForbidden`, `isFailed` or `failure` prop below SlotView; a leaf takes only `isSkeleton`), `region-reads-through-slot` (a connected half that calls an `*Swr` hook folds the read into a DataSlot; a pure half that takes a DataSlot renders SlotView) and `branch-intrinsic-state` (a branch keeps interaction state only; a leaf calls no hook).
9
+ - Fixed: the built declarations name `.js`, not the `.ts` sources (`tsc` keeps a relative `.ts` specifier in a `.d.ts`).
10
+ - Changed: the bundled canon pins follow release C (Grammar family 0.15.0, hfs 4.9.1, canon-fe 9.4.0, canon-be 3.6.0, jest-preset 2.4.1, stylelint-canon 2.2.1, tsconfig 2.1.0, prettier-config 1.1.0, and `@starci/ai-router` 0.2.1 pinned), so an app installs the released versions without pin drift.
11
+
12
+ ## 9.3.0 - 2026-10-28
13
+
14
+ - Changed (breaking for apps): owner ruling fe-shape-not-state. A pure half takes `{ shape?, props, on? }`; `base-props-atom` refuses the retired `state` key with a message naming the rename, and `no-data-status-shape` reads `XShape` unions (and still the old `XState` names, so a status word is caught during the rename).
15
+ - Changed: the bundled runtime copies follow the retirement of `@starci/cli`: the bundled canon pins replace the retired `@starci/cli` with `starci-harness` 1.1.0-alpha.1 (`source: cli/package.json`), the one command line a product installs for `starci app`, and follow the harness packages (Grammar family 0.14.0, hfs 4.9.0, canon-fe 9.3.0, canon-be 3.5.0); the runtime finds an app's `starci` bin in its installed `starci-harness`.
16
+
3
17
  ## 9.2.0 - 2026-10-27
4
18
 
5
19
  - Changed: owner ruling two-grammars: the bundled Grammar catalog declares exactly two families, `core` (`@starci/eslint-grammar-core`, `coreGrammarConfig`) and `offset-pop`; `starci`, `starci-core` and `heritage` are refused. The Grammar entry checks (button intent, control height, surface rules, UI rulings) accept `@starci/grammar/<common|core|offset-pop>`. The bundled canon pins follow the harness packages (Grammar family 0.13.0, hfs 4.8.0, canon-fe 9.2.0, canon-be 3.4.57, stylelint-canon 2.1.0).
package/README.md CHANGED
@@ -24,7 +24,11 @@ bare IDs under the `starci-grammar` namespace. This package exports shared AST,
24
24
  slot and configuration infrastructure through `@starci/eslint-canon-fe/lib/*`; family packages
25
25
  import those readers without copying them.
26
26
 
27
- Every universal law remains gathered by `index.mjs`; its recommendation enables every rule.
27
+ Every universal law remains gathered by `src/index.ts`; its recommendation enables every rule.
28
+ The package is TypeScript: `src/**/*.ts` builds to `dist/` (`npm run build`), the published entry and
29
+ the `lib/*` readers are the built files, `npm run typecheck` holds the source and specs strict, and
30
+ `npm test` runs the specs on the source. The bundled `runtime/` is read through the package import
31
+ `#runtime/*`.
28
32
  The `docs/` directory carries the detailed behavior of the documented laws. `why` supplies
29
33
  failure codes and English guidance for the catalogued rules.
30
34
 
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The rule that holds `brand-values.md` (HFS R61 `FE_STYLE_TOKEN_ONLY`, the TypeScript half; the CSS
3
+ * half belongs to `@starci/stylelint-canon`).
4
+ *
5
+ * ONE PLACE FOR A COLOUR. Colour values exist in `modules/brand/brand.css`, set only on the tokens
6
+ * the grammar declares, with a light and a dark value, and checked against the brand record. A hex
7
+ * in a component is a colour that has no dark value, cannot be re-branded, and drifts from the
8
+ * record: one repository carried three different ways to override the brand, and another 4.3k lines
9
+ * of hand-made CSS with a private token namespace.
10
+ *
11
+ * LENGTHS FOLLOW. A pixel length in TypeScript (`"12px"`, `{ width: 12 }` in a `style` prop) is a
12
+ * spacing decision that skipped the grammar's scale, so it does not move with density, zoom or a
13
+ * breakpoint. Spacing and sizing come from the grammar's utility tokens.
14
+ *
15
+ * WHAT IT DELIBERATELY LEAVES ALONE: a fragment identifier (`href="#top"`, `id`, `htmlFor`), a
16
+ * `data:` URI, a component's semantic appearance prop (a named role rather than a raw colour), and
17
+ * Tailwind arbitrary values (`w-[12px]`), which `no-arbitrary-value` already reports - two rules
18
+ * for one span would be two findings for one fix.
19
+ */
20
+ import { type Recommended } from "./lib/rule.js";
21
+ /** No raw colour, hex or pixel value outside `brand.css`. */
22
+ export declare const noRawBrandValue: import("./lib/rule.js").Rule<"color" | "length" | "named", []>;
23
+ /** The rules this law contributes to the plugin. */
24
+ export declare const rules: {
25
+ "no-raw-brand-value": import("./lib/rule.js").Rule<"color" | "length" | "named", []>;
26
+ };
27
+ /** Every rule is an error. */
28
+ export declare const recommended: Recommended;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The rule that holds `brand-values.md` (HFS R61 `FE_STYLE_TOKEN_ONLY`, the TypeScript half; the CSS
3
+ * half belongs to `@starci/stylelint-canon`).
4
+ *
5
+ * ONE PLACE FOR A COLOUR. Colour values exist in `modules/brand/brand.css`, set only on the tokens
6
+ * the grammar declares, with a light and a dark value, and checked against the brand record. A hex
7
+ * in a component is a colour that has no dark value, cannot be re-branded, and drifts from the
8
+ * record: one repository carried three different ways to override the brand, and another 4.3k lines
9
+ * of hand-made CSS with a private token namespace.
10
+ *
11
+ * LENGTHS FOLLOW. A pixel length in TypeScript (`"12px"`, `{ width: 12 }` in a `style` prop) is a
12
+ * spacing decision that skipped the grammar's scale, so it does not move with density, zoom or a
13
+ * breakpoint. Spacing and sizing come from the grammar's utility tokens.
14
+ *
15
+ * WHAT IT DELIBERATELY LEAVES ALONE: a fragment identifier (`href="#top"`, `id`, `htmlFor`), a
16
+ * `data:` URI, a component's semantic appearance prop (a named role rather than a raw colour), and
17
+ * Tailwind arbitrary values (`w-[12px]`), which `no-arbitrary-value` already reports - two rules
18
+ * for one span would be two findings for one fix.
19
+ */
20
+ import { isContentFile } from "./comments.js";
21
+ import { defineRule } from "./lib/rule.js";
22
+ /** A hex colour: 3, 4, 6 or 8 digits, not a longer word and not part of an identifier. */
23
+ const HEX_COLOR = /(?<![\w&])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/;
24
+ /** A functional colour notation. */
25
+ const COLOR_FUNCTION = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\(/i;
26
+ /** A pixel length, not inside an arbitrary-value bracket and not part of a longer number or word. */
27
+ const PIXEL_LENGTH = /(?<![\w.#[-])-?(?:\d+\.?\d*|\.\d+)px\b/;
28
+ /** Attributes whose value is a fragment or an id, where a `#abc` is not a colour. */
29
+ const NON_COLOR_ATTRS = new Set(["href", "id", "htmlFor", "to", "name", "aria-controls", "aria-labelledby", "aria-describedby"]);
30
+ /** SVG paint attributes: a named colour here is a raw brand value. */
31
+ const PAINT_ATTRS = new Set(["fill", "stroke", "stopColor", "floodColor", "lightingColor"]);
32
+ /** Style properties that take a colour. */
33
+ const COLOR_KEYS = new Set([
34
+ "color",
35
+ "background",
36
+ "backgroundColor",
37
+ "borderColor",
38
+ "outlineColor",
39
+ "fill",
40
+ "stroke",
41
+ "caretColor",
42
+ "accentColor",
43
+ "textDecorationColor",
44
+ ]);
45
+ /** Style properties that take a length. */
46
+ const LENGTH_KEYS = new Set([
47
+ "width",
48
+ "height",
49
+ "minWidth",
50
+ "maxWidth",
51
+ "minHeight",
52
+ "maxHeight",
53
+ "top",
54
+ "right",
55
+ "bottom",
56
+ "left",
57
+ "gap",
58
+ "margin",
59
+ "marginTop",
60
+ "marginRight",
61
+ "marginBottom",
62
+ "marginLeft",
63
+ "padding",
64
+ "paddingTop",
65
+ "paddingRight",
66
+ "paddingBottom",
67
+ "paddingLeft",
68
+ "fontSize",
69
+ "borderRadius",
70
+ "letterSpacing",
71
+ ]);
72
+ /** Values that carry no raw colour: a token reference or a keyword. */
73
+ const NEUTRAL_COLOR = /^(?:var\(|inherit$|initial$|unset$|revert$|currentcolor$|transparent$|none$)/i;
74
+ /** True when a JSX attribute name is an identifier the set holds (a namespaced name never is). */
75
+ const namedIn = (names, name) => name.type === "JSXIdentifier" && names.has(name.name);
76
+ /** Static string of a literal or an expression-free template, else null. */
77
+ const staticString = (node) => {
78
+ if (!node)
79
+ return null;
80
+ if (node.type === "Literal" && typeof node.value === "string")
81
+ return node.value;
82
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0)
83
+ return node.quasis[0]?.value.cooked ?? "";
84
+ return null;
85
+ };
86
+ /** No raw colour, hex or pixel value outside `brand.css`. */
87
+ export const noRawBrandValue = defineRule({
88
+ meta: {
89
+ type: "problem",
90
+ docs: { description: "No hex, colour function, named paint colour or px length in TypeScript; tokens only." },
91
+ schema: [],
92
+ messages: {
93
+ color: "A raw colour value (`{{value}}`). Colour values exist only in `modules/brand/brand.css`, on the tokens the grammar declares, with a light and a dark value. Here it has no dark value, cannot be re-branded and drifts from the brand record. Use a grammar token class or variable.",
94
+ length: "A raw pixel length (`{{value}}`). Spacing and size come from the grammar's scale so they move with density, zoom and breakpoint. Use a grammar utility token.",
95
+ named: "`{{key}}: {{value}}` puts a named colour in a style. Colour is a token, not a word: use a grammar token (`var(--...)` or a token class).",
96
+ },
97
+ },
98
+ create(context) {
99
+ if (isContentFile(context))
100
+ return {};
101
+ const source = context.sourceCode || context.getSourceCode();
102
+ /** Values already reported through an owner, so a string reports once. */
103
+ const claimed = new WeakSet();
104
+ /** True when a node sits inside a JSX `style={...}` attribute. */
105
+ const inStyleAttribute = (node) => source
106
+ .getAncestors(node)
107
+ .some((ancestor) => ancestor.type === "JSXAttribute" && ancestor.name.type === "JSXIdentifier" && ancestor.name.name === "style");
108
+ /** Reports the first raw value in a string, once. */
109
+ const scan = (node, text) => {
110
+ if (claimed.has(node) || typeof text !== "string" || text.startsWith("data:"))
111
+ return;
112
+ const parent = node.parent;
113
+ if (parent && parent.type === "JSXAttribute" && namedIn(NON_COLOR_ATTRS, parent.name))
114
+ return;
115
+ if (parent && parent.type === "JSXExpressionContainer" && parent.parent.type === "JSXAttribute") {
116
+ if (namedIn(NON_COLOR_ATTRS, parent.parent.name))
117
+ return;
118
+ }
119
+ if (parent && (parent.type === "ImportDeclaration" || parent.type === "ExportAllDeclaration" || parent.type === "ExportNamedDeclaration"))
120
+ return;
121
+ const color = HEX_COLOR.exec(text) || COLOR_FUNCTION.exec(text);
122
+ if (color)
123
+ return context.report({ node, messageId: "color", data: { value: color[0].replace(/\($/, "(...)") } });
124
+ const length = PIXEL_LENGTH.exec(text);
125
+ if (length)
126
+ context.report({ node, messageId: "length", data: { value: length[0] } });
127
+ };
128
+ return {
129
+ Literal(node) {
130
+ scan(node, node.value);
131
+ },
132
+ TemplateElement(node) {
133
+ scan(node, node.value && node.value.cooked);
134
+ },
135
+ JSXAttribute(node) {
136
+ if (node.name.type !== "JSXIdentifier" || !PAINT_ATTRS.has(node.name.name))
137
+ return;
138
+ const value = node.value && node.value.type === "JSXExpressionContainer" ? node.value.expression : node.value;
139
+ const text = staticString(value);
140
+ if (text === null || NEUTRAL_COLOR.test(text) || HEX_COLOR.test(text) || COLOR_FUNCTION.test(text))
141
+ return;
142
+ if (value)
143
+ claimed.add(value);
144
+ context.report({ node, messageId: "named", data: { key: node.name.name, value: text } });
145
+ },
146
+ Property(node) {
147
+ if (node.computed || node.key.type !== "Identifier" || !inStyleAttribute(node))
148
+ return;
149
+ const key = node.key.name;
150
+ if (COLOR_KEYS.has(key)) {
151
+ const text = staticString(node.value);
152
+ if (text === null || NEUTRAL_COLOR.test(text) || HEX_COLOR.test(text) || COLOR_FUNCTION.test(text))
153
+ return;
154
+ claimed.add(node.value);
155
+ context.report({ node, messageId: "named", data: { key, value: text } });
156
+ }
157
+ else if (LENGTH_KEYS.has(key) && node.value.type === "Literal" && typeof node.value.value === "number") {
158
+ if (node.value.value === 0)
159
+ return;
160
+ claimed.add(node.value);
161
+ context.report({ node: node.value, messageId: "length", data: { value: `${key}: ${node.value.value}` } });
162
+ }
163
+ },
164
+ };
165
+ },
166
+ });
167
+ /** The rules this law contributes to the plugin. */
168
+ export const rules = {
169
+ "no-raw-brand-value": noRawBrandValue,
170
+ };
171
+ /** Every rule is an error. */
172
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,11 @@
1
+ import { type Recommended } from "./lib/rule.js";
2
+ /** Every intent law is universal across Grammar families and app editions. */
3
+ export declare const rules: {
4
+ "button-intent-required": import("./lib/rule.js").Rule<"FE_BUTTON_INTENT_REQUIRED", []>;
5
+ "button-no-variant": import("./lib/rule.js").Rule<"FE_BUTTON_NO_VARIANT", []>;
6
+ "destructive-confirm-in-dialog": import("./lib/rule.js").Rule<"FE_DESTRUCTIVE_CONFIRM_IN_DIALOG", []>;
7
+ "single-commit-per-render": import("./lib/rule.js").Rule<"FE_SINGLE_COMMIT_PER_RENDER", []>;
8
+ "no-commit-in-collection-item": import("./lib/rule.js").Rule<"FE_NO_COMMIT_IN_COLLECTION_ITEM", []>;
9
+ };
10
+ /** All five laws reach consumers at error. */
11
+ export declare const recommended: Recommended;
@@ -0,0 +1,361 @@
1
+ /** CTA-6: app buttons declare consequences; Grammar owns their visual variants. */
2
+ import { attributeValue, stringOf, walk } from "./lib/ast.js";
3
+ import { functionOf, importOf, isFn, patternPath, unwrap, variableOf } from "./lib/bindings.js";
4
+ import { typedPropertyStrings } from "./lib/typed-class-values.js";
5
+ import ts from "typescript";
6
+ import { defineRule } from "./lib/rule.js";
7
+ /** The same-file variable definition of an identifier, or undefined. */
8
+ const variableDefinition = (context, node) => variableOf(context, node)?.defs.find((entry) => entry.type === "Variable");
9
+ /** The name a member expression reads: its static computed text or its identifier. */
10
+ const memberName = (node) => (node.computed ? stringOf(node.property) : node.property.name);
11
+ const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|offset-pop))?$/;
12
+ const BUTTONS = new Set(["Button", "IconButton"]);
13
+ const DIALOGS = new Set(["AlertDialog", "ConfirmDialog"]);
14
+ const ITEMS = new Set(["CollectionItem", "ItemActions", "ListItem", "ListRow", "ListBox.Item", "GridList.Item", "Table.Row", "TableRow"]);
15
+ /** The consuming TypeScript program exposes literal intents and spread properties without executing app code. */
16
+ const typedInfo = (context, node, key) => {
17
+ const services = context.sourceCode.parserServices;
18
+ if (!services?.program || !services.esTreeNodeToTSNodeMap || !node)
19
+ return null;
20
+ const checker = services.program.getTypeChecker();
21
+ const source = services.esTreeNodeToTSNodeMap.get(node);
22
+ const owner = checker.getTypeAtLocation(source);
23
+ const symbol = key === undefined ? null : owner.getProperty(key);
24
+ if (key !== undefined && !symbol)
25
+ return null;
26
+ const type = symbol ? checker.getTypeOfSymbolAtLocation(symbol, source) : owner;
27
+ const members = type.isUnion() ? type.types : [type];
28
+ return { values: members.filter((member) => member.isStringLiteral()).map((member) => member.value), optional: Boolean(symbol && (symbol.flags & ts.SymbolFlags.Optional) !== 0) };
29
+ };
30
+ /** Resolve the imported Grammar role without confusing a shadowed local component with its namesake. */
31
+ const roleOf = (context, name) => {
32
+ const parts = [];
33
+ let root = name;
34
+ while (root.type === "JSXMemberExpression") {
35
+ parts.unshift(root.property.name);
36
+ root = root.object;
37
+ }
38
+ if (root.type !== "JSXIdentifier")
39
+ return null;
40
+ const imported = importOf(context, root);
41
+ if (!imported || !GRAMMAR_ENTRY.test(imported.source))
42
+ return null;
43
+ if (imported.imported)
44
+ parts.unshift(imported.imported);
45
+ else if (!variableOf(context, root)?.defs.some((definition) => definition.node.type === "ImportNamespaceSpecifier"))
46
+ return null;
47
+ return parts.join(".");
48
+ };
49
+ /** Static object properties, including aliases and spreads, retain their button obligations. */
50
+ const objectProperty = (context, input, key, seen = new Set()) => {
51
+ const node = unwrap(input);
52
+ if (!node || seen.has(node))
53
+ return [];
54
+ const next = new Set(seen).add(node);
55
+ if (node.type === "ObjectExpression")
56
+ return node.properties.flatMap((property) => {
57
+ if (property.type === "SpreadElement")
58
+ return objectProperty(context, property.argument, key, next);
59
+ const name = property.computed ? stringOf(property.key) : property.key.type === "Identifier" ? property.key.name : property.key.value;
60
+ return name === key ? [property.value] : [];
61
+ });
62
+ if (node.type === "ConditionalExpression")
63
+ return [node.consequent, node.alternate].flatMap((branch) => objectProperty(context, branch, key, next));
64
+ if (node.type === "MemberExpression") {
65
+ const member = memberName(node);
66
+ return objectProperty(context, node.object, member, next).flatMap((value) => objectProperty(context, value, key, next));
67
+ }
68
+ if (node.type !== "Identifier")
69
+ return [];
70
+ const definition = variableDefinition(context, node);
71
+ if (!definition)
72
+ return [];
73
+ const path = patternPath(definition.node.id, node.name);
74
+ if (path === null)
75
+ return [];
76
+ const values = path.reduce((entries, name) => entries.flatMap((entry) => objectProperty(context, entry, name, next)), [definition.node.init]);
77
+ return values.flatMap((value) => objectProperty(context, value, key, next));
78
+ };
79
+ /** Possible statically authored values of an intent or step; dynamic values remain TypeScript's responsibility. */
80
+ const valuesOf = (context, input, seen = new Set()) => {
81
+ const node = unwrap(input);
82
+ if (!node || seen.has(node))
83
+ return [];
84
+ const next = new Set(seen).add(node);
85
+ const text = stringOf(node);
86
+ if (text !== null)
87
+ return [text];
88
+ if (node.type === "ConditionalExpression")
89
+ return [node.consequent, node.alternate].flatMap((branch) => valuesOf(context, branch, next));
90
+ if (node.type === "LogicalExpression")
91
+ return valuesOf(context, node.right, next);
92
+ if (node.type === "MemberExpression") {
93
+ const name = memberName(node);
94
+ const values = objectProperty(context, node.object, name, next).flatMap((value) => valuesOf(context, value, next));
95
+ return values.length ? values : typedInfo(context, node)?.values ?? [];
96
+ }
97
+ if (node.type !== "Identifier")
98
+ return typedInfo(context, node)?.values ?? [];
99
+ const definition = variableDefinition(context, node);
100
+ if (!definition)
101
+ return typedInfo(context, node)?.values ?? [];
102
+ const path = patternPath(definition.node.id, node.name);
103
+ if (path === null)
104
+ return [];
105
+ const values = path.reduce((entries, name) => entries.flatMap((entry) => objectProperty(context, entry, name, next)), [definition.node.init]);
106
+ return values.flatMap((value) => valuesOf(context, value, next));
107
+ };
108
+ /** An explicit attribute or statically resolved spread exposing a prop. */
109
+ const propsOf = (context, opening, key) => opening.attributes.flatMap((entry) => {
110
+ if (entry.type === "JSXAttribute")
111
+ return entry.name.name === key ? [{ node: entry, value: attributeValue(entry) }] : [];
112
+ const values = objectProperty(context, entry.argument, key);
113
+ if (values.length)
114
+ return values.map((value) => ({ node: entry, value }));
115
+ const authored = typedPropertyStrings(context, entry.argument, key, "variant");
116
+ if (authored.length)
117
+ return authored.map((value) => ({ node: entry, text: value.text }));
118
+ const typed = typedInfo(context, entry.argument, key);
119
+ return typed ? [{ node: entry, texts: typed.values }] : [];
120
+ });
121
+ const hasValue = (context, opening, key, value) => propsOf(context, opening, key).some((prop) => prop.text === value || prop.texts?.includes(value) || valuesOf(context, prop.value).includes(value));
122
+ const isButton = (context, opening) => BUTTONS.has(roleOf(context, opening.name));
123
+ /** A conditional props spread supplies intent only when every branch supplies it. */
124
+ const suppliesIntent = (context, input, seen = new Set()) => {
125
+ const node = unwrap(input);
126
+ if (!node || seen.has(node))
127
+ return false;
128
+ const next = new Set(seen).add(node);
129
+ if (node.type === "ConditionalExpression")
130
+ return [node.consequent, node.alternate].every((branch) => suppliesIntent(context, branch, next));
131
+ if (node.type === "Identifier") {
132
+ const definition = variableDefinition(context, node);
133
+ if (definition?.node.id.type === "Identifier")
134
+ return suppliesIntent(context, definition.node.init, next);
135
+ const typed = typedInfo(context, node, "intent");
136
+ return Boolean(typed && !typed.optional);
137
+ }
138
+ return objectProperty(context, node, "intent").length > 0 || typedPropertyStrings(context, node, "intent", "variant").length > 0;
139
+ };
140
+ const hasIntent = (context, opening) => opening.attributes.some((entry) => {
141
+ if (entry.type === "JSXSpreadAttribute")
142
+ return suppliesIntent(context, entry.argument);
143
+ if (entry.name.name !== "intent")
144
+ return false;
145
+ const value = unwrap(attributeValue(entry));
146
+ return value !== null && value !== undefined && !(value.type === "Literal" && value.value === null) && !(value.type === "Identifier" && value.name === "undefined");
147
+ });
148
+ /** JSX child render callbacks contribute content to their enclosing component render. */
149
+ const inlineRenderCallback = (node) => node.parent?.type === "JSXExpressionContainer" && node.parent.parent?.type !== "JSXAttribute";
150
+ const lexicalFunction = (node) => {
151
+ for (let current = node.parent; current; current = current.parent)
152
+ if (isFn(current))
153
+ return current;
154
+ return null;
155
+ };
156
+ /** The nearest function owns a render; a component defined in a dialog is still its own subtree. */
157
+ const functionOwner = (node) => {
158
+ for (let current = node.parent; current; current = current.parent)
159
+ if (isFn(current) && !inlineRenderCallback(current))
160
+ return current;
161
+ return null;
162
+ };
163
+ /** A trigger opens a dialog and is outside its confirmation subtree. */
164
+ const insideDialog = (context, opening) => {
165
+ const triggers = new Set();
166
+ for (let current = opening.parent; current; current = current.parent) {
167
+ if (isFn(current) && !inlineRenderCallback(current))
168
+ return false;
169
+ if (current.type === "JSXAttribute" && current.name.name === "trigger")
170
+ triggers.add(current.parent);
171
+ if (current.type === "JSXElement" && !triggers.has(current.openingElement) && DIALOGS.has(roleOf(context, current.openingElement.name)))
172
+ return true;
173
+ }
174
+ return false;
175
+ };
176
+ /** Normalize a condition so `ready` and `!ready` select opposite render paths. */
177
+ const guardOf = (context, input, positive) => {
178
+ const node = unwrap(input);
179
+ if (node.type === "UnaryExpression" && node.operator === "!")
180
+ return guardOf(context, node.argument, !positive);
181
+ if (node.type === "BinaryExpression" && ["===", "==", "!==", "!="].includes(node.operator)) {
182
+ const literal = node.right.type === "Literal" ? node.right : node.left.type === "Literal" ? node.left : null;
183
+ if (literal) {
184
+ const selector = context.sourceCode.getText(literal === node.right ? node.left : node.right).replace(/\s+/g, "");
185
+ const choice = JSON.stringify(literal.value);
186
+ const value = node.operator.startsWith("!") ? !positive : positive;
187
+ return { key: `${selector}===${choice}`, value, selector, choice };
188
+ }
189
+ }
190
+ return { key: context.sourceCode.getText(node).replace(/\s+/g, ""), value: positive };
191
+ };
192
+ const constrainPath = (context, path, test, positive) => {
193
+ const guard = guardOf(context, test, positive);
194
+ path.guards.set(guard.key, guard.value);
195
+ if (guard.selector && guard.value)
196
+ path.choices.set(guard.selector, guard.choice);
197
+ return path;
198
+ };
199
+ /** Conditional intent values retain the guards selecting commit instead of flattening both alternatives. */
200
+ const valuePaths = (context, input, seen = new Set()) => {
201
+ const node = unwrap(input);
202
+ if (!node || seen.has(node))
203
+ return [];
204
+ const next = new Set(seen).add(node);
205
+ if (node.type === "ConditionalExpression")
206
+ return [node.consequent, node.alternate].flatMap((branch, index) => valuePaths(context, branch, next).map((path) => constrainPath(context, path, node.test, index === 0)));
207
+ if (node.type === "Identifier") {
208
+ const definition = variableDefinition(context, node);
209
+ if (definition?.node.id.type === "Identifier")
210
+ return valuePaths(context, definition.node.init, next);
211
+ }
212
+ return valuesOf(context, node).includes("commit") ? [{ guards: new Map(), choices: new Map() }] : [];
213
+ };
214
+ /** Branch guards and return ownership identify mutually exclusive render paths. */
215
+ const renderPath = (context, opening, owner) => {
216
+ const guards = new Map();
217
+ const choices = new Map();
218
+ const path = { guards, choices };
219
+ let returned = null;
220
+ let child = opening;
221
+ for (let current = opening.parent; current && current !== owner; current = current.parent) {
222
+ if (current.type === "ReturnStatement" && lexicalFunction(current) === owner)
223
+ returned = current;
224
+ if (current.type === "ConditionalExpression" && child !== current.test)
225
+ constrainPath(context, path, current.test, child === current.consequent);
226
+ if (current.type === "IfStatement" && child !== current.test)
227
+ constrainPath(context, path, current.test, child === current.consequent);
228
+ if (current.type === "LogicalExpression" && child === current.right && current.operator !== "??")
229
+ constrainPath(context, path, current.left, current.operator === "&&");
230
+ child = current;
231
+ }
232
+ return { guards, choices, returned };
233
+ };
234
+ const canCoexist = (left, right) => {
235
+ if (left.returned && right.returned && left.returned !== right.returned)
236
+ return false;
237
+ if ([...left.choices].some(([selector, choice]) => right.choices.has(selector) && right.choices.get(selector) !== choice))
238
+ return false;
239
+ return [...left.guards].every(([condition, value]) => !right.guards.has(condition) || right.guards.get(condition) === value);
240
+ };
241
+ /** No explicit visual prop: any authored variant, including a known props spread, is an app bypass. */
242
+ const buttonNoVariant = defineRule({
243
+ meta: { type: "problem", docs: { description: "CTA-6: app buttons never choose a variant." }, schema: [], messages: {
244
+ FE_BUTTON_NO_VARIANT: "FE_BUTTON_NO_VARIANT: declare Button intent instead of variant; Grammar resolves the variant from intent and placement.",
245
+ } },
246
+ create(context) {
247
+ return { JSXOpeningElement(opening) {
248
+ if (!isButton(context, opening))
249
+ return;
250
+ for (const node of new Set(propsOf(context, opening, "variant").map((prop) => prop.node)))
251
+ context.report({ node, messageId: "FE_BUTTON_NO_VARIANT" });
252
+ } };
253
+ },
254
+ });
255
+ /** Every app button states its command's intent explicitly. */
256
+ const buttonIntentRequired = defineRule({
257
+ meta: { type: "problem", docs: { description: "CTA-6: every app Button and IconButton declares intent." }, schema: [], messages: {
258
+ FE_BUTTON_INTENT_REQUIRED: "FE_BUTTON_INTENT_REQUIRED: Button and IconButton require intent (commit, alternative, dismiss, neutral or destructive). Choose it from the command's consequence and rank.",
259
+ } },
260
+ create(context) {
261
+ return { JSXOpeningElement(opening) {
262
+ if (isButton(context, opening) && !hasIntent(context, opening))
263
+ context.report({ node: opening, messageId: "FE_BUTTON_INTENT_REQUIRED" });
264
+ } };
265
+ },
266
+ });
267
+ /** A destructive final press is contained by its same-file confirmation dialog. */
268
+ const destructiveConfirmInDialog = defineRule({
269
+ meta: { type: "problem", docs: { description: "CTA-6: destructive confirm buttons stay inside AlertDialog or ConfirmDialog." }, schema: [], messages: {
270
+ FE_DESTRUCTIVE_CONFIRM_IN_DIALOG: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG: a destructive confirm belongs inside the same-file AlertDialog or ConfirmDialog subtree. The trigger uses step=\"entry\".",
271
+ } },
272
+ create(context) {
273
+ return { JSXOpeningElement(opening) {
274
+ if (!isButton(context, opening) || !hasValue(context, opening, "intent", "destructive") || !hasValue(context, opening, "step", "confirm"))
275
+ return;
276
+ if (!insideDialog(context, opening))
277
+ context.report({ node: opening, messageId: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG" });
278
+ } };
279
+ },
280
+ });
281
+ /** A component render exposes one next action, with exclusive branches counted separately. */
282
+ const singleCommitPerRender = defineRule({
283
+ meta: { type: "problem", docs: { description: "CTA-6: at most one commit is visible in a component render." }, schema: [], messages: {
284
+ FE_SINGLE_COMMIT_PER_RENDER: "FE_SINGLE_COMMIT_PER_RENDER: this render contains another commit action. Keep the one next action as commit and classify its alternatives by consequence.",
285
+ } },
286
+ create(context) {
287
+ const owners = new Map();
288
+ return { JSXOpeningElement(opening) {
289
+ if (!isButton(context, opening) || !hasValue(context, opening, "intent", "commit"))
290
+ return;
291
+ const owner = functionOwner(opening) ?? context.sourceCode.ast;
292
+ const base = renderPath(context, opening, owner);
293
+ const paths = propsOf(context, opening, "intent").flatMap((prop) => prop.value ? valuePaths(context, prop.value) : prop.text === "commit" || prop.texts?.includes("commit") ? [{ guards: new Map(), choices: new Map() }] : [])
294
+ .map((path) => ({ returned: base.returned, guards: new Map([...base.guards, ...path.guards]), choices: new Map([...base.choices, ...path.choices]) }));
295
+ const prior = owners.get(owner) ?? [];
296
+ if (paths.some((path) => prior.some((entry) => canCoexist(entry, path))))
297
+ context.report({ node: opening, messageId: "FE_SINGLE_COMMIT_PER_RENDER" });
298
+ prior.push(...paths);
299
+ owners.set(owner, prior);
300
+ } };
301
+ },
302
+ });
303
+ /** A repeated row's next action is alternative; the enclosing view keeps the commit. */
304
+ const noCommitInCollectionItem = defineRule({
305
+ meta: { type: "problem", docs: { description: "CTA-6: collection item renders cannot expose commit actions." }, schema: [], messages: {
306
+ FE_NO_COMMIT_IN_COLLECTION_ITEM: "FE_NO_COMMIT_IN_COLLECTION_ITEM: collection items cannot use commit. Use alternative for an item's next action and reserve commit for the view's decision.",
307
+ } },
308
+ create(context) {
309
+ const reported = new Set();
310
+ const report = (opening) => {
311
+ if (!reported.has(opening) && isButton(context, opening) && hasValue(context, opening, "intent", "commit")) {
312
+ reported.add(opening);
313
+ context.report({ node: opening, messageId: "FE_NO_COMMIT_IN_COLLECTION_ITEM" });
314
+ }
315
+ };
316
+ const scanCallback = (callback) => {
317
+ if (!callback)
318
+ return;
319
+ walk(context.sourceCode, callback.body, (node) => {
320
+ if (isFn(node) && !inlineRenderCallback(node))
321
+ return false;
322
+ if (node.type === "JSXOpeningElement")
323
+ report(node);
324
+ return undefined;
325
+ });
326
+ };
327
+ return {
328
+ JSXAttribute(attribute) {
329
+ if (attribute.name.name === "renderCell" && roleOf(context, attribute.parent.name) === "DataTable")
330
+ scanCallback(functionOf(context, attributeValue(attribute)));
331
+ },
332
+ JSXOpeningElement(opening) {
333
+ for (let current = opening.parent; current; current = current.parent) {
334
+ if (isFn(current) && !inlineRenderCallback(current))
335
+ return;
336
+ if (current.type === "JSXElement" && ITEMS.has(roleOf(context, current.openingElement.name)))
337
+ return report(opening);
338
+ }
339
+ },
340
+ CallExpression(call) {
341
+ const callee = unwrap(call.callee);
342
+ if (callee.type !== "MemberExpression")
343
+ return;
344
+ const name = memberName(callee);
345
+ if (name !== "map")
346
+ return;
347
+ scanCallback(functionOf(context, call.arguments[0]));
348
+ },
349
+ };
350
+ },
351
+ });
352
+ /** Every intent law is universal across Grammar families and app editions. */
353
+ export const rules = {
354
+ "button-intent-required": buttonIntentRequired,
355
+ "button-no-variant": buttonNoVariant,
356
+ "destructive-confirm-in-dialog": destructiveConfirmInDialog,
357
+ "single-commit-per-render": singleCommitPerRender,
358
+ "no-commit-in-collection-item": noCommitInCollectionItem,
359
+ };
360
+ /** All five laws reach consumers at error. */
361
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,7 @@
1
+ import { type Recommended, type Rules } from "./lib/rule.js";
2
+ /** A callback stays a PKCE exchange and redirect, with every side effect owned by the auth helper. */
3
+ export declare const callbackShape: import("./lib/rule.js").Rule<"shape", []>;
4
+ /** The rule R287 contributes to the front-end plugin. */
5
+ export declare const rules: Rules;
6
+ /** Every callback shape violation is an error. */
7
+ export declare const recommended: Recommended;