@starci/eslint-canon-fe 5.1.2 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -2
- package/README.md +17 -18
- package/brand-values.mjs +1 -3
- package/class-names.mjs +9 -9
- package/client-boundary.mjs +125 -37
- package/comments.mjs +14 -21
- package/docs/client-boundary.md +26 -1
- package/docs/grammar-boundary.md +33 -0
- package/docs/hygiene.md +15 -4
- package/docs/lint-escape-hatch.md +3 -1
- package/docs/next-conventions.md +26 -0
- package/docs/size-growth.md +29 -0
- package/docs/spec-quality.md +15 -7
- package/docs/status-colors.md +32 -0
- package/docs/translation.md +4 -0
- package/docs/transport.md +36 -9
- package/e2e-shape.mjs +20 -23
- package/env-owner.mjs +1 -1
- package/file-layout.mjs +105 -103
- package/formatting.mjs +2 -4
- package/grammar-boundary.mjs +85 -9
- package/hooks-folder.mjs +17 -16
- package/hygiene.mjs +370 -92
- package/icon.mjs +21 -20
- package/index.mjs +27 -85
- package/landmark.mjs +0 -11
- package/lib/bindings.mjs +144 -0
- package/lib/config.mjs +79 -0
- package/lib/hfs.mjs +48 -0
- package/lib/next.mjs +6 -0
- package/lib/platform.mjs +66 -0
- package/lib/react.mjs +28 -0
- package/lib/scope.mjs +119 -19
- package/lib/status-tones.generated.mjs +9 -0
- package/lib/types.mjs +63 -0
- package/lib/why.mjs +41 -11
- package/lint-escape-hatch.mjs +17 -15
- package/loading.mjs +9 -16
- package/naming.mjs +8 -2
- package/next-conventions.mjs +109 -25
- package/package.json +9 -4
- package/props-and-slots.mjs +19 -13
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1072 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +42 -0
- package/runtime/scripts/lib/hfs-slots.mjs +722 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/next-contract.mjs +14 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/served-locale.mjs +30 -16
- package/shape-slot.mjs +22 -20
- package/size-and-state-budget.mjs +41 -23
- package/size-growth.mjs +58 -0
- package/spec-quality.mjs +95 -16
- package/status-colors.mjs +139 -0
- package/the-split.mjs +13 -13
- package/tokens.mjs +26 -34
- package/translation.mjs +52 -19
- package/transport.mjs +437 -89
- package/type-safety.mjs +8 -17
- package/typography.mjs +7 -11
- package/vendor-boundary.mjs +5 -27
- package/lint-adoption.mjs +0 -79
package/file-layout.mjs
CHANGED
|
@@ -6,35 +6,59 @@
|
|
|
6
6
|
* a constants folder, a utils folder and three hand-copied resting shapes, at which point the
|
|
7
7
|
* screen is a second codebase with its own private vocabulary that nobody else can reuse.
|
|
8
8
|
*
|
|
9
|
-
* Every rule here
|
|
10
|
-
* exact. The cost of that choice is stated where it
|
|
11
|
-
* a helper, only a folder from a folder, so each rule below names the
|
|
12
|
-
* to rather than merely refusing.
|
|
9
|
+
* Every rule here asks the HFS slot view where the file sits (slot, layer kind, role, tier) rather than
|
|
10
|
+
* reading the contents, which is what makes them cheap and exact. The cost of that choice is stated where it
|
|
11
|
+
* bites: a slot cannot tell a component from a helper, only a folder from a folder, so each rule below names the
|
|
12
|
+
* destination it sends things to rather than merely refusing.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
16
|
+
import { classOf, fileOf, inSlot, isComponentFile, isSpecFile, kindOfFile, roleOfFile, tierOfFile } from "./lib/scope.mjs"
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/(?:components|features)\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
|
|
18
|
+
/** The path segments of the linted file below the folder of its owner (the slot's `root`), directories only. */
|
|
19
|
+
const dirsBelowRoot = (context) => {
|
|
20
|
+
const { root } = classOf(context)
|
|
21
|
+
const relative = hfsOf(context).relative(fileOf(context))
|
|
22
|
+
return relative.slice(root.length + 1).split("/").slice(0, -1)
|
|
23
|
+
}
|
|
24
24
|
|
|
25
|
-
/** The
|
|
26
|
-
const
|
|
25
|
+
/** The folder segment that sits where a component layer folder is expected but is not one, or null: `components/shells/`, `src/shells/`. */
|
|
26
|
+
const unknownLayerFolder = (context) => {
|
|
27
|
+
const hfs = hfsOf(context)
|
|
28
|
+
const file = fileOf(context)
|
|
29
|
+
const found = hfs.classify(file)
|
|
30
|
+
const relative = hfs.relative(file)
|
|
31
|
+
if (found.status === "no-slot" && found.nearest?.slot === "fe.components") {
|
|
32
|
+
const prefix = found.nearest.matchedPrefix
|
|
33
|
+
return relative.startsWith(`${prefix}/`) ? (relative.slice(prefix.length + 1).split("/")[0] ?? null) : null
|
|
34
|
+
}
|
|
35
|
+
if (found.slot === "fe.package.ui" && !found.kind) {
|
|
36
|
+
const [folder, layer, ...rest] = relative.slice(found.root.length + 1).split("/")
|
|
37
|
+
return folder === "src" && rest.length > 0 ? layer : null
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
27
41
|
|
|
28
|
-
/** Folders
|
|
29
|
-
const NON_COMPONENT_FOLDERS =
|
|
42
|
+
/** Folders whose contents are not component code, whatever they are nested inside. */
|
|
43
|
+
const NON_COMPONENT_FOLDERS = new Set(["constants", "utils", "types", "hooks"])
|
|
30
44
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
/**
|
|
46
|
+
* The surface folder a feature-kind file sits in: its kind (`pages`, `layouts`, `overlays`), the surface name and the
|
|
47
|
+
* file's path inside it. A page or layout owner IS the surface folder; an overlay owner is its category and the
|
|
48
|
+
* surface is the folder below it.
|
|
49
|
+
*/
|
|
50
|
+
const surfaceFolder = (context) => {
|
|
51
|
+
if (!inSlot(context, "fe.feature")) return null
|
|
52
|
+
const { kind, bindings, root } = classOf(context)
|
|
53
|
+
const below = hfsOf(context).relative(fileOf(context)).slice(root.length + 1).split("/")
|
|
54
|
+
if (kind === "overlays") return below.length > 1 ? { tier: kind, name: below[0], rest: below.slice(1).join("/") } : null
|
|
55
|
+
return { tier: kind, name: bindings.name, rest: below.join("/") }
|
|
36
56
|
}
|
|
37
57
|
|
|
58
|
+
/** A file a surface folder may hold: one of the slot's roles (entry, drawing, styles) or a `.spec.` twin, directly in the folder. */
|
|
59
|
+
const belongsInSurfaceFolder = (context, folder) =>
|
|
60
|
+
!folder.rest.includes("/") && (roleOfFile(context) !== null || (isSpecFile(folder.rest) && folder.rest.includes(".spec.")))
|
|
61
|
+
|
|
38
62
|
// -- FILE-2 --------------------------------------------------------------------------------------
|
|
39
63
|
|
|
40
64
|
/** A page, layout or overlay folder holds its two halves and its colocated class-name module. */
|
|
@@ -49,8 +73,8 @@ export const surfaceFolderTwoFilesOnly = {
|
|
|
49
73
|
},
|
|
50
74
|
},
|
|
51
75
|
create(context) {
|
|
52
|
-
const folder = surfaceFolder(context
|
|
53
|
-
if (!folder ||
|
|
76
|
+
const folder = surfaceFolder(context)
|
|
77
|
+
if (!folder || belongsInSurfaceFolder(context, folder)) return {}
|
|
54
78
|
return {
|
|
55
79
|
Program(node) {
|
|
56
80
|
context.report({ node, messageId: "extra", data: folder })
|
|
@@ -69,15 +93,19 @@ export const noHelperFolderInComponents = {
|
|
|
69
93
|
schema: [],
|
|
70
94
|
messages: {
|
|
71
95
|
helper:
|
|
72
|
-
"`{{kind}}/` under
|
|
96
|
+
"`{{kind}}/` under a component owner - this is not component code, so it does not live in the component tree. A fetch is a `hooks/`, a pure function is a `modules/utils/`, a shape is a `modules/types/`, copy or a config map is a `resources/`. Left here it stays invisible to everyone who would have reused it, so the second author writes it again and the two drift.",
|
|
73
97
|
},
|
|
74
98
|
},
|
|
75
99
|
create(context) {
|
|
76
|
-
|
|
77
|
-
|
|
100
|
+
if (!isComponentFile(context)) return {}
|
|
101
|
+
const dirs = dirsBelowRoot(context)
|
|
102
|
+
// a package owner sits at the package root, so its `src/<layer>/<name>` prefix is not part of the owner
|
|
103
|
+
const inside = inSlot(context, "fe.package.ui") ? dirs.slice(3) : dirs
|
|
104
|
+
const folder = inside.find((name) => NON_COMPONENT_FOLDERS.has(name))
|
|
105
|
+
if (!folder) return {}
|
|
78
106
|
return {
|
|
79
107
|
Program(node) {
|
|
80
|
-
context.report({ node, messageId: "helper", data: { kind:
|
|
108
|
+
context.report({ node, messageId: "helper", data: { kind: folder } })
|
|
81
109
|
},
|
|
82
110
|
}
|
|
83
111
|
},
|
|
@@ -97,9 +125,9 @@ export const exportMatchesFolder = {
|
|
|
97
125
|
},
|
|
98
126
|
},
|
|
99
127
|
create(context) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
128
|
+
if (roleOfFile(context) !== "entry" || kindOfFile(context) === null) return {}
|
|
129
|
+
const folder = hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0]
|
|
130
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(folder)) return {}
|
|
103
131
|
const names = new Set()
|
|
104
132
|
const belongsToFamily = (name) =>
|
|
105
133
|
name === folder || (name.startsWith(folder) && name.length > folder.length && /^[A-Z]/.test(name.slice(folder.length)))
|
|
@@ -161,18 +189,28 @@ export const noRuntimeNamespace = {
|
|
|
161
189
|
|
|
162
190
|
// -- FILE-5 --------------------------------------------------------------------------------------
|
|
163
191
|
|
|
164
|
-
/**
|
|
165
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Whether the repository declares the shared UI package (slot `fe.package.ui` is opt-in): the classification of a file
|
|
194
|
+
* of the package's own root is `owned` only then.
|
|
195
|
+
*/
|
|
196
|
+
const uiPackageDeclared = (hfs) => {
|
|
197
|
+
const root = hfs.slot("fe.package.ui").path.replace(/<[^>]+>/g, "probe").replace(/\/$/, "")
|
|
198
|
+
return hfs.classify(`${root}/package.json`).status === "owned"
|
|
199
|
+
}
|
|
166
200
|
|
|
167
|
-
/**
|
|
168
|
-
const
|
|
201
|
+
/** The layer folders that know a feature: those a component owner or a feature owner has and the shared package does not. */
|
|
202
|
+
const featureTiers = (hfs) => {
|
|
203
|
+
const shared = new Set(hfs.slot("fe.package.ui").kinds)
|
|
204
|
+
return new Set([...hfs.slot("fe.components").layers, ...hfs.slot("fe.feature").kinds].filter((name) => !shared.has(name)))
|
|
205
|
+
}
|
|
169
206
|
|
|
170
207
|
/**
|
|
171
208
|
* In a monorepo, the shared package stops below the block.
|
|
172
209
|
*
|
|
173
|
-
* WHY
|
|
210
|
+
* WHY THE SLOT IS ENOUGH HERE. The feature line is already drawn by the layer: a leaf knows
|
|
174
211
|
* no domain and a block is a domain sentence, so which side of the workspace each belongs on
|
|
175
|
-
* follows from the
|
|
212
|
+
* follows from the layer the slot reports. The layers a shared package may hold are the `kinds` of
|
|
213
|
+
* `fe.package.ui`; an app holds the shared ones only when the repository declares that package.
|
|
176
214
|
*
|
|
177
215
|
* WHAT IT COST TO LEARN. A shared package carried one `blocks/FleetRow/` while its own header
|
|
178
216
|
* insisted that "a block carries feature meaning and therefore belongs to the app that owns the
|
|
@@ -195,16 +233,18 @@ export const monorepoTierBelongsToItsSide = {
|
|
|
195
233
|
},
|
|
196
234
|
},
|
|
197
235
|
create(context) {
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const
|
|
236
|
+
const hfs = hfsOf(context)
|
|
237
|
+
const folder = unknownLayerFolder(context)
|
|
238
|
+
const inPackage = inSlot(context, "fe.package.ui") && folder !== null && featureTiers(hfs).has(folder) ? folder : null
|
|
239
|
+
const tier = kindOfFile(context)
|
|
240
|
+
const inApp = inSlot(context, "fe.components") && tier !== null && hfs.slot("fe.package.ui").kinds.includes(tier) && uiPackageDeclared(hfs) ? tier : null
|
|
201
241
|
return {
|
|
202
242
|
Program(node) {
|
|
203
243
|
if (inPackage) {
|
|
204
|
-
context.report({ node, messageId: "featureInPackage", data: { tier: inPackage
|
|
244
|
+
context.report({ node, messageId: "featureInPackage", data: { tier: inPackage } })
|
|
205
245
|
return
|
|
206
246
|
}
|
|
207
|
-
if (inApp) context.report({ node, messageId: "vocabularyInApp", data: { tier: inApp
|
|
247
|
+
if (inApp) context.report({ node, messageId: "vocabularyInApp", data: { tier: inApp } })
|
|
208
248
|
},
|
|
209
249
|
}
|
|
210
250
|
},
|
|
@@ -212,25 +252,6 @@ export const monorepoTierBelongsToItsSide = {
|
|
|
212
252
|
|
|
213
253
|
// -- FILE-6 --------------------------------------------------------------------------------------
|
|
214
254
|
|
|
215
|
-
/** Anything under a routing tree, in either workspace shape. */
|
|
216
|
-
const ROUTE_TREE = /\/src\/app\/(.+)$/
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* What a routing tree is allowed to contain.
|
|
220
|
-
*
|
|
221
|
-
* Next decides these names, not this law: `page`, `layout`, `template`, `loading`, `error`,
|
|
222
|
-
* `not-found`, `global-error`, `default`, `route`, `sitemap`, `robots`, `manifest`, `opengraph-image`
|
|
223
|
-
* and `icon` are framework slots. Two more are admitted because the root layout mounts them and
|
|
224
|
-
* there is nowhere else they could go: `providers`, the client boundary above every route, and
|
|
225
|
-
* `globals`, the stylesheet the document imports. Everything else in that folder is a file somebody
|
|
226
|
-
* put there.
|
|
227
|
-
*/
|
|
228
|
-
const ROUTE_TREE_FILES =
|
|
229
|
-
/^(?:page|layout|template|loading|error|not-found|global-error|default|route|providers|globals|middleware|sitemap|robots|manifest|opengraph-image|twitter-image|apple-icon|icon|favicon)(?:\.[a-z-]+)?\.(?:tsx?|jsx?|css|json)$/
|
|
230
|
-
|
|
231
|
-
/** `app/api/**` is server code, not a screen; `_private/**` is Next's own opt-out folder. */
|
|
232
|
-
const ROUTE_TREE_EXEMPT = /^(?:api\/|_)/
|
|
233
|
-
|
|
234
255
|
/**
|
|
235
256
|
* A test sitting beside the thing it tests.
|
|
236
257
|
*
|
|
@@ -241,10 +262,9 @@ const ROUTE_TREE_EXEMPT = /^(?:api\/|_)/
|
|
|
241
262
|
*
|
|
242
263
|
* The name is deliberately not required to match `page` or `layout`. A route's tests split by
|
|
243
264
|
* CONCERN - what the screen renders, who may reach it, where the boundary sits - and forcing them
|
|
244
|
-
* into one `page.spec.tsx` would buy nothing but a longer file.
|
|
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.
|
|
245
267
|
*/
|
|
246
|
-
const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
|
|
247
|
-
|
|
248
268
|
/**
|
|
249
269
|
* A file under `app/` names which page renders at which URL, and does nothing else.
|
|
250
270
|
*
|
|
@@ -254,9 +274,9 @@ const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
|
|
|
254
274
|
* typecheck, four sealed screenshots and an approval, and arrived at the edge of a production write
|
|
255
275
|
* with every gate green - because every gate was reading rules, and this one was only prose.
|
|
256
276
|
*
|
|
257
|
-
* WHY IT READS THE
|
|
277
|
+
* WHY IT READS THE SLOT AND NOT THE CONTENTS. "Drawing" is not a property this can measure: a
|
|
258
278
|
* route that mounts one component and a route that arranges six both return JSX. What CAN be
|
|
259
|
-
* measured exactly is whether a file in the routing tree is one of the framework's own slots. A page
|
|
279
|
+
* measured exactly is whether a file in the routing tree (slot `fe.route`) is one of the framework's own slots. A page
|
|
260
280
|
* owner is a component, a component has a name, and a named component belongs in the tier that
|
|
261
281
|
* groups it with its siblings - `components/pages/<Name>/`, where the next author looks for it.
|
|
262
282
|
*
|
|
@@ -266,7 +286,7 @@ const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
|
|
|
266
286
|
export const routeTreeHoldsRoutesOnly = {
|
|
267
287
|
meta: {
|
|
268
288
|
type: "problem",
|
|
269
|
-
docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `features/pages/<Name
|
|
289
|
+
docs: { description: "`src/app/**` holds framework route files only; a page owner lives in `features/pages/<Name>/`." },
|
|
270
290
|
schema: [],
|
|
271
291
|
messages: {
|
|
272
292
|
stray:
|
|
@@ -274,14 +294,11 @@ export const routeTreeHoldsRoutesOnly = {
|
|
|
274
294
|
},
|
|
275
295
|
},
|
|
276
296
|
create(context) {
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
if (!
|
|
280
|
-
const rest =
|
|
281
|
-
if (ROUTE_TREE_EXEMPT.test(rest)) return {}
|
|
297
|
+
if (!inSlot(context, "fe.route")) return {}
|
|
298
|
+
const verdict = hfsOf(context).allows(fileOf(context))
|
|
299
|
+
if (!verdict || verdict.allowed) return {}
|
|
300
|
+
const rest = verdict.relative
|
|
282
301
|
const basename = rest.slice(rest.lastIndexOf("/") + 1)
|
|
283
|
-
if (ROUTE_TREE_TEST.test(basename)) return {}
|
|
284
|
-
if (ROUTE_TREE_FILES.test(basename)) return {}
|
|
285
302
|
return {
|
|
286
303
|
Program(node) {
|
|
287
304
|
context.report({ node, messageId: "stray", data: { rest, basename } })
|
|
@@ -290,9 +307,6 @@ export const routeTreeHoldsRoutesOnly = {
|
|
|
290
307
|
},
|
|
291
308
|
}
|
|
292
309
|
|
|
293
|
-
/** The rules this law contributes to the plugin. */
|
|
294
|
-
const TIER_PATH = /\/(?:src\/components|src\/features|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/|features\/)?)\/(leaves|composites|branches|blocks|layouts|overlays|pages)\//
|
|
295
|
-
|
|
296
310
|
function propertyName(node) {
|
|
297
311
|
if (node.key?.type === "Identifier") return node.key.name
|
|
298
312
|
if (node.key?.type === "Literal") return node.key.value
|
|
@@ -311,8 +325,8 @@ export const sourceTierMarkerMatchesFolder = {
|
|
|
311
325
|
},
|
|
312
326
|
},
|
|
313
327
|
create(context) {
|
|
314
|
-
const
|
|
315
|
-
if (
|
|
328
|
+
const tier = kindOfFile(context)
|
|
329
|
+
if (tier === null) return {}
|
|
316
330
|
const expected = {
|
|
317
331
|
blocks: "block",
|
|
318
332
|
branches: "branch",
|
|
@@ -321,7 +335,7 @@ export const sourceTierMarkerMatchesFolder = {
|
|
|
321
335
|
leaves: "leaf",
|
|
322
336
|
overlays: "overlay",
|
|
323
337
|
pages: "page",
|
|
324
|
-
}[
|
|
338
|
+
}[tier]
|
|
325
339
|
return {
|
|
326
340
|
ExportNamedDeclaration(node) {
|
|
327
341
|
const declaration = node.declaration
|
|
@@ -335,7 +349,7 @@ export const sourceTierMarkerMatchesFolder = {
|
|
|
335
349
|
property.type === "Property" && !property.computed && propertyName(property) === "shape")
|
|
336
350
|
const shape = shapeProperty?.value?.type === "Literal" ? shapeProperty.value.value : null
|
|
337
351
|
if (typeof shape === "string" && shape !== expected) {
|
|
338
|
-
context.report({ node: shapeProperty.value, messageId: "mismatch", data: { tier
|
|
352
|
+
context.report({ node: shapeProperty.value, messageId: "mismatch", data: { tier, shape } })
|
|
339
353
|
}
|
|
340
354
|
}
|
|
341
355
|
},
|
|
@@ -343,8 +357,6 @@ export const sourceTierMarkerMatchesFolder = {
|
|
|
343
357
|
},
|
|
344
358
|
}
|
|
345
359
|
|
|
346
|
-
const SHELL_TIER = /\/(?:src\/components|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/)?)\/shells\//
|
|
347
|
-
|
|
348
360
|
/** `shells/` no longer exists; vendor mechanics are closed named branches. */
|
|
349
361
|
export const noShellTier = {
|
|
350
362
|
meta: {
|
|
@@ -357,16 +369,13 @@ export const noShellTier = {
|
|
|
357
369
|
},
|
|
358
370
|
},
|
|
359
371
|
create(context) {
|
|
360
|
-
if (
|
|
372
|
+
if (unknownLayerFolder(context) !== "shells") return {}
|
|
361
373
|
return { Program(node) { context.report({ node, messageId: "shell" }) } }
|
|
362
374
|
},
|
|
363
375
|
}
|
|
364
376
|
|
|
365
377
|
// -- FILE-9 --------------------------------------------------------------------------------------
|
|
366
378
|
|
|
367
|
-
const UNIT_TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
368
|
-
const SEPARATE_FRONTEND_TEST_TREE = /\/(?:src\/tests|tests?\/unit|e2e)(?:\/|$)/
|
|
369
|
-
|
|
370
379
|
/** Frontend units are `.spec.` twins beside the source they exercise. */
|
|
371
380
|
export const unitTestColocated = {
|
|
372
381
|
meta: {
|
|
@@ -379,13 +388,13 @@ export const unitTestColocated = {
|
|
|
379
388
|
},
|
|
380
389
|
},
|
|
381
390
|
create(context) {
|
|
382
|
-
const file =
|
|
383
|
-
|
|
384
|
-
|
|
391
|
+
const file = fileOf(context)
|
|
392
|
+
if (!isSpecFile(file)) return {}
|
|
393
|
+
const name = file.slice(Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")) + 1)
|
|
385
394
|
return {
|
|
386
395
|
Program(node) {
|
|
387
|
-
if (
|
|
388
|
-
if (
|
|
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) } })
|
|
389
398
|
},
|
|
390
399
|
}
|
|
391
400
|
},
|
|
@@ -416,10 +425,6 @@ const ROUTE_SLOT_NAME = {
|
|
|
416
425
|
"not-found": "NotFound",
|
|
417
426
|
}
|
|
418
427
|
|
|
419
|
-
const ROUTE_SLOT_FILE = new RegExp(
|
|
420
|
-
`^(?:${Object.keys(ROUTE_SLOT_NAME).join("|")})(?:\\.[a-z-]+)?\\.tsx$`,
|
|
421
|
-
)
|
|
422
|
-
|
|
423
428
|
export const routeSlotFixedName = {
|
|
424
429
|
meta: {
|
|
425
430
|
type: "problem",
|
|
@@ -435,14 +440,11 @@ export const routeSlotFixedName = {
|
|
|
435
440
|
},
|
|
436
441
|
},
|
|
437
442
|
create(context) {
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
if (!
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
const slot = basename.match(ROUTE_SLOT_FILE)
|
|
444
|
-
if (!slot) return {}
|
|
445
|
-
const expected = ROUTE_SLOT_NAME[slot[0].split(".")[0]]
|
|
443
|
+
if (!inSlot(context, "fe.route")) return {}
|
|
444
|
+
const expected = ROUTE_SLOT_NAME[roleOfFile(context)]
|
|
445
|
+
if (!expected) return {}
|
|
446
|
+
const file = hfsOf(context).relative(fileOf(context))
|
|
447
|
+
const basename = file.slice(file.lastIndexOf("/") + 1)
|
|
446
448
|
let seen = false
|
|
447
449
|
const report = (node, actual) =>
|
|
448
450
|
context.report({
|
package/formatting.mjs
CHANGED
|
@@ -14,8 +14,7 @@
|
|
|
14
14
|
* `modules/i18n/**` is exempt: it is where the formatters are configured.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { isSpecFile } from "./lib/scope.mjs"
|
|
18
|
-
import { normalizePath } from "./lib/path.mjs"
|
|
17
|
+
import { fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
|
|
19
18
|
|
|
20
19
|
/** The locale-sensitive `Date` and `Number` methods. */
|
|
21
20
|
const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"])
|
|
@@ -58,8 +57,7 @@ export const useIntlFormatter = {
|
|
|
58
57
|
},
|
|
59
58
|
},
|
|
60
59
|
create(context) {
|
|
61
|
-
|
|
62
|
-
if (isSpecFile(file) || /\/modules\/i18n\//.test(file)) return {}
|
|
60
|
+
if (isSpecFile(fileOf(context)) || inSlot(context, "fe.modules.i18n")) return {}
|
|
63
61
|
return {
|
|
64
62
|
ImportDeclaration(node) {
|
|
65
63
|
const source = String(node.source.value)
|
package/grammar-boundary.mjs
CHANGED
|
@@ -1,16 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The boundary between product code and `@starci/grammar`.
|
|
3
3
|
*
|
|
4
|
-
* Grammar is a business-neutral HeroUI-backed component package. Product code
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Grammar is a business-neutral HeroUI-backed component package. Product code composes its components with ordinary React
|
|
5
|
+
* children and typed props. A page's structure and text are made of those components, not of raw HTML: a raw `<section>`, `<p>`
|
|
6
|
+
* or `<ul>` carries no density, no focus or landmark contract and no dark value from the design system, and the class names
|
|
7
|
+
* written by hand to compensate are where a second visual language starts.
|
|
8
|
+
*
|
|
9
|
+
* Which tags are refused is decided by the package: a tag is in the list only when `@starci/grammar` exports a component that
|
|
10
|
+
* renders it (`GRAMMAR_OWNERS` below, each entry verified against the package source by the twin test). A tag the grammar has no
|
|
11
|
+
* component for (`div`, `br`, `aside`, `article`) is not refused: there is nothing to compose instead, and refusing it would
|
|
12
|
+
* demand a workaround. When the grammar gains a component for one, the tag joins the list.
|
|
13
|
+
*
|
|
14
|
+
* WHERE IT APPLIES. App source that composes the grammar (slots `fe.feature`, `fe.components`, `fe.route`, `fe.hooks`) and any
|
|
15
|
+
* workspace package except the `<family>-ui` package (`fe.package.ui`), whose components are the renderers and are the one place
|
|
16
|
+
* a raw tag belongs. Spec files render raw HTML as a harness and are not judged. A file no slot owns is not judged either: the
|
|
17
|
+
* rule refuses to guess a scope.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
21
|
+
import { isSpecFile } from "./lib/scope.mjs"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The intrinsic elements of page structure and text, each with the grammar components that render it.
|
|
25
|
+
* Read against packages/grammar/src (`export const <Component>`); `grammar-boundary.test.mjs` fails when one is not exported.
|
|
26
|
+
*/
|
|
27
|
+
export const GRAMMAR_OWNERS = Object.freeze({
|
|
28
|
+
h1: "Heading", h2: "Heading", h3: "Heading", h4: "Heading", h5: "Heading", h6: "Heading",
|
|
29
|
+
p: "Text", span: "Text",
|
|
30
|
+
hr: "Divider",
|
|
31
|
+
label: "Label",
|
|
32
|
+
form: "Form",
|
|
33
|
+
fieldset: "Fieldset",
|
|
34
|
+
dl: "DescriptionList", dt: "DescriptionList", dd: "DescriptionList",
|
|
35
|
+
table: "DataTable", thead: "DataTable", tbody: "DataTable", tr: "DataTable", td: "DataTable", th: "DataTable",
|
|
36
|
+
ul: "SurfaceListCard or StaticStateRow", ol: "SurfaceListCard or StaticStateRow", li: "SurfaceListCard or StaticStateRow",
|
|
37
|
+
header: "SectionHeader or TopBar",
|
|
38
|
+
footer: "Footer",
|
|
39
|
+
nav: "Subnav, Breadcrumbs or NavigationFeatureNav",
|
|
40
|
+
section: "SurfaceCard",
|
|
41
|
+
main: "WorkspaceShell",
|
|
42
|
+
figure: "MediaFrame", figcaption: "MediaFrame",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** The slots of app source that compose the grammar: features, components, routes and hooks. */
|
|
46
|
+
const APP_SLOTS = new Set(["fe.feature", "fe.components", "fe.route", "fe.hooks"])
|
|
47
|
+
|
|
48
|
+
/** The slot of the package whose leaves ARE the renderers: the only place raw structural tags belong. */
|
|
49
|
+
const RENDERER_SLOT = "fe.package.ui"
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True for a file that composes the grammar: app source (`fe.feature`, `fe.components`, `fe.route`, `fe.hooks`) and any
|
|
53
|
+
* workspace package but the `<family>-ui` package, whose components are the renderers.
|
|
7
54
|
*/
|
|
55
|
+
const composesGrammar = (context, file) => {
|
|
56
|
+
const hfs = hfsOf(context)
|
|
57
|
+
const slot = hfs.slotOf(file)
|
|
58
|
+
if (slot === null || slot === RENDERER_SLOT) return false
|
|
59
|
+
return APP_SLOTS.has(slot) || hfs.tierOf(file) === "package"
|
|
60
|
+
}
|
|
8
61
|
|
|
9
|
-
/** No
|
|
10
|
-
export const
|
|
62
|
+
/** No raw structural or text element in app source or in a package that composes the grammar. */
|
|
63
|
+
export const noRawStructuralElement = {
|
|
64
|
+
meta: {
|
|
65
|
+
type: "problem",
|
|
66
|
+
docs: { description: "Page structure and text are grammar components, not raw `div`-family HTML; only `fe.package.ui` renderers draw raw tags." },
|
|
67
|
+
schema: [],
|
|
68
|
+
messages: {
|
|
69
|
+
raw:
|
|
70
|
+
"A raw `<{{tag}}>`. Page structure and text are composed from the grammar (`@starci/grammar`, or the repository's `<family>-ui` package): use {{owner}}. A raw tag brings none of the design system's density, landmark and state contract, and the class names written to make up for it start a second visual language. If the grammar cannot express this structure, add the component to the grammar or the ui package rather than drawing it here.",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
create(context) {
|
|
74
|
+
const file = context.filename || context.getFilename()
|
|
75
|
+
if (isSpecFile(file) || !composesGrammar(context, file)) return {}
|
|
76
|
+
return {
|
|
77
|
+
JSXOpeningElement(node) {
|
|
78
|
+
if (node.name.type !== "JSXIdentifier") return
|
|
79
|
+
const owner = Object.hasOwn(GRAMMAR_OWNERS, node.name.name) ? GRAMMAR_OWNERS[node.name.name] : null
|
|
80
|
+
if (owner !== null) context.report({ node, messageId: "raw", data: { tag: node.name.name, owner: `\`${owner}\`` } })
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
11
85
|
|
|
12
|
-
/**
|
|
13
|
-
export const
|
|
86
|
+
/** The rules this law contributes to the plugin. */
|
|
87
|
+
export const rules = {
|
|
88
|
+
"no-raw-structural-element": noRawStructuralElement,
|
|
89
|
+
}
|
|
14
90
|
|
|
15
|
-
/**
|
|
16
|
-
export const recommended = {}
|
|
91
|
+
/** Every rule is an error. */
|
|
92
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/hooks-folder.mjs
CHANGED
|
@@ -15,14 +15,8 @@
|
|
|
15
15
|
* architecture machine that sees the whole tree.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
/** The path below the first `hooks/` folder, as segments, or null when the file is not in one. */
|
|
22
|
-
const belowHooks = (file) => {
|
|
23
|
-
const match = /\/src\/hooks\/(.+)$/.exec(file) || /\/hooks\/(.+)$/.exec(file)
|
|
24
|
-
return match ? match[1].split("/") : null
|
|
25
|
-
}
|
|
18
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
19
|
+
import { baseName, classOf, fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
|
|
26
20
|
|
|
27
21
|
/** Names that mark a hook. */
|
|
28
22
|
const isHookName = (name) => /^use[A-Z0-9]/.test(name)
|
|
@@ -67,20 +61,27 @@ export const hooksFolderHoldsHooksOnly = {
|
|
|
67
61
|
},
|
|
68
62
|
},
|
|
69
63
|
create(context) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
// Slot `fe.hooks` owns `apps/<app>/src/hooks/<domain>/`; a file no slot puts there is not judged here.
|
|
65
|
+
if (!inSlot(context, "fe.hooks")) return {}
|
|
66
|
+
const file = fileOf(context)
|
|
67
|
+
const found = classOf(context)
|
|
68
|
+
// A file directly in `hooks/` is its own root: the slot binds the file name as the `domain`.
|
|
69
|
+
const directlyInHooks = hfsOf(context).relative(file) === found.root
|
|
73
70
|
const name = baseName(file)
|
|
74
71
|
const spec = isSpecFile(file)
|
|
75
72
|
const subject = spec ? name.replace(/\.(?:test|spec)\.(?:ts|tsx)$/, ".ts") : name
|
|
76
|
-
const domain =
|
|
73
|
+
const domain = found.bindings.domain
|
|
77
74
|
const isHookFile = /^use[A-Z0-9]\w*\.ts$/.test(subject)
|
|
78
|
-
|
|
75
|
+
// The slot names the domain's entry (`index.ts`, role `entry`) and its shared helper file (`<domain>.shared.ts`, role `shared`).
|
|
76
|
+
// Both sit directly in the domain folder: a nested `x/index.ts` is a file the slot does not name.
|
|
77
|
+
const directlyInDomain = hfsOf(context).relative(file) === `${found.root}/${name}`
|
|
78
|
+
const isEntryFile = directlyInDomain && (found.role === "entry" || (spec && subject === "index.ts"))
|
|
79
|
+
const isSharedFile = directlyInDomain && (found.role === "shared" || (spec && subject === `${domain}.shared.ts`))
|
|
79
80
|
|
|
80
81
|
return {
|
|
81
82
|
Program(program) {
|
|
82
|
-
if (
|
|
83
|
-
if (!isHookFile && !isSharedFile) context.report({ node: program, messageId: "notHook", data: { name } })
|
|
83
|
+
if (directlyInHooks) return context.report({ node: program, messageId: "domain" })
|
|
84
|
+
if (!isHookFile && !isSharedFile && !isEntryFile) context.report({ node: program, messageId: "notHook", data: { name } })
|
|
84
85
|
},
|
|
85
86
|
ImportDeclaration(node) {
|
|
86
87
|
if (SERVER_ONLY.test(String(node.source.value)) && !spec) {
|
|
@@ -88,7 +89,7 @@ export const hooksFolderHoldsHooksOnly = {
|
|
|
88
89
|
}
|
|
89
90
|
},
|
|
90
91
|
"Program:exit"(program) {
|
|
91
|
-
if (spec || (!isHookFile && !isSharedFile)) return
|
|
92
|
+
if (spec || directlyInHooks || (!isHookFile && !isSharedFile)) return
|
|
92
93
|
const exported = program.body
|
|
93
94
|
.filter((node) => node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration")
|
|
94
95
|
.flatMap((node) => exportedNames(node).map((exportedName) => ({ node, name: exportedName })))
|