@starci/eslint-canon-fe 3.0.2 → 5.1.2

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 +35 -0
  2. package/README.md +41 -16
  3. package/brand-values.mjs +174 -0
  4. package/class-names.mjs +1 -1
  5. package/client-boundary.mjs +199 -0
  6. package/comments.mjs +144 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +99 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/hooks-folder.md +29 -0
  13. package/docs/hygiene.md +111 -0
  14. package/docs/lint-escape-hatch.md +32 -0
  15. package/docs/lists.md +74 -0
  16. package/docs/native-controls.md +73 -0
  17. package/docs/next-conventions.md +206 -0
  18. package/docs/size-and-state-budget.md +73 -0
  19. package/docs/spec-quality.md +139 -0
  20. package/docs/translation.md +50 -0
  21. package/docs/transport.md +168 -0
  22. package/docs/type-safety.md +95 -0
  23. package/e2e-shape.mjs +348 -0
  24. package/env-owner.mjs +134 -0
  25. package/file-layout.mjs +100 -7
  26. package/formatting.mjs +106 -0
  27. package/hooks-folder.mjs +119 -0
  28. package/hygiene.mjs +235 -0
  29. package/icon.mjs +210 -211
  30. package/index.mjs +292 -131
  31. package/lib/ast.mjs +94 -0
  32. package/lib/path.mjs +2 -0
  33. package/lib/scope.mjs +42 -0
  34. package/lib/why.mjs +304 -0
  35. package/lint-adoption.mjs +15 -52
  36. package/lint-escape-hatch.mjs +91 -56
  37. package/lists.mjs +197 -0
  38. package/loading.mjs +163 -164
  39. package/naming.mjs +143 -143
  40. package/native-controls.mjs +112 -0
  41. package/next-conventions.mjs +346 -0
  42. package/package.json +7 -3
  43. package/props-and-slots.mjs +4 -2
  44. package/served-locale.mjs +160 -160
  45. package/shape-slot.mjs +339 -0
  46. package/size-and-state-budget.mjs +196 -0
  47. package/spec-quality.mjs +239 -0
  48. package/the-split.mjs +133 -134
  49. package/tokens.mjs +267 -268
  50. package/translation.mjs +262 -122
  51. package/transport.mjs +404 -0
  52. package/type-safety.mjs +156 -74
  53. package/typography.mjs +82 -83
