@starci/eslint-canon-fe 6.0.1 → 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.
- package/CHANGELOG.md +19 -0
- package/README.md +3 -5
- package/client-boundary.mjs +6 -8
- package/comments.mjs +38 -15
- package/docs/brand-values.md +2 -2
- package/docs/client-boundary.md +10 -10
- package/docs/env-owner.md +4 -4
- package/docs/formatting.md +2 -2
- package/docs/grammar-boundary.md +2 -2
- package/docs/hooks-folder.md +2 -2
- package/docs/hygiene.md +8 -8
- package/docs/lint-escape-hatch.md +2 -2
- package/docs/lists.md +6 -6
- package/docs/native-controls.md +6 -6
- package/docs/next-conventions.md +20 -20
- package/docs/size-and-state-budget.md +6 -6
- package/docs/size-growth.md +2 -2
- package/docs/status-colors.md +2 -2
- package/docs/translation.md +2 -2
- package/docs/transport.md +16 -16
- package/docs/type-safety.md +8 -8
- package/env-owner.mjs +2 -3
- package/file-layout.mjs +3 -43
- package/formatting.mjs +2 -2
- package/grammar-boundary.mjs +1 -2
- package/hooks-folder.mjs +6 -7
- package/index.mjs +3 -27
- package/lib/config.mjs +6 -22
- package/lib/scope.mjs +3 -12
- package/lib/why.mjs +0 -65
- package/lint-escape-hatch.mjs +3 -3
- package/lists.mjs +0 -4
- package/loading.mjs +2 -2
- package/native-controls.mjs +0 -4
- package/next-conventions.mjs +3 -7
- package/package.json +1 -1
- package/props-and-slots.mjs +4 -5
- package/runtime/knowledge/hfs/slots.yaml +148 -109
- package/runtime/scripts/lib/hfs-allows.mjs +15 -0
- package/runtime/scripts/lib/hfs-slots.mjs +13 -6
- package/runtime/scripts/lib/language.mjs +107 -0
- package/served-locale.mjs +2 -2
- package/shape-slot.mjs +2 -3
- package/size-and-state-budget.mjs +1 -4
- package/size-growth.mjs +1 -2
- package/status-colors.mjs +0 -2
- package/tokens.mjs +1 -1
- package/translation.mjs +1 -29
- package/transport.mjs +4 -8
- package/docs/e2e-shape.md +0 -161
- package/docs/spec-quality.md +0 -147
- package/e2e-shape.mjs +0 -345
- package/spec-quality.mjs +0 -318
|
@@ -24,9 +24,9 @@ A component file has at most 300 lines.
|
|
|
24
24
|
|
|
25
25
|
**Finding code:** `FE_SIZE_AND_STATE_BUDGET`
|
|
26
26
|
|
|
27
|
-
**
|
|
27
|
+
**Why:** `<file>` is `<n>` lines long, over the 300-line limit for a component.
|
|
28
28
|
|
|
29
|
-
**
|
|
29
|
+
**Fix:** Separate rendering from data and split each region into its own unit.
|
|
30
30
|
|
|
31
31
|
## `starci-fe/unit-hook-budget`
|
|
32
32
|
|
|
@@ -46,9 +46,9 @@ const Feed = () => { /* up to 6 x useState and 6 data hooks */ }
|
|
|
46
46
|
|
|
47
47
|
**Finding code:** `FE_SIZE_AND_STATE_BUDGET`
|
|
48
48
|
|
|
49
|
-
**
|
|
49
|
+
**Why:** `<unit>` has `<n>` data hooks / `<m>` useState, over the limit of 6.
|
|
50
50
|
|
|
51
|
-
**
|
|
51
|
+
**Fix:** Group state that changes together into a reducer or a dedicated hook; split each data region into its own connected block.
|
|
52
52
|
|
|
53
53
|
## `starci-fe/no-hand-rolled-polling`
|
|
54
54
|
|
|
@@ -68,6 +68,6 @@ const d = useQueryFeedSwr({ refreshInterval: 5000 })
|
|
|
68
68
|
|
|
69
69
|
**Finding code:** `FE_SIZE_AND_STATE_BUDGET`
|
|
70
70
|
|
|
71
|
-
**
|
|
71
|
+
**Why:** `<unit>` writes its own poll loop (`setInterval` or a self-calling `setTimeout`).
|
|
72
72
|
|
|
73
|
-
**
|
|
73
|
+
**Fix:** Refresh through a data hook (`refreshInterval`) or a socket, exactly one mechanism per resource.
|
package/docs/size-growth.md
CHANGED
|
@@ -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
|
-
**
|
|
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
|
-
**
|
|
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.
|
package/docs/status-colors.md
CHANGED
|
@@ -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
|
-
**
|
|
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
|
-
**
|
|
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`.
|
package/docs/translation.md
CHANGED
|
@@ -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
|
-
**
|
|
52
|
+
**Why:** The text `"<text>"` at `<file>:<line>` does not go through `t()`.
|
|
53
53
|
|
|
54
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
49
|
+
**Why:** `fetch` in `<file>` has no `signal`. The client must have a timeout and an `AbortSignal`.
|
|
50
50
|
|
|
51
|
-
**
|
|
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
|
-
**
|
|
71
|
+
**Why:** `<file>` keeps shared `let`/`var` state in the API layer. Tokens and locale must not live in a singleton.
|
|
72
72
|
|
|
73
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
140
|
+
**Why:** `<file>` hand-types a wire type or casts a response. Use types generated from `contract/`.
|
|
141
141
|
|
|
142
|
-
**
|
|
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
|
-
**
|
|
168
|
+
**Why:** The `switch` on `kind` in `<file>` has an `ok` branch but lacks one of refused, invalid, not-found, unavailable.
|
|
169
169
|
|
|
170
|
-
**
|
|
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
|
-
**
|
|
193
|
+
**Why:** `<file>` declares another result union (`ok` or `kind`) outside `outcome.ts`. A repo has only one `Outcome<T>`.
|
|
194
194
|
|
|
195
|
-
**
|
|
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.
|
package/docs/type-safety.md
CHANGED
|
@@ -24,9 +24,9 @@ const row = parseCourseRow(payload)
|
|
|
24
24
|
|
|
25
25
|
**Finding code:** `FE_TYPE_ESCAPE`
|
|
26
26
|
|
|
27
|
-
**
|
|
27
|
+
**Why:** `<file>` casts through `unknown` (`as unknown as T`): the compiler forgets everything it knows exactly where the data enters.
|
|
28
28
|
|
|
29
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
**
|
|
71
|
+
**Why:** `<file>` uses `x!`: it asserts a value is present without proving it.
|
|
72
72
|
|
|
73
|
-
**
|
|
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
|
-
**
|
|
93
|
+
**Why:** `<file>` uses `any`: it turns off type checking for that value and everything inferred from it.
|
|
94
94
|
|
|
95
|
-
**
|
|
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
|
|
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)
|
|
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,
|
|
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)
|
|
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("/") &&
|
|
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
|
|
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 (
|
|
60
|
+
if (inSlot(context, "fe.modules.i18n")) return {}
|
|
61
61
|
return {
|
|
62
62
|
ImportDeclaration(node) {
|
|
63
63
|
const source = String(node.source.value)
|
package/grammar-boundary.mjs
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
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"
|
|
79
|
-
const isSharedFile = directlyInDomain && (found.role === "shared"
|
|
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))
|
|
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 (
|
|
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
|
|
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
|
|
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: { ...
|
|
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,
|
|
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
|
|
36
|
+
* @returns {Array<object>} The flat config: one ignore block and one typed source block.
|
|
42
37
|
*/
|
|
43
|
-
export const buildFeConfig = ({ hfs, plugin, source,
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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`, `
|
|
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
|
|
55
|
-
export const isProductSource = (context) => PRODUCT_TIERS.has(tierOfFile(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
|
|