@starci/eslint-canon-fe 5.1.2 → 6.0.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 (68) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +17 -18
  3. package/brand-values.mjs +1 -3
  4. package/class-names.mjs +9 -9
  5. package/client-boundary.mjs +125 -37
  6. package/comments.mjs +14 -21
  7. package/docs/client-boundary.md +26 -1
  8. package/docs/grammar-boundary.md +33 -0
  9. package/docs/hygiene.md +15 -4
  10. package/docs/lint-escape-hatch.md +3 -1
  11. package/docs/next-conventions.md +26 -0
  12. package/docs/size-growth.md +29 -0
  13. package/docs/spec-quality.md +15 -7
  14. package/docs/status-colors.md +32 -0
  15. package/docs/translation.md +4 -0
  16. package/docs/transport.md +36 -9
  17. package/e2e-shape.mjs +20 -23
  18. package/env-owner.mjs +1 -1
  19. package/file-layout.mjs +105 -103
  20. package/formatting.mjs +2 -4
  21. package/grammar-boundary.mjs +85 -9
  22. package/hooks-folder.mjs +17 -16
  23. package/hygiene.mjs +370 -92
  24. package/icon.mjs +21 -20
  25. package/index.mjs +27 -85
  26. package/landmark.mjs +0 -11
  27. package/lib/bindings.mjs +144 -0
  28. package/lib/config.mjs +79 -0
  29. package/lib/hfs.mjs +48 -0
  30. package/lib/next.mjs +6 -0
  31. package/lib/platform.mjs +66 -0
  32. package/lib/react.mjs +28 -0
  33. package/lib/scope.mjs +119 -19
  34. package/lib/status-tones.generated.mjs +9 -0
  35. package/lib/types.mjs +63 -0
  36. package/lib/why.mjs +41 -11
  37. package/lint-escape-hatch.mjs +17 -15
  38. package/loading.mjs +9 -16
  39. package/naming.mjs +8 -2
  40. package/next-conventions.mjs +109 -25
  41. package/package.json +9 -4
  42. package/props-and-slots.mjs +19 -13
  43. package/runtime/engine/plain-object.mjs +5 -0
  44. package/runtime/engine/runtime-root.mjs +32 -0
  45. package/runtime/engine/yaml.mjs +161 -0
  46. package/runtime/knowledge/hfs/slots.yaml +1072 -0
  47. package/runtime/scripts/lib/git.mjs +53 -0
  48. package/runtime/scripts/lib/glob.mjs +23 -0
  49. package/runtime/scripts/lib/hfs-allows.mjs +42 -0
  50. package/runtime/scripts/lib/hfs-slots.mjs +722 -0
  51. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  52. package/runtime/scripts/lib/next-contract.mjs +14 -0
  53. package/runtime/scripts/lib/path-key.mjs +17 -0
  54. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  55. package/served-locale.mjs +30 -16
  56. package/shape-slot.mjs +22 -20
  57. package/size-and-state-budget.mjs +41 -23
  58. package/size-growth.mjs +58 -0
  59. package/spec-quality.mjs +95 -16
  60. package/status-colors.mjs +139 -0
  61. package/the-split.mjs +13 -13
  62. package/tokens.mjs +26 -34
  63. package/translation.mjs +52 -19
  64. package/transport.mjs +437 -89
  65. package/type-safety.mjs +8 -17
  66. package/typography.mjs +7 -11
  67. package/vendor-boundary.mjs +5 -27
  68. package/lint-adoption.mjs +0 -79
package/lib/react.mjs ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * React hooks of the front-end canon, decided by the import: a call is `useEffect` when its callee is the `useEffect`
3
+ * export of `react` (named import, alias, default or namespace member), not when something is spelled that way.
4
+ */
5
+ import { importOf, unwrap } from "./bindings.mjs"
6
+
7
+ /** The hooks that run work after render and may return its cleanup. */
8
+ export const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect"])
9
+
10
+ /**
11
+ * The React export a call invokes, or null when the callee is not an export of `react`.
12
+ *
13
+ * @param {object} context - The ESLint rule context.
14
+ * @param {object} call - An ESTree call expression.
15
+ * @returns {string|null} The export name (`useEffect`, `useSyncExternalStore`, ...).
16
+ */
17
+ export const reactHookOf = (context, call) => {
18
+ const callee = unwrap(call.callee)
19
+ if (callee.type === "Identifier") {
20
+ const from = importOf(context, callee)
21
+ return from && from.source === "react" && from.imported ? from.imported : null
22
+ }
23
+ if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && callee.property.type === "Identifier") {
24
+ const from = importOf(context, callee.object)
25
+ return from && from.source === "react" && from.imported === null ? callee.property.name : null
26
+ }
27
+ return null
28
+ }
package/lib/scope.mjs CHANGED
@@ -6,31 +6,74 @@
6
6
  * environment, and the disagreement shows up as a finding nobody can satisfy.
7
7
  */
8
8
 