package/comments.mjs CHANGED
@@ -1,190 +1,144 @@
1
- /**
2
- * The rules that hold `comments.md`.
3
- *
4
- * All three reach further than their names suggest, and deliberately. A language rule that read
5
- * only comments would leave the same sentence legal one line lower as an identifier or a diagnostic
6
- * string; an emoji rule that read only JSX would miss the one in a log message. So each walks
7
- * comments, identifiers, string literals, template chunks and JSX text alike - everywhere prose can
8
- * hide in a source file.
9
- *
10
- * The exceptions are paths, not judgements, and that is on purpose: a locale dictionary IS the other
11
- * language, and a fixture reproducing a real string has to reproduce it exactly. A judgement-based
12
- * exception would be argued per file forever.
13
- */
14
-
15
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
16
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
17
-
18
- /** The letters that mark the second language this source is kept free of. */
19
- export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
20
-
21
- /** The endonym a language picker must be able to render in its own script. */
22
- const ENDONYM = /Tiếng Việt/
23
-
24
- /** A functional literal, marked with the reason it stays. */
25
- const OK_PRAGMA = /\bvn-ok:/
26
-
27
- /**
28
- * Extended pictographs, or a regional-indicator pair.
29
- *
30
- * Two tests rather than one character class, so the class stays free of joiners and combining marks
31
- * - a single class covering both trips the misleading-character-class rule, and a rule that has to
32
- * be silenced to exist is one nobody trusts.
33
- */
34
- export const hasEmoji = (text) =>
35
- typeof text === "string" &&
36
- (/\p{Extended_Pictographic}/u.test(text) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text))
37
-
38
- /**
39
- * Paths whose second-language text or emoji is CONTENT rather than authoring.
40
- *
41
- * Two kinds only: the locale dictionaries, which are the product's other language, and fixtures,
42
- * which reproduce real strings and would be testing something else if they translated them.
43
- */
44
- export const CONTENT_PATHS = [
45
- /\/messages\/[a-z-]+\.json$/i,
46
- /\/src\/resources\//,
47
- /\/__fixtures?__\//,
48
- /\/fixtures?\//,
49
- /\.fixture\.(ts|tsx|js|mjs|cjs)$/i,
50
- /\.test\.(ts|tsx|js|mjs|cjs)$/i,
51
- /\.spec\.(ts|tsx|js|mjs|cjs)$/i,
52
- ]
53
-
54
- /** True when a file holds content rather than authoring. */
55
- export const isContentFile = (filename) => {
56
- const file = normalizePath(filename)
57
- return CONTENT_PATHS.some((pattern) => pattern.test(file))
58
- }
59
-
60
- /** Walk every place prose can hide, and hand each to one check. */
61
- const proseVisitors = (context, report) => {
62
- const source = context.sourceCode || context.getSourceCode()
63
- return {
64
- Program() {
65
- for (const comment of source.getAllComments()) report(comment, comment.value)
66
- },
67
- Identifier(node) {
68
- report(node, node.name)
69
- },
70
- Literal(node) {
71
- if (typeof node.value === "string") report(node, node.value)
72
- },
73
- TemplateElement(node) {
74
- report(node, node.value && node.value.cooked)
75
- },
76
- JSXText(node) {
77
- report(node, node.value)
78
- },
79
- }
80
- }
81
-
82
- // -- COMMENTS-1 ------------------------------------------------------------------------------------
83
-
84
- /** Every export opens with a documentation block. */
85
- export const requireExportJsdoc = {
86
- meta: {
87
- type: "suggestion",
88
- docs: { description: "Exported declarations open with a documentation block." },
89
- schema: [],
90
- messages: {
91
- jsdoc:
92
- "`{{name}}` is exported with no documentation block. An export is read far more often than it is written, and by people who never open the body - name the ROLE it plays, not the signature, which the signature already states.",
93
- },
94
- },
95
- create(context) {
96
- const source = context.sourceCode || context.getSourceCode()
97
- const hasBlock = (node) =>
98
- source.getCommentsBefore(node).some((comment) => comment.type === "Block" && comment.value.startsWith("*"))
99
- const check = (node) => {
100
- const declaration = node.declaration
101
- if (!declaration) return
102
- const kinds = ["VariableDeclaration", "TSInterfaceDeclaration", "FunctionDeclaration", "TSTypeAliasDeclaration"]
103
- if (!kinds.includes(declaration.type) || hasBlock(node)) return
104
- const id =
105
- declaration.id || (declaration.declarations && declaration.declarations[0] && declaration.declarations[0].id)
106
- context.report({ node: id || declaration, messageId: "jsdoc", data: { name: (id && id.name) || "this export" } })
107
- }
108
- return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check }
109
- },
110
- }
111
-
112
- // -- COMMENTS-2 · COMMENTS-3 -----------------------------------------------------------------------
113
-
114
- /** The source is English, and the exceptions are paths plus one marked pragma. */
115
- export const noSecondLanguageInSource = {
116
- meta: {
117
- type: "problem",
118
- docs: { description: "Source authoring is English; locale content and marked literals are exempt." },
119
- schema: [],
120
- messages: {
121
- second:
122
- "Second-language prose in source authoring. The bar is a stranger who joins in a year and does not share the first language of whoever wrote this line - a codebase with two languages has two populations of readers, and the smaller one stops reading the parts it cannot. If this is a literal the running program matches on or emits, keep it and mark the line with `vn-ok: <reason>`.",
123
- },
124
- },
125
- create(context) {
126
- if (isContentFile(context.filename || context.getFilename())) return {}
127
- const source = context.sourceCode || context.getSourceCode()
128
- /*
129
- * Lines carrying the pragma, gathered once.
130
- *
131
- * The message tells the author to "mark the LINE", and until this existed
132
- * that was not true of a literal: the exemption tested the node's OWN text,
133
- * which for a string is its value, so `"Học viện Mộc" // vn-ok: ...` still
134
- * reported and no phrasing could satisfy it. The rule's own valid fixture
135
- * only passed because its string carried no diacritics at all.
136
- *
137
- * A marked line exempts the node ON it and the statement BELOW it, which are
138
- * the two placements the message's wording admits.
139
- */
140
- const marked = new Set()
141
- for (const comment of source.getAllComments()) {
142
- if (!OK_PRAGMA.test(comment.value)) continue
143
- marked.add(comment.loc.start.line)
144
- marked.add(comment.loc.end.line + 1)
145
- }
146
- return proseVisitors(context, (node, text) => {
147
- if (!text || !SECOND_LANGUAGE_LETTER.test(text)) return
148
- if (ENDONYM.test(text) || OK_PRAGMA.test(text)) return
149
- if (marked.has(node.loc.start.line)) return
150
- context.report({ node, messageId: "second" })
151
- })
152
- },
153
- }
154
-
155
- // -- COMMENTS-4 ------------------------------------------------------------------------------------
156
-
157
- /** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
158
- export const noEmojiInSource = {
159
- meta: {
160
- type: "problem",
161
- docs: { description: "No emoji in source authoring." },
162
- schema: [],
163
- messages: {
164
- emoji:
165
- "Unicode emoji in source authoring. It renders differently on every platform, sorts unpredictably, breaks a terminal that is not expecting it, and does not mean the same thing in two countries. Generic marks belong to the icon vocabulary; product reactions use the attributed checked-in SVG artwork through the reaction leaf.",
166
- },
167
- },
168
- create(context) {
169
- if (isContentFile(context.filename || context.getFilename())) return {}
170
- return proseVisitors(context, (node, text) => {
171
- if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
172
- })
173
- },
174
- }
175
-
176
- /** The rules this law contributes to the plugin. */
177
- export const rules = {
178
- "require-export-jsdoc": requireExportJsdoc,
179
- "no-second-language-in-source": noSecondLanguageInSource,
180
- "no-emoji-in-source": noEmojiInSource,
181
- }
182
-
183
- /**
184
- * The level this law asks for, as the plugin's own opinion.
185
- *
186
- * `require-export-jsdoc` is the one a repository adopting this with history should expect a count
187
- * from - every undocumented export in the tree reports at once. The other two are usually already
188
- * at zero, because a second language in source is noticed by readers long before a rule arrives.
189
- */
190
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
1
+ /**
2
+ * The rules that hold `comments.md`.
3
+ *
4
+ * Both reach further than their names suggest, and deliberately. An emoji rule that read only JSX
5
+ * would miss the one in a log message, so it walks comments, identifiers, string literals, template
6
+ * chunks and JSX text alike - everywhere prose can hide in a source file.
7
+ *
8
+ * THE SECOND-LANGUAGE RULE IS GONE. It carried a `vn-ok: <reason>` pragma, an escape hatch that was
9
+ * used 490 times in one repository, and one escape is enough to turn a rule into a comment
10
+ * convention. Its job is now held by `no-hardcoded-copy` in `translation.mjs`, which has no
11
+ * pragma: user-facing text comes from a `next-intl` catalogue, and the catalogue is the only place
12
+ * a second language is content.
13
+ *
14
+ * The exceptions that remain are paths, not judgements, and that is on purpose: a locale dictionary
15
+ * IS the other language, and a fixture reproducing a real string has to reproduce it exactly. A
16
+ * judgement-based exception would be argued per file forever.
17
+ */
18
+
19
+ import { normalizePath } from "./lib/path.mjs"
20
+
21
+ /** The letters that mark the second language a source string must not carry outside a catalogue. */
22
+ export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
23
+
24
+ /**
25
+ * Extended pictographs, or a regional-indicator pair.
26
+ *
27
+ * Two tests rather than one character class, so the class stays free of joiners and combining marks
28
+ * - a single class covering both trips the misleading-character-class rule, and a rule that has to
29
+ * be silenced to exist is one nobody trusts.
30
+ */
31
+ export const hasEmoji = (text) =>
32
+ typeof text === "string" &&
33
+ (/\p{Extended_Pictographic}/u.test(text) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text))
34
+
35
+ /**
36
+ * Paths whose second-language text or emoji is CONTENT rather than authoring.
37
+ *
38
+ * Two kinds only: the locale dictionaries (`messages/<locale>.json`), which are the product's other
39
+ * language, and fixtures and specs, which reproduce real strings and would be testing something else
40
+ * if they translated them. There is no "copy module" path: a `resources/` folder of strings is
41
+ * copy that skipped the catalogue.
42
+ */
43
+ export const CONTENT_PATHS = [
44
+ /\/messages\/[a-z-]+\.json$/i,
45
+ /\/__fixtures?__\//,
46
+ /\/fixtures?\//,
47
+ /\.fixture\.(ts|tsx|js|mjs|cjs)$/i,
48
+ /\.test\.(ts|tsx|js|mjs|cjs)$/i,
49
+ /\.spec\.(ts|tsx|js|mjs|cjs)$/i,
50
+ ]
51
+
52
+ /** True when a file holds content rather than authoring. */
53
+ export const isContentFile = (filename) => {
54
+ const file = normalizePath(filename)
55
+ return CONTENT_PATHS.some((pattern) => pattern.test(file))
56
+ }
57
+
58
+ /** Walk every place prose can hide, and hand each to one check. */
59
+ const proseVisitors = (context, report) => {
60
+ const source = context.sourceCode || context.getSourceCode()
61
+ return {
62
+ Program() {
63
+ for (const comment of source.getAllComments()) report(comment, comment.value)
64
+ },
65
+ Identifier(node) {
66
+ report(node, node.name)
67
+ },
68
+ Literal(node) {
69
+ if (typeof node.value === "string") report(node, node.value)
70
+ },
71
+ TemplateElement(node) {
72
+ report(node, node.value && node.value.cooked)
73
+ },
74
+ JSXText(node) {
75
+ report(node, node.value)
76
+ },
77
+ }
78
+ }
79
+
80
+ // -- COMMENTS-1 ------------------------------------------------------------------------------------
81
+
82
+ /** Every export opens with a documentation block. */
83
+ export const requireExportJsdoc = {
84
+ meta: {
85
+ type: "suggestion",
86
+ docs: { description: "Exported declarations open with a documentation block." },
87
+ schema: [],
88
+ messages: {
89
+ jsdoc:
90
+ "`{{name}}` is exported with no documentation block. An export is read far more often than it is written, and by people who never open the body - name the ROLE it plays, not the signature, which the signature already states.",
91
+ },
92
+ },
93
+ create(context) {
94
+ const source = context.sourceCode || context.getSourceCode()
95
+ const hasBlock = (node) =>
96
+ source.getCommentsBefore(node).some((comment) => comment.type === "Block" && comment.value.startsWith("*"))
97
+ const check = (node) => {
98
+ const declaration = node.declaration
99
+ if (!declaration) return
100
+ const kinds = ["VariableDeclaration", "TSInterfaceDeclaration", "FunctionDeclaration", "TSTypeAliasDeclaration"]
101
+ if (!kinds.includes(declaration.type) || hasBlock(node)) return
102
+ const id =
103
+ declaration.id || (declaration.declarations && declaration.declarations[0] && declaration.declarations[0].id)
104
+ context.report({ node: id || declaration, messageId: "jsdoc", data: { name: (id && id.name) || "this export" } })
105
+ }
106
+ return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check }
107
+ },
108
+ }
109
+
110
+ // -- COMMENTS-4 ------------------------------------------------------------------------------------
111
+
112
+ /** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
113
+ export const noEmojiInSource = {
114
+ meta: {
115
+ type: "problem",
116
+ docs: { description: "No emoji in source authoring." },
117
+ schema: [],
118
+ messages: {
119
+ emoji:
120
+ "Unicode emoji in source authoring. It renders differently on every platform, sorts unpredictably, breaks a terminal that is not expecting it, and does not mean the same thing in two countries. Generic marks belong to the icon vocabulary; product reactions use the attributed checked-in SVG artwork through the reaction leaf.",
121
+ },
122
+ },
123
+ create(context) {
124
+ if (isContentFile(context.filename || context.getFilename())) return {}
125
+ return proseVisitors(context, (node, text) => {
126
+ if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
127
+ })
128
+ },
129
+ }
130
+
131
+ /** The rules this law contributes to the plugin. */
132
+ export const rules = {
133
+ "require-export-jsdoc": requireExportJsdoc,
134
+ "no-emoji-in-source": noEmojiInSource,
135
+ }
136
+
137
+ /**
138
+ * The level this law asks for, as the plugin's own opinion.
139
+ *
140
+ * `require-export-jsdoc` is the one a repository adopting this with history should expect a count
141
+ * from - every undocumented export in the tree reports at once. The other two are usually already
142
+ * at zero, because a second language in source is noticed by readers long before a rule arrives.
143
+ */
144
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -0,0 +1,30 @@
1
+ # No raw brand values in TypeScript
2
+
3
+ Law module: `brand-values.mjs`. Catalogue: R61 FE_STYLE_TOKEN_ONLY (TypeScript half; CSS belongs to @starci/stylelint-canon).
4
+
5
+ Colour values exist only in `modules/brand/brand.css`, on the tokens the grammar declares, with a light and a dark value. A hex, a colour function, a named paint colour or a pixel length in TypeScript skips the token scale.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/no-raw-brand-value`
10
+
11
+ No hex, colour function, named paint colour or px length in TypeScript; tokens only.
12
+
13
+ **Invalid** (`src/components/blocks/Feed/index.tsx`)
14
+
15
+ ```ts
16
+ const c = "#ff0000"
17
+ <div style={{ width: 12 }} />
18
+ ```
19
+
20
+ **Valid** (`src/components/blocks/Feed/index.tsx`)
21
+
22
+ ```ts
23
+ <div className="bg-surface p-4" />
24
+ ```
25
+
26
+ **Finding code:** `FE_STYLE_TOKEN_ONLY`
27
+
28
+ **Vì sao (why):** `<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`.
29
+
30
+ **Cách sửa:** Dùng lớp hoặc biến token của grammar; giá trị màu chỉ khai trong `modules/brand/brand.css`.
@@ -0,0 +1,99 @@
1
+ # Client boundary
2
+
3
+ Law module: `client-boundary.mjs`. Catalogue: R55 FE_CLIENT_BOUNDARY.
4
+
5
+ Server first. `"use client"` appears only on the `index.tsx` of an interactive block (or overlay), on a leaf whose interaction is intrinsic, on a branch or leaf of a workspace package (`packages/<pkg>/src/{branches,leaves}/`, where the grammar tiers sit directly under `src/`), and on `error.tsx` / `global-error.tsx`, which Next requires to be client components. Routes, pages and layouts are server components.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/use-client-only-at-boundary`
10
+
11
+ `"use client"` appears only at a block index, a leaf, or a Next error boundary.
12
+
13
+ **Invalid** (`src/app/[locale]/layout.tsx`)
14
+
15
+ ```ts
16
+ "use client"
17
+ export default function Layout() { return null }
18
+ ```
19
+
20
+ **Valid** (`src/components/blocks/Feed/index.tsx`)
21
+
22
+ ```ts
23
+ "use client"
24
+ export const Feed = () => null
25
+ ```
26
+
27
+ **Finding code:** `FE_CLIENT_BOUNDARY`
28
+
29
+ **Vì sao (why):** `"use client"` ở `<file>` (`<slot>` không được là client component).
30
+
31
+ **Cách sửa:** Đặt `"use client"` ở `index.tsx` của block có tương tác, leaf, hoặc `error.tsx`/`global-error.tsx`; giữ layout và page là server component.
32
+
33
+ ## `starci-fe/client-no-server-import`
34
+
35
+ A `"use client"` file does not import `server-only`, `next/headers`, a Node built-in or a server reader (sub-check `FE_CLIENT_SERVER_IMPORT`).
36
+
37
+ **Invalid** (`src/components/blocks/Feed/index.tsx`)
38
+
39
+ ```tsx
40
+ "use client"
41
+ import { cookies } from "next/headers"
42
+ ```
43
+
44
+ **Valid** (`src/components/blocks/Feed/index.tsx`)
45
+
46
+ ```tsx
47
+ "use client"
48
+ import useSWR from "swr"
49
+ ```
50
+
51
+ **Finding code:** `FE_CLIENT_SERVER_IMPORT`
52
+
53
+ **Vì sao (why):** Client component ở `<file>` nhập mã chỉ tồn tại ở server (`server-only`, `next/headers`, module Node, server reader).
54
+
55
+ **Cách sửa:** Đọ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.
56
+
57
+ ## `starci-fe/web-storage-only-in-modules`
58
+
59
+ `localStorage` and `sessionStorage` are touched only inside `modules/` (sub-check `FE_STORAGE_OUTSIDE_MODULES`).
60
+
61
+ **Invalid** (`src/components/blocks/Wallet/index.tsx`)
62
+
63
+ ```tsx
64
+ const raw = sessionStorage.getItem(KEY)
65
+ ```
66
+
67
+ **Valid** (`src/components/blocks/Wallet/index.tsx`)
68
+
69
+ ```tsx
70
+ const draft = useTopUpDraft()
71
+ ```
72
+
73
+ **Finding code:** `FE_STORAGE_OUTSIDE_MODULES`
74
+
75
+ **Vì sao (why):** `<file>` dùng `localStorage`/`sessionStorage` ngoài `modules/`; storage không có ở server và ném lỗi khi đầy hạn mức.
76
+
77
+ **Cách sửa:** Đặt đọc/ghi sau một hook hoặc module trong `modules/` có bảo vệ (`typeof window`, try/catch) rồi gọi từ block.
78
+
79
+ ## `starci-fe/no-dangerous-html`
80
+
81
+ `dangerouslySetInnerHTML` only on `<script>`; never on a visible element (sub-check `FE_DANGEROUS_HTML`).
82
+
83
+ **Invalid** (`src/components/blocks/Feed/component.tsx`)
84
+
85
+ ```tsx
86
+ <div dangerouslySetInnerHTML={{ __html: html }} />
87
+ ```
88
+
89
+ **Valid** (`src/components/blocks/Feed/component.tsx`)
90
+
91
+ ```tsx
92
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON_LD }} />
93
+ ```
94
+
95
+ **Finding code:** `FE_DANGEROUS_HTML`
96
+
97
+ **Vì sao (why):** `dangerouslySetInnerHTML` trên `<tag>` ở `<file>`: chuỗi bất kỳ trở thành HTML chạy được.
98
+
99
+ **Cách sửa:** Render nội dung thành phần tử; chỉ `<script>` mang JSON-LD hoặc mã theme từ hằng số của app mới được dùng.
@@ -0,0 +1,161 @@
1
+ # e2e shape
2
+
3
+ Law module: `e2e-shape.mjs`. Catalogue: R66 FE_E2E_SHAPE.
4
+
5
+ These rules govern `e2e/**` and `playwright.config.*`, not `src/`; `starciFeConfig` attaches them through its second block. Specs are `e2e/<area>/<name>.e2e-spec.ts`; the Playwright projects cover 1440x900, 768x1024 and 390x844; no absolute path, no docker, no write outside the repository, no `test.skip`, and typed helpers.
6
+
7
+ Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-fe/e2e-spec-location`
10
+
11
+ An e2e spec is `e2e/<area>/<name>.e2e-spec.ts`; helpers live under `support/` or `fixtures/`.
12
+
13
+ **Invalid** (`e2e/play.spec.ts`)
14
+
15
+ ```ts
16
+ // file: e2e/play.spec.ts
17
+ ```
18
+
19
+ **Valid** (`e2e/course/play.e2e-spec.ts`)
20
+
21
+ ```ts
22
+ // file: e2e/course/play.e2e-spec.ts
23
+ ```
24
+
25
+ **Finding code:** `FE_E2E_SHAPE`
26
+
27
+ **Vì sao (why):** Spec e2e `<file>` không nằm ở `e2e/<area>/<name>.e2e-spec.ts`.
28
+
29
+ **Cách sửa:** Đưa spec vào `e2e/<area>/` với hậu tố `.e2e-spec.ts`; helper vào `e2e/support/` hoặc `e2e/fixtures/`.
30
+
31
+ ## `starci-fe/e2e-no-absolute-path`
32
+
33
+ No absolute filesystem path in e2e source.
34
+
35
+ **Invalid** (`e2e/support/session.ts`)
36
+
37
+ ```ts
38
+ const p = "D:/repos/nivo-backend/out"
39
+ ```
40
+
41
+ **Valid** (`e2e/support/session.ts`)
42
+
43
+ ```ts
44
+ const p = path.join(__dirname, "fixtures", "a.png")
45
+ ```
46
+
47
+ **Finding code:** `FE_E2E_SHAPE`
48
+
49
+ **Vì sao (why):** Spec e2e `<file>` dùng đường dẫn tuyệt đối.
50
+
51
+ **Cách sửa:** Dựng đường dẫn từ cấu hình hoặc thư mục của chính spec.
52
+
53
+ ## `starci-fe/e2e-no-docker`
54
+
55
+ An e2e suite does not start or require Docker.
56
+
57
+ **Invalid** (`e2e/support/stack.ts`)
58
+
59
+ ```ts
60
+ execSync("docker compose up -d")
61
+ ```
62
+
63
+ **Valid** (`e2e/course/play.e2e-spec.ts`)
64
+
65
+ ```ts
66
+ await page.goto(baseUrl)
67
+ ```
68
+
69
+ **Finding code:** `FE_E2E_SHAPE`
70
+
71
+ **Vì sao (why):** Spec e2e `<file>` phụ thuộc vào docker.
72
+
73
+ **Cách sửa:** Trỏ suite tới một URL từ cấu hình; môi trường tự cung cấp stack.
74
+
75
+ ## `starci-fe/e2e-no-cross-repo-write`
76
+
77
+ An e2e file write never targets an absolute path or climbs out of the repository.
78
+
79
+ **Invalid** (`e2e/support/seed.ts`)
80
+
81
+ ```ts
82
+ await fs.writeFile("../backend/seed.json", data)
83
+ ```
84
+
85
+ **Valid** (`e2e/support/seed.ts`)
86
+
87
+ ```ts
88
+ await fs.writeFile(path.join(outDir, "report.json"), data)
89
+ ```
90
+
91
+ **Finding code:** `FE_E2E_SHAPE`
92
+
93
+ **Vì sao (why):** Spec e2e `<file>` ghi ra ngoài repo của app (đường dẫn tuyệt đối hoặc `..`).
94
+
95
+ **Cách sửa:** Chỉ ghi vào thư mục kết quả của chính repo này.
96
+
97
+ ## `starci-fe/e2e-no-skip`
98
+
99
+ No `test.skip`, `test.fixme` or `describe.skip`: a missing environment fails the run.
100
+
101
+ **Invalid** (`e2e/course/play.e2e-spec.ts`)
102
+
103
+ ```ts
104
+ test.skip(!process.env.BASE_URL, "needs env")
105
+ ```
106
+
107
+ **Valid** (`e2e/course/play.e2e-spec.ts`)
108
+
109
+ ```ts
110
+ test("plays", async ({ page }) => {})
111
+ ```
112
+
113
+ **Finding code:** `FE_E2E_SHAPE`
114
+
115
+ **Vì sao (why):** Spec e2e `<file>` bỏ qua test (`test.skip`/`fixme`) — thiếu môi trường phải làm suite đỏ, không được xanh.
116
+
117
+ **Cách sửa:** Sửa môi trường hoặc xóa test; không bỏ qua có điều kiện.
118
+
119
+ ## `starci-fe/e2e-typed-helpers`
120
+
121
+ e2e helper parameters are typed and nothing is `any`.
122
+
123
+ **Invalid** (`e2e/support/session.ts`)
124
+
125
+ ```ts
126
+ export const login = async (page, role) => page
127
+ ```
128
+
129
+ **Valid** (`e2e/support/session.ts`)
130
+
131
+ ```ts
132
+ export const login = async (page: Page, role: Role) => page
133
+ ```
134
+
135
+ **Finding code:** `FE_E2E_SHAPE`
136
+
137
+ **Vì sao (why):** Helper e2e ở `<file>` có tham số không kiểu hoặc dùng `any`.
138
+
139
+ **Cách sửa:** 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.
140
+
141
+ ## `starci-fe/playwright-viewports`
142
+
143
+ playwright.config declares the viewports 1440x900, 768x1024, 390x844 and no others.
144
+
145
+ **Invalid** (`playwright.config.ts`)
146
+
147
+ ```ts
148
+ projects: [{ use: { viewport: { width: 1440, height: 900 } } }]
149
+ ```
150
+
151
+ **Valid** (`playwright.config.ts`)
152
+
153
+ ```ts
154
+ projects: three, each with one of 1440x900, 768x1024, 390x844
155
+ ```
156
+
157
+ **Finding code:** `FE_E2E_SHAPE`
158
+
159
+ **Vì sao (why):** `playwright.config` không khai đúng ba viewport 1440x900, 768x1024, 390x844.
160
+
161
+ **Cách sửa:** Mỗi project khai một viewport; đủ ba, không thêm cỡ thứ tư.