@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,105 @@
1
+ /**
2
+ * The rule that holds `native-controls.md` (HFS R62, `FE_NATIVE_FORM_CONTROL`).
3
+ *
4
+ * THE GRAMMAR OWNS THE CONTROL. A bare `<select>`, `<input>`, `<textarea>` or `<button>` in product
5
+ * source is a control with no focus ring, no dark value, no density, no disabled or invalid state
6
+ * from the design system - it looks native on one platform and is styled by hand on the next, and
7
+ * the hand styling is where the private colour and pixel values came from (see `brand-values`).
8
+ * The renderer the grammar publishes is the same element with the state the product needs.
9
+ *
10
+ * NO TIER IS EXEMPT. The grammar package itself is not linted by this canon (it is where the native
11
+ * element legitimately lives); everything the product owns - leaves included - renders the grammar's
12
+ * control. If the grammar lacks the control, the fix is to add it to the grammar, not to draw one here.
13
+ */
14
+ import { defineRule } from "./lib/rule.js";
15
+ import { attribute } from "./lib/ast.js";
16
+ /** Intrinsic elements a reader operates, that the grammar renders for the product. */
17
+ const NATIVE = new Set(["select", "input", "textarea", "button"]);
18
+ /** No native form control in product source. */
19
+ export const noNativeFormControl = defineRule({
20
+ meta: {
21
+ type: "problem",
22
+ docs: { description: "Use the grammar's renderers; never a bare select, input, textarea or button." },
23
+ schema: [],
24
+ messages: {
25
+ native: "A bare `<{{tag}}>`. The grammar owns this control: it brings the focus ring, disabled and invalid states, density and the dark value, and a hand-styled native element brings none of them. Use the grammar's renderer; if it does not exist yet, add it to the grammar rather than drawing one here.",
26
+ },
27
+ },
28
+ create(context) {
29
+ return {
30
+ JSXOpeningElement(node) {
31
+ if (node.name.type !== "JSXIdentifier" || !NATIVE.has(node.name.name))
32
+ return;
33
+ context.report({ node, messageId: "native", data: { tag: node.name.name } });
34
+ },
35
+ };
36
+ },
37
+ });
38
+ // -- NATIVE-2 --------------------------------------------------------------------------------------
39
+ /** No bare `<img>`; the framework's image owns size, format, lazy loading and layout shift. */
40
+ export const noNativeImg = defineRule({
41
+ meta: {
42
+ type: "problem",
43
+ docs: { description: "Use `next/image`, never a bare `<img>`." },
44
+ schema: [],
45
+ messages: {
46
+ img: "A bare `<img>` ships the original file at its original size, reserves no space (the page jumps when it loads) and is never lazy by policy. Use `Image` from `next/image` with `width` and `height`, or `fill` and `sizes`, and an `alt` from the message catalogue.",
47
+ },
48
+ },
49
+ create(context) {
50
+ return {
51
+ JSXOpeningElement(node) {
52
+ if (node.name.type === "JSXIdentifier" && node.name.name === "img")
53
+ context.report({ node, messageId: "img" });
54
+ },
55
+ };
56
+ },
57
+ });
58
+ // -- NATIVE-3 --------------------------------------------------------------------------------------
59
+ /** Every `next/image` reserves its space: a size, or `fill` inside a sized parent with `sizes`. */
60
+ export const imageHasSize = defineRule({
61
+ meta: {
62
+ type: "problem",
63
+ docs: { description: "`next/image` carries `width` and `height`, or `fill` with `sizes`." },
64
+ schema: [],
65
+ messages: {
66
+ size: "This `Image` has no `width` and `height` and is not `fill`. The browser cannot reserve its space, so the page shifts when it loads and the layout score falls. Give it both dimensions, or `fill` inside a sized parent.",
67
+ sizes: "This `Image` is `fill` with no `sizes`. Next then assumes the image is as wide as the viewport and downloads the largest variant on every screen. State the rendered width: `sizes=\"(min-width: 768px) 33vw, 100vw\"`.",
68
+ },
69
+ },
70
+ create(context) {
71
+ const locals = new Set();
72
+ return {
73
+ ImportDeclaration(node) {
74
+ if (node.source.value !== "next/image")
75
+ return;
76
+ for (const specifier of node.specifiers)
77
+ if (specifier.type === "ImportDefaultSpecifier")
78
+ locals.add(specifier.local.name);
79
+ },
80
+ JSXOpeningElement(node) {
81
+ if (node.name.type !== "JSXIdentifier" || !locals.has(node.name.name))
82
+ return;
83
+ // A spread may carry the size; the rule judges what it can read.
84
+ if (node.attributes.some((entry) => entry.type === "JSXSpreadAttribute"))
85
+ return;
86
+ const fill = attribute(node, "fill");
87
+ if (fill) {
88
+ if (!attribute(node, "sizes"))
89
+ context.report({ node, messageId: "sizes" });
90
+ return;
91
+ }
92
+ if (!attribute(node, "width") || !attribute(node, "height"))
93
+ context.report({ node, messageId: "size" });
94
+ },
95
+ };
96
+ },
97
+ });
98
+ /** The rules this law contributes to the plugin. */
99
+ export const rules = {
100
+ "no-native-form-control": noNativeFormControl,
101
+ "no-native-img": noNativeImg,
102
+ "image-has-size": imageHasSize,
103
+ };
104
+ /** Every rule is an error. */
105
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The rules that hold `next-conventions.md` (HFS `FE_NEXT_CONVENTIONS`, with the lead's locale
3
+ * decision: ONE locale pattern everywhere - `next-intl`, a `[locale]` segment, default `vi`,
4
+ * `localePrefix: "as-needed"`, and `proxy.ts` on Next 16).
5
+ *
6
+ * WHY ONE PATTERN. Three repositories held three spellings of the same thing: a `[lang]` segment in
7
+ * the standard's own example, two i18n stacks in one app so that `/` redirected into a 404, and a
8
+ * `middleware.ts` that Next 16 no longer accepts under that name. Each is invisible to a type
9
+ * check and each is found by a reader hitting the wrong page. The rules below hold the spellings a
10
+ * single file can show; whether `routing.ts` carries `localePrefix: "as-needed"` is the
11
+ * architecture machine's read of `modules/i18n`.
12
+ */
13
+ import { type Recommended, type Rules } from "./lib/rule.js";
14
+ /** Next 16 names the request interceptor `proxy.ts`; `middleware.ts` is forbidden. */
15
+ export declare const noMiddlewareFile: import("./lib/rule.js").Rule<"export" | "file", []>;
16
+ /** The locale segment is `[locale]`. */
17
+ export declare const localeSegmentIsLocale: import("./lib/rule.js").Rule<"segment", []>;
18
+ /** Exactly one i18n stack: next-intl. */
19
+ export declare const noSecondI18nStack: import("./lib/rule.js").Rule<"stack", []>;
20
+ /** `<html lang>` follows the `[locale]` segment. */
21
+ export declare const htmlLangFromLocale: import("./lib/rule.js").Rule<"lang", []>;
22
+ /** Every page names itself: `metadata` or `generateMetadata`. */
23
+ export declare const pageExportsMetadata: import("./lib/rule.js").Rule<"metadata", []>;
24
+ /** A Suspense boundary shows something while it waits. */
25
+ export declare const noNullSuspenseFallback: import("./lib/rule.js").Rule<"nullFallback", []>;
26
+ /** Navigation helpers come from the locale-aware public routes or shared i18n entry. */
27
+ export declare const navigationFromIntl: import("./lib/rule.js").Rule<"link" | "helper", []>;
28
+ /** No hand-drawn `<a>` for an internal route; an external one opens safely. */
29
+ export declare const noNativeAnchor: import("./lib/rule.js").Rule<"internal" | "rel", []>;
30
+ /** No route written by hand outside `modules/routes`. */
31
+ export declare const noHardcodedRoute: import("./lib/rule.js").Rule<"route", []>;
32
+ /**
33
+ * The next-intl stack is written once per repository.
34
+ *
35
+ * `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and
36
+ * `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace
37
+ * member counts), have one owner. A shared i18n package owns its stack. In a one-app repository, routing and request
38
+ * config belong to modules/i18n while createNavigation belongs to modules/routes. The proxy imports routing without
39
+ * evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
40
+ *
41
+ * It does not replace `no-second-i18n-stack`: that rule refuses another i18n LIBRARY at its import, this one refuses a second copy of
42
+ * next-intl's own stack. Different evidence, different remedy.
43
+ */
44
+ export declare const i18nStackInOneModule: import("./lib/rule.js").Rule<"stack" | "proxyNavigation", []>;
45
+ /** The rules this law contributes to the plugin. */
46
+ export declare const rules: Rules;
47
+ /** Every rule is an error. */
48
+ export declare const recommended: Recommended;
@@ -0,0 +1,452 @@
1
+ /**
2
+ * The rules that hold `next-conventions.md` (HFS `FE_NEXT_CONVENTIONS`, with the lead's locale
3
+ * decision: ONE locale pattern everywhere - `next-intl`, a `[locale]` segment, default `vi`,
4
+ * `localePrefix: "as-needed"`, and `proxy.ts` on Next 16).
5
+ *
6
+ * WHY ONE PATTERN. Three repositories held three spellings of the same thing: a `[lang]` segment in
7
+ * the standard's own example, two i18n stacks in one app so that `/` redirected into a 404, and a
8
+ * `middleware.ts` that Next 16 no longer accepts under that name. Each is invisible to a type
9
+ * check and each is found by a reader hitting the wrong page. The rules below hold the spellings a
10
+ * single file can show; whether `routing.ts` carries `localePrefix: "as-needed"` is the
11
+ * architecture machine's read of `modules/i18n`.
12
+ */
13
+ import { attribute, attributeValue, calleeName, leadingTextOf, stringOf } from "./lib/ast.js";
14
+ import { variableOf } from "./lib/bindings.js";
15
+ import { forbiddingConcern } from "./lib/fe-stack.js";
16
+ import { hfsOf, requiredRoot } from "./lib/hfs.js";
17
+ import { defineRule } from "./lib/rule.js";
18
+ import { baseName, classOf, classifyImport, fileOf, inSlot, roleOfFile, slotOfFile } from "./lib/scope.js";
19
+ /** Another i18n stack: a second place that decides which language a reader sees (one the fe-stack catalog forbids by name is fe-stack-import's finding). */
20
+ const OTHER_I18N = /^(?:react-i18next|i18next|next-i18next|react-intl|next-translate|typesafe-i18n|rosetta)(?:\/.*)?$|^@lingui\/.+$/;
21
+ /** The name an identifier spells; undefined for a string-literal export or import name. */
22
+ const identifierName = (node) => node.type === "Identifier" ? node.name : undefined;
23
+ /** The name a declaration's `id` spells: undefined when it has no id or the id is not an identifier. */
24
+ const declaredName = (declaration) => {
25
+ if (!("id" in declaration) || !declaration.id)
26
+ return undefined;
27
+ return "name" in declaration.id ? declaration.id.name : undefined;
28
+ };
29
+ /** True when a declaration carries a truthy `id`. */
30
+ const hasId = (declaration) => declaration !== null && declaration !== undefined && "id" in declaration && Boolean(declaration.id);
31
+ // -- FE-NEXT-1 -------------------------------------------------------------------------------------
32
+ /** Next 16 names the request interceptor `proxy.ts`; `middleware.ts` is forbidden. */
33
+ export const noMiddlewareFile = defineRule({
34
+ meta: {
35
+ type: "problem",
36
+ docs: { description: "`middleware.ts` is forbidden: the request interceptor is `proxy.ts` and exports `proxy`." },
37
+ schema: [],
38
+ messages: {
39
+ file: "`{{name}}` is not the request interceptor file: Next 16 reads `proxy.ts`. Rename the file and export the handler as `proxy`.",
40
+ export: "The handler in `proxy.ts` is exported as `middleware`, but Next 16 reads `proxy`. Export it as `proxy`.",
41
+ },
42
+ },
43
+ create(context) {
44
+ const file = fileOf(context);
45
+ // `middleware.ts` has a slot of its own (`fe.source-root-middleware`, forbidden); the interceptor is the `proxy` role of `fe.source-root-pinned`.
46
+ if (inSlot(context, "fe.source-root-middleware")) {
47
+ return { Program: (node) => context.report({ node, messageId: "file", data: { name: baseName(file) } }) };
48
+ }
49
+ if (!inSlot(context, "fe.source-root-pinned") || roleOfFile(context) !== "proxy")
50
+ return {};
51
+ const check = (node) => {
52
+ const names = [];
53
+ const declaration = node.declaration;
54
+ if (hasId(declaration))
55
+ names.push(declaredName(declaration));
56
+ if (declaration && declaration.type === "VariableDeclaration") {
57
+ for (const d of declaration.declarations)
58
+ if (d.id.type === "Identifier")
59
+ names.push(d.id.name);
60
+ }
61
+ for (const specifier of node.type === "ExportNamedDeclaration" ? node.specifiers : [])
62
+ names.push(identifierName(specifier.exported));
63
+ if (names.includes("middleware"))
64
+ context.report({ node, messageId: "export" });
65
+ };
66
+ return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check };
67
+ },
68
+ });
69
+ // -- FE-NEXT-2 -------------------------------------------------------------------------------------
70
+ /** The locale segment is `[locale]`. */
71
+ export const localeSegmentIsLocale = defineRule({
72
+ meta: {
73
+ type: "problem",
74
+ docs: { description: "The locale route segment is `[locale]`, never `[lang]`." },
75
+ schema: [],
76
+ messages: {
77
+ segment: "This file sits under a `[{{name}}]` segment. The one locale pattern is `[locale]` (next-intl, default `vi`, `localePrefix: \"as-needed\"`); a second spelling means a second routing table.",
78
+ },
79
+ },
80
+ create(context) {
81
+ // A route folder is a segment of slot `fe.route`; the locale one is `[locale]`, never another spelling.
82
+ if (!inSlot(context, "fe.route"))
83
+ return {};
84
+ const hfs = hfsOf(context);
85
+ const file = fileOf(context);
86
+ const below = hfs.relative(file).slice(requiredRoot(classOf(context)).length + 1).split("/");
87
+ const name = below.slice(0, -1).map((segment) => /^\[(lang|lng|language|i18n)\]$/.exec(segment)).find(Boolean)?.[1];
88
+ if (!name)
89
+ return {};
90
+ return { Program: (node) => context.report({ node, messageId: "segment", data: { name } }) };
91
+ },
92
+ });
93
+ // -- FE-NEXT-3 -------------------------------------------------------------------------------------
94
+ /** Exactly one i18n stack: next-intl. */
95
+ export const noSecondI18nStack = defineRule({
96
+ meta: {
97
+ type: "problem",
98
+ docs: { description: "`next-intl` is the only i18n library." },
99
+ schema: [],
100
+ messages: {
101
+ stack: "`{{name}}` is a second i18n stack. Two stacks decide the language twice and disagree at the edges (one app redirected `/` into a 404 that way). Use `next-intl` through `modules/i18n`.",
102
+ },
103
+ },
104
+ create(context) {
105
+ return {
106
+ ImportDeclaration(node) {
107
+ const source = String(node.source.value);
108
+ if (OTHER_I18N.test(source) && !forbiddingConcern(source))
109
+ context.report({ node, messageId: "stack", data: { name: source } });
110
+ },
111
+ };
112
+ },
113
+ });
114
+ // -- FE-NEXT-4 -------------------------------------------------------------------------------------
115
+ /** `<html lang>` follows the `[locale]` segment. */
116
+ export const htmlLangFromLocale = defineRule({
117
+ meta: {
118
+ type: "problem",
119
+ docs: { description: "`<html lang>` is taken from the `[locale]` segment, never a literal." },
120
+ schema: [],
121
+ messages: {
122
+ lang: "`<html lang=\"...\">` is a literal. A Vietnamese product that ships `lang=\"en\"` mislabels every page for screen readers, translation prompts and search engines. Set it from the `[locale]` param: `lang={locale}`.",
123
+ },
124
+ },
125
+ create(context) {
126
+ return {
127
+ JSXOpeningElement(node) {
128
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "html")
129
+ return;
130
+ for (const attribute of node.attributes) {
131
+ if (attribute.type !== "JSXAttribute" || attribute.name.name !== "lang" || !attribute.value)
132
+ continue;
133
+ const value = attribute.value;
134
+ const literal = value.type === "Literal" ||
135
+ (value.type === "JSXExpressionContainer" &&
136
+ (value.expression.type === "Literal" ||
137
+ (value.expression.type === "TemplateLiteral" && value.expression.expressions.length === 0)));
138
+ if (literal)
139
+ context.report({ node: attribute, messageId: "lang" });
140
+ }
141
+ },
142
+ };
143
+ },
144
+ });
145
+ // -- FE-NEXT-5 -------------------------------------------------------------------------------------
146
+ /** The names one named-export statement introduces. */
147
+ const statementExportNames = (statement) => {
148
+ if (statement.type !== "ExportNamedDeclaration")
149
+ return [];
150
+ const declaration = statement.declaration;
151
+ const names = statement.specifiers.map((specifier) => identifierName(specifier.exported));
152
+ if (hasId(declaration))
153
+ names.push(declaredName(declaration));
154
+ if (declaration?.type === "VariableDeclaration") {
155
+ for (const entry of declaration.declarations) {
156
+ if (entry.id.type === "Identifier")
157
+ names.push(entry.id.name);
158
+ }
159
+ }
160
+ return names;
161
+ };
162
+ /** The names a module exports, from declarations and specifiers. */
163
+ const exportedNames = (program) => new Set(program.body.flatMap(statementExportNames));
164
+ /** Every page names itself: `metadata` or `generateMetadata`. */
165
+ export const pageExportsMetadata = defineRule({
166
+ meta: {
167
+ type: "problem",
168
+ docs: { description: "Every `page.tsx` exports `metadata` or `generateMetadata`." },
169
+ schema: [],
170
+ messages: {
171
+ metadata: "This page exports neither `metadata` nor `generateMetadata`, so it ships with whatever title its layout happens to have: every page of the section is called the same in the tab, in the history, in a shared link and in a search result. Export `metadata` (or `generateMetadata` when the title comes from data), with the title and description taken from the message catalogue.",
172
+ },
173
+ },
174
+ create(context) {
175
+ // A page is the file the slot gives the role `page` (spec files carry no role).
176
+ if (!inSlot(context, "fe.route") || roleOfFile(context) !== "page")
177
+ return {};
178
+ return {
179
+ "Program:exit"(program) {
180
+ const names = exportedNames(program);
181
+ if (!names.has("metadata") && !names.has("generateMetadata"))
182
+ context.report({ node: program, messageId: "metadata" });
183
+ },
184
+ };
185
+ },
186
+ });
187
+ // -- FE-NEXT-6 -------------------------------------------------------------------------------------
188
+ /** A Suspense boundary shows something while it waits. */
189
+ export const noNullSuspenseFallback = defineRule({
190
+ meta: {
191
+ type: "problem",
192
+ docs: { description: "`<Suspense>` has a fallback that renders something; `fallback={null}` is forbidden." },
193
+ schema: [],
194
+ messages: {
195
+ nullFallback: "`<Suspense>` with a `null` (or missing) fallback: while the subtree loads the reader sees a hole where content will jump in, with no sign anything is happening. Render the loading state of the thing that is loading (the block's skeleton).",
196
+ },
197
+ },
198
+ create(context) {
199
+ return {
200
+ JSXOpeningElement(node) {
201
+ const name = node.name;
202
+ const isSuspense = (name.type === "JSXIdentifier" && name.name === "Suspense") ||
203
+ (name.type === "JSXMemberExpression" && name.property.name === "Suspense");
204
+ if (!isSuspense)
205
+ return;
206
+ const fallback = attribute(node, "fallback");
207
+ const value = attributeValue(fallback);
208
+ const empty = !fallback ||
209
+ !value ||
210
+ (value.type === "Literal" && value.value === null) ||
211
+ (value.type === "Identifier" && value.name === "undefined");
212
+ if (empty)
213
+ context.report({ node, messageId: "nullFallback" });
214
+ },
215
+ };
216
+ },
217
+ });
218
+ // -- FE-NEXT-7 -------------------------------------------------------------------------------------
219
+ /** The locale-aware navigation owner and framework boundaries outside its provider. */
220
+ const isNavigationOwner = (context) => inSlot(context, "fe.package.i18n") ||
221
+ (inSlot(context, "fe.modules.routes") && hfsOf(context).apps.length === 1) ||
222
+ (inSlot(context, "fe.source-root-pinned") && roleOfFile(context) === "proxy") ||
223
+ (inSlot(context, "fe.route") && roleOfFile(context) === "global-error");
224
+ /** The router helpers that are locale-blind in `next/navigation`. */
225
+ const LOCALE_BLIND = new Set(["useRouter", "usePathname", "redirect", "permanentRedirect"]);
226
+ /** Navigation helpers come from the locale-aware public routes or shared i18n entry. */
227
+ export const navigationFromIntl = defineRule({
228
+ meta: {
229
+ type: "problem",
230
+ docs: { description: "`Link`, `useRouter`, `usePathname` and `redirect` come from the locale-aware routes or shared i18n public entry, not from Next." },
231
+ schema: [],
232
+ messages: {
233
+ link: "`next/link` does not know the locale: a link built with it drops the `[locale]` prefix. Import Link from the locale-aware public routes entry or shared i18n package, built from the one routing configuration.",
234
+ helper: "`{{name}}` from `next/navigation` does not know the locale. Import it from the locale-aware public routes entry or shared i18n package, built from the one routing configuration.",
235
+ },
236
+ },
237
+ create(context) {
238
+ if (isNavigationOwner(context))
239
+ return {};
240
+ return {
241
+ ImportDeclaration(node) {
242
+ if (node.importKind === "type")
243
+ return;
244
+ const source = String(node.source.value);
245
+ if (source === "next/link")
246
+ return context.report({ node, messageId: "link" });
247
+ if (source !== "next/navigation")
248
+ return;
249
+ for (const specifier of node.specifiers) {
250
+ if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type" && LOCALE_BLIND.has(identifierName(specifier.imported))) {
251
+ context.report({ node: specifier, messageId: "helper", data: { name: identifierName(specifier.imported) } });
252
+ }
253
+ }
254
+ },
255
+ };
256
+ },
257
+ });
258
+ // -- FE-NEXT-8 -------------------------------------------------------------------------------------
259
+ /** An href that leaves the app: a scheme, a protocol-relative URL, or an in-page anchor. */
260
+ const isExternalHref = (text) => /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(text);
261
+ /** No hand-drawn `<a>` for an internal route; an external one opens safely. */
262
+ export const noNativeAnchor = defineRule({
263
+ meta: {
264
+ type: "problem",
265
+ docs: { description: "An internal link uses the locale-aware public navigation entry; an external `_blank` link carries `rel`." },
266
+ schema: [],
267
+ messages: {
268
+ internal: "`<a href=\"{{href}}\">` for a route of this app reloads the page, skips prefetch and drops the locale prefix. Use the locale-aware Link with an href built by modules/routes.",
269
+ rel: "`target=\"_blank\"` with no `rel`. The opened page can reach back through `window.opener`. Add `rel=\"noopener noreferrer\"`.",
270
+ },
271
+ },
272
+ create(context) {
273
+ return {
274
+ JSXOpeningElement(node) {
275
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "a")
276
+ return;
277
+ const href = stringOf(attributeValue(attribute(node, "href")));
278
+ if (href !== null && href !== "" && !isExternalHref(href))
279
+ context.report({ node, messageId: "internal", data: { href } });
280
+ const target = stringOf(attributeValue(attribute(node, "target")));
281
+ const rel = attribute(node, "rel");
282
+ if (target === "_blank" && !rel)
283
+ context.report({ node, messageId: "rel" });
284
+ },
285
+ };
286
+ },
287
+ });
288
+ // -- FE-NEXT-9 -------------------------------------------------------------------------------------
289
+ /** The functions whose first argument is a route: the router's methods and the two redirects. */
290
+ const isNavigation = (name) => name === "redirect" || name === "permanentRedirect" || /(?:^|\.)router\.(?:push|replace|prefetch)$/.test(name);
291
+ /** True for `/x`, `/x/y`; false for `//host` and for `/` alone, which every app owns. */
292
+ const isInternalPath = (text) => text.startsWith("/") && !text.startsWith("//") && text.length > 1;
293
+ /** The places that may write a route: the routes module (`fe.modules.routes`) and the i18n module (`fe.modules.i18n`). */
294
+ const isRoutesModule = (context) => inSlot(context, "fe.modules.routes", "fe.modules.i18n");
295
+ /** No route written by hand outside `modules/routes`. */
296
+ export const noHardcodedRoute = defineRule({
297
+ meta: {
298
+ type: "problem",
299
+ docs: { description: "A route path is built by `modules/routes`, never written as a literal at a link or a navigation call." },
300
+ schema: [],
301
+ messages: {
302
+ route: "`{{path}}` is a route written by hand. When the route moves, every copy of it silently points at a 404, and nothing type-checks the difference. Build it with the href builder in `modules/routes` and use that one function everywhere.",
303
+ },
304
+ },
305
+ create(context) {
306
+ if (isRoutesModule(context))
307
+ return {};
308
+ const source = context.sourceCode ?? context.getSourceCode();
309
+ const report = (node, text) => context.report({ node, messageId: "route", data: { path: text } });
310
+ return {
311
+ JSXAttribute(node) {
312
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "href")
313
+ return;
314
+ const value = attributeValue(node);
315
+ const lead = leadingTextOf(value);
316
+ if (lead !== null && isInternalPath(lead))
317
+ report(node, source.getText(value ?? undefined));
318
+ },
319
+ CallExpression(node) {
320
+ const name = calleeName(node.callee);
321
+ if (!name || !isNavigation(name))
322
+ return;
323
+ const first = node.arguments[0];
324
+ const lead = leadingTextOf(first);
325
+ if (first && lead !== null && isInternalPath(lead))
326
+ report(first, source.getText(first));
327
+ },
328
+ };
329
+ },
330
+ });
331
+ // -- FE-NEXT-10 -----------------------------------------------------------------------------------
332
+ /** The next-intl stack factories: module specifier -> the export that builds one layer of the stack (`default` for a default export). */
333
+ const STACK_FACTORIES = new Map([
334
+ ["next-intl/routing", new Set(["defineRouting"])],
335
+ ["next-intl/navigation", new Set(["createNavigation"])],
336
+ ["next-intl/server", new Set(["getRequestConfig"])],
337
+ ["next-intl/middleware", new Set(["default"])],
338
+ ]);
339
+ /** The public name selected by a named or default import. */
340
+ const importedName = (specifier) => {
341
+ if (specifier.type === "ImportSpecifier")
342
+ return specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
343
+ if (specifier.type === "ImportDefaultSpecifier")
344
+ return "default";
345
+ return null;
346
+ };
347
+ /** The known next-intl factory selected from an import. */
348
+ const stackFactory = (definition, exported) => {
349
+ const parent = definition.parent;
350
+ // An `import x = require()` binding has no source; reading one has always stopped the run.
351
+ if (parent.type !== "ImportDeclaration")
352
+ throw new TypeError("an import-equals binding has no import source");
353
+ const source = String(parent.source.value);
354
+ if (!STACK_FACTORIES.get(source)?.has(exported))
355
+ return null;
356
+ return { source, name: exported === "default" ? "createMiddleware" : exported };
357
+ };
358
+ /** A factory called through a namespace import, else null. */
359
+ const namespaceFactory = (context, callee) => {
360
+ if (callee.type !== "MemberExpression" || callee.computed || callee.object.type !== "Identifier" || callee.property.type !== "Identifier")
361
+ return null;
362
+ const definition = variableOf(context, callee.object)?.defs[0] ?? null;
363
+ if (definition?.type !== "ImportBinding" || definition.node.type !== "ImportNamespaceSpecifier")
364
+ return null;
365
+ return stackFactory(definition, callee.property.name);
366
+ };
367
+ /** The name a stack factory has where the call reads it: "defineRouting" (next-intl/routing), else null. */
368
+ const stackFactoryOf = (context, callee) => {
369
+ if (callee.type !== "Identifier")
370
+ return namespaceFactory(context, callee);
371
+ const definition = variableOf(context, callee)?.defs[0] ?? null;
372
+ if (definition?.type !== "ImportBinding")
373
+ return null;
374
+ return stackFactory(definition, importedName(definition.node));
375
+ };
376
+ /**
377
+ * The next-intl stack is written once per repository.
378
+ *
379
+ * `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and
380
+ * `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace
381
+ * member counts), have one owner. A shared i18n package owns its stack. In a one-app repository, routing and request
382
+ * config belong to modules/i18n while createNavigation belongs to modules/routes. The proxy imports routing without
383
+ * evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
384
+ *
385
+ * It does not replace `no-second-i18n-stack`: that rule refuses another i18n LIBRARY at its import, this one refuses a second copy of
386
+ * next-intl's own stack. Different evidence, different remedy.
387
+ */
388
+ export const i18nStackInOneModule = defineRule({
389
+ meta: {
390
+ type: "problem",
391
+ docs: { description: "A shared i18n package owns the stack; one app keeps routing/request config in i18n and navigation in routes, with a routing-only proxy entry." },
392
+ schema: [],
393
+ messages: {
394
+ stack: "`{{name}}` builds the next-intl stack outside its owner. Keep {{where}} and import the owned result through its public entry.",
395
+ proxyNavigation: "The proxy imports navigation runtime through {{source}}. Import routing from the i18n public entry, or use the shared package's routing-only proxy export; the proxy must not evaluate the routes/navigation barrel.",
396
+ },
397
+ },
398
+ create(context) {
399
+ const hfs = hfsOf(context);
400
+ const slot = slotOfFile(context);
401
+ const single = hfs.apps.length === 1;
402
+ const proxy = single && slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy";
403
+ const proxyFile = slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy";
404
+ const where = single ? "routing/request config in modules/i18n and navigation in modules/routes" : "the stack in the shared i18n package that every app calls";
405
+ const safeProxyImport = (node) => {
406
+ if (!proxyFile || !node.source || (node.type === "ImportDeclaration" ? node.importKind === "type" : node.exportKind === "type"))
407
+ return;
408
+ const specifiers = node.type === "ExportAllDeclaration" ? undefined : node.specifiers;
409
+ if (specifiers?.length && specifiers.every((entry) => (entry.type === "ImportSpecifier" && entry.importKind === "type") || (entry.type === "ExportSpecifier" && entry.exportKind === "type")))
410
+ return;
411
+ const source = String(node.source.value);
412
+ if (source === "next-intl/navigation" || classifyImport(context, source)?.slot === "fe.modules.routes") {
413
+ context.report({ node, messageId: "proxyNavigation", data: { source } });
414
+ }
415
+ };
416
+ return {
417
+ ImportDeclaration: safeProxyImport,
418
+ ExportNamedDeclaration: safeProxyImport,
419
+ ExportAllDeclaration: safeProxyImport,
420
+ CallExpression(node) {
421
+ const factory = stackFactoryOf(context, node.callee);
422
+ // In a one-app repository the proxy (role `proxy`) mounts the locale middleware built from the one routing; a shared repository
423
+ // builds it in the i18n package and the proxy re-exports it.
424
+ if (factory?.name === "createMiddleware" && proxy)
425
+ return;
426
+ if (slot === "fe.package.i18n")
427
+ return;
428
+ if (single && factory?.name === "createNavigation" && slot === "fe.modules.routes")
429
+ return;
430
+ if (single && factory?.name !== "createNavigation" && slot === "fe.modules.i18n")
431
+ return;
432
+ if (factory)
433
+ context.report({ node, messageId: "stack", data: { name: factory.name, where } });
434
+ },
435
+ };
436
+ },
437
+ });
438
+ /** The rules this law contributes to the plugin. */
439
+ export const rules = {
440
+ "no-middleware-file": noMiddlewareFile,
441
+ "locale-segment-is-locale": localeSegmentIsLocale,
442
+ "no-second-i18n-stack": noSecondI18nStack,
443
+ "html-lang-from-locale": htmlLangFromLocale,
444
+ "page-exports-metadata": pageExportsMetadata,
445
+ "no-null-suspense-fallback": noNullSuspenseFallback,
446
+ "navigation-from-intl": navigationFromIntl,
447
+ "no-native-anchor": noNativeAnchor,
448
+ "no-hardcoded-route": noHardcodedRoute,
449
+ "i18n-stack-in-one-module": i18nStackInOneModule,
450
+ };
451
+ /** Every rule is an error. */
452
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -0,0 +1,5 @@
1
+ import type { Recommended, Rules } from "./lib/rule.js";
2
+ /** The rules this law contributes to the plugin: one per graph enforcer of this profile. */
3
+ export declare const rules: Rules;
4
+ /** Every one at error. */
5
+ export declare const recommended: Recommended;
@@ -7,17 +7,13 @@
7
7
  * rules and their codes is `LINT_ENFORCERS` of the machine (scripts/hfs/architecture/surface.mjs); `starci app check` keeps the
