@starci/eslint-canon-fe 9.3.0 → 9.4.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 +9 -0
- package/README.md +5 -1
- package/dist/brand-values.d.ts +28 -0
- package/dist/brand-values.js +172 -0
- package/dist/button-intent.d.ts +11 -0
- package/dist/button-intent.js +361 -0
- package/dist/callback-shape.d.ts +7 -0
- package/dist/callback-shape.js +396 -0
- package/dist/class-names.d.ts +19 -0
- package/dist/class-names.js +170 -0
- package/dist/client-boundary.d.ts +42 -0
- package/dist/client-boundary.js +404 -0
- package/dist/comments.d.ts +47 -0
- package/dist/comments.js +156 -0
- package/dist/control-height.d.ts +7 -0
- package/dist/control-height.js +83 -0
- package/dist/drawing.d.ts +31 -0
- package/dist/drawing.js +376 -0
- package/dist/env-owner.d.ts +22 -0
- package/dist/env-owner.js +291 -0
- package/dist/fe-stack.d.ts +10 -0
- package/dist/fe-stack.js +73 -0
- package/dist/file-layout.d.ts +71 -0
- package/dist/file-layout.js +465 -0
- package/dist/formatting.d.ts +25 -0
- package/dist/formatting.js +104 -0
- package/dist/function-budget.d.ts +9 -0
- package/dist/function-budget.js +48 -0
- package/dist/grammar-shim.d.ts +5 -0
- package/dist/grammar-shim.js +126 -0
- package/dist/hooks-folder.d.ts +34 -0
- package/dist/hooks-folder.js +320 -0
- package/dist/hygiene.d.ts +39 -0
- package/dist/hygiene.js +540 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +310 -0
- package/{landmark.mjs → dist/landmark.d.ts} +4 -6
- package/dist/landmark.js +6 -0
- package/dist/lib/ast.d.ts +36 -0
- package/dist/lib/ast.js +94 -0
- package/dist/lib/bindings.d.ts +84 -0
- package/dist/lib/bindings.js +168 -0
- package/dist/lib/class-text.d.ts +3 -0
- package/dist/lib/class-text.js +30 -0
- package/dist/lib/config.d.ts +81 -0
- package/dist/lib/config.js +141 -0
- package/dist/lib/drawing-scope.d.ts +5 -0
- package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
- package/dist/lib/eslint-boundary.d.ts +23 -0
- package/dist/lib/eslint-boundary.js +6 -0
- package/dist/lib/fe-stack.d.ts +49 -0
- package/dist/lib/fe-stack.js +98 -0
- package/dist/lib/hfs-view.d.ts +74 -0
- package/dist/lib/hfs-view.js +6 -0
- package/dist/lib/hfs.d.ts +34 -0
- package/dist/lib/hfs.js +72 -0
- package/dist/lib/next.d.ts +4 -0
- package/dist/lib/next.js +10 -0
- package/dist/lib/own-hook-origin.d.ts +17 -0
- package/dist/lib/own-hook-origin.js +52 -0
- package/dist/lib/package-root.d.ts +4 -0
- package/dist/lib/package-root.js +10 -0
- package/dist/lib/path.d.ts +2 -0
- package/{lib/path.mjs → dist/lib/path.js} +1 -1
- package/dist/lib/platform.d.ts +26 -0
- package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
- package/dist/lib/program-for.d.ts +5 -0
- package/dist/lib/program-for.js +23 -0
- package/dist/lib/pure-owner-helper.d.ts +10 -0
- package/dist/lib/pure-owner-helper.js +156 -0
- package/dist/lib/react.d.ts +16 -0
- package/dist/lib/react.js +22 -0
- package/dist/lib/redux-hook-origin.d.ts +4 -0
- package/dist/lib/redux-hook-origin.js +50 -0
- package/dist/lib/rule.d.ts +28 -0
- package/dist/lib/rule.js +5 -0
- package/dist/lib/scope.d.ts +88 -0
- package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
- package/dist/lib/typed-class-values.d.ts +13 -0
- package/dist/lib/typed-class-values.js +92 -0
- package/dist/lib/types.d.ts +44 -0
- package/dist/lib/types.js +64 -0
- package/dist/lib/why.d.ts +350 -0
- package/dist/lib/why.js +350 -0
- package/dist/lint-escape-hatch.d.ts +24 -0
- package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
- package/dist/lists.d.ts +31 -0
- package/dist/lists.js +202 -0
- package/dist/loading.d.ts +34 -0
- package/dist/loading.js +157 -0
- package/dist/naming.d.ts +33 -0
- package/dist/naming.js +176 -0
- package/dist/native-controls.d.ts +24 -0
- package/dist/native-controls.js +105 -0
- package/dist/next-conventions.d.ts +48 -0
- package/dist/next-conventions.js +452 -0
- package/dist/project-graph.d.ts +5 -0
- package/{project-graph.mjs → dist/project-graph.js} +8 -12
- package/dist/props-and-slots.d.ts +19 -0
- package/dist/props-and-slots.js +320 -0
- package/dist/served-locale.d.ts +38 -0
- package/dist/served-locale.js +263 -0
- package/dist/shape-slot.d.ts +18 -0
- package/dist/shape-slot.js +324 -0
- package/dist/size-and-state-budget.d.ts +46 -0
- package/dist/size-and-state-budget.js +424 -0
- package/dist/size-growth.d.ts +24 -0
- package/dist/size-growth.js +64 -0
- package/dist/sonar-parity.d.ts +5 -0
- package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
- package/dist/surface-rules.d.ts +18 -0
- package/dist/surface-rules.js +491 -0
- package/dist/swr-family.d.ts +15 -0
- package/dist/swr-family.js +419 -0
- package/dist/the-split.d.ts +27 -0
- package/dist/the-split.js +157 -0
- package/dist/tokens.d.ts +9 -0
- package/dist/tokens.js +35 -0
- package/dist/translation.d.ts +51 -0
- package/dist/translation.js +403 -0
- package/dist/transport.d.ts +73 -0
- package/dist/transport.js +885 -0
- package/dist/type-safety.d.ts +43 -0
- package/dist/type-safety.js +139 -0
- package/dist/ui-rulings.d.ts +11 -0
- package/dist/ui-rulings.js +121 -0
- package/package.json +31 -9
- package/runtime/knowledge/hfs/canon-pins.yaml +17 -10
- package/brand-values.mjs +0 -172
- package/button-intent.mjs +0 -320
- package/callback-shape.mjs +0 -332
- package/class-names.mjs +0 -149
- package/client-boundary.mjs +0 -400
- package/comments.mjs +0 -159
- package/control-height.mjs +0 -77
- package/drawing.mjs +0 -321
- package/env-owner.mjs +0 -244
- package/fe-stack.mjs +0 -86
- package/file-layout.mjs +0 -472
- package/formatting.mjs +0 -110
- package/function-budget.mjs +0 -49
- package/grammar-shim.mjs +0 -123
- package/hooks-folder.mjs +0 -300
- package/hygiene.mjs +0 -513
- package/index.mjs +0 -317
- package/lib/ast.mjs +0 -97
- package/lib/bindings.mjs +0 -159
- package/lib/class-text.mjs +0 -29
- package/lib/config.mjs +0 -142
- package/lib/fe-stack.mjs +0 -77
- package/lib/hfs.mjs +0 -49
- package/lib/next.mjs +0 -6
- package/lib/own-hook-origin.mjs +0 -53
- package/lib/program-for.mjs +0 -22
- package/lib/pure-owner-helper.mjs +0 -148
- package/lib/react.mjs +0 -28
- package/lib/redux-hook-origin.mjs +0 -44
- package/lib/typed-class-values.mjs +0 -81
- package/lib/types.mjs +0 -63
- package/lib/why.mjs +0 -352
- package/lists.mjs +0 -193
- package/loading.mjs +0 -156
- package/naming.mjs +0 -185
- package/native-controls.mjs +0 -108
- package/next-conventions.mjs +0 -447
- package/props-and-slots.mjs +0 -276
- package/served-locale.mjs +0 -243
- package/size-and-state-budget.mjs +0 -376
- package/size-growth.mjs +0 -62
- package/surface-rules.mjs +0 -443
- package/swr-family.mjs +0 -326
- package/the-split.mjs +0 -155
- package/tokens.mjs +0 -39
- package/translation.mjs +0 -392
- package/transport.mjs +0 -830
- package/type-safety.mjs +0 -147
- package/ui-rulings.mjs +0 -134
package/comments.mjs
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `comments.md`.
|
|
3
|
-
*
|
|
4
|
-
* Both reach further than their names suggest, and deliberately. An emoji rule that read only JSX
|
|
5
|
-
* would miss the one in a log message, so it walks comments, identifiers, string literals, template
|
|
6
|
-
* chunks and JSX text alike - everywhere prose can hide in a source file.
|
|
7
|
-
*
|
|
8
|
-
* `no-vietnamese-in-source` holds the English-only law: identifiers, string literals, template text, JSX text,
|
|
9
|
-
* comments and test titles carry no Vietnamese letter. There is no exemption marker. Detection is
|
|
10
|
-
* structural on characters (`scripts/lib/language.mjs`, folded to NFC), never a word list.
|
|
11
|
-
*
|
|
12
|
-
* The exceptions that remain are placements, not judgements, and that is on purpose: a locale dictionary
|
|
13
|
-
* IS the other language, and a fixture reproducing a real string has to reproduce it exactly. A
|
|
14
|
-
* judgement-based exception would be argued per file forever. The Vietnamese rule has no placement exemption at all.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { hasSecondLanguage } from "./runtime/scripts/lib/language.mjs"
|
|
18
|
-
import { jsdocBefore } from "./runtime/scripts/lib/jsdoc.mjs"
|
|
19
|
-
import { fileOf, inSlot } from "./lib/scope.mjs"
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Extended pictographs, or a regional-indicator pair.
|
|
23
|
-
*
|
|
24
|
-
* Two tests rather than one character class, so the class stays free of joiners and combining marks
|
|
25
|
-
* - a single class covering both trips the misleading-character-class rule, and a rule that has to
|
|
26
|
-
* be silenced to exist is one nobody trusts.
|
|
27
|
-
*/
|
|
28
|
-
const hasEmoji = (text) =>
|
|
29
|
-
typeof text === "string" &&
|
|
30
|
-
(/\p{Extended_Pictographic}/u.test(text) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text))
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Files whose second-language text or emoji is CONTENT rather than authoring, decided by what the file IS.
|
|
34
|
-
*
|
|
35
|
-
* One kind only: the locale dictionaries (a `.json` file of an i18n slot, `fe.modules.i18n` or `fe.package.i18n`),
|
|
36
|
-
* which are the product's other language. There is no "copy module": a `resources/` folder of strings is copy that
|
|
37
|
-
* skipped the catalogue.
|
|
38
|
-
*
|
|
39
|
-
* @param {object} context - The ESLint rule context.
|
|
40
|
-
* @returns {boolean} True when the linted file holds content rather than authoring.
|
|
41
|
-
*/
|
|
42
|
-
export const isContentFile = (context) => {
|
|
43
|
-
const file = fileOf(context)
|
|
44
|
-
return inSlot(context, "fe.modules.i18n", "fe.package.i18n") && file.endsWith(".json")
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Walk every place prose can hide, and hand each to one check. */
|
|
48
|
-
const proseVisitors = (context, report) => {
|
|
49
|
-
const source = context.sourceCode || context.getSourceCode()
|
|
50
|
-
return {
|
|
51
|
-
Program() {
|
|
52
|
-
for (const comment of source.getAllComments()) report(comment, comment.value)
|
|
53
|
-
},
|
|
54
|
-
Identifier(node) {
|
|
55
|
-
report(node, node.name)
|
|
56
|
-
},
|
|
57
|
-
JSXIdentifier(node) {
|
|
58
|
-
report(node, node.name)
|
|
59
|
-
},
|
|
60
|
-
PrivateIdentifier(node) {
|
|
61
|
-
report(node, node.name)
|
|
62
|
-
},
|
|
63
|
-
Literal(node) {
|
|
64
|
-
if (typeof node.value === "string") report(node, node.value)
|
|
65
|
-
},
|
|
66
|
-
TemplateElement(node) {
|
|
67
|
-
report(node, node.value && node.value.cooked)
|
|
68
|
-
},
|
|
69
|
-
JSXText(node) {
|
|
70
|
-
report(node, node.value)
|
|
71
|
-
},
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// -- COMMENTS-1 ------------------------------------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
/** Every export opens with a documentation block. */
|
|
78
|
-
export const requireExportJsdoc = {
|
|
79
|
-
meta: {
|
|
80
|
-
type: "suggestion",
|
|
81
|
-
docs: { description: "Exported declarations open with a documentation block." },
|
|
82
|
-
schema: [],
|
|
83
|
-
messages: {
|
|
84
|
-
jsdoc:
|
|
85
|
-
"`{{name}}` is exported without an adjacent JSDoc description. An export is read far more often than it is written, and by people who never open the body - name the ROLE it plays, not the signature, which the signature already states.",
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
|
-
create(context) {
|
|
89
|
-
const source = context.sourceCode || context.getSourceCode()
|
|
90
|
-
const hasBlock = (node) => jsdocBefore(source, node) !== null
|
|
91
|
-
const check = (node) => {
|
|
92
|
-
const declaration = node.declaration
|
|
93
|
-
if (!declaration) return
|
|
94
|
-
const kinds = ["VariableDeclaration", "TSInterfaceDeclaration", "FunctionDeclaration", "TSTypeAliasDeclaration"]
|
|
95
|
-
if (!kinds.includes(declaration.type) || hasBlock(node)) return
|
|
96
|
-
const id =
|
|
97
|
-
declaration.id || (declaration.declarations && declaration.declarations[0] && declaration.declarations[0].id)
|
|
98
|
-
context.report({ node: id || declaration, messageId: "jsdoc", data: { name: (id && id.name) || "this export" } })
|
|
99
|
-
}
|
|
100
|
-
return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check }
|
|
101
|
-
},
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// -- COMMENTS-4 ------------------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
/** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
|
|
107
|
-
export const noEmojiInSource = {
|
|
108
|
-
meta: {
|
|
109
|
-
type: "problem",
|
|
110
|
-
docs: { description: "No emoji in source authoring." },
|
|
111
|
-
schema: [],
|
|
112
|
-
messages: {
|
|
113
|
-
emoji:
|
|
114
|
-
"Unicode emoji in source authoring. It renders differently on every platform, sorts unpredictably, breaks a terminal that is not expecting it, and does not mean the same thing in two countries. Generic marks belong to the icon vocabulary; product reactions use the attributed checked-in SVG artwork through the reaction leaf.",
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
create(context) {
|
|
118
|
-
if (isContentFile(context)) return {}
|
|
119
|
-
return proseVisitors(context, (node, text) => {
|
|
120
|
-
if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
|
|
121
|
-
})
|
|
122
|
-
},
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// -- COMMENTS-5 ------------------------------------------------------------------------------------
|
|
126
|
-
|
|
127
|
-
/** Source prose is English: no Vietnamese letter in an identifier, string, comment, JSX text or test title. */
|
|
128
|
-
export const noVietnameseInSource = {
|
|
129
|
-
meta: {
|
|
130
|
-
type: "problem",
|
|
131
|
-
docs: { description: "No Vietnamese in source authoring, specs included." },
|
|
132
|
-
schema: [],
|
|
133
|
-
messages: {
|
|
134
|
-
vietnamese:
|
|
135
|
-
"A Vietnamese letter in source. Every reader has to be able to read all of the code, its comments and its test titles, and half of a two-language file is unavailable to somebody. Write it in English; product copy lives in the `messages/<locale>.json` catalogue behind `t()`; there is no pragma.",
|
|
136
|
-
},
|
|
137
|
-
},
|
|
138
|
-
create(context) {
|
|
139
|
-
return proseVisitors(context, (node, text) => {
|
|
140
|
-
if (hasSecondLanguage(text)) context.report({ node, messageId: "vietnamese" })
|
|
141
|
-
})
|
|
142
|
-
},
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** The rules this law contributes to the plugin. */
|
|
146
|
-
export const rules = {
|
|
147
|
-
"require-export-jsdoc": requireExportJsdoc,
|
|
148
|
-
"no-emoji-in-source": noEmojiInSource,
|
|
149
|
-
"no-vietnamese-in-source": noVietnameseInSource,
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* The level this law asks for, as the plugin's own opinion.
|
|
154
|
-
*
|
|
155
|
-
* `require-export-jsdoc` is the one a repository adopting this with history should expect a count
|
|
156
|
-
* from - every undocumented export in the tree reports at once. The other two are usually already
|
|
157
|
-
* at zero, because a second language in source is noticed by readers long before a rule arrives.
|
|
158
|
-
*/
|
|
159
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/control-height.mjs
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/** CONTROL-HEIGHT-1: one control height; the app never picks a larger step or mixes steps in one row. */
|
|
2
|
-
import { attributeValue, stringOf } from "./lib/ast.mjs"
|
|
3
|
-
import { importOf } from "./lib/bindings.mjs"
|
|
4
|
-
|
|
5
|
-
const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|offset-pop))?$/
|
|
6
|
-
const CONTROLS = new Set(["Button", "IconButton", "Switch", "Input", "SearchField", "Select", "Tabs", "SegmentedControl", "Textarea", "NumberField", "ComboBox"])
|
|
7
|
-
/** A marketing hero call to action on a landing page is the one place the 48px step is allowed. */
|
|
8
|
-
const LANDING_PATH = /[\\/](?:landing|hero|marketing)[\\/]|(?:Landing|Hero)[^\\/]*[\\/]/
|
|
9
|
-
/** The default size and its two spellings: the vendor `md` and Grammar's `control` are the same 40px height. */
|
|
10
|
-
const DEFAULT_SIZE = new Set(["md", "control"])
|
|
11
|
-
|
|
12
|
-
/** The Grammar control a JSX name resolves to; a shadowed or foreign component of the same name is not one. */
|
|
13
|
-
const controlOf = (context, name) => {
|
|
14
|
-
let root = name
|
|
15
|
-
const parts = []
|
|
16
|
-
while (root.type === "JSXMemberExpression") {
|
|
17
|
-
parts.unshift(root.property.name)
|
|
18
|
-
root = root.object
|
|
19
|
-
}
|
|
20
|
-
if (root.type !== "JSXIdentifier") return null
|
|
21
|
-
const imported = importOf(context, root)
|
|
22
|
-
if (!imported || !GRAMMAR_ENTRY.test(imported.source)) return null
|
|
23
|
-
const role = imported.imported ?? parts.shift()
|
|
24
|
-
return role !== undefined && parts.length === 0 && CONTROLS.has(role) ? role : null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** The statically authored size of a control: `control` when absent, null when it is dynamic. */
|
|
28
|
-
const sizeOf = (opening) => {
|
|
29
|
-
const attribute = opening.attributes.find((entry) => entry.type === "JSXAttribute" && entry.name.name === "size")
|
|
30
|
-
if (!attribute) return "control"
|
|
31
|
-
const text = stringOf(attributeValue(attribute))
|
|
32
|
-
if (text === null) return null
|
|
33
|
-
return DEFAULT_SIZE.has(text) ? "control" : text
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The Grammar controls a row holds directly, looking through fragments. */
|
|
37
|
-
const rowControls = (context, element) => element.children.flatMap((child) => {
|
|
38
|
-
if (child.type === "JSXFragment") return rowControls(context, child)
|
|
39
|
-
if (child.type !== "JSXElement") return []
|
|
40
|
-
return controlOf(context, child.openingElement.name) === null ? [] : [child.openingElement]
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
const controlHeight = {
|
|
44
|
-
meta: { type: "problem", docs: { description: "CONTROL-HEIGHT-1: one 40px control height, no larger step outside a landing hero, no mixed sizes in one row." }, schema: [], messages: {
|
|
45
|
-
FE_CONTROL_HEIGHT: "FE_CONTROL_HEIGHT: {{detail}} Grammar draws every standalone control at one 40px height; omit size (sm is for a control nested in an item or a dense toolbar, lg only for a landing hero call to action).",
|
|
46
|
-
} },
|
|
47
|
-
create(context) {
|
|
48
|
-
const landing = LANDING_PATH.test(context.filename)
|
|
49
|
-
const reported = new Set()
|
|
50
|
-
const report = (node, detail) => {
|
|
51
|
-
if (reported.has(node)) return
|
|
52
|
-
reported.add(node)
|
|
53
|
-
context.report({ node, messageId: "FE_CONTROL_HEIGHT", data: { detail } })
|
|
54
|
-
}
|
|
55
|
-
return {
|
|
56
|
-
JSXOpeningElement(opening) {
|
|
57
|
-
const role = controlOf(context, opening.name)
|
|
58
|
-
if (role === null) return
|
|
59
|
-
const size = sizeOf(opening)
|
|
60
|
-
if (size === "xl" || (size === "lg" && !landing)) report(opening, `${role} size="${size}" is a larger control step than the app chrome allows.`)
|
|
61
|
-
},
|
|
62
|
-
"JSXElement, JSXFragment"(element) {
|
|
63
|
-
const controls = rowControls(context, element)
|
|
64
|
-
const sizes = controls.map(sizeOf)
|
|
65
|
-
if (sizes.includes(null) || new Set(sizes).size < 2) return
|
|
66
|
-
const index = sizes.findIndex((size) => size !== sizes[0])
|
|
67
|
-
report(controls[index], "this control sits in a row with controls of another size.")
|
|
68
|
-
},
|
|
69
|
-
}
|
|
70
|
-
},
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The one rule of the law. */
|
|
74
|
-
export const rules = { "control-height": controlHeight }
|
|
75
|
-
|
|
76
|
-
/** The rule reaches consumers at error. */
|
|
77
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/drawing.mjs
DELETED
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold the drawing law (the `drawing` slot role: a surface's pure `component.tsx`).
|
|
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
|
-
* status recipe applied per resource, and it is never a drawn shape. 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 feature composition may also take named `render` callbacks inside `props` to render supplied
|
|
12
|
-
* blocks. A layout alone also takes `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 { classifyImport, roleOfFile } from "./lib/scope.mjs"
|
|
23
|
-
|
|
24
|
-
import { inSplitTier, pureHalf } from "./lib/drawing-scope.mjs"
|
|
25
|
-
|
|
26
|
-
/** Type names that carry a rendered tree rather than data. */
|
|
27
|
-
const NON_ATOM_TYPE = /^(?:ReactNode|ReactElement|ReactPortal|JSX\.Element|Element|ComponentType|FC|FunctionComponent|ElementType|ComponentProps|RenderFunction)$/
|
|
28
|
-
|
|
29
|
-
/** Feature composition's render callbacks return a rendered value and take only data. */
|
|
30
|
-
const RENDER_RESULT_TYPE = /^(?:ReactNode|ReactElement|JSX\.Element)$/
|
|
31
|
-
const RENDER_PROP_NAME = /^render(?:[A-Z][A-Za-z0-9]*)?$/
|
|
32
|
-
|
|
33
|
-
/** The printed name of a type reference, qualified names included. */
|
|
34
|
-
const typeNameOf = (node) => {
|
|
35
|
-
const name = node?.typeName
|
|
36
|
-
if (!name) return ""
|
|
37
|
-
if (name.type === "Identifier") return name.name
|
|
38
|
-
if (name.type === "TSQualifiedName") return `${typeNameOf({ typeName: name.left })}.${name.right.name}`
|
|
39
|
-
return ""
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Every type this file declares, by name, so references can be followed locally. */
|
|
43
|
-
const collectLocalTypes = (program) => {
|
|
44
|
-
const types = new Map()
|
|
45
|
-
const visit = (statement) => {
|
|
46
|
-
const declaration = statement?.type === "ExportNamedDeclaration" ? statement.declaration : statement
|
|
47
|
-
if (declaration?.type === "TSTypeAliasDeclaration") types.set(declaration.id.name, declaration.typeAnnotation)
|
|
48
|
-
if (declaration?.type === "TSInterfaceDeclaration") types.set(declaration.id.name, declaration.body)
|
|
49
|
-
}
|
|
50
|
-
for (const statement of program.body) visit(statement)
|
|
51
|
-
return types
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The members of an object-shaped type node, following local names and intersections. */
|
|
55
|
-
const membersOf = (node, types, seen = new Set()) => {
|
|
56
|
-
if (!node) return []
|
|
57
|
-
if (node.type === "TSTypeLiteral") return node.members
|
|
58
|
-
if (node.type === "TSInterfaceBody") return node.body
|
|
59
|
-
if (node.type === "TSIntersectionType") return node.types.flatMap((part) => membersOf(part, types, seen))
|
|
60
|
-
if (node.type === "TSTypeReference") {
|
|
61
|
-
const name = typeNameOf(node)
|
|
62
|
-
if (seen.has(name) || !types.has(name)) return []
|
|
63
|
-
seen.add(name)
|
|
64
|
-
return membersOf(types.get(name), types, seen)
|
|
65
|
-
}
|
|
66
|
-
return []
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** The key a property signature declares. */
|
|
70
|
-
const keyOf = (member) => (member.key?.type === "Identifier" ? member.key.name : member.key?.value)
|
|
71
|
-
|
|
72
|
-
// -- DRAWING-1 ----------------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
/** The pure half takes atom data and actions; feature composition may receive named render callbacks. */
|
|
75
|
-
export const basePropsAtom = {
|
|
76
|
-
meta: {
|
|
77
|
-
type: "problem",
|
|
78
|
-
docs: { description: "A pure `XBase` takes `shape`, atom `props` and `on` actions; feature composition may receive named render callbacks in `props`." },
|
|
79
|
-
schema: [],
|
|
80
|
-
messages: {
|
|
81
|
-
tree:
|
|
82
|
-
"`{{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.",
|
|
83
|
-
action:
|
|
84
|
-
"`{{path}}` is a function inside `props`. Actions live in `on`; feature composition may receive named `render` callbacks that take atoms and return rendered blocks.",
|
|
85
|
-
onData:
|
|
86
|
-
"`on.{{key}}` is not an action. `on` holds functions only; data belongs in `props`.",
|
|
87
|
-
member:
|
|
88
|
-
"`{{key}}` is not part of the pure contract. A pure half takes `shape`, `props` and `on`{{children}}.",
|
|
89
|
-
renamed:
|
|
90
|
-
"`state` is the retired name of the drawn shape: rename it `shape` (owner ruling fe-shape-not-state).",
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
create(context) {
|
|
94
|
-
const tier = pureHalf(context)
|
|
95
|
-
if (!tier) return {}
|
|
96
|
-
let types = new Map()
|
|
97
|
-
|
|
98
|
-
/** Resolve render results locally; an optional empty result does not make data a render prop. */
|
|
99
|
-
const isRenderResult = (node, seen = new Set()) => {
|
|
100
|
-
if (!node) return false
|
|
101
|
-
if (node.type === "TSUnionType") {
|
|
102
|
-
const empty = (part) => ["TSNullKeyword", "TSUndefinedKeyword"].includes(part.type)
|
|
103
|
-
|| part.type === "TSLiteralType" && part.literal.value === null
|
|
104
|
-
return node.types.some((part) => isRenderResult(part, new Set(seen)))
|
|
105
|
-
&& node.types.every((part) => empty(part) || isRenderResult(part, new Set(seen)))
|
|
106
|
-
}
|
|
107
|
-
if (node.type !== "TSTypeReference") return false
|
|
108
|
-
const name = typeNameOf(node)
|
|
109
|
-
if (RENDER_RESULT_TYPE.test(name.replace(/^React\./, ""))) return true
|
|
110
|
-
if (!types.has(name) || seen.has(name)) return false
|
|
111
|
-
return isRenderResult(types.get(name), new Set(seen).add(name))
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const checkRenderCallback = (node, path, allowed) => {
|
|
115
|
-
if (!allowed || !isRenderResult(node.returnType?.typeAnnotation)) return false
|
|
116
|
-
for (const parameter of node.params ?? node.parameters ?? []) {
|
|
117
|
-
checkData(parameter.typeAnnotation?.typeAnnotation, `${path}(${parameter.name ?? "arg"})`, new Set(), false, true)
|
|
118
|
-
}
|
|
119
|
-
return true
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const checkDataReference = (node, path, seen, renderProp, renderParameter) => {
|
|
123
|
-
const name = typeNameOf(node)
|
|
124
|
-
if (NON_ATOM_TYPE.test(renderParameter ? name.replace(/^React\./, "") : name)) {
|
|
125
|
-
context.report({ node, messageId: "tree", data: { path, type: name } })
|
|
126
|
-
return
|
|
127
|
-
}
|
|
128
|
-
for (const argument of node.typeArguments?.params ?? node.typeParameters?.params ?? []) checkData(argument, path, seen, false, renderParameter)
|
|
129
|
-
if (types.has(name) && !seen.has(name)) {
|
|
130
|
-
const nextSeen = renderProp || renderParameter ? new Set(seen) : seen
|
|
131
|
-
nextSeen.add(name)
|
|
132
|
-
checkData(types.get(name), path, nextSeen, renderProp, renderParameter)
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const checkDataMember = (member, path, seen, renderParameter) => {
|
|
137
|
-
const memberPath = `${path}.${keyOf(member)}`
|
|
138
|
-
const allowsRender = tier !== "blocks" && path === "props" && RENDER_PROP_NAME.test(keyOf(member))
|
|
139
|
-
if (member.type === "TSMethodSignature") {
|
|
140
|
-
if (!checkRenderCallback(member, memberPath, allowsRender)) context.report({ node: member, messageId: "action", data: { path: memberPath } })
|
|
141
|
-
} else if (member.type === "TSPropertySignature") {
|
|
142
|
-
checkData(member.typeAnnotation?.typeAnnotation, memberPath, seen, allowsRender, renderParameter)
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Walk a data type: report trees and functions anywhere inside it. */
|
|
147
|
-
const checkData = (node, path, seen, renderProp = false, renderParameter = false) => {
|
|
148
|
-
if (!node) return
|
|
149
|
-
switch (node.type) {
|
|
150
|
-
case "TSFunctionType":
|
|
151
|
-
if (checkRenderCallback(node, path, renderProp)) return
|
|
152
|
-
context.report({ node, messageId: "action", data: { path } })
|
|
153
|
-
return
|
|
154
|
-
case "TSTypeReference":
|
|
155
|
-
checkDataReference(node, path, seen, renderProp, renderParameter)
|
|
156
|
-
return
|
|
157
|
-
case "TSUnionType":
|
|
158
|
-
case "TSIntersectionType":
|
|
159
|
-
for (const part of node.types) checkData(part, path, seen, renderProp, renderParameter)
|
|
160
|
-
return
|
|
161
|
-
case "TSArrayType":
|
|
162
|
-
checkData(node.elementType, path, seen, false, renderParameter)
|
|
163
|
-
return
|
|
164
|
-
case "TSTypeOperator":
|
|
165
|
-
checkData(node.typeAnnotation, path, seen, false, renderParameter)
|
|
166
|
-
return
|
|
167
|
-
case "TSTypeLiteral":
|
|
168
|
-
case "TSInterfaceBody":
|
|
169
|
-
for (const member of node.members ?? node.body) checkDataMember(member, path, seen, renderParameter)
|
|
170
|
-
return
|
|
171
|
-
default:
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Walk `on`: every member is a function whose parameters are data. */
|
|
176
|
-
const checkActions = (node) => {
|
|
177
|
-
for (const member of membersOf(node, types)) {
|
|
178
|
-
const key = keyOf(member)
|
|
179
|
-
const type = member.type === "TSMethodSignature" ? member : member.typeAnnotation?.typeAnnotation
|
|
180
|
-
if (type?.type !== "TSFunctionType" && type?.type !== "TSMethodSignature") {
|
|
181
|
-
context.report({ node: member, messageId: "onData", data: { key } })
|
|
182
|
-
continue
|
|
183
|
-
}
|
|
184
|
-
for (const parameter of type.params ?? type.parameters ?? []) {
|
|
185
|
-
checkData(parameter.typeAnnotation?.typeAnnotation, `on.${key}(${parameter.name ?? "arg"})`, new Set())
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return {
|
|
191
|
-
Program(node) {
|
|
192
|
-
types = collectLocalTypes(node)
|
|
193
|
-
},
|
|
194
|
-
VariableDeclarator(node) {
|
|
195
|
-
if (node.id?.type !== "Identifier" || !/^[A-Z][A-Za-z0-9]*Base$/.test(node.id.name)) return
|
|
196
|
-
const fn = node.init
|
|
197
|
-
if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression") return
|
|
198
|
-
const annotation = fn.params?.[0]?.typeAnnotation?.typeAnnotation
|
|
199
|
-
for (const member of membersOf(annotation, types)) {
|
|
200
|
-
const key = keyOf(member)
|
|
201
|
-
const type = member.typeAnnotation?.typeAnnotation
|
|
202
|
-
if (key === "shape") continue
|
|
203
|
-
if (key === "state") {
|
|
204
|
-
context.report({ node: member, messageId: "renamed" })
|
|
205
|
-
continue
|
|
206
|
-
}
|
|
207
|
-
if (key === "props") checkData(type, "props", new Set())
|
|
208
|
-
else if (key === "on") checkActions(type)
|
|
209
|
-
else if (key === "children" && tier === "layouts") continue
|
|
210
|
-
else {
|
|
211
|
-
context.report({
|
|
212
|
-
node: member,
|
|
213
|
-
messageId: "member",
|
|
214
|
-
data: { key, children: tier === "layouts" ? ", plus the router's `children`" : "" },
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
},
|
|
219
|
-
}
|
|
220
|
-
},
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// -- DRAWING-2 ----------------------------------------------------------------------------------
|
|
224
|
-
|
|
225
|
-
/** Only the sibling `index.tsx` (and its specs) may reach `component.tsx`; only `X` leaves the folder. */
|
|
226
|
-
export const baseImportPair = {
|
|
227
|
-
meta: {
|
|
228
|
-
type: "problem",
|
|
229
|
-
docs: { description: "A pure `component.tsx` is imported only by its sibling `index.tsx`, which never re-exports `XBase`." },
|
|
230
|
-
schema: [],
|
|
231
|
-
messages: {
|
|
232
|
-
reach:
|
|
233
|
-
"`{{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.",
|
|
234
|
-
reexport:
|
|
235
|
-
"`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.",
|
|
236
|
-
},
|
|
237
|
-
},
|
|
238
|
-
create(context) {
|
|
239
|
-
const isOwnIndex = roleOfFile(context) === "entry"
|
|
240
|
-
const inTier = inSplitTier(context)
|
|
241
|
-
// An import reaches a pure half when the file it resolves to has the `drawing` role of its slot.
|
|
242
|
-
const reachesPure = (source) => classifyImport(context, source)?.role === "drawing"
|
|
243
|
-
|
|
244
|
-
const check = (node, source) => {
|
|
245
|
-
if (typeof source !== "string" || !reachesPure(source)) return
|
|
246
|
-
const isSibling = /^\.\/[^/]+$/.test(source)
|
|
247
|
-
if (isSibling && isOwnIndex) return
|
|
248
|
-
if (isSibling && !inTier) return
|
|
249
|
-
context.report({ node, messageId: "reach", data: { source } })
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return {
|
|
253
|
-
ImportDeclaration(node) {
|
|
254
|
-
check(node, node.source?.value)
|
|
255
|
-
},
|
|
256
|
-
ExportAllDeclaration(node) {
|
|
257
|
-
check(node, node.source?.value)
|
|
258
|
-
},
|
|
259
|
-
ExportNamedDeclaration(node) {
|
|
260
|
-
const source = node.source?.value
|
|
261
|
-
if (!source) return
|
|
262
|
-
check(node, source)
|
|
263
|
-
if (!isOwnIndex || !inTier || node.exportKind === "type") return
|
|
264
|
-
for (const specifier of node.specifiers ?? []) {
|
|
265
|
-
const name = specifier.exported?.name ?? specifier.local?.name
|
|
266
|
-
if (specifier.exportKind !== "type" && /^[A-Z][A-Za-z0-9]*Base$/.test(name)) {
|
|
267
|
-
context.report({ node: specifier, messageId: "reexport", data: { name } })
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
},
|
|
271
|
-
}
|
|
272
|
-
},
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// -- DRAWING-3 ----------------------------------------------------------------------------------
|
|
276
|
-
|
|
277
|
-
/** Words that name a data status or an open/closed lifecycle, never a drawn shape. */
|
|
278
|
-
const DATA_STATUS = new Set([
|
|
279
|
-
"pending", "loading", "failed", "error", "errored", "empty", "forbidden", "unavailable",
|
|
280
|
-
"skeleton", "closed", "idle", "refreshing", "retrying",
|
|
281
|
-
])
|
|
282
|
-
|
|
283
|
-
/** A surface's `XShape` names shapes only. */
|
|
284
|
-
export const noDataStatusShape = {
|
|
285
|
-
meta: {
|
|
286
|
-
type: "problem",
|
|
287
|
-
docs: { description: "`XShape` of a pure half names drawn shapes, never a data status or open/closed." },
|
|
288
|
-
schema: [],
|
|
289
|
-
messages: {
|
|
290
|
-
status:
|
|
291
|
-
"`{{name}}` lists \"{{value}}\" as a shape. Loading, forbidden, error and empty describe resource status: pass them as data to the owned status renderer. Open/closed is a boolean atom. A shape is a layout the owner approves as one drawing.",
|
|
292
|
-
},
|
|
293
|
-
},
|
|
294
|
-
create(context) {
|
|
295
|
-
if (!pureHalf(context)) return {}
|
|
296
|
-
const check = (name, node) => {
|
|
297
|
-
const members = node?.type === "TSUnionType" ? node.types : [node]
|
|
298
|
-
for (const member of members) {
|
|
299
|
-
const value = member?.type === "TSLiteralType" ? member.literal?.value : undefined
|
|
300
|
-
if (typeof value === "string" && DATA_STATUS.has(value)) {
|
|
301
|
-
context.report({ node: member, messageId: "status", data: { name, value } })
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
return {
|
|
306
|
-
TSTypeAliasDeclaration(node) {
|
|
307
|
-
if (/^[A-Z][A-Za-z0-9]*(?:Shape|State)$/.test(node.id.name)) check(node.id.name, node.typeAnnotation)
|
|
308
|
-
},
|
|
309
|
-
}
|
|
310
|
-
},
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** The rules this law contributes to the plugin. */
|
|
314
|
-
export const rules = {
|
|
315
|
-
"base-props-atom": basePropsAtom,
|
|
316
|
-
"base-import-pair": baseImportPair,
|
|
317
|
-
"no-data-status-shape": noDataStatusShape,
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Every FE canon rule is an error; existing debt is fixed before adoption. */
|
|
321
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|