@starci/eslint-canon-fe 5.1.2 → 6.0.1

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +17 -18
  3. package/brand-values.mjs +1 -3
  4. package/class-names.mjs +9 -9
  5. package/client-boundary.mjs +125 -37
  6. package/comments.mjs +14 -21
  7. package/docs/client-boundary.md +26 -1
  8. package/docs/grammar-boundary.md +33 -0
  9. package/docs/hygiene.md +15 -4
  10. package/docs/lint-escape-hatch.md +3 -1
  11. package/docs/next-conventions.md +26 -0
  12. package/docs/size-growth.md +29 -0
  13. package/docs/spec-quality.md +15 -7
  14. package/docs/status-colors.md +32 -0
  15. package/docs/translation.md +4 -0
  16. package/docs/transport.md +36 -9
  17. package/e2e-shape.mjs +20 -23
  18. package/env-owner.mjs +1 -1
  19. package/file-layout.mjs +105 -103
  20. package/formatting.mjs +2 -4
  21. package/grammar-boundary.mjs +85 -9
  22. package/hooks-folder.mjs +17 -16
  23. package/hygiene.mjs +370 -92
  24. package/icon.mjs +21 -20
  25. package/index.mjs +27 -85
  26. package/landmark.mjs +0 -11
  27. package/lib/bindings.mjs +144 -0
  28. package/lib/config.mjs +79 -0
  29. package/lib/hfs.mjs +48 -0
  30. package/lib/next.mjs +6 -0
  31. package/lib/platform.mjs +66 -0
  32. package/lib/react.mjs +28 -0
  33. package/lib/scope.mjs +119 -19
  34. package/lib/status-tones.generated.mjs +9 -0
  35. package/lib/types.mjs +63 -0
  36. package/lib/why.mjs +41 -11
  37. package/lint-escape-hatch.mjs +17 -15
  38. package/loading.mjs +9 -16
  39. package/naming.mjs +8 -2
  40. package/next-conventions.mjs +109 -25
  41. package/package.json +9 -4
  42. package/props-and-slots.mjs +19 -13
  43. package/runtime/engine/plain-object.mjs +5 -0
  44. package/runtime/engine/runtime-root.mjs +32 -0
  45. package/runtime/engine/yaml.mjs +161 -0
  46. package/runtime/knowledge/hfs/slots.yaml +1072 -0
  47. package/runtime/scripts/lib/git.mjs +53 -0
  48. package/runtime/scripts/lib/glob.mjs +23 -0
  49. package/runtime/scripts/lib/hfs-allows.mjs +42 -0
  50. package/runtime/scripts/lib/hfs-slots.mjs +722 -0
  51. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  52. package/runtime/scripts/lib/next-contract.mjs +14 -0
  53. package/runtime/scripts/lib/path-key.mjs +17 -0
  54. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  55. package/served-locale.mjs +30 -16
  56. package/shape-slot.mjs +22 -20
  57. package/size-and-state-budget.mjs +104 -36
  58. package/size-growth.mjs +58 -0
  59. package/spec-quality.mjs +95 -16
  60. package/status-colors.mjs +139 -0
  61. package/the-split.mjs +13 -13
  62. package/tokens.mjs +26 -34
  63. package/translation.mjs +52 -19
  64. package/transport.mjs +437 -89
  65. package/type-safety.mjs +8 -17
  66. package/typography.mjs +7 -11
  67. package/vendor-boundary.mjs +5 -27
  68. 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 reads the PATH rather than the contents, which is what makes them cheap and
10
- * exact. The cost of that choice is stated where it bites: a path rule cannot tell a component from
11
- * a helper, only a folder from a folder, so each rule below names the destination it sends things
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 { normalizePath } from "./lib/path.mjs"
15
+ import { hfsOf } from "./lib/hfs.mjs"
16
+ import { classOf, fileOf, inSlot, isComponentFile, isSpecFile, kindOfFile, roleOfFile, tierOfFile } from "./lib/scope.mjs"
16
17
 