8
8
  * findings that have no TypeScript file to sit on. A graph that cannot be built stops the run with the machine's message.
9
9
  */
10
- import { LINT_ENFORCERS } from "./runtime/scripts/hfs/architecture/surface.mjs"
11
- import { projectRule } from "./runtime/scripts/hfs/project-rule.mjs"
12
- import { hfsOf } from "./lib/hfs.mjs"
13
- import { dirname, join } from "node:path"
14
- import { fileURLToPath } from "node:url"
15
-
16
- const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "runtime")
17
- const mine = LINT_ENFORCERS.filter((enforcer) => enforcer.plugins.includes("fe"))
18
-
10
+ import { LINT_ENFORCERS } from "#runtime/scripts/hfs/architecture/surface.mjs";
11
+ import { projectRule } from "#runtime/scripts/hfs/project-rule.mjs";
12
+ import { hfsOf } from "./lib/hfs.js";
13
+ import { RUNTIME_ROOT } from "./lib/package-root.js";
14
+ const RUNTIME = RUNTIME_ROOT;
15
+ const mine = LINT_ENFORCERS.filter((enforcer) => enforcer.plugins.includes("fe"));
19
16
  /** The rules this law contributes to the plugin: one per graph enforcer of this profile. */
20
- export const rules = Object.fromEntries(mine.map((e) => [e.id, projectRule({ enforcer: e, runtimeRoot: RUNTIME, hfsOf })]))
21
-
17
+ export const rules = Object.fromEntries(mine.map((e) => [e.id, projectRule({ enforcer: e, runtimeRoot: RUNTIME, hfsOf })]));
22
18
  /** Every one at error. */
23
- export const recommended = Object.fromEntries(mine.map((e) => [`starci-fe/${e.id}`, "error"]))
19
+ export const recommended = Object.fromEntries(mine.map((e) => [`starci-fe/${e.id}`, "error"]));