9
+ import { dirname, join, resolve } from "node:path"
10
+ import { hfsOf } from "./hfs.mjs"
9
11
  import { normalizePath } from "./path.mjs"
10
12
 
11
- /** A spec or test file - it asserts about production shapes rather than being one. */
12
- export const isSpecFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx|mts|cts)$/.test(normalizePath(filename))
13
+ /**
14
+ * The HFS slot id of the file being linted, read from the slot view (`settings.starci.hfs`).
15
+ *
16
+ * @param {object} context - The ESLint rule context.
17
+ * @returns {string | null} The slot id, or null when no slot owns the file.
18
+ */
19
+ export const slotOfFile = (context) => hfsOf(context).slotOf(context.filename || context.getFilename())
13
20
 
14
- /** Product source: under `src/`, and not a spec. */
15
- export const isProductFile = (filename) => {
16
- const file = normalizePath(filename)
17
- return file.includes("/src/") && !isSpecFile(file)
18
- }
21
+ /**
22
+ * True when the file being linted sits in one of the given slots.
23
+ *
24
+ * @param {object} context - The ESLint rule context.
25
+ * @param {...string} ids - Slot ids.
26
+ * @returns {boolean} Whether the file's slot is one of them.
27
+ */
28
+ export const inSlot = (context, ...ids) => ids.includes(slotOfFile(context))
29
+
30
+ /** The filename a rule is linting. */
31
+ export const fileOf = (context) => context.filename || context.getFilename()
32
+
33
+ /** The classification of the linted file from the slot view: `{ slot, root, bindings, kind?, role? }` or a no-slot status. */
34
+ export const classOf = (context) => hfsOf(context).classify(fileOf(context))
35
+
36
+ /** The tier of the linted file in the import direction matrix (`route`, `feature`, `components`, `hooks`, `foundation`, `modules`, `transport`, `e2e`, `package`, `none`), or null when no slot owns it. */
37
+ export const tierOfFile = (context) => hfsOf(context).tierOf(fileOf(context))
38
+
39
+ /** The folder kind the slot reports for the linted file (a component layer, a feature kind), or null. */
40
+ export const kindOfFile = (context) => classOf(context).kind ?? null
41
+
42
+ /** The role the slot reports for the linted file (`entry`, `drawing`, `styles`, `shared`, `page`, `layout`, ...), or null. */
43
+ export const roleOfFile = (context) => classOf(context).role ?? null
44
+
45
+ /**
46
+ * A spec or test file - it asserts about production shapes rather than being one. The slots define a spec by its name
47
+ * (`allows: "*.spec.tsx"`), so this stays a file-name test.
48
+ */
49
+ export const isSpecFile = (filename) => /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalizePath(filename))
50
+
51
+ /** The tiers of product source: everything a slot places in an app's route, feature, component, hook or module tiers and in a shared package. */
52
+ const PRODUCT_TIERS = new Set(["route", "feature", "components", "hooks", "foundation", "modules", "transport", "package"])
53
+
54
+ /** Product source: a file of a product tier, and not a spec. */
55
+ export const isProductSource = (context) => PRODUCT_TIERS.has(tierOfFile(context)) && !isSpecFile(fileOf(context))
19
56
 
