@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.
- package/CHANGELOG.md +35 -0
- package/README.md +41 -16
- package/brand-values.mjs +174 -0
- package/class-names.mjs +1 -1
- package/client-boundary.mjs +199 -0
- package/comments.mjs +144 -190
- package/docs/brand-values.md +30 -0
- package/docs/client-boundary.md +99 -0
- package/docs/e2e-shape.md +161 -0
- package/docs/env-owner.md +52 -0
- package/docs/formatting.md +30 -0
- package/docs/hooks-folder.md +29 -0
- package/docs/hygiene.md +111 -0
- package/docs/lint-escape-hatch.md +32 -0
- package/docs/lists.md +74 -0
- package/docs/native-controls.md +73 -0
- package/docs/next-conventions.md +206 -0
- package/docs/size-and-state-budget.md +73 -0
- package/docs/spec-quality.md +139 -0
- package/docs/translation.md +50 -0
- package/docs/transport.md +168 -0
- package/docs/type-safety.md +95 -0
- package/e2e-shape.mjs +348 -0
- package/env-owner.mjs +134 -0
- package/file-layout.mjs +100 -7
- package/formatting.mjs +106 -0
- package/hooks-folder.mjs +119 -0
- package/hygiene.mjs +235 -0
- package/icon.mjs +210 -211
- package/index.mjs +292 -131
- package/lib/ast.mjs +94 -0
- package/lib/path.mjs +2 -0
- package/lib/scope.mjs +42 -0
- package/lib/why.mjs +304 -0
- package/lint-adoption.mjs +15 -52
- package/lint-escape-hatch.mjs +91 -56
- package/lists.mjs +197 -0
- package/loading.mjs +163 -164
- package/naming.mjs +143 -143
- package/native-controls.mjs +112 -0
- package/next-conventions.mjs +346 -0
- package/package.json +7 -3
- package/props-and-slots.mjs +4 -2
- package/served-locale.mjs +160 -160
- package/shape-slot.mjs +339 -0
- package/size-and-state-budget.mjs +196 -0
- package/spec-quality.mjs +239 -0
- package/the-split.mjs +133 -134
- package/tokens.mjs +267 -268
- package/translation.mjs +262 -122
- package/transport.mjs +404 -0
- package/type-safety.mjs +156 -74
- package/typography.mjs +82 -83
package/served-locale.mjs
CHANGED
|
@@ -1,161 +1,161 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
2
|
* Rules for the SERVED LOCALE law - `patterns/fe/served-locale/INDEX.md`.
|
|
3
|
-
*
|
|
4
|
-
* WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
|
|
5
|
-
* connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
|
|
6
|
-
* an English course on a `/vi/` URL. The chrome came from the dictionary and the content came from an
|
|
7
|
-
* API that was never told which language to serve, so the failure looked like a translation gap and
|
|
8
|
-
* was looked for in the dictionary, where there was nothing wrong. The header was missing, and no
|
|
9
|
-
* gate had anything to say about a header nobody wrote.
|
|
10
|
-
*
|
|
11
|
-
* WHAT THEY HOLD. That the transport chain declares a locale, and that exactly one place writes it.
|
|
12
|
-
* Both are file-at-a-time facts: the chain is assembled in one file, and a header is written where it
|
|
13
|
-
* is written.
|
|
14
|
-
*
|
|
15
|
-
* WHAT THEY CANNOT HOLD, stated rather than implied. Neither rule can tell whether the value the link
|
|
16
|
-
* computes is CORRECT - whether it reads the address, the cookie or a constant - because that is
|
|
17
|
-
* inside a function these rules only see the name of. A link that attaches `x-locale: "en"`
|
|
18
|
-
* unconditionally satisfies both and is wrong. That case is a review question and LOCALE-2 is where
|
|
19
|
-
* it is argued; pretending otherwise would be the rule claiming a guarantee it does not have.
|
|
20
|
-
*
|
|
21
|
-
* WHY THE FIRST RULE KEYS ON THE TERMINAL LINK RATHER THAN ON THE FILE PATH. A repository may name
|
|
22
|
-
* its client folder anything, and a path pattern that guessed wrong would either miss the real chain
|
|
23
|
-
* or fire on every helper beside it. The chain is identifiable by what it does: it builds the link
|
|
24
|
-
* that talks to the network.
|
|
25
|
-
*
|
|
26
|
-
* WITH ONE EXEMPTION THAT WAS FOUND BY RUNNING IT, NOT BY THINKING ABOUT IT. "Constructs the
|
|
27
|
-
* terminal link" is also true of the file that DEFINES that link, so the first version reported
|
|
28
|
-
* `links/http.ts` and its spec on a repository that had done everything right. There was no correct
|
|
29
|
-
* way to satisfy it there - a locale link inside the http link is a chain hiding in a link - and a
|
|
30
|
-
* rule with no correct way to satisfy it is a finding about the rule. A file under `links/` is one
|
|
31
|
-
* link; a chain is assembled elsewhere, which is where the locale belongs.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
35
|
-
const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The link file that owns the locale header.
|
|
39
|
-
*
|
|
40
|
-
* This is the ONE file allowed to write it, and it is recognised by path rather than by content:
|
|
41
|
-
* the point of the second rule is that the header appears in one place, so that place has to be
|
|
42
|
-
* named somewhere, and a name is what the law and the rule can both refer to.
|
|
43
|
-
*/
|
|
44
|
-
export const isLocaleLinkFile = (filename) => /\/links\/locale\.[cm]?tsx?$/.test(normalize(filename))
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* One link's own implementation, rather than a chain of them.
|
|
48
|
-
*
|
|
49
|
-
* WHY THIS EXEMPTION EXISTS, AND IT WAS MEASURED. The first version of the chain rule keyed only on
|
|
50
|
-
* "constructs the terminal link", which is true of the file that DEFINES that link - `links/http.ts`
|
|
51
|
-
* builds `new HttpLink(...)`, because that is its whole job. Run against real source it reported
|
|
52
|
-
* that file and its spec, and there was no correct way to satisfy it there: attaching a locale link
|
|
53
|
-
* inside the http link would be a chain hiding in a link.
|
|
54
|
-
*
|
|
55
|
-
* A file under `links/` is one link. A chain is assembled somewhere else, which is exactly where the
|
|
56
|
-
* locale belongs.
|
|
57
|
-
*/
|
|
58
|
-
export const isLinkImplementationFile = (filename) => /\/links\/[^/]+$/.test(normalize(filename))
|
|
59
|
-
|
|
60
|
-
/** A spec or test file - it asserts about production shapes rather than being one. */
|
|
61
|
-
export const isTestFile = (filename) => /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalize(filename))
|
|
62
|
-
|
|
63
|
-
/** The header a request declares its language in. */
|
|
64
|
-
export const LOCALE_HEADER = "x-locale"
|
|
65
|
-
|
|
66
|
-
/** Callee names that construct the terminal link - the one that actually reaches the network. */
|
|
67
|
-
const TERMINAL_LINK_CALLEES = new Set(["createHttpLink", "HttpLink", "createUploadLink", "BatchHttpLink"])
|
|
68
|
-
|
|
69
|
-
/** Callee names that attach the reader's locale to an operation. */
|
|
70
|
-
const LOCALE_LINK_CALLEES = new Set(["createAttachLocaleLink", "createLocaleLink"])
|
|
71
|
-
|
|
72
|
-
/** The callee name of a call or `new` expression, when it has a plain one. */
|
|
73
|
-
const calleeName = (node) => {
|
|
74
|
-
const callee = node && node.callee
|
|
75
|
-
if (!callee) return null
|
|
76
|
-
if (callee.type === "Identifier") return callee.name
|
|
77
|
-
if (callee.type === "MemberExpression" && callee.property && callee.property.type === "Identifier") {
|
|
78
|
-
return callee.property.name
|
|
79
|
-
}
|
|
80
|
-
return null
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Every string-ish key a property can carry, so `"x-locale"` and `["x-locale"]` read the same. */
|
|
84
|
-
const propertyKeyOf = (property) => {
|
|
85
|
-
if (!property || property.type !== "Property") return null
|
|
86
|
-
const key = property.key
|
|
87
|
-
if (!key) return null
|
|
88
|
-
if (key.type === "Identifier" && !property.computed) return key.name
|
|
89
|
-
if (key.type === "Literal" && typeof key.value === "string") return key.value
|
|
90
|
-
return null
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export const rules = {
|
|
94
|
-
"api-client-attaches-the-locale": {
|
|
95
|
-
meta: {
|
|
96
|
-
type: "problem",
|
|
97
|
-
docs: {
|
|
98
|
-
description:
|
|
99
|
-
"A transport chain that builds the terminal HTTP link must also attach the reader's locale, so every call declares the language it wants served.",
|
|
100
|
-
},
|
|
101
|
-
schema: [],
|
|
102
|
-
messages: {
|
|
103
|
-
missing:
|
|
104
|
-
"This assembles the API transport but never attaches the reader's locale, so every call it makes is served the server's default language. Add the locale link to the chain, beside the auth link and unconditionally - a guest reads in a language too. A surface that is bilingual in its chrome and monolingual in its content reads as a translation gap, which is why it gets looked for in the dictionary rather than here.",
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
create(context) {
|
|
108
|
-
const filename = context.filename || context.getFilename()
|
|
109
|
-
// A single link's implementation is not a chain - `links/http.ts` constructs the terminal link
|
|
110
|
-
// because defining it IS its job, and there is no correct way to attach a locale there. A
|
|
111
|
-
// spec asserts about a chain rather than being one.
|
|
112
|
-
if (isLinkImplementationFile(filename) || isTestFile(filename)) return {}
|
|
113
|
-
let terminalNode = null
|
|
114
|
-
let attachesLocale = false
|
|
115
|
-
const note = (node) => {
|
|
116
|
-
const name = calleeName(node)
|
|
117
|
-
if (name === null) return
|
|
118
|
-
if (TERMINAL_LINK_CALLEES.has(name)) terminalNode = terminalNode || node
|
|
119
|
-
if (LOCALE_LINK_CALLEES.has(name)) attachesLocale = true
|
|
120
|
-
}
|
|
121
|
-
return {
|
|
122
|
-
CallExpression: note,
|
|
123
|
-
NewExpression: note,
|
|
124
|
-
"Program:exit"() {
|
|
125
|
-
if (terminalNode && !attachesLocale) {
|
|
126
|
-
context.report({ node: terminalNode, messageId: "missing" })
|
|
127
|
-
}
|
|
128
|
-
},
|
|
129
|
-
}
|
|
130
|
-
},
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
"locale-header-belongs-to-the-link": {
|
|
134
|
-
meta: {
|
|
135
|
-
type: "problem",
|
|
136
|
-
docs: {
|
|
137
|
-
description:
|
|
138
|
-
"The locale header is written by the locale link and nowhere else, so one place answers which language a request is in.",
|
|
139
|
-
},
|
|
140
|
-
schema: [],
|
|
141
|
-
messages: {
|
|
142
|
-
elsewhere:
|
|
143
|
-
"Only the locale link may write `x-locale`. Setting it here is a second answer to which language this request is in, and the two diverge the first time one of them is updated - usually leaving a single hook correct and the rest of the surface on the server's default. Let the transport attach it.",
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
create(context) {
|
|
147
|
-
const filename = context.filename || context.getFilename()
|
|
148
|
-
if (isLocaleLinkFile(filename)) return {}
|
|
149
|
-
return {
|
|
150
|
-
Property(node) {
|
|
151
|
-
if (propertyKeyOf(node) === LOCALE_HEADER) {
|
|
152
|
-
context.report({ node, messageId: "elsewhere" })
|
|
153
|
-
}
|
|
154
|
-
},
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
},
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Every FE canon rule is an error; see `sources/fe/index.mjs` for why there is no warn tier. */
|
|
161
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
|
3
|
+
*
|
|
4
|
+
* WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
|
|
5
|
+
* connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
|
|
6
|
+
* an English course on a `/vi/` URL. The chrome came from the dictionary and the content came from an
|
|
7
|
+
* API that was never told which language to serve, so the failure looked like a translation gap and
|
|
8
|
+
* was looked for in the dictionary, where there was nothing wrong. The header was missing, and no
|
|
9
|
+
* gate had anything to say about a header nobody wrote.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THEY HOLD. That the transport chain declares a locale, and that exactly one place writes it.
|
|
12
|
+
* Both are file-at-a-time facts: the chain is assembled in one file, and a header is written where it
|
|
13
|
+
* is written.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THEY CANNOT HOLD, stated rather than implied. Neither rule can tell whether the value the link
|
|
16
|
+
* computes is CORRECT - whether it reads the address, the cookie or a constant - because that is
|
|
17
|
+
* inside a function these rules only see the name of. A link that attaches `x-locale: "en"`
|
|
18
|
+
* unconditionally satisfies both and is wrong. That case is a review question and LOCALE-2 is where
|
|
19
|
+
* it is argued; pretending otherwise would be the rule claiming a guarantee it does not have.
|
|
20
|
+
*
|
|
21
|
+
* WHY THE FIRST RULE KEYS ON THE TERMINAL LINK RATHER THAN ON THE FILE PATH. A repository may name
|
|
22
|
+
* its client folder anything, and a path pattern that guessed wrong would either miss the real chain
|
|
23
|
+
* or fire on every helper beside it. The chain is identifiable by what it does: it builds the link
|
|
24
|
+
* that talks to the network.
|
|
25
|
+
*
|
|
26
|
+
* WITH ONE EXEMPTION THAT WAS FOUND BY RUNNING IT, NOT BY THINKING ABOUT IT. "Constructs the
|
|
27
|
+
* terminal link" is also true of the file that DEFINES that link, so the first version reported
|
|
28
|
+
* `links/http.ts` and its spec on a repository that had done everything right. There was no correct
|
|
29
|
+
* way to satisfy it there - a locale link inside the http link is a chain hiding in a link - and a
|
|
30
|
+
* rule with no correct way to satisfy it is a finding about the rule. A file under `links/` is one
|
|
31
|
+
* link; a chain is assembled elsewhere, which is where the locale belongs.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
35
|
+
const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The link file that owns the locale header.
|
|
39
|
+
*
|
|
40
|
+
* This is the ONE file allowed to write it, and it is recognised by path rather than by content:
|
|
41
|
+
* the point of the second rule is that the header appears in one place, so that place has to be
|
|
42
|
+
* named somewhere, and a name is what the law and the rule can both refer to.
|
|
43
|
+
*/
|
|
44
|
+
export const isLocaleLinkFile = (filename) => /\/links\/locale\.[cm]?tsx?$/.test(normalize(filename))
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One link's own implementation, rather than a chain of them.
|
|
48
|
+
*
|
|
49
|
+
* WHY THIS EXEMPTION EXISTS, AND IT WAS MEASURED. The first version of the chain rule keyed only on
|
|
50
|
+
* "constructs the terminal link", which is true of the file that DEFINES that link - `links/http.ts`
|
|
51
|
+
* builds `new HttpLink(...)`, because that is its whole job. Run against real source it reported
|
|
52
|
+
* that file and its spec, and there was no correct way to satisfy it there: attaching a locale link
|
|
53
|
+
* inside the http link would be a chain hiding in a link.
|
|
54
|
+
*
|
|
55
|
+
* A file under `links/` is one link. A chain is assembled somewhere else, which is exactly where the
|
|
56
|
+
* locale belongs.
|
|
57
|
+
*/
|
|
58
|
+
export const isLinkImplementationFile = (filename) => /\/links\/[^/]+$/.test(normalize(filename))
|
|
59
|
+
|
|
60
|
+
/** A spec or test file - it asserts about production shapes rather than being one. */
|
|
61
|
+
export const isTestFile = (filename) => /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalize(filename))
|
|
62
|
+
|
|
63
|
+
/** The header a request declares its language in. */
|
|
64
|
+
export const LOCALE_HEADER = "x-locale"
|
|
65
|
+
|
|
66
|
+
/** Callee names that construct the terminal link - the one that actually reaches the network. */
|
|
67
|
+
const TERMINAL_LINK_CALLEES = new Set(["createHttpLink", "HttpLink", "createUploadLink", "BatchHttpLink"])
|
|
68
|
+
|
|
69
|
+
/** Callee names that attach the reader's locale to an operation. */
|
|
70
|
+
const LOCALE_LINK_CALLEES = new Set(["createAttachLocaleLink", "createLocaleLink"])
|
|
71
|
+
|
|
72
|
+
/** The callee name of a call or `new` expression, when it has a plain one. */
|
|
73
|
+
const calleeName = (node) => {
|
|
74
|
+
const callee = node && node.callee
|
|
75
|
+
if (!callee) return null
|
|
76
|
+
if (callee.type === "Identifier") return callee.name
|
|
77
|
+
if (callee.type === "MemberExpression" && callee.property && callee.property.type === "Identifier") {
|
|
78
|
+
return callee.property.name
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every string-ish key a property can carry, so `"x-locale"` and `["x-locale"]` read the same. */
|
|
84
|
+
const propertyKeyOf = (property) => {
|
|
85
|
+
if (!property || property.type !== "Property") return null
|
|
86
|
+
const key = property.key
|
|
87
|
+
if (!key) return null
|
|
88
|
+
if (key.type === "Identifier" && !property.computed) return key.name
|
|
89
|
+
if (key.type === "Literal" && typeof key.value === "string") return key.value
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const rules = {
|
|
94
|
+
"api-client-attaches-the-locale": {
|
|
95
|
+
meta: {
|
|
96
|
+
type: "problem",
|
|
97
|
+
docs: {
|
|
98
|
+
description:
|
|
99
|
+
"A transport chain that builds the terminal HTTP link must also attach the reader's locale, so every call declares the language it wants served.",
|
|
100
|
+
},
|
|
101
|
+
schema: [],
|
|
102
|
+
messages: {
|
|
103
|
+
missing:
|
|
104
|
+
"This assembles the API transport but never attaches the reader's locale, so every call it makes is served the server's default language. Add the locale link to the chain, beside the auth link and unconditionally - a guest reads in a language too. A surface that is bilingual in its chrome and monolingual in its content reads as a translation gap, which is why it gets looked for in the dictionary rather than here.",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
create(context) {
|
|
108
|
+
const filename = context.filename || context.getFilename()
|
|
109
|
+
// A single link's implementation is not a chain - `links/http.ts` constructs the terminal link
|
|
110
|
+
// because defining it IS its job, and there is no correct way to attach a locale there. A
|
|
111
|
+
// spec asserts about a chain rather than being one.
|
|
112
|
+
if (isLinkImplementationFile(filename) || isTestFile(filename)) return {}
|
|
113
|
+
let terminalNode = null
|
|
114
|
+
let attachesLocale = false
|
|
115
|
+
const note = (node) => {
|
|
116
|
+
const name = calleeName(node)
|
|
117
|
+
if (name === null) return
|
|
118
|
+
if (TERMINAL_LINK_CALLEES.has(name)) terminalNode = terminalNode || node
|
|
119
|
+
if (LOCALE_LINK_CALLEES.has(name)) attachesLocale = true
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
CallExpression: note,
|
|
123
|
+
NewExpression: note,
|
|
124
|
+
"Program:exit"() {
|
|
125
|
+
if (terminalNode && !attachesLocale) {
|
|
126
|
+
context.report({ node: terminalNode, messageId: "missing" })
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
"locale-header-belongs-to-the-link": {
|
|
134
|
+
meta: {
|
|
135
|
+
type: "problem",
|
|
136
|
+
docs: {
|
|
137
|
+
description:
|
|
138
|
+
"The locale header is written by the locale link and nowhere else, so one place answers which language a request is in.",
|
|
139
|
+
},
|
|
140
|
+
schema: [],
|
|
141
|
+
messages: {
|
|
142
|
+
elsewhere:
|
|
143
|
+
"Only the locale link may write `x-locale`. Setting it here is a second answer to which language this request is in, and the two diverge the first time one of them is updated - usually leaving a single hook correct and the rest of the surface on the server's default. Let the transport attach it.",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
create(context) {
|
|
147
|
+
const filename = context.filename || context.getFilename()
|
|
148
|
+
if (isLocaleLinkFile(filename)) return {}
|
|
149
|
+
return {
|
|
150
|
+
Property(node) {
|
|
151
|
+
if (propertyKeyOf(node) === LOCALE_HEADER) {
|
|
152
|
+
context.report({ node, messageId: "elsewhere" })
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every FE canon rule is an error; see `sources/fe/index.mjs` for why there is no warn tier. */
|
|
161
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/shape-slot.mjs
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the shape-slot law (example: examples/shape-slot).
|
|
3
|
+
*
|
|
4
|
+
* TWO AXES, AND ONLY ONE IS DRAWN. A surface's `state` is its SHAPE: a layout the owner approves as
|
|
5
|
+
* one drawing. A slot's data status (loading, forbidden, error, empty) is not a shape; it is a
|
|
6
|
+
* recipe applied per api through `SlotView`, and it is never drawn. Mixing the two is what made
|
|
7
|
+
* every block ship four hand-drawn variants that disagreed with each other.
|
|
8
|
+
*
|
|
9
|
+
* THE PURE HALF TAKES ATOMS. `component.tsx` receives `{ state, props, on }`: `props` is data
|
|
10
|
+
* (strings, numbers, booleans, arrays, plain objects), `on` is actions whose arguments are data,
|
|
11
|
+
* and the children it renders are written inside it, never handed in. A layout alone also takes
|
|
12
|
+
* `children`, because the router puts the page there.
|
|
13
|
+
*
|
|
14
|
+
* ONLY ITS OWN INDEX MAY IMPORT IT. Other tiers use the connected export, so the pure half stays a
|
|
15
|
+
* private drawing surface that audit can render from a fixture.
|
|
16
|
+
*
|
|
17
|
+
* Every rule reads one file. Types imported from elsewhere are trusted as data; only names this
|
|
18
|
+
* file declares are followed, which is exact for the tree the convention produces and silent,
|
|
19
|
+
* never wrong, beyond it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
23
|
+
|
|
24
|
+
/** The pure half of a split tier, and which tier it is. */
|
|
25
|
+
const pureHalf = (filename) => {
|
|
26
|
+
const match = normalizePath(filename).match(
|
|
27
|
+
/\/src\/(?:components|features)\/(blocks|pages|layouts|overlays)\/(?:[^/]+\/)*[A-Z][A-Za-z0-9]*\/component\.tsx$/,
|
|
28
|
+
)
|
|
29
|
+
return match ? match[1] : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Specs render the pure half from fixtures; that is the point of it. */
|
|
33
|
+
const isSpec = (filename) => /\.(spec|test)\.[cm]?[jt]sx?$/.test(normalizePath(filename))
|
|
34
|
+
|
|
35
|
+
/** Type names that carry a rendered tree rather than data. */
|
|
36
|
+
const NON_ATOM_TYPE = /^(?:ReactNode|ReactElement|ReactPortal|JSX\.Element|Element|ComponentType|FC|FunctionComponent|ElementType|ComponentProps|RenderFunction)$/
|
|
37
|
+
|
|
38
|
+
/** The printed name of a type reference, qualified names included. */
|
|
39
|
+
const typeNameOf = (node) => {
|
|
40
|
+
const name = node?.typeName
|
|
41
|
+
if (!name) return ""
|
|
42
|
+
if (name.type === "Identifier") return name.name
|
|
43
|
+
if (name.type === "TSQualifiedName") return `${typeNameOf({ typeName: name.left })}.${name.right.name}`
|
|
44
|
+
return ""
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every type this file declares, by name, so references can be followed locally. */
|
|
48
|
+
const collectLocalTypes = (program) => {
|
|
49
|
+
const types = new Map()
|
|
50
|
+
const visit = (statement) => {
|
|
51
|
+
const declaration = statement?.type === "ExportNamedDeclaration" ? statement.declaration : statement
|
|
52
|
+
if (declaration?.type === "TSTypeAliasDeclaration") types.set(declaration.id.name, declaration.typeAnnotation)
|
|
53
|
+
if (declaration?.type === "TSInterfaceDeclaration") types.set(declaration.id.name, declaration.body)
|
|
54
|
+
}
|
|
55
|
+
for (const statement of program.body) visit(statement)
|
|
56
|
+
return types
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The members of an object-shaped type node, following local names and intersections. */
|
|
60
|
+
const membersOf = (node, types, seen = new Set()) => {
|
|
61
|
+
if (!node) return []
|
|
62
|
+
if (node.type === "TSTypeLiteral") return node.members
|
|
63
|
+
if (node.type === "TSInterfaceBody") return node.body
|
|
64
|
+
if (node.type === "TSIntersectionType") return node.types.flatMap((part) => membersOf(part, types, seen))
|
|
65
|
+
if (node.type === "TSTypeReference") {
|
|
66
|
+
const name = typeNameOf(node)
|
|
67
|
+
if (seen.has(name) || !types.has(name)) return []
|
|
68
|
+
seen.add(name)
|
|
69
|
+
return membersOf(types.get(name), types, seen)
|
|
70
|
+
}
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The key a property signature declares. */
|
|
75
|
+
const keyOf = (member) => (member.key?.type === "Identifier" ? member.key.name : member.key?.value)
|
|
76
|
+
|
|
77
|
+
// -- SHAPE-SLOT-1 ----------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** The pure half takes `{ state, props, on }` of atoms; only a layout also takes `children`. */
|
|
80
|
+
export const basePropsAtom = {
|
|
81
|
+
meta: {
|
|
82
|
+
type: "problem",
|
|
83
|
+
docs: { description: "A pure `XBase` takes `state`, atom `props` and `on` actions; no component or tree crosses in." },
|
|
84
|
+
schema: [],
|
|
85
|
+
messages: {
|
|
86
|
+
tree:
|
|
87
|
+
"`{{path}}` hands a rendered tree (`{{type}}`) into the pure half. Write the child inside `component.tsx` and pass it atoms (ids, strings, flags); a tree handed in cannot be drawn, audited or reused.",
|
|
88
|
+
action:
|
|
89
|
+
"`{{path}}` is a function inside `props`. Actions live in `on`, so `props` stays pure data that a fixture can hold.",
|
|
90
|
+
onData:
|
|
91
|
+
"`on.{{key}}` is not an action. `on` holds functions only; data belongs in `props`.",
|
|
92
|
+
member:
|
|
93
|
+
"`{{key}}` is not part of the pure contract. A pure half takes `state`, `props` and `on`{{children}}.",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
create(context) {
|
|
97
|
+
const tier = pureHalf(context.filename || context.getFilename())
|
|
98
|
+
if (!tier) return {}
|
|
99
|
+
let types = new Map()
|
|
100
|
+
|
|
101
|
+
/** Walk a data type: report trees and functions anywhere inside it. */
|
|
102
|
+
const checkData = (node, path, seen) => {
|
|
103
|
+
if (!node) return
|
|
104
|
+
switch (node.type) {
|
|
105
|
+
case "TSFunctionType":
|
|
106
|
+
context.report({ node, messageId: "action", data: { path } })
|
|
107
|
+
return
|
|
108
|
+
case "TSTypeReference": {
|
|
109
|
+
const name = typeNameOf(node)
|
|
110
|
+
if (NON_ATOM_TYPE.test(name)) {
|
|
111
|
+
context.report({ node, messageId: "tree", data: { path, type: name } })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
for (const argument of node.typeArguments?.params ?? node.typeParameters?.params ?? []) checkData(argument, path, seen)
|
|
115
|
+
if (types.has(name) && !seen.has(name)) {
|
|
116
|
+
seen.add(name)
|
|
117
|
+
checkData(types.get(name), path, seen)
|
|
118
|
+
}
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
case "TSUnionType":
|
|
122
|
+
case "TSIntersectionType":
|
|
123
|
+
for (const part of node.types) checkData(part, path, seen)
|
|
124
|
+
return
|
|
125
|
+
case "TSArrayType":
|
|
126
|
+
checkData(node.elementType, path, seen)
|
|
127
|
+
return
|
|
128
|
+
case "TSTypeOperator":
|
|
129
|
+
checkData(node.typeAnnotation, path, seen)
|
|
130
|
+
return
|
|
131
|
+
case "TSTypeLiteral":
|
|
132
|
+
case "TSInterfaceBody":
|
|
133
|
+
for (const member of node.members ?? node.body) {
|
|
134
|
+
if (member.type === "TSMethodSignature") {
|
|
135
|
+
context.report({ node: member, messageId: "action", data: { path: `${path}.${keyOf(member)}` } })
|
|
136
|
+
} else if (member.type === "TSPropertySignature") {
|
|
137
|
+
checkData(member.typeAnnotation?.typeAnnotation, `${path}.${keyOf(member)}`, seen)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
default:
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Walk `on`: every member is a function whose parameters are data. */
|
|
146
|
+
const checkActions = (node) => {
|
|
147
|
+
for (const member of membersOf(node, types)) {
|
|
148
|
+
const key = keyOf(member)
|
|
149
|
+
const type = member.type === "TSMethodSignature" ? member : member.typeAnnotation?.typeAnnotation
|
|
150
|
+
if (type?.type !== "TSFunctionType" && type?.type !== "TSMethodSignature") {
|
|
151
|
+
context.report({ node: member, messageId: "onData", data: { key } })
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
for (const parameter of type.params ?? type.parameters ?? []) {
|
|
155
|
+
checkData(parameter.typeAnnotation?.typeAnnotation, `on.${key}(${parameter.name ?? "arg"})`, new Set())
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
Program(node) {
|
|
162
|
+
types = collectLocalTypes(node)
|
|
163
|
+
},
|
|
164
|
+
VariableDeclarator(node) {
|
|
165
|
+
if (node.id?.type !== "Identifier" || !/^[A-Z][A-Za-z0-9]*Base$/.test(node.id.name)) return
|
|
166
|
+
const fn = node.init
|
|
167
|
+
if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression") return
|
|
168
|
+
const annotation = fn.params?.[0]?.typeAnnotation?.typeAnnotation
|
|
169
|
+
for (const member of membersOf(annotation, types)) {
|
|
170
|
+
const key = keyOf(member)
|
|
171
|
+
const type = member.typeAnnotation?.typeAnnotation
|
|
172
|
+
if (key === "state") continue
|
|
173
|
+
if (key === "props") checkData(type, "props", new Set())
|
|
174
|
+
else if (key === "on") checkActions(type)
|
|
175
|
+
else if (key === "children" && tier === "layouts") continue
|
|
176
|
+
else {
|
|
177
|
+
context.report({
|
|
178
|
+
node: member,
|
|
179
|
+
messageId: "member",
|
|
180
|
+
data: { key, children: tier === "layouts" ? ", plus the router's `children`" : "" },
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// -- SHAPE-SLOT-2 ----------------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Only the sibling `index.tsx` (and its specs) may reach `component.tsx`; only `X` leaves the folder. */
|
|
192
|
+
export const baseImportPair = {
|
|
193
|
+
meta: {
|
|
194
|
+
type: "problem",
|
|
195
|
+
docs: { description: "A pure `component.tsx` is imported only by its sibling `index.tsx`, which never re-exports `XBase`." },
|
|
196
|
+
schema: [],
|
|
197
|
+
messages: {
|
|
198
|
+
reach:
|
|
199
|
+
"`{{source}}` reaches into another surface's pure half. Import that surface's connected export from its folder; the pure half is a private drawing surface.",
|
|
200
|
+
reexport:
|
|
201
|
+
"`index.tsx` re-exports `{{name}}`. The folder exports only its connected `X`; the pure `XBase` stays behind it so no other tier can bypass the owner.",
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
create(context) {
|
|
205
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
206
|
+
const base = filename.split("/").pop() || ""
|
|
207
|
+
const isOwnIndex = base === "index.tsx"
|
|
208
|
+
const spec = isSpec(filename)
|
|
209
|
+
const inTier = /\/src\/(?:components|features)\/(blocks|pages|layouts|overlays)\//.test(filename)
|
|
210
|
+
const reachesPure = (source) => /(?:^|\/)component(?:\.tsx)?$/.test(source)
|
|
211
|
+
|
|
212
|
+
const check = (node, source) => {
|
|
213
|
+
if (typeof source !== "string" || !reachesPure(source)) return
|
|
214
|
+
const isSibling = source === "./component" || source === "./component.tsx"
|
|
215
|
+
if (isSibling && (isOwnIndex || spec)) return
|
|
216
|
+
if (isSibling && !inTier) return
|
|
217
|
+
context.report({ node, messageId: "reach", data: { source } })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
ImportDeclaration(node) {
|
|
222
|
+
check(node, node.source?.value)
|
|
223
|
+
},
|
|
224
|
+
ExportAllDeclaration(node) {
|
|
225
|
+
check(node, node.source?.value)
|
|
226
|
+
},
|
|
227
|
+
ExportNamedDeclaration(node) {
|
|
228
|
+
const source = node.source?.value
|
|
229
|
+
if (!source) return
|
|
230
|
+
check(node, source)
|
|
231
|
+
if (!isOwnIndex || !inTier || node.exportKind === "type") return
|
|
232
|
+
for (const specifier of node.specifiers ?? []) {
|
|
233
|
+
const name = specifier.exported?.name ?? specifier.local?.name
|
|
234
|
+
if (specifier.exportKind !== "type" && /^[A-Z][A-Za-z0-9]*Base$/.test(name)) {
|
|
235
|
+
context.report({ node: specifier, messageId: "reexport", data: { name } })
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// -- SHAPE-SLOT-3 ----------------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/** Words that name a data status or an open/closed lifecycle, never a drawn shape. */
|
|
246
|
+
const DATA_STATUS = new Set([
|
|
247
|
+
"pending", "loading", "failed", "error", "errored", "empty", "forbidden", "unavailable",
|
|
248
|
+
"skeleton", "closed", "idle", "refreshing", "retrying",
|
|
249
|
+
])
|
|
250
|
+
|
|
251
|
+
/** A surface's `XState` names shapes only. */
|
|
252
|
+
export const noDataStatusShape = {
|
|
253
|
+
meta: {
|
|
254
|
+
type: "problem",
|
|
255
|
+
docs: { description: "`XState` of a pure half names drawn shapes, never a data status or open/closed." },
|
|
256
|
+
schema: [],
|
|
257
|
+
messages: {
|
|
258
|
+
status:
|
|
259
|
+
"`{{name}}` lists \"{{value}}\" as a shape. Loading, forbidden, error and empty are a slot's data status: pass them as `Slot<T>` in `props` and render them through `SlotView`. Open/closed is an atom (`isOpen`). A shape is a layout the owner approves as one drawing.",
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
create(context) {
|
|
263
|
+
if (!pureHalf(context.filename || context.getFilename())) return {}
|
|
264
|
+
const check = (name, node) => {
|
|
265
|
+
const members = node?.type === "TSUnionType" ? node.types : [node]
|
|
266
|
+
for (const member of members) {
|
|
267
|
+
const value = member?.type === "TSLiteralType" ? member.literal?.value : undefined
|
|
268
|
+
if (typeof value === "string" && DATA_STATUS.has(value)) {
|
|
269
|
+
context.report({ node: member, messageId: "status", data: { name, value } })
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
TSTypeAliasDeclaration(node) {
|
|
275
|
+
if (/^[A-Z][A-Za-z0-9]*State$/.test(node.id.name)) check(node.id.name, node.typeAnnotation)
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// -- SHAPE-SLOT-4 ----------------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/** Flags that are a slot's data status. */
|
|
284
|
+
const STATUS_FLAG = /^(?:isLoading|isError|isForbidden|isPending|isValidating|isEmpty)$/
|
|
285
|
+
|
|
286
|
+
/** The name a test expression reads, if it is a status flag. */
|
|
287
|
+
const statusFlagOf = (node) => {
|
|
288
|
+
if (!node) return null
|
|
289
|
+
if (node.type === "UnaryExpression") return statusFlagOf(node.argument)
|
|
290
|
+
if (node.type === "ChainExpression") return statusFlagOf(node.expression)
|
|
291
|
+
if (node.type === "Identifier" && STATUS_FLAG.test(node.name)) return node.name
|
|
292
|
+
if (node.type === "MemberExpression" && node.property?.type === "Identifier" && STATUS_FLAG.test(node.property.name)) {
|
|
293
|
+
return node.property.name
|
|
294
|
+
}
|
|
295
|
+
if (node.type === "LogicalExpression" || node.type === "BinaryExpression") return statusFlagOf(node.left) ?? statusFlagOf(node.right)
|
|
296
|
+
return null
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A block's pure half never branches on a data status itself. */
|
|
300
|
+
export const slotStatusThroughSlotView = {
|
|
301
|
+
meta: {
|
|
302
|
+
type: "problem",
|
|
303
|
+
docs: { description: "A block's pure half renders data status through `SlotView`, never its own branch." },
|
|
304
|
+
schema: [],
|
|
305
|
+
messages: {
|
|
306
|
+
branch:
|
|
307
|
+
"This branches on `{{flag}}` by hand. Every slot renders its status through `SlotView`, the one recipe: a second branch drifts from it and becomes one more variant somebody draws.",
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
create(context) {
|
|
311
|
+
if (pureHalf(context.filename || context.getFilename()) !== "blocks") return {}
|
|
312
|
+
const check = (node, test) => {
|
|
313
|
+
const flag = statusFlagOf(test)
|
|
314
|
+
if (flag) context.report({ node, messageId: "branch", data: { flag } })
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
ConditionalExpression(node) {
|
|
318
|
+
check(node, node.test)
|
|
319
|
+
},
|
|
320
|
+
IfStatement(node) {
|
|
321
|
+
check(node, node.test)
|
|
322
|
+
},
|
|
323
|
+
LogicalExpression(node) {
|
|
324
|
+
if (node.operator === "&&" && node.parent?.type === "JSXExpressionContainer") check(node, node.left)
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The rules this law contributes to the plugin. */
|
|
331
|
+
export const rules = {
|
|
332
|
+
"base-props-atom": basePropsAtom,
|
|
333
|
+
"base-import-pair": baseImportPair,
|
|
334
|
+
"no-data-status-shape": noDataStatusShape,
|
|
335
|
+
"slot-status-through-slotview": slotStatusThroughSlotView,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Every FE canon rule is an error; existing debt is fixed before adoption. */
|
|
339
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|