@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.
- package/CHANGELOG.md +78 -2
- package/README.md +17 -18
- package/brand-values.mjs +1 -3
- package/class-names.mjs +9 -9
- package/client-boundary.mjs +125 -37
- package/comments.mjs +14 -21
- package/docs/client-boundary.md +26 -1
- package/docs/grammar-boundary.md +33 -0
- package/docs/hygiene.md +15 -4
- package/docs/lint-escape-hatch.md +3 -1
- package/docs/next-conventions.md +26 -0
- package/docs/size-growth.md +29 -0
- package/docs/spec-quality.md +15 -7
- package/docs/status-colors.md +32 -0
- package/docs/translation.md +4 -0
- package/docs/transport.md +36 -9
- package/e2e-shape.mjs +20 -23
- package/env-owner.mjs +1 -1
- package/file-layout.mjs +105 -103
- package/formatting.mjs +2 -4
- package/grammar-boundary.mjs +85 -9
- package/hooks-folder.mjs +17 -16
- package/hygiene.mjs +370 -92
- package/icon.mjs +21 -20
- package/index.mjs +27 -85
- package/landmark.mjs +0 -11
- package/lib/bindings.mjs +144 -0
- package/lib/config.mjs +79 -0
- package/lib/hfs.mjs +48 -0
- package/lib/next.mjs +6 -0
- package/lib/platform.mjs +66 -0
- package/lib/react.mjs +28 -0
- package/lib/scope.mjs +119 -19
- package/lib/status-tones.generated.mjs +9 -0
- package/lib/types.mjs +63 -0
- package/lib/why.mjs +41 -11
- package/lint-escape-hatch.mjs +17 -15
- package/loading.mjs +9 -16
- package/naming.mjs +8 -2
- package/next-conventions.mjs +109 -25
- package/package.json +9 -4
- package/props-and-slots.mjs +19 -13
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1072 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +42 -0
- package/runtime/scripts/lib/hfs-slots.mjs +722 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/next-contract.mjs +14 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/served-locale.mjs +30 -16
- package/shape-slot.mjs +22 -20
- package/size-and-state-budget.mjs +41 -23
- package/size-growth.mjs +58 -0
- package/spec-quality.mjs +95 -16
- package/status-colors.mjs +139 -0
- package/the-split.mjs +13 -13
- package/tokens.mjs +26 -34
- package/translation.mjs +52 -19
- package/transport.mjs +437 -89
- package/type-safety.mjs +8 -17
- package/typography.mjs +7 -11
- package/vendor-boundary.mjs +5 -27
- 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
|
-
/**
|
|
12
|
-
|
|
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
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
/**
|
|
21
|
-
export const
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
27
|
-
export const
|
|
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 = (
|
|
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
|
|
33
|
-
export const
|
|
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
|
-
/**
|
|
42
|
-
|
|
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
|
|
32
|
-
fixVi: "Gọi client của
|
|
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
|
|
47
|
-
fixVi: "Thêm nhánh 401
|
|
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: "
|
|
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
|
-
"
|
|
229
|
+
"effect-subscription-needs-cleanup": {
|
|
225
230
|
code: "FE_EFFECT_CLEANUP",
|
|
226
|
-
vi: "`<
|
|
227
|
-
fixVi: "
|
|
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>`
|
|
232
|
-
fixVi: "Đọc phía client bằng SWR gọi client của app (`hooks/`), phía route
|
|
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
|
}
|
package/lint-escape-hatch.mjs
CHANGED
|
@@ -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 {
|
|
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 = (
|
|
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
|
|
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 {
|
|
15
|
+
import { classOf, fileOf, isComponentFile, isSpecFile } from "./lib/scope.mjs"
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
const
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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 {}
|