@starci/eslint-canon-fe 6.0.2 → 7.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 (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -5
  3. package/client-boundary.mjs +2 -5
  4. package/comments.mjs +38 -15
  5. package/docs/brand-values.md +2 -2
  6. package/docs/client-boundary.md +10 -10
  7. package/docs/env-owner.md +4 -4
  8. package/docs/formatting.md +2 -2
  9. package/docs/grammar-boundary.md +2 -2
  10. package/docs/hooks-folder.md +2 -2
  11. package/docs/hygiene.md +8 -8
  12. package/docs/lint-escape-hatch.md +2 -2
  13. package/docs/lists.md +6 -6
  14. package/docs/native-controls.md +6 -6
  15. package/docs/next-conventions.md +20 -20
  16. package/docs/size-and-state-budget.md +6 -6
  17. package/docs/size-growth.md +2 -2
  18. package/docs/status-colors.md +2 -2
  19. package/docs/translation.md +2 -2
  20. package/docs/transport.md +16 -16
  21. package/docs/type-safety.md +8 -8
  22. package/env-owner.mjs +2 -3
  23. package/file-layout.mjs +3 -43
  24. package/formatting.mjs +2 -2
  25. package/grammar-boundary.mjs +1 -2
  26. package/hooks-folder.mjs +6 -7
  27. package/index.mjs +3 -27
  28. package/lib/config.mjs +6 -22
  29. package/lib/scope.mjs +3 -12
  30. package/lib/why.mjs +0 -65
  31. package/lint-escape-hatch.mjs +3 -3
  32. package/lists.mjs +0 -4
  33. package/loading.mjs +2 -2
  34. package/native-controls.mjs +0 -4
  35. package/next-conventions.mjs +3 -7
  36. package/package.json +1 -1
  37. package/props-and-slots.mjs +4 -5
  38. package/runtime/knowledge/hfs/slots.yaml +145 -107
  39. package/runtime/scripts/lib/hfs-allows.mjs +15 -0
  40. package/runtime/scripts/lib/hfs-slots.mjs +13 -6
  41. package/runtime/scripts/lib/language.mjs +107 -0
  42. package/served-locale.mjs +2 -2
  43. package/shape-slot.mjs +2 -3
  44. package/size-and-state-budget.mjs +1 -4
  45. package/size-growth.mjs +1 -2
  46. package/status-colors.mjs +0 -2
  47. package/tokens.mjs +1 -1
  48. package/translation.mjs +1 -29
  49. package/transport.mjs +4 -8
  50. package/docs/e2e-shape.md +0 -161
  51. package/docs/spec-quality.md +0 -147
  52. package/e2e-shape.mjs +0 -345
  53. package/spec-quality.mjs +0 -318
@@ -24,6 +24,6 @@ A source file over the budget is neither new nor longer than its recorded size.
24
24
 
25
25
  **Finding code:** `HFS_SIZE_GROWTH`
26
26
 
27
- **Vì sao (why):** `<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.
27
+ **Why:** `<file>` exceeds the line budget: a new file is already too long, or an old file is longer than its version at the parent commit. Every file must be small enough for one person to read entirely and one test to cover entirely.
28
28
 
29
- **Cách sửa:** 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.
29
+ **Fix:** Split the new part into its own file by responsibility; a file already over budget may only stay the same or get shorter.
@@ -27,6 +27,6 @@ No class that paints text, an icon or a text decoration with a solid status tone
27
27
 
28
28
  **Finding code:** `FE_STYLE_TOKEN_ONLY`
29
29
 
30
- **Vì sao (why):** `<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.
30
+ **Why:** `<file>` colours text or an icon with a solid status tone (`<what>`, for example `text-success`). A solid tone is a background colour; used as a text colour it reads below 4.5:1 on the page background.
31
31
 
32
- **Cách sửa:** Đổ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`.
32
+ **Fix:** Change to the soft pair: `text-<tone>-soft-foreground` (with `bg-<tone>-soft` if it sits on a light background); solid tones are used only with `bg-<tone>` and `text-<tone>-foreground`.
@@ -49,6 +49,6 @@ A template literal with substitutions is copy when its static parts contain a wo
49
49
 
50
50
  **Finding code:** `FE_I18N_LITERAL`
51
51
 
52
- **Vì sao (why):** Chữ `"<text>"` ở `<file>:<line>` không đi qua `t()`.
52
+ **Why:** The text `"<text>"` at `<file>:<line>` does not go through `t()`.
53
53
 
54
- **Cách sửa:** Chuyển câu vào `modules/i18n/messages/<locale>.json` và đọc bằng `t("khóa")`; không có ngoại lệ và không có `vn-ok`.
54
+ **Fix:** Move the sentence into `modules/i18n/messages/<locale>.json` and read it with `t("key")`; there is no exception and no `vn-ok`.
package/docs/transport.md CHANGED
@@ -24,9 +24,9 @@ const r = await client.get(url)
24
24
 
25
25
  **Finding code:** `FE_TRANSPORT_OWNER`
26
26
 
27
- **Vì sao (why):** `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.
27
+ **Why:** `fetch` (or `Request`, `EventSource`, `sendBeacon`, another HTTP library) in `<file>` is outside the single client (`modules/api/client.ts`, or `src/client.ts` of the shared api package). Each repo has exactly one transport path.
28
28
 
29
- **Cách sửa:** Gọi client của repo và nhận `Outcome<T>`; không tự gọi `fetch`.
29
+ **Fix:** Call the repo's client and receive `Outcome<T>`; do not call `fetch` yourself.
30
30
 
31
31
  ## `starci-fe/client-fetch-has-signal`
32
32
 
@@ -46,9 +46,9 @@ fetch(url, { method: "GET", signal: AbortSignal.timeout(8000) })
46
46
 
47
47
  **Finding code:** `FE_TRANSPORT_OWNER`
48
48
 
49
- **Vì sao (why):** `fetch` ở `<file>` không có `signal`. Client bắt buộc có timeout và `AbortSignal`.
49
+ **Why:** `fetch` in `<file>` has no `signal`. The client must have a timeout and an `AbortSignal`.
50
50
 
51
- **Cách sửa:** Truyền `signal` (`AbortSignal.timeout(...)` kết hợp với tín hiệu của người gọi).
51
+ **Fix:** Pass a `signal` (`AbortSignal.timeout(...)` combined with the caller's signal).
52
52
 
53
53
  ## `starci-fe/no-shared-transport-state`
54
54
 
@@ -68,9 +68,9 @@ export const request = (token: string) => token
68
68
 
69
69
  **Finding code:** `FE_TRANSPORT_OWNER`
70
70
 
71
- **Vì sao (why):** `<file>` giữ trạng thái dùng chung dạng `let`/`var` trong tầng API. Token và locale không được nằm trong singleton.
71
+ **Why:** `<file>` keeps shared `let`/`var` state in the API layer. Tokens and locale must not live in a singleton.
72
72
 
73
- **Cách sửa:** Truyền credential và locale bằng tham số hoặc context.
73
+ **Fix:** Pass credentials and locale as parameters or context.
74
74
 
75
75
  ## `starci-fe/client-maps-auth-to-refused`
76
76
 
@@ -91,9 +91,9 @@ if (r.status === 401 || r.status === 403) return { ok: false, kind: "refused" }
91
91
 
92
92
  **Finding code:** `FE_HTTP_STATUS_COLLAPSE`
93
93
 
94
- **Vì sao (why):** 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.
94
+ **Why:** The client in `<file>` has no branch comparing `response.status` with 401 and 403 and returning `{ kind: "refused" }`, so the "sign-in required" state can never be reached.
95
95
 
96
- **Cách sửa:** Thêm nhánh `response.status === 401 || response.status === 403` trả `{ ok: false, kind: "refused" }` ngay trong client.
96
+ **Fix:** Add a branch `response.status === 401 || response.status === 403` returning `{ ok: false, kind: "refused" }` inside the client.
97
97
 
98
98
  ## `starci-fe/no-http-status-collapse`
99
99
 
@@ -113,9 +113,9 @@ if (!res.ok) return toOutcome(res.status)
113
113
 
114
114
  **Finding code:** `FE_HTTP_STATUS_COLLAPSE`
115
115
 
116
- **Vì sao (why):** `<file>` gộp mọi mã HTTP thành một nhánh (hoặc trả null khi phản hồi lỗi). 401/403 phải thành `refused`.
116
+ **Why:** `<file>` collapses every HTTP code into one branch (or returns null on an error response). 401/403 must become `refused`.
117
117
 
118
- **Cách sửa:** Trả `Outcome` theo mã: `refused` (401/403), `not-found`, `invalid`, `unavailable`; không trả nguyên văn lỗi của server làm lý do.
118
+ **Fix:** Return an `Outcome` by code: `refused` (401/403), `not-found`, `invalid`, `unavailable`; do not return the server's raw error as the reason.
119
119
 
120
120
  ## `starci-fe/no-hand-typed-wire`
121
121
 
@@ -137,9 +137,9 @@ const x: CourseQuery = parse(raw)
137
137
 
138
138
  **Finding code:** `FE_WIRE_GENERATED`
139
139
 
140
- **Vì sao (why):** `<file>` tự gõ kiểu wire hoặc ép kiểu phản hồi. Dùng kiểu sinh từ `contract/`.
140
+ **Why:** `<file>` hand-types a wire type or casts a response. Use types generated from `contract/`.
141
141
 
142
- **Cách sửa:** Chạy codegen từ bản sao hợp đồng ở `modules/api/contract/` và nhập kiểu sinh ra; đưa tài liệu GraphQL vào tệp `.graphql`.
142
+ **Fix:** Run codegen from the contract copy at `modules/api/contract/` and import the generated types; put GraphQL documents in `.graphql` files.
143
143
 
144
144
  ## `starci-fe/outcome-kinds-exhaustive`
145
145
 
@@ -165,9 +165,9 @@ switch (outcome.kind) {
165
165
 
166
166
  **Finding code:** `FE_OUTCOME_KIND_UNHANDLED`
167
167
 
168
- **Vì sao (why):** `switch` trên `kind` ở `<file>` có nhánh `ok` nhưng thiếu một trong refused, invalid, not-found, unavailable.
168
+ **Why:** The `switch` on `kind` in `<file>` has an `ok` branch but lacks one of refused, invalid, not-found, unavailable.
169
169
 
170
- **Cách sửa:** 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`.
170
+ **Fix:** Write all five branches of `Outcome<T>`, one screen per branch; do not rely on `default`.
171
171
 
172
172
  ## `starci-fe/one-outcome-union`
173
173
 
@@ -190,6 +190,6 @@ export type InviteOutcome = Outcome<{ id: string }>
190
190
 
191
191
  **Finding code:** `FE_HTTP_STATUS_COLLAPSE`
192
192
 
193
- **Vì sao (why):** `<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>`.
193
+ **Why:** `<file>` declares another result union (`ok` or `kind`) outside `outcome.ts`. A repo has only one `Outcome<T>`.
194
194
 
195
- **Cách sửa:** 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.
195
+ **Fix:** Use `Outcome<T>` from `modules/api/outcome.ts` (or the shared api package); add business detail through a second parameter instead of declaring a new union.
@@ -24,9 +24,9 @@ const row = parseCourseRow(payload)
24
24
 
25
25
  **Finding code:** `FE_TYPE_ESCAPE`
26
26
 
27
- **Vì sao (why):** `<file>` ép kiểu qua `unknown` (`as unknown as T`): trình biên dịch quên mọi thứ nó biết ngay tại chỗ dữ liệu đi vào.
27
+ **Why:** `<file>` casts through `unknown` (`as unknown as T`): the compiler forgets everything it knows exactly where the data enters.
28
28
 
29
- **Cách sửa:** Thu hẹp từ `unknown` bằng type guard hoặc parser; không ép kiểu.
29
+ **Fix:** Narrow from `unknown` with a type guard or parser; do not cast.
30
30
 
31
31
  ## `starci-fe/no-type-assertion`
32
32
 
@@ -46,9 +46,9 @@ const row = isCourseRow(payload) ? payload : null
46
46
 
47
47
  **Finding code:** `FE_TYPE_ESCAPE`
48
48
 
49
- **Vì sao (why):** `<file>` dùng `as T` hoặc `<T>x`: một lời khẳng định trình biên dịch không kiểm được. Sai thì lỗi rơi vào trình duyệt của người dùng.
49
+ **Why:** `<file>` uses `as T` or `<T>x`: an assertion the compiler cannot check. If wrong, the error lands in the user's browser.
50
50
 
51
- **Cách sửa:** Thu hẹp bằng type guard, `in`, discriminant hoặc parser tại nơi dữ liệu đi vào; dùng `satisfies` nếu chỉ muốn kiểm một literal.
51
+ **Fix:** Narrow with a type guard, `in`, a discriminant or a parser where the data enters; use `satisfies` if you only want to check a literal.
52
52
 
53
53
  ## `starci-fe/no-non-null-assertion`
54
54
 
@@ -68,9 +68,9 @@ const first = rows[0] ?? fallbackRow
68
68
 
69
69
  **Finding code:** `FE_TYPE_ESCAPE`
70
70
 
71
- **Vì sao (why):** `<file>` dùng `x!`: khẳng định giá trị có mặt mà không chứng minh.
71
+ **Why:** `<file>` uses `x!`: it asserts a value is present without proving it.
72
72
 
73
- **Cách sửa:** Xử lý nhánh vắng mặt (`if`, `??`, return sớm) hoặc sửa kiểu để giá trị không thể vắng.
73
+ **Fix:** Handle the absent branch (`if`, `??`, early return) or fix the type so the value cannot be absent.
74
74
 
75
75
  ## `starci-fe/no-explicit-any`
76
76
 
@@ -90,6 +90,6 @@ const parse = (value: unknown) => (isCourseRow(value) ? value.id : null)
90
90
 
91
91
  **Finding code:** `FE_TYPE_ESCAPE`
92
92
 
93
- **Vì sao (why):** `<file>` dùng `any`: tắt kiểm kiểu cho giá trị đó và mọi thứ suy ra từ nó.
93
+ **Why:** `<file>` uses `any`: it turns off type checking for that value and everything inferred from it.
94
94
 
95
- **Cách sửa:** Dùng kiểu thật, generic, hoặc `unknown` rồi thu hẹp tại chỗ dùng.
95
+ **Fix:** Use a real type, a generic, or `unknown` and then narrow at the point of use.
package/env-owner.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  * shape that makes it provable: one reader, and no literal fallback next to it.
13
13
  */
14
14
 
15
- import { isConfigModule, isSpecFile } from "./lib/scope.mjs"
15
+ import { isConfigModule } from "./lib/scope.mjs"
16
16
 
17
17
  /** `process.env`, or `import.meta.env`. */
18
18
  const isEnvObject = (node) => {
@@ -67,7 +67,7 @@ export const noEnvOutsideConfig = {
67
67
  },
68
68
  create(context) {
69
69
  const filename = context.filename || context.getFilename()
70
- if (isConfigModule(context) || isSpecFile(filename)) return {}
70
+ if (isConfigModule(context)) return {}
71
71
  return {
72
72
  MemberExpression(node) {
73
73
  if (isEnvObject(node)) context.report({ node, messageId: "env" })
@@ -101,7 +101,6 @@ export const noHardcodedEndpointFallback = {
101
101
  },
102
102
  },
103
103
  create(context) {
104
- if (isSpecFile(context.filename || context.getFilename())) return {}
105
104
  return {
106
105
  LogicalExpression(node) {
107
106
  if (node.operator !== "??" && node.operator !== "||") return
package/file-layout.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { hfsOf } from "./lib/hfs.mjs"
16
- import { classOf, fileOf, inSlot, isComponentFile, isSpecFile, kindOfFile, roleOfFile, tierOfFile } from "./lib/scope.mjs"
16
+ import { classOf, fileOf, inSlot, isComponentFile, kindOfFile, roleOfFile, tierOfFile } from "./lib/scope.mjs"
17
17
 
18
18
  /** The path segments of the linted file below the folder of its owner (the slot's `root`), directories only. */
19
19
  const dirsBelowRoot = (context) => {
@@ -55,9 +55,9 @@ const surfaceFolder = (context) => {
55
55
  return { tier: kind, name: bindings.name, rest: below.join("/") }
56
56
  }
57
57
 
58
- /** A file a surface folder may hold: one of the slot's roles (entry, drawing, styles) or a `.spec.` twin, directly in the folder. */
58
+ /** A file a surface folder may hold: one of the slot's roles (entry, drawing, styles), directly in the folder. */
59
59
  const belongsInSurfaceFolder = (context, folder) =>
60
- !folder.rest.includes("/") && (roleOfFile(context) !== null || (isSpecFile(folder.rest) && folder.rest.includes(".spec.")))
60
+ !folder.rest.includes("/") && roleOfFile(context) !== null
61
61
 
62
62
  // -- FILE-2 --------------------------------------------------------------------------------------
63
63
 
@@ -252,19 +252,6 @@ export const monorepoTierBelongsToItsSide = {
252
252
 
253
253
  // -- FILE-6 --------------------------------------------------------------------------------------
254
254
 
255
- /**
256
- * A test sitting beside the thing it tests.
257
- *
258
- * EXEMPT, because a test is not a component and cannot become the second page this rule exists to
259
- * prevent - it ships in no bundle and no route renders it. Refusing it would push route tests out of
260
- * the one directory where the next reader looks for them, which is the opposite of the twin-test
261
- * habit every other tier here follows.
262
- *
263
- * The name is deliberately not required to match `page` or `layout`. A route's tests split by
264
- * CONCERN - what the screen renders, who may reach it, where the boundary sits - and forcing them
265
- * into one `page.spec.tsx` would buy nothing but a longer file. A spec is named `.spec.` by the slot
266
- * definitions themselves, so that stays a file-name test.
267
- */
268
255
  /**
269
256
  * A file under `app/` names which page renders at which URL, and does nothing else.
270
257
  *
@@ -374,32 +361,6 @@ export const noShellTier = {
374
361
  },
375
362
  }
376
363
 
377
- // -- FILE-9 --------------------------------------------------------------------------------------
378
-
379
- /** Frontend units are `.spec.` twins beside the source they exercise. */
380
- export const unitTestColocated = {
381
- meta: {
382
- type: "problem",
383
- docs: { description: "Frontend unit tests are colocated `.spec.` files; frontend owns no separate test tree." },
384
- schema: [],
385
- messages: {
386
- suffix: "`{{name}}` uses the generic `.test.` suffix. A frontend unit is a colocated `.spec.` twin beside its production owner.",
387
- bucket: "`{{path}}` files a frontend unit in a separate test bucket. Move the cases beside their production owner; only backend E2E owns a separate test tree.",
388
- },
389
- },
390
- create(context) {
391
- const file = fileOf(context)
392
- if (!isSpecFile(file)) return {}
393
- const name = file.slice(Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")) + 1)
394
- return {
395
- Program(node) {
396
- if (name.includes(".test.")) context.report({ node, messageId: "suffix", data: { name } })
397
- if (tierOfFile(context) === "e2e") context.report({ node, messageId: "bucket", data: { path: hfsOf(context).relative(file) } })
398
- },
399
- }
400
- },
401
- }
402
-
403
364
  /**
404
365
  * The component a route slot mounts takes the slot's own name.
405
366
  *
@@ -494,7 +455,6 @@ export const rules = {
494
455
  "export-matches-folder": exportMatchesFolder,
495
456
  "no-runtime-namespace": noRuntimeNamespace,
496
457
  "monorepo-tier-belongs-to-its-side": monorepoTierBelongsToItsSide,
497
- "unit-test-colocated": unitTestColocated,
498
458
  }
499
459
 
500
460
  /**
package/formatting.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  * `modules/i18n/**` is exempt: it is where the formatters are configured.
15
15
  */
16
16
 
17
- import { fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
17
+ import { fileOf, inSlot } from "./lib/scope.mjs"
18
18
 
19
19
  /** The locale-sensitive `Date` and `Number` methods. */
20
20
  const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"])
@@ -57,7 +57,7 @@ export const useIntlFormatter = {
57
57
  },
58
58
  },
59
59
  create(context) {
60
- if (isSpecFile(fileOf(context)) || inSlot(context, "fe.modules.i18n")) return {}
60
+ if (inSlot(context, "fe.modules.i18n")) return {}
61
61
  return {
62
62
  ImportDeclaration(node) {
63
63
  const source = String(node.source.value)
@@ -18,7 +18,6 @@
18
18
  */
19
19
 
20
20
  import { hfsOf } from "./lib/hfs.mjs"
21
- import { isSpecFile } from "./lib/scope.mjs"
22
21
 
23
22
  /**
24
23
  * The intrinsic elements of page structure and text, each with the grammar components that render it.
@@ -72,7 +71,7 @@ export const noRawStructuralElement = {
72
71
  },
73
72
  create(context) {
74
73
  const file = context.filename || context.getFilename()
75
- if (isSpecFile(file) || !composesGrammar(context, file)) return {}
74
+ if (!composesGrammar(context, file)) return {}
76
75
  return {
77
76
  JSXOpeningElement(node) {
78
77
  if (node.name.type !== "JSXIdentifier") return
package/hooks-folder.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { hfsOf } from "./lib/hfs.mjs"
19
- import { baseName, classOf, fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
19
+ import { baseName, classOf, fileOf, inSlot } from "./lib/scope.mjs"
20
20
 
21
21
  /** Names that mark a hook. */
22
22
  const isHookName = (name) => /^use[A-Z0-9]/.test(name)
@@ -68,15 +68,14 @@ export const hooksFolderHoldsHooksOnly = {
68
68
  // A file directly in `hooks/` is its own root: the slot binds the file name as the `domain`.
69
69
  const directlyInHooks = hfsOf(context).relative(file) === found.root
70
70
  const name = baseName(file)
71
- const spec = isSpecFile(file)
72
- const subject = spec ? name.replace(/\.(?:test|spec)\.(?:ts|tsx)$/, ".ts") : name
71
+ const subject = name
73
72
  const domain = found.bindings.domain
74
73
  const isHookFile = /^use[A-Z0-9]\w*\.ts$/.test(subject)
75
74
  // The slot names the domain's entry (`index.ts`, role `entry`) and its shared helper file (`<domain>.shared.ts`, role `shared`).
76
75
  // Both sit directly in the domain folder: a nested `x/index.ts` is a file the slot does not name.
77
76
  const directlyInDomain = hfsOf(context).relative(file) === `${found.root}/${name}`
78
- const isEntryFile = directlyInDomain && (found.role === "entry" || (spec && subject === "index.ts"))
79
- const isSharedFile = directlyInDomain && (found.role === "shared" || (spec && subject === `${domain}.shared.ts`))
77
+ const isEntryFile = directlyInDomain && (found.role === "entry")
78
+ const isSharedFile = directlyInDomain && (found.role === "shared")
80
79
 
81
80
  return {
82
81
  Program(program) {
@@ -84,12 +83,12 @@ export const hooksFolderHoldsHooksOnly = {
84
83
  if (!isHookFile && !isSharedFile && !isEntryFile) context.report({ node: program, messageId: "notHook", data: { name } })
85
84
  },
86
85
  ImportDeclaration(node) {
87
- if (SERVER_ONLY.test(String(node.source.value)) && !spec) {
86
+ if (SERVER_ONLY.test(String(node.source.value))) {
88
87
  context.report({ node, messageId: "server", data: { source: node.source.value } })
89
88
  }
90
89
  },
91
90
  "Program:exit"(program) {
92
- if (spec || directlyInHooks || (!isHookFile && !isSharedFile)) return
91
+ if (directlyInHooks || (!isHookFile && !isSharedFile)) return
93
92
  const exported = program.body
94
93
  .filter((node) => node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration")
95
94
  .flatMap((node) => exportedNames(node).map((exportedName) => ({ node, name: exportedName })))
package/index.mjs CHANGED
@@ -19,7 +19,6 @@ import { recommended as brandValuesRecommended, rules as brandValuesRules } from
19
19
  import { recommended as clientBoundaryRecommended, rules as clientBoundaryRules } from "./client-boundary.mjs"
20
20
  import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
21
21
  import { recommended as classNamesRecommended, rules as classNamesRules } from "./class-names.mjs"
22
- import { recommended as e2eShapeRecommended, rules as e2eShapeRules, scope as e2eShapeScope } from "./e2e-shape.mjs"
23
22
  import { recommended as envOwnerRecommended, rules as envOwnerRules } from "./env-owner.mjs"
24
23
  import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
25
24
  import { recommended as grammarBoundaryRecommended, rules as grammarBoundaryRules } from "./grammar-boundary.mjs"
@@ -39,7 +38,6 @@ import { recommended as servedLocaleRecommended, rules as servedLocaleRules } fr
39
38
  import { recommended as shapeSlotRecommended, rules as shapeSlotRules } from "./shape-slot.mjs"
40
39
  import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-and-state-budget.mjs"
41
40
  import { recommended as sizeGrowthRecommended, rules as sizeGrowthRules } from "./size-growth.mjs"
42
- import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
43
41
  import { recommended as statusColorsRecommended, rules as statusColorsRules } from "./status-colors.mjs"
44
42
  import { recommended as splitRecommended, rules as splitRules } from "./the-split.mjs"
45
43
  import { recommended as tokensRecommended, rules as tokensRules } from "./tokens.mjs"
@@ -61,7 +59,6 @@ const CONTRIBUTIONS = [
61
59
  rules: classNamesRules,
62
60
  recommended: classNamesRecommended,
63
61
  },
64
- { law: "e2e-shape", rules: e2eShapeRules, recommended: e2eShapeRecommended, scope: e2eShapeScope },
65
62
  { law: "env-owner", rules: envOwnerRules, recommended: envOwnerRecommended },
66
63
  { law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
67
64
  { law: "grammar-boundary", rules: grammarBoundaryRules, recommended: grammarBoundaryRecommended },
@@ -81,7 +78,6 @@ const CONTRIBUTIONS = [
81
78
  { law: "shape-slot", rules: shapeSlotRules, recommended: shapeSlotRecommended },
82
79
  { law: "size-and-state-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
83
80
  { law: "size-growth", rules: sizeGrowthRules, recommended: sizeGrowthRecommended },
84
- { law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
85
81
  { law: "status-colors", rules: statusColorsRules, recommended: statusColorsRecommended },
86
82
  { law: "the-split", rules: splitRules, recommended: splitRecommended },
87
83
  { law: "tokens", rules: tokensRules, recommended: tokensRecommended },
@@ -138,24 +134,6 @@ export const recommended = Object.fromEntries(
138
134
  CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)),
139
135
  )
140
136
 
141
- /** Which tree each law governs: `src` unless the law says otherwise. */
142
- const scopeOf = (entry) => entry.scope ?? "src"
143
-
144
- /**
145
- * The levels the source rules ask for: every law except the e2e one.
146
- *
147
- * The audit compares a printed config of a PRODUCTION probe file with a map, so the map must hold
148
- * only the rules that reach that file. The e2e rules govern another tree and are absent from it.
149
- */
150
- export const sourceRecommended = Object.fromEntries(
151
- CONTRIBUTIONS.filter((entry) => scopeOf(entry) === "src").flatMap((entry) => Object.entries(entry.recommended)),
152
- )
153
-
154
- /** The levels the e2e rules ask for; they reach `e2e/**` and `playwright.config.*` only. */
155
- export const e2eRecommended = Object.fromEntries(
156
- CONTRIBUTIONS.filter((entry) => scopeOf(entry) === "e2e").flatMap((entry) => Object.entries(entry.recommended)),
157
- )
158
-
159
137
  /** Flat-config options that make inline directives ineffective rather than merely forbidden. */
160
138
  export { linterOptions } from "./lib/config.mjs"
161
139
  export { loadHfs } from "./lib/hfs.mjs"
@@ -228,12 +206,12 @@ export const reactHooksRules = (reactHooks) => {
228
206
  * The whole flat config of a front end: `export default starciFeConfig({ hfs: loadHfs(import.meta.url) })`.
229
207
  *
230
208
  * It owns nothing the repository could choose: which files are linted comes from the HFS profile (every app's and every
231
- * workspace package's `src/`, and the e2e tree), not which rules are on, not their level, not whether an inline comment may
209
+ * workspace package's `src/`), not which rules are on, not their level, not whether an inline comment may
232
210
  * switch one off. NO RULE IS OFF - the factory throws rather than emit a block in which a published rule is missing or
233
211
  * below error, so a rule added to this package reaches every repository or the build of this package fails.
234
212
  *
235
213
  * @param {{ hfs: object }} input - The HFS view of the repository (`loadHfs(import.meta.url)`).
236
- * @returns {object[]} The flat config: ignores, the typed source block, the e2e block.
214
+ * @returns {object[]} The flat config: ignores and the typed source block.
237
215
  */
238
216
  export const starciFeConfig = ({ hfs } = {}) => {
239
217
  const published = Object.keys(rules).map((name) => `starci-fe/${name}`)
@@ -246,8 +224,6 @@ export const starciFeConfig = ({ hfs } = {}) => {
246
224
  hfs,
247
225
  plugin,
248
226
  reactHooks,
249
- source: { ...sourceRecommended, ...reactHooksRules(reactHooks) },
250
- // The escape-hatch fence is a source rule that must also cover the e2e tree.
251
- e2e: { ...e2eRecommended, "starci-fe/no-inline-lint-config": "error" },
227
+ source: { ...recommended, ...reactHooksRules(reactHooks) },
252
228
  })
253
229
  }
package/lib/config.mjs CHANGED
@@ -6,8 +6,7 @@
6
6
  * import { loadHfs, starciFeConfig } from "@starci/eslint-canon-fe"
7
7
  * export default starciFeConfig({ hfs: loadHfs(import.meta.url) })
8
8
  *
9
- * The factory owns everything else: which files are linted (the source of every app and every workspace package, and the
10
- * e2e tree), typed linting on the source (`parserOptions.projectService`, so a rule asks what a node IS rather than what
9
+ * 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
11
10
  * it is called), the ignores, the linter options that make a disable comment impossible, every canon rule at `error`,
12
11
  * the React Hooks rules at `error`, and `settings.starci.hfs` - the slot view each path-scoped rule asks instead of a path
13
12
  * pattern. It takes no rule overrides, globs or ignores: there is no parameter through which a repository could weaken
@@ -21,9 +20,6 @@ export const linterOptions = Object.freeze({ noInlineConfig: true, reportUnusedD
21
20
  /** The product source of a front end: every app's `src/` and every workspace package's `src/` (slots fe.* and repo.packages). */
22
21
  export const SOURCE_FILES = Object.freeze(["apps/*/src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"])
23
22
 
24
- /** The e2e tree (slots fe.e2e, fe.e2e-support) and the Playwright config beside it. */
25
- export const E2E_FILES = Object.freeze(["e2e/**/*.{ts,tsx}", "playwright.config.ts"])
26
-
27
23
  /** Build output, installed packages, generated wire types and the harness's report directory are never linted. */
28
24
  export const IGNORED = Object.freeze(["**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/__generated__/**", ".starci/**"])
29
25
 
@@ -36,20 +32,17 @@ const levelOf = (setting) => (Array.isArray(setting) ? setting[0] : setting)
36
32
  * @param {object} input.hfs - The HFS view of the repository (`loadHfs(import.meta.url)`).
37
33
  * @param {object} input.plugin - The canon plugin.
38
34
  * @param {Record<string, unknown>} input.source - The source-tree levels (canon rules plus the React Hooks rules).
39
- * @param {Record<string, unknown>} input.e2e - The e2e-tree levels.
40
35
  * @param {object} input.reactHooks - The React Hooks plugin.
41
- * @returns {Array<object>} The flat config: one ignore block, one typed source block and one e2e block.
36
+ * @returns {Array<object>} The flat config: one ignore block and one typed source block.
42
37
  */
43
- export const buildFeConfig = ({ hfs, plugin, source, e2e, reactHooks }) => {
38
+ export const buildFeConfig = ({ hfs, plugin, source, reactHooks }) => {
44
39
  if (!hfs || typeof hfs.slotOf !== "function" || typeof hfs.repoRoot !== "string") {
45
40
  throw new Error("starciFeConfig needs { hfs: loadHfs(import.meta.url) } - the rules read the repository's slots through it")
46
41
  }
47
42
  if (hfs.profile !== "fe") throw new Error(`starciFeConfig lints a front end; hfs.json declares profile ${hfs.profile}`)
48
- for (const [tree, levels] of [["source", source], ["e2e", e2e]]) {
49
- if (Object.keys(levels).length === 0) throw new Error(`starciFeConfig received an empty ${tree} recommendation - a config with no rules is not adoption`)
50
- const weak = Object.entries(levels).filter(([, setting]) => levelOf(setting) !== "error").map(([name]) => name)
51
- if (weak.length > 0) throw new Error(`starciFeConfig refuses ${tree} rules not at error: ${weak.join(", ")}`)
52
- }
43
+ if (Object.keys(source).length === 0) throw new Error("starciFeConfig received an empty source recommendation - a config with no rules is not adoption")
44
+ const weak = Object.entries(source).filter(([, setting]) => levelOf(setting) !== "error").map(([name]) => name)
45
+ if (weak.length > 0) throw new Error(`starciFeConfig refuses source rules not at error: ${weak.join(", ")}`)
53
46
  const settings = { starci: { hfs } }
54
47
  return [
55
48
  { ignores: [...IGNORED] },
@@ -66,14 +59,5 @@ export const buildFeConfig = ({ hfs, plugin, source, e2e, reactHooks }) => {
66
59
  settings,
67
60
  rules: { ...source },
68
61
  },
69
- {
70
- // The e2e tree is typed by tsconfig.e2e.json, which the project service does not open; its rules read syntax only.
71
- files: [...E2E_FILES],
72
- languageOptions: { parser: tsParser, ecmaVersion: "latest", sourceType: "module" },
73
- linterOptions: { ...linterOptions },
74
- plugins: { "starci-fe": plugin },
75
- settings,
76
- rules: { ...e2e },
77
- },
78
62
  ]
79
63
  }
package/lib/scope.mjs CHANGED
@@ -33,7 +33,7 @@ export const fileOf = (context) => context.filename || context.getFilename()
33
33
  /** The classification of the linted file from the slot view: `{ slot, root, bindings, kind?, role? }` or a no-slot status. */
34
34
  export const classOf = (context) => hfsOf(context).classify(fileOf(context))
35
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. */
36
+ /** The tier of the linted file in the import direction matrix (`route`, `feature`, `components`, `hooks`, `foundation`, `modules`, `transport`, `package`, `none`), or null when no slot owns it. */
37
37
  export const tierOfFile = (context) => hfsOf(context).tierOf(fileOf(context))
38
38
 
39
39
  /** The folder kind the slot reports for the linted file (a component layer, a feature kind), or null. */
@@ -42,17 +42,11 @@ export const kindOfFile = (context) => classOf(context).kind ?? null
42
42
  /** The role the slot reports for the linted file (`entry`, `drawing`, `styles`, `shared`, `page`, `layout`, ...), or null. */
43
43
  export const roleOfFile = (context) => classOf(context).role ?? null
44
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
45
  /** 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
46
  const PRODUCT_TIERS = new Set(["route", "feature", "components", "hooks", "foundation", "modules", "transport", "package"])
53
47
 
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))
48
+ /** Product source: a file of a product tier. */
49
+ export const isProductSource = (context) => PRODUCT_TIERS.has(tierOfFile(context))
56
50
 
57
51
  /** A file of a component tier: slot `fe.components` or `fe.package.ui`, inside one of its layer folders (blocks, composites, branches, leaves). */
58
52
  export const isComponentFile = (context) => inSlot(context, "fe.components", "fe.package.ui") && kindOfFile(context) !== null
@@ -63,9 +57,6 @@ export const isComponentPath = (context, file) => {
63
57
  return (found.slot === "fe.components" || found.slot === "fe.package.ui") && found.kind !== undefined
64
58
  }
65
59
 
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
60
  /** The one place that may read the environment: slot `fe.modules.config`. */
70
61
  export const isConfigModule = (context) => inSlot(context, "fe.modules.config")
71
62
 
package/lib/why.mjs CHANGED
@@ -121,71 +121,6 @@ export const why = {
121
121
  vi: "`<unit>` tự viết vòng poll (`setInterval` hoặc `setTimeout` tự gọi lại).",
122
122
  fixVi: "Làm tươi qua hook dữ liệu (`refreshInterval`) hoặc socket, mỗi resource đúng một cơ chế.",
123
123
  },
124
- "e2e-spec-location": {
125
- code: "FE_E2E_SHAPE",
126
- vi: "Spec e2e `<file>` không nằm ở `e2e/<area>/<name>.e2e-spec.ts`.",
127
- fixVi: "Đưa spec vào `e2e/<area>/` với hậu tố `.e2e-spec.ts`; helper vào `e2e/support/` hoặc `e2e/fixtures/`.",
128
- },
129
- "e2e-no-absolute-path": {
130
- code: "FE_E2E_SHAPE",
131
- vi: "Spec e2e `<file>` dùng đường dẫn tuyệt đối.",
132
- fixVi: "Dựng đường dẫn từ cấu hình hoặc thư mục của chính spec.",
133
- },
134
- "e2e-no-docker": {
135
- code: "FE_E2E_SHAPE",
136
- vi: "Spec e2e `<file>` phụ thuộc vào docker.",
137
- fixVi: "Trỏ suite tới một URL từ cấu hình; môi trường tự cung cấp stack.",
138
- },
139
- "e2e-no-cross-repo-write": {
140
- code: "FE_E2E_SHAPE",
141
- vi: "Spec e2e `<file>` ghi ra ngoài repo của app (đường dẫn tuyệt đối hoặc `..`).",
142
- fixVi: "Chỉ ghi vào thư mục kết quả của chính repo này.",
143
- },
144
- "e2e-no-skip": {
145
- code: "FE_E2E_SHAPE",
146
- vi: "Spec e2e `<file>` bỏ qua test (`test.skip`/`fixme`) — thiếu môi trường phải làm suite đỏ, không được xanh.",
147
- fixVi: "Sửa môi trường hoặc xóa test; không bỏ qua có điều kiện.",
148
- },
149
- "e2e-typed-helpers": {
150
- code: "FE_E2E_SHAPE",
151
- vi: "Helper e2e ở `<file>` có tham số không kiểu hoặc dùng `any`.",
152
- fixVi: "Khai kiểu cho mọi tham số của helper; thay `any` bằng kiểu thật hoặc `unknown` kèm kiểm tra.",
153
- },
154
- "playwright-viewports": {
155
- code: "FE_E2E_SHAPE",
156
- vi: "`playwright.config` không khai đúng ba viewport 1440x900, 768x1024, 390x844.",
157
- fixVi: "Mỗi project khai một viewport; đủ ba, không thêm cỡ thứ tư.",
158
- },
159
- "no-class-string-in-spec": {
160
- code: "FE_SPEC_QUALITY",
161
- vi: "Spec `<file>` ghim chuỗi class. Spec kiểm hành vi, không kiểm stylesheet.",
162
- fixVi: "Assert theo role, tên, trạng thái hoặc chữ hiển thị.",
163
- },
164
- "spec-tests-its-neighbour": {
165
- code: "FE_SPEC_QUALITY",
166
- vi: "Spec `<file>` không import chủ thể nằm cạnh nó.",
167
- fixVi: "Đưa spec về cạnh chủ thể, hoặc test đúng đơn vị nằm cạnh.",
168
- },
169
- "no-barrel-spec": {
170
- code: "FE_SPEC_QUALITY",
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.",
172
- fixVi: "Xóa spec; test từng đơn vị mà barrel xuất lại, cạnh chính đơn vị đó.",
173
- },
174
- "no-double-cast-in-spec": {
175
- code: "FE_SPEC_QUALITY",
176
- vi: "Spec `<file>` ép kiểu hai lần (`as unknown as`).",
177
- fixVi: "Dựng giá trị bằng factory có kiểu (`mock<T>()` hoặc fixture builder).",
178
- },
179
- "connected-spec-has-axe": {
180
- code: "FE_SPEC_QUALITY",
181
- vi: "Spec `<file>` của màn hình kết nối không có assert axe.",
182
- fixVi: "Thêm `expect(await axe(container)).toHaveNoViolations()`.",
183
- },
184
- "no-mocked-translations": {
185
- code: "FE_I18N_CATALOG",
186
- vi: "Spec `<file>` giả lập `next-intl`, nên chỉ kiểm khóa chứ không kiểm catalog 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`).",
188
- },
189
124
  "no-inline-lint-config": {
190
125
  code: "HFS_INLINE_SUPPRESSION",
191
126
  vi: "Có chú thích tắt luật ở `<file>:<line>`. HFS không cho tắt tại chỗ — sửa code, hoặc đề xuất đổi luật.",