20
- /** The e2e tree: `e2e/**` and the Playwright config beside it. */
21
- export const isE2eFile = (filename) => {
22
- const file = normalizePath(filename)
23
- return /(?:^|\/)e2e\//.test(file) || /(?:^|\/)playwright\.config\.[cm]?[jt]s$/.test(file)
57
+ /** A file of a component tier: slot `fe.components` or `fe.package.ui`, inside one of its layer folders (blocks, composites, branches, leaves). */
58
+ export const isComponentFile = (context) => inSlot(context, "fe.components", "fe.package.ui") && kindOfFile(context) !== null
59
+
60
+ /** True when the given file (an absolute or repo-relative name, e.g. where an import resolves to) sits in a component layer of slot `fe.components` or `fe.package.ui`. */
61
+ export const isComponentPath = (context, file) => {
62
+ const found = hfsOf(context).classify(file)
63
+ return (found.slot === "fe.components" || found.slot === "fe.package.ui") && found.kind !== undefined
24
64
  }
25
65
 
26
- /** The one place that may read the environment. */
27
- export const isConfigModule = (filename) => /\/modules\/config\//.test(normalizePath(filename))
66
+ /** The e2e tree (tier `e2e`: `fe.e2e`, `fe.e2e-support`) and the Playwright config (role `playwright` of `fe.tool-config`). */
67
+ export const isE2eSource = (context) => tierOfFile(context) === "e2e" || roleOfFile(context) === "playwright"
68
+
69
+ /** The one place that may read the environment: slot `fe.modules.config`. */
70
+ export const isConfigModule = (context) => inSlot(context, "fe.modules.config")
28
71
 
29
- /** The one place that may call `fetch`. */
30
- export const isApiClient = (filename) => /\/modules\/api\/client\.[cm]?tsx?$/.test(normalizePath(filename))
72
+ /** The one place that may call `fetch`: slot `fe.transport.client` (one-app repository) or `fe.package.api.client` (shared package). */
73
+ export const isApiClient = (context) => inSlot(context, "fe.transport.client", "fe.package.api.client")
31
74
 
32
- /** The route files Next mounts as a segment slot. */
33
- export const ROUTE_SLOTS = ["page", "layout", "template", "loading", "not-found", "default", "route"]
75
+ /** The file that may declare the one Outcome union: slot `fe.transport.outcome` or `fe.package.api.outcome`. */
76
+ export const isOutcomeModule = (context) => inSlot(context, "fe.transport.outcome", "fe.package.api.outcome")
34
77
 
35
78
  /** The basename of a path without directories. */
36
79
  export const baseName = (filename) => normalizePath(filename).split("/").pop() ?? ""
@@ -38,5 +81,62 @@ export const baseName = (filename) => normalizePath(filename).split("/").pop() ?
38
81
  /** The file name with its extension removed. */
39
82
  export const stem = (filename) => baseName(filename).replace(/\.[cm]?[jt]sx?$/, "")
40
83
 
41
- /** The component tiers a workspace package holds directly under `src/` (`slots.yaml` `fe.package.ui`). */
42
- export const PACKAGE_TIERS = ["composites", "branches", "leaves"]
84
+ /**
85
+ * The references in the file that resolve to no declaration of the file, i.e. to a global (`fetch`, `Request`, `window`).
86
+ * An import, a parameter, a local `const fetch` binds the name and is not one; a type-position use is not a value reference.
87
+ *
88
+ * @param {object} context - The ESLint rule context.
89
+ * @param {Set<string>} names - The global names to look for.
90
+ * @returns {Array<object>} The ESTree identifiers that reference such a global as a value.
91
+ */
92
+ export const globalReferences = (context, names) => {
93
+ const sourceCode = context.sourceCode ?? context.getSourceCode()
94
+ const found = []
95
+ const visit = (scope) => {
96
+ for (const reference of scope.references) {
97
+ if (!names.has(reference.identifier.name) || reference.isValueReference === false) continue
98
+ if (!reference.resolved || reference.resolved.defs.length === 0) found.push(reference.identifier)
99
+ }
100
+ scope.childScopes.forEach(visit)
101
+ }
102
+ visit(sourceCode.scopeManager.globalScope)
103
+ return found
104
+ }
105
+
106
+ /**
107
+ * Where an import specifier of the linted file points, as an absolute path without an extension: a relative specifier, or `@/`
108
+ * from the owning app's `src/` (the app directory is the directory part of slot `fe.app.next`). Null for a package or a file no app owns.
109
+ *
110
+ * @param {object} context - The ESLint rule context.
111
+ * @param {string} specifier - The module specifier.
112
+ * @returns {string | null} The absolute path, or null.
113
+ */
114
+ export const resolveImport = (context, specifier) => {
115
+ const hfs = hfsOf(context)
116
+ const file = fileOf(context)
117
+ if (specifier.startsWith(".")) return resolve(dirname(file), specifier)
118
+ if (!specifier.startsWith("@/")) return null
119
+ const app = hfs.classify(file).bindings?.app
120
+ if (app === undefined) return null
121
+ const appDirectory = hfs.slot("fe.app.next").path.split("/{")[0].replace("<app>", app)
122
+ return join(hfs.repoRoot, appDirectory, "src", specifier.slice(2))
123
+ }
124
+
125
+ /**
126
+ * The classification of the file an import specifier resolves to, trying the extensions a module file has. Null when the
127
+ * specifier resolves to no path.
128
+ *
129
+ * @param {object} context - The ESLint rule context.
130
+ * @param {string} specifier - The module specifier.
131
+ * @returns {object | null} The slot classification (`{ slot, root, role, ... }`) of the first candidate a slot owns.
132
+ */
133
+ export const classifyImport = (context, specifier) => {
134
+ const target = resolveImport(context, specifier)
135
+ if (target === null) return null
136
+ const hfs = hfsOf(context)
137
+ // The specifier names no extension: try the ones a module file has and take the candidate a slot gives a role, else the first a slot owns.
138
+ const owned = [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`, target]
139
+ .map((candidate) => hfs.classify(candidate))
140
+ .filter((found) => found.slot && found.status === "owned")
141
+ return owned.find((found) => found.role) ?? owned[0] ?? null
142
+ }
@@ -0,0 +1,9 @@
1
+ // Generated by packages/stylelint/lib/write-vocabulary.mjs from packages/grammar/src. Do not edit: run `npm run vocabulary` in packages/stylelint.
2
+
3
+ /** The status tones of the grammar: each has a solid pair and a soft pair (`--<tone>-soft`, `--<tone>-soft-foreground`). */
4
+ export const STATUS_TONES = [
5
+ "success",
6
+ "warning",
7
+ "danger",
8
+ "info",
9
+ ]
package/lib/types.mjs ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Type-aware helpers of the front-end canon: a rule asks what a node IS (its TypeScript type, the symbol an identifier
3
+ * resolves to, the module a declaration comes from), never what it is called. `starciFeConfig` turns typed linting on for
4
+ * the source tree (`parserOptions.projectService`), so these helpers refuse to run without the program instead of falling
5
+ * back to a name match.
6
+ */
7
+ import ts from "typescript"
8
+
9
+ /**
10
+ * The TypeScript services of the file being linted.
11
+ *
12
+ * @param {object} context - The ESLint rule context.
13
+ * @returns {{ program: object, checker: object, toTs: (node: object) => object }} The program, its checker and the ESTree to TS node map.
14
+ */
15
+ export const typed = (context) => {
16
+ const services = context.sourceCode?.parserServices ?? context.parserServices
17
+ if (!services?.program || !services.esTreeNodeToTSNodeMap) {
18
+ throw new Error(`${context.id ?? "a starci-fe rule"} needs typed linting (parserOptions.projectService); lint through starciFeConfig, which turns it on`)
19
+ }
20
+ const program = services.program
21
+ return { program, checker: program.getTypeChecker(), toTs: (node) => services.esTreeNodeToTSNodeMap.get(node) }
22
+ }
23
+
24
+ /**
25
+ * True when the type of an expression is a promise (a thenable carrying a `then` method), after union members are read.
26
+ *
27
+ * @param {object} context - The ESLint rule context.
28
+ * @param {object} node - An ESTree expression.
29
+ * @returns {boolean} Whether evaluating the node yields a promise.
30
+ */
31
+ export const isPromiseValued = (context, node) => {
32
+ const { checker, toTs } = typed(context)
33
+ const tsNode = toTs(node)
34
+ if (!tsNode) return false
35
+ const type = checker.getTypeAtLocation(tsNode)
36
+ const parts = type.isUnion?.() ? type.types : [type]
37
+ return parts.some((part) => Boolean(part.getProperty?.("then")))
38
+ }
39
+
40
+ /**
41
+ * The declarations the symbol of an identifier resolves to, after import aliases are followed.
42
+ *
43
+ * @param {object} context - The ESLint rule context.
44
+ * @param {object} identifier - An ESTree identifier (or any node with a symbol).
45
+ * @returns {Array<object>} The TypeScript declarations (empty when the checker has no symbol).
46
+ */
47
+ export const declarationsOf = (context, identifier) => {
48
+ const { checker, toTs } = typed(context)
49
+ const tsNode = toTs(identifier)
50
+ if (!tsNode) return []
51
+ let symbol = checker.getSymbolAtLocation(tsNode)
52
+ if (!symbol) return []
53
+ if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
54
+ return symbol.getDeclarations?.() ?? []
55
+ }
56
+
57
+ /**
58
+ * The forward-slash file name a declaration lives in.
59
+ *
60
+ * @param {object} declaration - A TypeScript declaration.
61
+ * @returns {string} Its source file name.
62
+ */
63
+ export const fileOf = (declaration) => String(declaration.getSourceFile().fileName).split("\\").join("/")
package/lib/why.mjs CHANGED
@@ -28,8 +28,8 @@ export const why = {
28
28
  },
29
29
  "fetch-only-in-api-client": {
30
30
  code: "FE_TRANSPORT_OWNER",
31
- vi: "`fetch` (hoặc thư viện HTTP khác) ở `<file>` nằm ngoài `modules/api/client.ts`. Mỗi app chỉ có đúng một đường truyền.",
32
- fixVi: "Gọi client của app và nhận `Outcome<T>`; không tự gọi `fetch`.",
31
+ vi: "`fetch` (hoặc `Request`, `EventSource`, `sendBeacon`, thư viện HTTP khác) ở `<file>` nằm ngoài client duy nhất (`modules/api/client.ts`, hoặc `src/client.ts` của gói api dùng chung). Mỗi repo chỉ có đúng một đường truyền.",
32
+ fixVi: "Gọi client của repo và nhận `Outcome<T>`; không tự gọi `fetch`.",
33
33
  },
34
34
  "client-fetch-has-signal": {
35
35
  code: "FE_TRANSPORT_OWNER",
@@ -43,8 +43,8 @@ export const why = {
43
43
  },
44
44
  "client-maps-auth-to-refused": {
45
45
  code: "FE_HTTP_STATUS_COLLAPSE",
46
- vi: "Client ở `<file>` không đổi 401/403 thành `refused`, nên trạng thái \"cần đăng nhập\" không bao giờ đạt được.",
47
- fixVi: "Thêm nhánh 401/403 trả `{ kind: \"refused\" }` trong client.",
46
+ vi: "Client ở `<file>` không có nhánh so sánh `response.status` với 401 và 403 rồi trả `{ kind: \"refused\" }`, nên trạng thái \"cần đăng nhập\" không bao giờ đạt được.",
47
+ fixVi: "Thêm nhánh `response.status === 401 || response.status === 403` trả `{ ok: false, kind: \"refused\" }` ngay trong client.",
48
48
  },
49
49
  "no-http-status-collapse": {
50
50
  code: "FE_HTTP_STATUS_COLLAPSE",
@@ -96,6 +96,11 @@ export const why = {
96
96
  vi: "`<file>` dùng giá trị màu/độ dài thô (`<what>`). Màu và khoảng cách chỉ đến từ token grammar; màu thương hiệu chỉ ở `brand.css`.",
97
97
  fixVi: "Dùng lớp hoặc biến token của grammar; giá trị màu chỉ khai trong `modules/brand/brand.css`.",
98
98
  },
99
+ "status-text-uses-soft-foreground": {
100
+ code: "FE_STYLE_TOKEN_ONLY",
101
+ vi: "`<file>` tô chữ hoặc icon bằng tông trạng thái đặc (`<what>`, ví dụ `text-success`). Tông đặc là màu nền; dùng làm màu chữ nó đọc dưới 4.5:1 trên nền trang.",
102
+ fixVi: "Đổi thành cặp mềm: `text-<tông>-soft-foreground` (kèm `bg-<tông>-soft` nếu nằm trên nền nhạt); tông đặc chỉ dùng với `bg-<tông>` và `text-<tông>-foreground`.",
103
+ },
99
104
  "no-native-form-control": {
100
105
  code: "FE_NATIVE_FORM_CONTROL",
101
106
  vi: "`<tag>` thô ở `<file>`. Dùng renderer của grammar.",
@@ -163,7 +168,7 @@ export const why = {
163
168
  },
164
169
  "no-barrel-spec": {
165
170
  code: "FE_SPEC_QUALITY",
166
- vi: "Spec `<file>` đặt cạnh một barrel. Barrel không có hành vi để test.",
171
+ vi: "Spec `<file>` đặt cạnh một `index` chỉ có import và re-export (barrel). Barrel không có hành vi để test.",
167
172
  fixVi: "Xóa spec; test từng đơn vị mà barrel xuất lại, cạnh chính đơn vị đó.",
168
173
  },
169
174
  "no-double-cast-in-spec": {
@@ -179,7 +184,7 @@ export const why = {
179
184
  "no-mocked-translations": {
180
185
  code: "FE_I18N_CATALOG",
181
186
  vi: "Spec `<file>` giả lập `next-intl`, nên chỉ kiểm khóa chứ không kiểm catalog thật.",
182
- fixVi: "Render trong `NextIntlClientProvider` với `messages/<locale>.json` thật.",
187
+ fixVi: "Component client: render trong `NextIntlClientProvider` với `messages/<locale>.json` thật. Helper server (`next-intl/server`): factory của mock dựng translator từ catalog thật (`createTranslator` với `modules/i18n/messages/<locale>.json`).",
183
188
  },
184
189
  "no-inline-lint-config": {
185
190
  code: "HFS_INLINE_SUPPRESSION",
@@ -221,15 +226,15 @@ export const why = {
221
226
  vi: "Component trong `.map` ở `<file>` nhận object hoặc array literal làm prop: mỗi hàng mỗi lần render một giá trị mới, memo không bao giờ trúng.",
222
227
  fixVi: "Nâng hằng số ra ngoài component hoặc dựng một lần trước `.map`.",
223
228
  },
224
- "timer-needs-effect-cleanup": {
229
+ "effect-subscription-needs-cleanup": {
225
230
  code: "FE_EFFECT_CLEANUP",
226
- vi: "`<timer>` ở `<file>` chạy ngoài effect hoặc trong effect không có cleanup gọi `clearTimeout`/`clearInterval`.",
227
- fixVi: "Đặt timer trong `useEffect` và trả `() => clearTimeout(id)`; hoặc chuyển vào một hook làm việc đó.",
231
+ vi: "`<what>` ở `<file>` (timer, frame, listener, observer, socket hoặc subscription) được effect hoặc `subscribe` khởi động nhưng cleanup trả về không giải phóng đúng handle/target/listener đó.",
232
+ fixVi: "Giữ handle và trả cleanup gọi `clearTimeout(id)`, `cancelAnimationFrame(id)`, `removeEventListener` cùng type và listener, `.disconnect()`, `.close()` hoặc `.unsubscribe()` trên đúng đối tượng.",
228
233
  },
229
234
  "no-data-fetch-in-effect": {
230
235
  code: "FE_EFFECT_FETCH",
231
- vi: "`useEffect` ở `<file>` tải dữ liệu (`await`, `fetch`, `.then`): không cache, không gộp request, không trạng thái loading/lỗi, không huỷ.",
232
- fixVi: "Đọc phía client bằng SWR gọi client của app (`hooks/`), phía route bằng server reader `modules/api/<domain>/read-*.ts`.",
236
+ vi: "`useEffect` ở `<file>` khởi động một promise (`await`, `fetch`, `.then`, `void load()`, `mutate()`/`refresh()` gọi thẳng): không cache, không gộp request, không trạng thái loading/lỗi, không huỷ. Độ tươi của dữ liệu đến từ key của SWR, không đến từ effect.",
237
+ fixVi: "Đọc phía client bằng SWR gọi client của app (`hooks/`) với key theo thứ thay đổi; gọi `mutate()` từ sự kiện làm dữ liệu đổi (handler, message socket); phía route dùng server reader `modules/api/<domain>/read-*.ts`.",
233
238
  },
234
239
  "no-empty-catch": {
235
240
  code: "FE_SWALLOWED_ERROR",
@@ -276,6 +281,11 @@ export const why = {
276
281
  vi: "Client component ở `<file>` nhập mã chỉ tồn tại ở server (`server-only`, `next/headers`, module Node, server reader).",
277
282
  fixVi: "Đọc dữ liệu ở server component hoặc server reader rồi truyền xuống bằng props; hoặc dùng SWR gọi client của app.",
278
283
  },
284
+ "server-module-marks-server-only": {
285
+ code: "FE_SERVER_ONLY_MARK",
286
+ vi: "Module `<file>` nhập API chỉ có ở server (`next/headers`, `next/server`, `next-intl/server`, module Node hoặc module đã là server-only) nhưng không mở đầu bằng `import \"server-only\"`.",
287
+ fixVi: "Đặt `import \"server-only\"` làm câu lệnh đầu tiên của tệp; tệp route (`page`, `layout`, `route`, `proxy`) là server component sẵn nên không cần.",
288
+ },
279
289
  "web-storage-only-in-modules": {
280
290
  code: "FE_STORAGE_OUTSIDE_MODULES",
281
291
  vi: "`<file>` dùng `localStorage`/`sessionStorage` ngoài `modules/`; storage không có ở server và ném lỗi khi đầy hạn mức.",
@@ -301,4 +311,24 @@ export const why = {
301
311
  vi: "`switch` trên `kind` ở `<file>` có nhánh `ok` nhưng thiếu một trong refused, invalid, not-found, unavailable.",
302
312
  fixVi: "Viết đủ năm nhánh của `Outcome<T>`, mỗi nhánh một màn; không dựa vào `default`.",
303
313
  },
314
+ "one-outcome-union": {
315
+ code: "FE_HTTP_STATUS_COLLAPSE",
316
+ vi: "`<file>` khai báo thêm một union kết quả (`ok` hoặc `kind`) ngoài `outcome.ts`. Repo chỉ có một `Outcome<T>`.",
317
+ fixVi: "Dùng `Outcome<T>` của `modules/api/outcome.ts` (hoặc gói api dùng chung); thêm chi tiết nghiệp vụ qua tham số thứ hai thay vì khai báo union mới.",
318
+ },
319
+ "i18n-stack-in-one-module": {
320
+ code: "FE_I18N_PLACEMENT",
321
+ vi: "`<file>` tự dựng một tầng của stack next-intl (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). Stack chỉ được viết một lần cho cả repo.",
322
+ fixVi: "Gọi factory `createAppI18n` của gói i18n dùng chung trong `modules/i18n/index.ts` và nhập kết quả; repo một app thì chỉ viết stack trong `modules/i18n` của app đó.",
323
+ },
324
+ "no-raw-structural-element": {
325
+ code: "FE_NATIVE_FORM_CONTROL",
326
+ vi: "`<tag>` thô ở `<file>`. Cấu trúc trang và chữ được ghép từ component của grammar, không viết HTML thô.",
327
+ fixVi: "Thay bằng component grammar tương ứng (`Heading`, `Text`, `SurfaceCard`, ...); nếu grammar chưa có, thêm vào grammar hoặc gói ui thay vì vẽ tại chỗ.",
328
+ },
329
+ "file-size-growth": {
330
+ code: "HFS_SIZE_GROWTH",
331
+ vi: "`<file>` vượt ngân sách dòng: file mới đã dài quá mức, hoặc file cũ dài hơn bản ở commit cha. Mỗi file phải nhỏ để một người đọc hết và một bài test phủ hết.",
332
+ fixVi: "Tách phần mới sang file riêng theo trách nhiệm; một file đã vượt ngân sách chỉ được giữ nguyên hoặc ngắn lại.",
333
+ },
304
334
  }
@@ -8,6 +8,10 @@
8
8
  * eslint-disable / -next-line / -line / -enable / -env, and `/* eslint rule: "off" *\/`
9
9
  * @ts-ignore, @ts-expect-error, @ts-nocheck
10
10
  * vn-ok: <reason> - the retired second-language pragma; it excuses nothing any more
11
+ * NOSONAR, @sonar-ignore - Sonar's own switches
12
+ * istanbul ignore, c8 ignore, v8 ignore - a line taken out of the coverage the gate reads
13
+ * prettier-ignore - a region taken out of the formatter
14
+ * stylelint-disable / -enable - a stylelint switch that reached a TS file
11
15
  *
12
16
  * TWO LAYERS, BECAUSE ONE IS NOT ENOUGH. The flat-config fence (`noInlineConfig`) makes an ESLint
13
17
  * directive INEFFECTIVE; this rule makes it a FINDING, so the attempt is named in the log instead of
@@ -15,14 +19,10 @@
15
19
  * nothing is dead weight that reads as if it did.
16
20
  */
17
21
 
18
- import { isE2eFile, isProductFile, isSpecFile } from "./lib/scope.mjs"
19
- import { normalizePath } from "./lib/path.mjs"
22
+ import { fileOf, isE2eSource, isProductSource, isSpecFile } from "./lib/scope.mjs"
20
23
 
21
24
  /** Product source, its specs and the e2e tree are governed; canon tests deliberately build forbidden directives. */
22
- const isGoverned = (filename) => {
23
- const file = normalizePath(filename)
24
- return isProductFile(file) || isSpecFile(file) || isE2eFile(file)
25
- }
25
+ const isGoverned = (context) => isProductSource(context) || isSpecFile(fileOf(context)) || isE2eSource(context)
26
26
 
27
27
  /**
28
28
  * Any directive that changes ESLint's active rule set inside a source file.
@@ -39,6 +39,13 @@ const ESLINT_DIRECTIVE = /^\s*eslint-(?:disable(?:-next-line|-line)?|enable|env)
39
39
  /** A TypeScript directive that turns the compiler's check off for the next line or the file. */
40
40
  const TS_DIRECTIVE = /^\s*(?:\/\s*)?@ts-(?:ignore|expect-error|nocheck)\b/
41
41
 
42
+ /**
43
+ * A quality-tool switch other than ESLint's: Sonar honours `NOSONAR` anywhere in a comment (case-insensitively), so it is
44
+ * matched anywhere; the others are read by their tool from the start of the comment, so they are anchored there too.
45
+ */
46
+ const TOOL_DIRECTIVE =
47
+ /\bNOSONAR\b|^\s*@sonar-ignore\b|^\s*(?:istanbul|c8|v8)\s+ignore\b|^\s*prettier-ignore(?:-start|-end)?\b|^\s*stylelint-(?:disable(?:-next-line|-line)?|enable)\b/i
48
+
42
49
  /** The retired second-language pragma. Present anywhere in a comment, it is a finding. */
43
50
  const RETIRED_PRAGMA = /\bvn-ok:/
44
51
 
@@ -53,18 +60,21 @@ export const noInlineLintConfig = {
53
60
  "Inline ESLint configuration makes this file the author of whether repository law applies. Remove the directive and fix the code or the shared rule; there is no local exception path.",
54
61
  typescript:
55
62
  "A TypeScript suppression directive. It turns the compiler off for code that is failing the check for a reason. Fix the type, or change the shared contract that produced it; there is no local exception path.",
63
+ tool:
64
+ "A Sonar, coverage, formatter or stylelint suppression (`NOSONAR`, `@sonar-ignore`, `istanbul ignore`, `c8 ignore`, `prettier-ignore`, `stylelint-disable`). It removes this code from a gate that is failing it for a reason, and the next reader cannot tell a measured exception from an avoided fix. Fix the code, or change the shared rule or the coverage threshold; there is no local exception path.",
56
65
  pragma:
57
66
  "`vn-ok:` was the second-language escape hatch and it is retired: user-facing text comes from a `next-intl` catalogue through `t()`, and there is no comment that excuses a literal. Remove the pragma and move the string.",
58
67
  },
59
68
  },
60
69
  create(context) {
61
- if (!isGoverned(context.filename || context.getFilename())) return {}
70
+ if (!isGoverned(context)) return {}
62
71
  const source = context.sourceCode || context.getSourceCode()
63
72
  return {
64
73
  Program() {
65
74
  for (const comment of source.getAllComments()) {
66
75
  if (ESLINT_DIRECTIVE.test(comment.value)) context.report({ node: comment, messageId: "directive" })
67
76
  else if (TS_DIRECTIVE.test(comment.value)) context.report({ node: comment, messageId: "typescript" })
77
+ else if (TOOL_DIRECTIVE.test(comment.value)) context.report({ node: comment, messageId: "tool" })
68
78
  else if (RETIRED_PRAGMA.test(comment.value)) context.report({ node: comment, messageId: "pragma" })
69
79
  }
70
80
  },
@@ -81,11 +91,3 @@ export const rules = {
81
91
  export const recommended = {
82
92
  "starci-fe/no-inline-lint-config": "error",
83
93
  }
84
-
85
- /**
86
- * Consuming flat configs apply this beside the recommended rules.
87
- *
88
- * `noInlineConfig` makes an inline directive ineffective; `reportUnusedDisableDirectives` makes one
89
- * that suppresses nothing an error, so a leftover from before the fence is not mistaken for a live one.
90
- */
91
- export const linterOptions = Object.freeze({ noInlineConfig: true, reportUnusedDisableDirectives: "error" })
package/loading.mjs CHANGED
@@ -12,16 +12,10 @@
12
12
  * lint rule is for - the one nothing else will ever report.
13
13
  */
14
14
 
15
- import { normalizePath } from "./lib/path.mjs"
15
+ import { classOf, fileOf, isComponentFile, isSpecFile } from "./lib/scope.mjs"
16
16
 
17
- /** The component tree; a placeholder in a test fixture is a fixture, not a second tree. */
18
- const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
19
-
20
- /** A twin test may build a resting shape by hand to assert against it. */
21
- const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
22
-
23
- /** Product component source these rules govern. */
24
- const isGoverned = (filename) => isComponentFile(filename) && !isTestFile(filename)
17
+ /** Product component source these rules govern: a component owner's file, and not a spec (a placeholder in a test fixture is a fixture, not a second tree). */
18
+ const isGoverned = (context) => isComponentFile(context) && !isSpecFile(fileOf(context))
25
19
 
26
20
  /** A flag that means "waiting", in the spellings this codebase uses. */
27
21
  const WAITING_FLAG = /\bis(?:Loading|Skeleton|Pending)\b/
@@ -54,13 +48,12 @@ export const noRestingTwinComponent = {
54
48
  },
55
49
  },
56
50
  create(context) {
57
- const file = normalizePath(context.filename || context.getFilename())
58
- if (!isGoverned(file)) return {}
59
- const hit = file.match(/\/([A-Za-z0-9]*Skeleton)\/index\.tsx?$/) || file.match(/\/([A-Za-z0-9]*Skeleton)\.tsx?$/)
60
- if (!hit || !TWIN_NAME.test(hit[1])) return {}
51
+ if (!isGoverned(context)) return {}
52
+ const owner = classOf(context)
53
+ if (owner.role !== "entry" || !TWIN_NAME.test(owner.bindings?.name ?? "")) return {}
61
54
  return {
62
55
  Program(node) {
63
- context.report({ node, messageId: "twin", data: { name: hit[1] } })
56
+ context.report({ node, messageId: "twin", data: { name: owner.bindings.name } })
64
57
  },
65
58
  }
66
59
  },
@@ -82,7 +75,7 @@ export const noPlaceholderProp = {
82
75
  },
83
76
  },
84
77
  create(context) {
85
- if (!isGoverned(context.filename || context.getFilename())) return {}
78
+ if (!isGoverned(context)) return {}
86
79
  return {
87
80
  JSXAttribute(node) {
88
81
  const name = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
@@ -122,7 +115,7 @@ export const noRestingBranchAtCallSite = {
122
115
  },
123
116
  },
124
117
  create(context) {
125
- if (!isGoverned(context.filename || context.getFilename())) return {}
118
+ if (!isGoverned(context)) return {}
126
119
  const source = context.sourceCode || context.getSourceCode()
127
120
  /** Root element name of one arm, or null when the arm is not an element. */
128
121
  const armName = (expression) => {
package/naming.mjs CHANGED
@@ -11,6 +11,10 @@
11
11
  * describes an action is not its business.
12
12
  */
13
13
 
14
+ import { hfsOf } from "./lib/hfs.mjs"
15
+ import { NEXT_RESERVED_EXPORTS } from "./lib/next.mjs"
16
+ import { fileOf, inSlot, roleOfFile } from "./lib/scope.mjs"
17
+
14
18
  /** Parents that make a function declaration module-level rather than nested. */
15
19
  const MODULE_LEVEL_PARENTS = new Set(["Program", "ExportNamedDeclaration", "ExportDefaultDeclaration"])
16
20
 
@@ -86,10 +90,13 @@ export const noDirectConstAlias = {
86
90
  },
87
91
  },
88
92
  create(context) {
93
+ // A route segment file (slot `fe.route`, a role: page, layout, route, ...) is the one place Next dictates the exported name; `export const generateMetadata = x` is the contract.
94
+ const segmentFile = inSlot(context, "fe.route") && roleOfFile(context) !== null
89
95
  return {
90
96
  VariableDeclarator(node) {
91
97
  if (node.parent?.kind !== "const") return
92
98
  if (node.id?.type !== "Identifier" || node.init?.type !== "Identifier") return
99
+ if (segmentFile && NEXT_RESERVED_EXPORTS.has(node.id.name) && node.parent.parent?.type === "ExportNamedDeclaration") return
93
100
  context.report({
94
101
  node: node.id,
95
102
  messageId: "alias",
@@ -149,8 +156,7 @@ export const noSecondLanguageInPath = {
149
156
  },
150
157
  },
151
158
  create(context) {
152
- const filename = context.filename || context.getFilename()
153
- const offending = segmentsOf(filename).find(
159
+ const offending = segmentsOf(hfsOf(context).relative(fileOf(context))).find(
154
160
  (segment) => SECOND_LANGUAGE_PATH.test(segment) || ROMANISED.includes(segment.replace(/[()[\]]/g, "")),
155
161
  )
156
162
  if (!offending) return {}