@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,168 @@
1
+ import { stringOf } from "./ast.js";
2
+ /** The globals that all name the browser's global object. */
3
+ const GLOBAL_OBJECT = new Set(["window", "globalThis", "self"]);
4
+ /** Wrappers that change a node's type, not its identity. */
5
+ const TRANSPARENT = new Set(["TSAsExpression", "TSNonNullExpression", "TSSatisfiesExpression", "TSTypeAssertion", "ChainExpression", "ParenthesizedExpression"]);
6
+ /** True for a node that only wraps the type of its `expression`. */
7
+ const isTransparent = (node) => TRANSPARENT.has(node.type);
8
+ export function unwrap(node) {
9
+ let current = node;
10
+ while (current && isTransparent(current))
11
+ current = current.expression;
12
+ return current;
13
+ }
14
+ /**
15
+ * The node's parent with the type-only wrappers skipped upward.
16
+ *
17
+ * @param node - An ESTree node.
18
+ * @returns The first parent that is not a type-only wrapper.
19
+ */
20
+ export const parentOf = (node) => {
21
+ let current = node.parent;
22
+ while (current && TRANSPARENT.has(current.type))
23
+ current = current.parent;
24
+ return current ?? null;
25
+ };
26
+ /** True for a function expression, an arrow function or a function declaration. */
27
+ export const isFn = (node) => Boolean(node) && (node?.type === "FunctionExpression" || node?.type === "ArrowFunctionExpression" || node?.type === "FunctionDeclaration");
28
+ /**
29
+ * The scope-manager variable an identifier resolves to, or null for a global.
30
+ *
31
+ * @param context - The ESLint rule context.
32
+ * @param identifier - An ESTree identifier.
33
+ * @returns The variable.
34
+ */
35
+ export const variableOf = (context, identifier) => {
36
+ for (let scope = context.sourceCode.getScope(identifier); scope; scope = scope.upper) {
37
+ const variable = scope.set.get(identifier.name);
38
+ if (variable)
39
+ return variable;
40
+ }
41
+ return null;
42
+ };
43
+ /**
44
+ * Where an identifier was imported from.
45
+ *
46
+ * @param context - The ESLint rule context.
47
+ * @param identifier - An ESTree identifier.
48
+ * @returns The origin, or null when the identifier is not an import.
49
+ */
50
+ export const importOf = (context, identifier) => {
51
+ const variable = variableOf(context, identifier);
52
+ const definition = variable?.defs.find((entry) => entry.type === "ImportBinding");
53
+ if (!definition || definition.type !== "ImportBinding")
54
+ return null;
55
+ const specifier = definition.node;
56
+ const parent = definition.parent;
57
+ const source = String(parent.type === "ImportDeclaration" ? parent.source.value : undefined);
58
+ if (specifier.type === "ImportSpecifier") {
59
+ return { source, imported: specifier.imported.type === "Identifier" ? specifier.imported.name : String(specifier.imported.value) };
60
+ }
61
+ return { source, imported: null };
62
+ };
63
+ /** The property path a destructured binding selects from its initializer; null for an unselected or rest binding. */
64
+ export const patternPath = (pattern, name) => {
65
+ if (pattern.type === "Identifier")
66
+ return pattern.name === name ? [] : null;
67
+ if (pattern.type === "AssignmentPattern")
68
+ return patternPath(pattern.left, name);
69
+ if (pattern.type !== "ObjectPattern")
70
+ return null;
71
+ for (const property of pattern.properties) {
72
+ if (property.type !== "Property")
73
+ continue;
74
+ const nested = patternPath(property.value, name);
75
+ if (nested !== null) {
76
+ const key = property.key;
77
+ const step = property.computed ? stringOf(key) : key.type === "Identifier" ? key.name : key.type === "Literal" ? propertyValue(key.value) : null;
78
+ return [step, ...nested];
79
+ }
80
+ }
81
+ return null;
82
+ };
83
+ /** The value of a literal property key, as the path step it names. */
84
+ const propertyValue = (value) => typeof value === "string" || typeof value === "number" ? value : value === null ? null : String(value);
85
+ /**
86
+ * The function an expression stands for: the function itself, or the function a same-file variable or declaration holds.
87
+ *
88
+ * @param context - The ESLint rule context.
89
+ * @param node - An ESTree expression.
90
+ * @returns The function node, or null when it cannot be resolved in this file.
91
+ */
92
+ export const functionOf = (context, node) => {
93
+ const inner = unwrap(node);
94
+ if (!inner)
95
+ return null;
96
+ if (isFn(inner))
97
+ return inner;
98
+ if (inner.type !== "Identifier")
99
+ return null;
100
+ const variable = variableOf(context, inner);
101
+ for (const definition of variable?.defs ?? []) {
102
+ if (definition.type === "FunctionName" && definition.node.type === "FunctionDeclaration")
103
+ return definition.node;
104
+ if (definition.type === "Variable" && definition.node.id.type === "Identifier") {
105
+ const init = unwrap(definition.node.init);
106
+ if (isFn(init))
107
+ return init;
108
+ }
109
+ }
110
+ return null;
111
+ };
112
+ /** A stable numeric id per scope-manager variable, so keys compare by binding and not by spelling. */
113
+ const VARIABLE_IDS = new WeakMap();
114
+ let nextVariableId = 1;
115
+ const idOf = (variable) => {
116
+ const known = VARIABLE_IDS.get(variable);
117
+ if (known !== undefined)
118
+ return known;
119
+ const id = nextVariableId++;
120
+ VARIABLE_IDS.set(variable, id);
121
+ return id;
122
+ };
123
+ /**
124
+ * The key of an expression that names a place: `v12` for a variable, `v12.current` for a member path, `this.timer` for
125
+ * a member of `this`, `g:window` for the browser's global object under any of its names. Anything else has no key.
126
+ *
127
+ * @param context - The ESLint rule context.
128
+ * @param node - An ESTree expression.
129
+ * @returns The key.
130
+ */
131
+ export const keyOf = (context, node) => {
132
+ const inner = unwrap(node);
133
+ if (!inner)
134
+ return null;
135
+ if (inner.type === "Identifier") {
136
+ const variable = variableOf(context, inner);
137
+ if (variable && variable.defs.length > 0)
138
+ return `v${idOf(variable)}`;
139
+ return GLOBAL_OBJECT.has(inner.name) ? "g:window" : `g:${inner.name}`;
140
+ }
141
+ if (inner.type === "ThisExpression")
142
+ return "this";
143
+ if (inner.type === "MemberExpression") {
144
+ const head = keyOf(context, inner.object);
145
+ if (head === null)
146
+ return null;
147
+ if (!inner.computed && inner.property.type === "Identifier")
148
+ return `${head}.${inner.property.name}`;
149
+ if (inner.computed && inner.property.type === "Literal")
150
+ return `${head}[${String(inner.property.value)}]`;
151
+ return null;
152
+ }
153
+ return null;
154
+ };
155
+ /**
156
+ * The receiver and the member name of a call written as `receiver.name(...)`, or the bare name of `name(...)`.
157
+ *
158
+ * @param call - An ESTree call expression.
159
+ * @returns The parts, or null for a computed or exotic callee.
160
+ */
161
+ export const calleeParts = (call) => {
162
+ const callee = unwrap(call.callee);
163
+ if (callee.type === "Identifier")
164
+ return { receiver: null, name: callee.name };
165
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier")
166
+ return { receiver: callee.object, name: callee.property.name };
167
+ return null;
168
+ };
@@ -0,0 +1,3 @@
1
+ import type { AnyContext, Listener, Node } from "./rule.js";
2
+ /** Visit class strings in product JSX, declarations and named class arrays. */
3
+ export declare const classTextVisitors: (context: AnyContext, report: (node: Node, text: string | null) => void) => Listener;
@@ -0,0 +1,30 @@
1
+ import { isClassAttribute, staticText } from "./ast.js";
2
+ import { isProductSource } from "./scope.js";
3
+ /** Static strings in JSX, constants and arrays, preserving the authored class order. */
4
+ const classText = (value) => {
5
+ if (value?.type === "JSXExpressionContainer")
6
+ return classText(value.expression);
7
+ if (value?.type === "ArrayExpression") {
8
+ const members = value.elements.map((element) => classText(element)).filter((text) => Boolean(text));
9
+ return members.length > 0 ? members.join(" ") : null;
10
+ }
11
+ return staticText(value);
12
+ };
13
+ /** Visit class strings in product JSX, declarations and named class arrays. */
14
+ export const classTextVisitors = (context, report) => {
15
+ if (!isProductSource(context))
16
+ return {};
17
+ return {
18
+ JSXAttribute(node) {
19
+ if (isClassAttribute(node))
20
+ report(node, classText(node.value));
21
+ },
22
+ VariableDeclarator(node) {
23
+ report(node, classText(node.init));
24
+ },
25
+ Property(node) {
26
+ if (!node.computed && node.key.type === "Identifier" && node.key.name === "classes")
27
+ report(node, classText(node.value));
28
+ },
29
+ };
30
+ };
@@ -0,0 +1,81 @@
1
+ import type { ESLint, Linter } from "eslint";
2
+ import type { Rules } from "./rule.js";
3
+ /** The canon plugin as this package builds it: its name and its rules. */
4
+ export interface CanonPlugin {
5
+ readonly meta: {
6
+ readonly name: string;
7
+ };
8
+ readonly rules: Rules;
9
+ }
10
+ /** The plugins of the Sonar way rules (scripts/lib/sonar-way.mjs). */
11
+ export interface SonarPlugins {
12
+ readonly sonarjs: ESLint.Plugin;
13
+ readonly unicorn: ESLint.Plugin;
14
+ }
15
+ /** A disable directive is a weakening of lint, and one that suppresses nothing is dead weight. */
16
+ export declare const linterOptions: Readonly<{
17
+ noInlineConfig: true;
18
+ reportUnusedDisableDirectives: "error";
19
+ }>;
20
+ /** The product source of a front end: every app's `src/` and every workspace package's `src/` (slots fe.* and repo.packages). */
21
+ export declare const SOURCE_FILES: readonly string[];
22
+ /** Build output, installed packages, generated wire types and the harness's report directory are never linted. */
23
+ export declare const IGNORED: readonly string[];
24
+ /**
25
+ * The type-aware typescript-eslint rules the canon borrows, all `error` and the same set the back-end canon borrows for the type-aware laws: a promise is awaited or
26
+ * handled (`no-floating-promises`, `no-misused-promises`), a `switch` over a union names every member, nothing flows out of an `any` (the `no-unsafe-*` family),
27
+ * and the Sonar rules that need the checker: S1874 (a deprecated API), S6551 (an object turned into text) and S7503 (an `async` function that never awaits).
28
+ */
29
+ export declare const BORROWED: Readonly<Record<string, "error">>;
30
+ /**
31
+ * The flat config of one front-end repository.
32
+ *
33
+ * @param input - Attachment options.
34
+ * @param input.hfs - The HFS view of the repository (`loadHfs(import.meta.url)`).
35
+ * @param input.plugin - The canon plugin.
36
+ * @param input.source - The source-tree levels (canon rules plus the React Hooks rules).
37
+ * @param input.reactHooks - The React Hooks plugin.
38
+ * @param input.tsPlugin - The typescript-eslint plugin.
39
+ * @param input.sonar - The plugins of the Sonar way rules (scripts/lib/sonar-way.mjs).
40
+ * @returns The flat config: one ignore block, one typed source block and the Sonar way block.
41
+ */
42
+ export declare const buildFeConfig: ({ hfs, plugin, source, reactHooks, tsPlugin, sonar }: {
43
+ readonly hfs: unknown;
44
+ readonly plugin: CanonPlugin;
45
+ readonly source: Linter.RulesRecord;
46
+ readonly reactHooks: ESLint.Plugin;
47
+ readonly tsPlugin: ESLint.Plugin;
48
+ readonly sonar: SonarPlugins;
49
+ }) => Linter.Config[];
50
+ /**
51
+ * The canon rules that need no slot view: they judge a node and its types, never where the file sits in an app. A tree that is no HFS front end (a
52
+ * runtime package, the harness UI) is linted by exactly these plus the type-aware, React Hooks and Sonar way rules. The set is closed by
53
+ * config.spec.mjs: every other rule of the plugin refuses to run without `settings.starci.hfs`, so a rule added to the plugin is either listed here
54
+ * or proven slot-bound there.
55
+ */
56
+ export declare const SLOT_FREE_RULES: readonly string[];
57
+ /** The source of a plain front-end tree: its `src/`. */
58
+ export declare const PLAIN_SOURCE_FILES: readonly string[];
59
+ /**
60
+ * The flat config of a TypeScript front-end tree that is no HFS app: the same fences as an app (typed linting, no inline directive, every rule at
61
+ * `error`) over the rules that need no slot view. It takes the root of the tree and nothing else.
62
+ *
63
+ * @param input - Attachment options.
64
+ * @param input.root - The absolute directory holding the tree's `tsconfig.json` and `src/`.
65
+ * @param input.plugin - The canon plugin.
66
+ * @param input.hooks - The React Hooks rules at error.
67
+ * @param input.reactHooks - The React Hooks plugin.
68
+ * @param input.tsPlugin - The typescript-eslint plugin.
69
+ * @param input.sonar - The plugins of the Sonar way rules.
70
+ * @param input.canon - Whether the slot-free canon rules are on.
71
+ * @returns The flat config: one ignore block, one typed source block and the Sonar way block.
72
+ */
73
+ export declare const buildPlainSourceConfig: ({ root, plugin, hooks, reactHooks, tsPlugin, sonar, canon }: {
74
+ readonly root: unknown;
75
+ readonly plugin: CanonPlugin;
76
+ readonly hooks: Linter.RulesRecord;
77
+ readonly reactHooks: ESLint.Plugin;
78
+ readonly tsPlugin: ESLint.Plugin;
79
+ readonly sonar: SonarPlugins;
80
+ readonly canon?: boolean;
81
+ }) => Linter.Config[];
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The one flat config of a StarCi front end.
3
+ *
4
+ * A repository's `eslint.config.mjs` is managed by `starci app sync` (R05/R16/R17). Its template composes this universal
5
+ * factory with the explicitly selected Grammar family's factory, passing the same HFS view to both.
6
+ *
7
+ * The factory owns everything else: which files are linted (the source of every app and every workspace package), typed linting on the source (`parserOptions.projectService`, so a rule asks what a node IS rather than what
8
+ * it is called), the ignores, the linter options that make a disable comment impossible, every canon rule at `error`,
9
+ * the React Hooks rules at `error`, and `settings.starci.hfs` - the slot view each path-scoped rule asks instead of a path
10
+ * pattern. It takes no rule overrides, globs or ignores: there is no parameter through which a repository could weaken
11
+ * the canon.
12
+ */
13
+ import tsParser from "@typescript-eslint/parser";
14
+ import { OUTSIDE_THE_SCAN_FILES } from "#runtime/scripts/lib/sonar-syntax-rules.mjs";
15
+ import { sonarWayRules } from "#runtime/scripts/lib/sonar-way.mjs";
16
+ import { eslintPlugin } from "./eslint-boundary.js";
17
+ import { isHfsView } from "./hfs.js";
18
+ /** A disable directive is a weakening of lint, and one that suppresses nothing is dead weight. */
19
+ export const linterOptions = Object.freeze({ noInlineConfig: true, reportUnusedDisableDirectives: "error" });
20
+ /** The product source of a front end: every app's `src/` and every workspace package's `src/` (slots fe.* and repo.packages). */
21
+ export const SOURCE_FILES = Object.freeze(["apps/*/src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"]);
22
+ /** Build output, installed packages, generated wire types and the harness's report directory are never linted. */
23
+ export const IGNORED = Object.freeze(["**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/__generated__/**", ".starci/**"]);
24
+ /**
25
+ * The type-aware typescript-eslint rules the canon borrows, all `error` and the same set the back-end canon borrows for the type-aware laws: a promise is awaited or
26
+ * handled (`no-floating-promises`, `no-misused-promises`), a `switch` over a union names every member, nothing flows out of an `any` (the `no-unsafe-*` family),
27
+ * and the Sonar rules that need the checker: S1874 (a deprecated API), S6551 (an object turned into text) and S7503 (an `async` function that never awaits).
28
+ */
29
+ export const BORROWED = Object.freeze({
30
+ "@typescript-eslint/no-floating-promises": "error",
31
+ "@typescript-eslint/no-misused-promises": "error",
32
+ "@typescript-eslint/switch-exhaustiveness-check": "error",
33
+ "@typescript-eslint/no-unsafe-assignment": "error",
34
+ "@typescript-eslint/no-unsafe-member-access": "error",
35
+ "@typescript-eslint/no-unsafe-call": "error",
36
+ "@typescript-eslint/no-unsafe-return": "error",
37
+ "@typescript-eslint/no-unsafe-argument": "error",
38
+ "@typescript-eslint/no-deprecated": "error",
39
+ "@typescript-eslint/no-base-to-string": "error",
40
+ "@typescript-eslint/require-await": "error",
41
+ });
42
+ const levelOf = (setting) => (Array.isArray(setting) ? setting[0] : setting);
43
+ /**
44
+ * The flat config of one front-end repository.
45
+ *
46
+ * @param input - Attachment options.
47
+ * @param input.hfs - The HFS view of the repository (`loadHfs(import.meta.url)`).
48
+ * @param input.plugin - The canon plugin.
49
+ * @param input.source - The source-tree levels (canon rules plus the React Hooks rules).
50
+ * @param input.reactHooks - The React Hooks plugin.
51
+ * @param input.tsPlugin - The typescript-eslint plugin.
52
+ * @param input.sonar - The plugins of the Sonar way rules (scripts/lib/sonar-way.mjs).
53
+ * @returns The flat config: one ignore block, one typed source block and the Sonar way block.
54
+ */
55
+ export const buildFeConfig = ({ hfs, plugin, source, reactHooks, tsPlugin, sonar }) => {
56
+ if (!isHfsView(hfs) || typeof hfs.repoRoot !== "string") {
57
+ throw new Error("starciFeConfig needs { hfs: loadHfs(import.meta.url) } - the rules read the repository's slots through it");
58
+ }
59
+ if (hfs.profile !== "fe")
60
+ throw new Error(`starciFeConfig lints the fe side of an app (its eslint.config.mjs lives in fe/); this view is ${hfs.side ? `the ${hfs.side} side` : "the app root"}`);
61
+ if (Object.keys(source).length === 0)
62
+ throw new Error("starciFeConfig received an empty source recommendation - a config with no rules is not adoption");
63
+ const weak = Object.entries(source).filter(([, setting]) => levelOf(setting) !== "error").map(([name]) => name);
64
+ if (weak.length > 0)
65
+ throw new Error(`starciFeConfig refuses source rules not at error: ${weak.join(", ")}`);
66
+ const settings = { starci: { hfs } };
67
+ return [
68
+ { ignores: [...IGNORED] },
69
+ {
70
+ files: [...SOURCE_FILES],
71
+ languageOptions: {
72
+ parser: tsParser,
73
+ ecmaVersion: "latest",
74
+ sourceType: "module",
75
+ parserOptions: { projectService: true, tsconfigRootDir: hfs.repoRoot, ecmaFeatures: { jsx: true } },
76
+ },
77
+ linterOptions: { ...linterOptions },
78
+ plugins: { "starci-fe": eslintPlugin(plugin), "react-hooks": reactHooks, "@typescript-eslint": tsPlugin },
79
+ settings,
80
+ rules: { ...source, ...BORROWED },
81
+ },
82
+ // Sonar way: what the scan raises is refused while the code is written; a spec is outside the scan, so outside this block.
83
+ { files: [...SOURCE_FILES], ignores: [...OUTSIDE_THE_SCAN_FILES], plugins: { sonarjs: sonar.sonarjs, unicorn: sonar.unicorn }, rules: sonarWayRules(sonar.sonarjs, "fe").rules },
84
+ ];
85
+ };
86
+ /**
87
+ * The canon rules that need no slot view: they judge a node and its types, never where the file sits in an app. A tree that is no HFS front end (a
88
+ * runtime package, the harness UI) is linted by exactly these plus the type-aware, React Hooks and Sonar way rules. The set is closed by
89
+ * config.spec.mjs: every other rule of the plugin refuses to run without `settings.starci.hfs`, so a rule added to the plugin is either listed here
90
+ * or proven slot-bound there.
91
+ */
92
+ export const SLOT_FREE_RULES = Object.freeze([
93
+ "client-no-server-import", "no-server-action", "no-dangerous-html", "response-cookie-attributes", "require-export-jsdoc", "no-vietnamese-in-source",
94
+ "no-hardcoded-endpoint-fallback", "no-runtime-namespace", "list-item-has-key", "no-index-key", "no-inline-literal-prop-in-list", "prefer-arrow-export",
95
+ "handler-on-prefix", "no-native-form-control", "no-native-img", "image-has-size", "no-second-i18n-stack", "html-lang-from-locale", "no-null-suspense-fallback",
96
+ "no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "no-copy-in-call", "no-alert", "no-failure-collapse",
97
+ "outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
98
+ "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
99
+ "nested-control-variant", "no-list-panel-padding", "no-app-surface-paint", "divider-full-bleed", "control-height", "selection-fill-no-check", "no-grammar-shim",
100
+ "button-intent-required", "button-no-variant", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
101
+ ]);
102
+ /** The source of a plain front-end tree: its `src/`. */
103
+ export const PLAIN_SOURCE_FILES = Object.freeze(["src/**/*.{ts,tsx}"]);
104
+ /**
105
+ * The flat config of a TypeScript front-end tree that is no HFS app: the same fences as an app (typed linting, no inline directive, every rule at
106
+ * `error`) over the rules that need no slot view. It takes the root of the tree and nothing else.
107
+ *
108
+ * @param input - Attachment options.
109
+ * @param input.root - The absolute directory holding the tree's `tsconfig.json` and `src/`.
110
+ * @param input.plugin - The canon plugin.
111
+ * @param input.hooks - The React Hooks rules at error.
112
+ * @param input.reactHooks - The React Hooks plugin.
113
+ * @param input.tsPlugin - The typescript-eslint plugin.
114
+ * @param input.sonar - The plugins of the Sonar way rules.
115
+ * @param input.canon - Whether the slot-free canon rules are on.
116
+ * @returns The flat config: one ignore block, one typed source block and the Sonar way block.
117
+ */
118
+ export const buildPlainSourceConfig = ({ root, plugin, hooks, reactHooks, tsPlugin, sonar, canon = true }) => {
119
+ if (typeof root !== "string" || root === "")
120
+ throw new Error("starciPlainSourceConfig needs { root } - the directory of the tree's tsconfig.json and src/");
121
+ const canonRules = canon ? Object.fromEntries(SLOT_FREE_RULES.map((name) => [`starci-fe/${name}`, "error"])) : {};
122
+ const missing = SLOT_FREE_RULES.filter((name) => !(name in plugin.rules));
123
+ if (missing.length > 0)
124
+ throw new Error(`starciPlainSourceConfig lists rules the canon plugin does not publish: ${missing.join(", ")}`);
125
+ return [
126
+ { ignores: [...IGNORED] },
127
+ {
128
+ files: [...PLAIN_SOURCE_FILES],
129
+ languageOptions: {
130
+ parser: tsParser,
131
+ ecmaVersion: "latest",
132
+ sourceType: "module",
133
+ parserOptions: { projectService: true, tsconfigRootDir: root, ecmaFeatures: { jsx: true } },
134
+ },
135
+ linterOptions: { ...linterOptions },
136
+ plugins: { "starci-fe": eslintPlugin(plugin), "react-hooks": reactHooks, "@typescript-eslint": tsPlugin },
137
+ rules: { ...canonRules, ...hooks, ...BORROWED },
138
+ },
139
+ { files: [...PLAIN_SOURCE_FILES], ignores: [...OUTSIDE_THE_SCAN_FILES], plugins: { sonarjs: sonar.sonarjs, unicorn: sonar.unicorn }, rules: sonarWayRules(sonar.sonarjs, "fe").rules },
140
+ ];
141
+ };
@@ -0,0 +1,5 @@
1
+ import type { AnyContext } from "./rule.js";
2
+ /** Whether the file belongs to an HFS owner with a connected/presentational pair. */
3
+ export declare const inSplitTier: (context: AnyContext) => boolean;
4
+ /** The paired presentation file's component kind, or null outside that scope. */
5
+ export declare const pureHalf: (context: AnyContext) => string | null;
@@ -1,15 +1,10 @@
1
1
  /** Shared HFS scope of connected/presentational component pairs. */