17
- /** Tiers whose folder holds exactly the two halves of one surface. */
18
- // `features/` is the current root for route-facing surfaces (knowledge FE-FOLDER-1); `components/` is the
19
- // earlier Academy layout, still accepted so existing trees stay valid.
20
- const TWO_FILE_TIERS = /\/src\/(?:components|features)\/(pages|layouts)\/([^/]+)\/(.+)$/
21
-
22
- /** Overlays carry a feature category between the tier and the surface. */
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 two halves, and the twin test of each, are the whole of what a surface folder may hold. */
26
- const ALLOWED_IN_SURFACE_FOLDER = /^(?:component|index|classNames)(?:\.spec)?\.tsx?$/
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 that are not component code, whatever they are nested inside. */
29
- const NON_COMPONENT_FOLDERS = /\/src\/components\/.*\/(constants|utils|types|hooks)\//
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
- /** Whether the path sits in ONE surface folder, and what that folder is named. */
32
- const surfaceFolder = (filename) => {
33
- const file = normalizePath(filename)
34
- const hit = file.match(TWO_FILE_TIERS) || file.match(TWO_FILE_TIERS_WITH_CATEGORY)
35
- return hit ? { tier: hit[1], name: hit[2], rest: hit[3] } : null
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.filename || context.getFilename())
53
- if (!folder || ALLOWED_IN_SURFACE_FOLDER.test(folder.rest)) return {}
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 `src/components/**` - 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.",
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
- const hit = normalizePath(context.filename || context.getFilename()).match(NON_COMPONENT_FOLDERS)
77
- if (!hit) return {}
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: hit[1] } })
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
- const hit = normalizePath(context.filename || context.getFilename()).match(/\/([A-Z][A-Za-z0-9]*)\/index\.tsx?$/)
101
- if (!hit) return {}
102
- const folder = hit[1]
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
- /** Tiers that know a feature, and therefore belong to the app that owns it. */
165
- const FEATURE_TIERS = /\/packages\/[^/]+\/src\/(blocks|overlays|pages|layouts)\//
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
- /** Tiers that know no feature, and therefore belong to the shared package. */
168
- const VOCABULARY_TIERS = /\/apps\/[^/]+\/src\/(?:components\/)?(leaves|composites|branches)\//
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 A PATH RULE IS ENOUGH HERE. The feature line is already drawn by the tier name: a leaf knows
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 folder it is already in. Nothing needs to read the file.
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 file = normalizePath(context.filename || context.getFilename())
199
- const inPackage = file.match(FEATURE_TIERS)
200
- const inApp = file.match(VOCABULARY_TIERS)
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[1] } })
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[1] } })
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 FILENAME AND NOT THE CONTENTS. "Drawing" is not a property this can measure: a
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>/` (or the earlier `components/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
- const file = normalizePath(context.filename || context.getFilename())
278
- const hit = file.match(ROUTE_TREE)
279
- if (!hit) return {}
280
- const rest = hit[1]
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 hit = normalizePath(context.filename || context.getFilename()).match(TIER_PATH)
315
- if (!hit) return {}
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
- }[hit[1]]
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: hit[1], shape } })
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 (!SHELL_TIER.test(normalizePath(context.filename || context.getFilename()))) return {}
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 = normalizePath(context.filename || context.getFilename())
383
- const match = file.match(UNIT_TEST_FILE)
384
- if (!match) return {}
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 (match[1] === "test") context.report({ node, messageId: "suffix", data: { name: file.slice(file.lastIndexOf("/") + 1) } })
388
- if (SEPARATE_FRONTEND_TEST_TREE.test(file)) context.report({ node, messageId: "bucket", data: { path: file } })
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
- const file = normalizePath(context.filename || context.getFilename())
439
- const hit = file.match(ROUTE_TREE)
440
- if (!hit) return {}
441
- const basename = hit[1].slice(hit[1].lastIndexOf("/") + 1)
442
- if (ROUTE_TREE_TEST.test(basename)) return {}
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
- const file = normalizePath(context.filename || context.getFilename())
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)
@@ -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 may compose its
5
- * components with ordinary React children and typed props; this contribution deliberately does
6
- * not impose a second rendering protocol or a product-specific adapter lane.
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 product-specific AST restrictions are needed for the neutral Grammar boundary. */
10
- export const rules = {}
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
- /** No repository audit is coupled to the neutral Grammar boundary. */
13
- export const audits = {}
86
+ /** The rules this law contributes to the plugin. */
87
+ export const rules = {
88
+ "no-raw-structural-element": noRawStructuralElement,
89
+ }
14
90
 
15
- /** The neutral boundary contributes no mandatory rule levels. */
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 { baseName, isSpecFile } from "./lib/scope.mjs"
19
- import { normalizePath } from "./lib/path.mjs"
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
- const file = normalizePath(context.filename || context.getFilename())
71
- const segments = belowHooks(file)
72
- if (!segments) return {}
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 = segments.length >= 2 ? segments[segments.length - 2] : null
73
+ const domain = found.bindings.domain
77
74
  const isHookFile = /^use[A-Z0-9]\w*\.ts$/.test(subject)
78
- const isSharedFile = domain !== null && subject === `${domain}.shared.ts`
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 (segments.length < 2) return context.report({ node: program, messageId: "domain" })
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 })))