2
- import { hfsOf } from "./hfs.mjs"
3
- import { fileOf, inSlot, kindOfFile, roleOfFile } from "./scope.mjs"
4
-
5
- const SPLIT_KINDS = new Set(["blocks", "pages", "layouts", "overlays"])
6
-
2
+ import { hfsOf } from "./hfs.js";
3
+ import { fileOf, inSlot, kindOfFile, roleOfFile } from "./scope.js";
4
+ const SPLIT_KINDS = new Set(["blocks", "pages", "layouts", "overlays"]);
7
5
  /** The owning surface folder of the linted entry or presentation file. */
8
- const surfaceName = (context) => hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0]
9
-
6
+ const surfaceName = (context) => hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0];
10
7
  /** Whether the file belongs to an HFS owner with a connected/presentational pair. */
11
- export const inSplitTier = (context) => inSlot(context, "fe.components", "fe.feature") && SPLIT_KINDS.has(kindOfFile(context))
12
-
8
+ export const inSplitTier = (context) => inSlot(context, "fe.components", "fe.feature") && SPLIT_KINDS.has(kindOfFile(context));
13
9
  /** The paired presentation file's component kind, or null outside that scope. */
14
- export const pureHalf = (context) =>
15
- inSplitTier(context) && roleOfFile(context) === "drawing" && /^[A-Z][A-Za-z0-9]*$/.test(surfaceName(context)) ? kindOfFile(context) : null
10
+ export const pureHalf = (context) => inSplitTier(context) && roleOfFile(context) === "drawing" && /^[A-Z][A-Za-z0-9]*$/.test(String(surfaceName(context))) ? kindOfFile(context) : null;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Where a canon rule meets ESLint's own types.
3
+ *
4
+ * The canon writes its rules against typescript-eslint's `RuleModule` (lib/rule.ts): its AST carries the JSX and
5
+ * TypeScript nodes the rules read, which ESLint's ESTree types lack. ESLint's `RuleTester`, `Linter` and flat-config types
6
+ * describe the same runtime objects with ESLint's own context and visitor types, and neither declaration is assignable to
7
+ * the other (typescript-eslint's context still declares the scope readers ESLint 9 removed). The objects are identical at
8
+ * run time, so the two views are joined here, in one place, and nowhere else.
9
+ */
10
+ import type { TSESLint } from "@typescript-eslint/utils";
11
+ import type { ESLint, Rule as EslintRule } from "eslint";
12
+ import type { Rule, Rules } from "./rule.js";
13
+ /** A canon rule (or a rule already written at ESLint's type, as the Grammar families write theirs) as ESLint's rule type: the same object. */
14
+ export declare const eslintRule: (rule: Rule<string, readonly unknown[]> | EslintRule.RuleModule) => EslintRule.RuleModule;
15
+ /** A rule at typescript-eslint's rule type with mutable options, the type the shared back-end fixtures declare their testers with: the same object. */
16
+ export declare const sharedRule: (rule: Rule<string, readonly unknown[]> | EslintRule.RuleModule) => TSESLint.RuleModule<string, unknown[]>;
17
+ /** The canon's rules as an ESLint plugin: the same objects, under ESLint's plugin type. */
18
+ export declare const eslintPlugin: (plugin: {
19
+ readonly meta: {
20
+ readonly name: string;
21
+ };
22
+ readonly rules: Rules;
23
+ }) => ESLint.Plugin;
@@ -0,0 +1,6 @@
1
+ /** A canon rule (or a rule already written at ESLint's type, as the Grammar families write theirs) as ESLint's rule type: the same object. */
2
+ export const eslintRule = (rule) => rule;
3
+ /** A rule at typescript-eslint's rule type with mutable options, the type the shared back-end fixtures declare their testers with: the same object. */
4
+ export const sharedRule = (rule) => rule;
5
+ /** The canon's rules as an ESLint plugin: the same objects, under ESLint's plugin type. */
6
+ export const eslintPlugin = (plugin) => plugin;
@@ -0,0 +1,49 @@
1
+ /** A concern of the catalog: its canonical packages, its HFS home and the packages it forbids (the catalog's own schema). */
2
+ export interface FeConcern {
3
+ readonly id: string;
4
+ readonly tier?: string;
5
+ readonly packages?: readonly string[];
6
+ readonly home?: readonly string[];
7
+ readonly pattern?: string;
8
+ readonly forbids?: readonly string[];
9
+ readonly why?: string;
10
+ }
11
+ /** A forbidden package glob and the concern that forbids it. */
12
+ export interface FeForbiddenPattern {
13
+ readonly expression: RegExp;
14
+ readonly concern: FeConcern;
15
+ }
16
+ /** The parsed catalog. */
17
+ export interface FeStack {
18
+ readonly concerns: readonly FeConcern[];
19
+ readonly forbidden: ReadonlyMap<string, FeConcern>;
20
+ readonly patterns: readonly FeForbiddenPattern[];
21
+ }
22
+ /**
23
+ * The parsed catalog: its concerns, and every forbidden package mapped to the concern that forbids it.
24
+ *
25
+ * @returns The catalog.
26
+ */
27
+ export declare const feStack: () => FeStack;
28
+ /**
29
+ * The package a module specifier names (`@scope/name` or `name`, its subpath dropped), or null for a relative, aliased,
30
+ * node-builtin or empty specifier.
31
+ *
32
+ * @param specifier - The module specifier.
33
+ * @returns The package name.
34
+ */
35
+ export declare const packageNameOf: (specifier: unknown) => string | null;
36
+ /**
37
+ * The concern whose `forbids` lists the package a specifier names, else null.
38
+ *
39
+ * @param specifier - The module specifier.
40
+ * @returns The concern (`{ id, packages, home, pattern, forbids, why }`).
41
+ */
42
+ export declare const forbiddingConcern: (specifier: unknown) => FeConcern | null;
43
+ /**
44
+ * The canonical packages of a concern.
45
+ *
46
+ * @param id - The concern id (`icons`, `graphql`, ...).
47
+ * @returns Its packages; empty for an unknown concern.
48
+ */
49
+ export declare const canonicalPackages: (id: string) => readonly string[];
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The one door between the front-end lint rules and the fe-stack catalog (`knowledge/hfs/fe-stack.yaml`): for each common
3
+ * front-end concern the ONE canonical library, its HFS home and the alternatives a front end never imports.
4
+ *
5
+ * The catalog is read from this package's `runtime/` copy, the same way the slot manifest and the canon pins are (a byte copy
6
+ * of the runtime's knowledge file, refreshed by `scripts/hfs/sync-runtime.mjs`), so a rule in a product repository judges
7
+ * against the catalog of the canon version it installed. A rule that needs the catalog and finds none stops the lint run
8
+ * with an error naming the fix: a rule must not guess which libraries are forbidden.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { parseYaml } from "#runtime/engine/yaml.mjs";
13
+ import { globExpression } from "#runtime/scripts/lib/glob.mjs";
14
+ import { RUNTIME_ROOT } from "./package-root.js";
15
+ const CATALOG = join(RUNTIME_ROOT, "knowledge", "hfs", "fe-stack.yaml");
16
+ /**
17
+ * True for a concern row. The catalog is this package's own shipped file, its schema proved by fe-stack.spec; a row is read
18
+ * as a concern once it is an object, as the catalog has always been read.
19
+ */
20
+ const isConcern = (row) => typeof row === "object" && row !== null;
21
+ /** The `concerns` of a parsed catalog document. */
22
+ const concernsOf = (document) => typeof document === "object" && document !== null && "concerns" in document ? document.concerns : undefined;
23
+ /** The code or message of a read failure. */
24
+ const reasonOf = (error) => {
25
+ if (typeof error !== "object" || error === null)
26
+ return undefined;
27
+ if ("code" in error && error.code !== undefined && error.code !== null)
28
+ return error.code;
29
+ return "message" in error ? error.message : undefined;
30
+ };
31
+ let loaded = null;
32
+ /**
33
+ * The parsed catalog: its concerns, and every forbidden package mapped to the concern that forbids it.
34
+ *
35
+ * @returns The catalog.
36
+ */
37
+ export const feStack = () => {
38
+ if (loaded)
39
+ return loaded;
40
+ let text;
41
+ try {
42
+ text = readFileSync(CATALOG, "utf8");
43
+ }
44
+ catch (error) {
45
+ throw new Error(`@starci/eslint-canon-fe reads the fe-stack catalog from runtime/knowledge/hfs/fe-stack.yaml and it is missing (${String(reasonOf(error))}): regenerate the runtime copy (scripts/hfs/sync-runtime.mjs)`);
46
+ }
47
+ const concerns = concernsOf(parseYaml(text));
48
+ if (!Array.isArray(concerns) || concerns.length === 0)
49
+ throw new Error("runtime/knowledge/hfs/fe-stack.yaml declares no concerns: the fe-stack catalog is malformed");
50
+ const rows = concerns.map((row) => {
51
+ if (!isConcern(row))
52
+ throw new TypeError(`runtime/knowledge/hfs/fe-stack.yaml has a concern that is not an object: ${String(row)}`);
53
+ return row;
54
+ });
55
+ const forbidden = new Map();
56
+ for (const concern of rows)
57
+ for (const name of concern.forbids ?? [])
58
+ forbidden.set(String(name), concern);
59
+ const patterns = [...forbidden].filter(([name]) => name.includes("*")).map(([name, concern]) => ({ expression: globExpression(name), concern }));
60
+ loaded = Object.freeze({ concerns: Object.freeze(rows), forbidden, patterns: Object.freeze(patterns) });
61
+ return loaded;
62
+ };
63
+ /**
64
+ * The package a module specifier names (`@scope/name` or `name`, its subpath dropped), or null for a relative, aliased,
65
+ * node-builtin or empty specifier.
66
+ *
67
+ * @param specifier - The module specifier.
68
+ * @returns The package name.
69
+ */
70
+ export const packageNameOf = (specifier) => {
71
+ const text = typeof specifier === "string" ? specifier : "";
72
+ if (text === "" || text.startsWith(".") || text.startsWith("/") || text.startsWith("@/") || text.startsWith("~/") || text.startsWith("node:"))
73
+ return null;
74
+ const parts = text.split("/");
75
+ if (text.startsWith("@"))
76
+ return parts.length >= 2 && parts[1] !== "" ? `${parts[0]}/${parts[1]}` : null;
77
+ return parts[0] ?? null;
78
+ };
79
+ /**
80
+ * The concern whose `forbids` lists the package a specifier names, else null.
81
+ *
82
+ * @param specifier - The module specifier.
83
+ * @returns The concern (`{ id, packages, home, pattern, forbids, why }`).
84
+ */
85
+ export const forbiddingConcern = (specifier) => {
86
+ const name = packageNameOf(specifier);
87
+ if (name === null)
88
+ return null;
89
+ const { forbidden, patterns } = feStack();
90
+ return forbidden.get(name) ?? patterns.find(({ expression }) => expression.test(name))?.concern ?? null;
91
+ };
92
+ /**
93
+ * The canonical packages of a concern.
94
+ *
95
+ * @param id - The concern id (`icons`, `graphql`, ...).
96
+ * @returns Its packages; empty for an unknown concern.
97
+ */
98
+ export const canonicalPackages = (id) => feStack().concerns.find((concern) => concern.id === id)?.packages ?? [];