@starci/eslint-canon-fe 4.0.0 → 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.
Files changed (81) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +33 -16
  3. package/brand-values.mjs +172 -0
  4. package/class-names.mjs +9 -9
  5. package/client-boundary.mjs +287 -0
  6. package/comments.mjs +137 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +124 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/grammar-boundary.md +33 -0
  13. package/docs/hooks-folder.md +29 -0
  14. package/docs/hygiene.md +122 -0
  15. package/docs/lint-escape-hatch.md +34 -0
  16. package/docs/lists.md +74 -0
  17. package/docs/native-controls.md +73 -0
  18. package/docs/next-conventions.md +232 -0
  19. package/docs/size-and-state-budget.md +73 -0
  20. package/docs/size-growth.md +29 -0
  21. package/docs/spec-quality.md +147 -0
  22. package/docs/status-colors.md +32 -0
  23. package/docs/translation.md +54 -0
  24. package/docs/transport.md +195 -0
  25. package/docs/type-safety.md +95 -0
  26. package/e2e-shape.mjs +345 -0
  27. package/env-owner.mjs +134 -0
  28. package/file-layout.mjs +105 -104
  29. package/formatting.mjs +104 -0
  30. package/grammar-boundary.mjs +85 -9
  31. package/hooks-folder.mjs +120 -0
  32. package/hygiene.mjs +513 -0
  33. package/icon.mjs +212 -212
  34. package/index.mjs +243 -142
  35. package/landmark.mjs +0 -11
  36. package/lib/ast.mjs +94 -0
  37. package/lib/bindings.mjs +144 -0
  38. package/lib/config.mjs +79 -0
  39. package/lib/hfs.mjs +48 -0
  40. package/lib/next.mjs +6 -0
  41. package/lib/path.mjs +2 -0
  42. package/lib/platform.mjs +66 -0
  43. package/lib/react.mjs +28 -0
  44. package/lib/scope.mjs +142 -0
  45. package/lib/status-tones.generated.mjs +9 -0
  46. package/lib/types.mjs +63 -0
  47. package/lib/why.mjs +334 -0
  48. package/lint-escape-hatch.mjs +93 -56
  49. package/lists.mjs +197 -0
  50. package/loading.mjs +156 -164
  51. package/naming.mjs +149 -143
  52. package/native-controls.mjs +112 -0
  53. package/next-conventions.mjs +430 -0
  54. package/package.json +15 -6
  55. package/props-and-slots.mjs +19 -13
  56. package/runtime/engine/plain-object.mjs +5 -0
  57. package/runtime/engine/runtime-root.mjs +32 -0
  58. package/runtime/engine/yaml.mjs +161 -0
  59. package/runtime/knowledge/hfs/slots.yaml +1072 -0
  60. package/runtime/scripts/lib/git.mjs +53 -0
  61. package/runtime/scripts/lib/glob.mjs +23 -0
  62. package/runtime/scripts/lib/hfs-allows.mjs +42 -0
  63. package/runtime/scripts/lib/hfs-slots.mjs +722 -0
  64. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  65. package/runtime/scripts/lib/next-contract.mjs +14 -0
  66. package/runtime/scripts/lib/path-key.mjs +17 -0
  67. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  68. package/served-locale.mjs +174 -160
  69. package/shape-slot.mjs +22 -21
  70. package/size-and-state-budget.mjs +214 -0
  71. package/size-growth.mjs +58 -0
  72. package/spec-quality.mjs +318 -0
  73. package/status-colors.mjs +139 -0
  74. package/the-split.mjs +133 -134
  75. package/tokens.mjs +259 -268
  76. package/translation.mjs +295 -122
  77. package/transport.mjs +752 -0
  78. package/type-safety.mjs +147 -74
  79. package/typography.mjs +78 -83
  80. package/vendor-boundary.mjs +5 -27
  81. package/lint-adoption.mjs +0 -116
package/file-layout.mjs CHANGED
@@ -6,36 +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
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
16
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
15
+ import { hfsOf } from "./lib/hfs.mjs"
16
+ import { classOf, fileOf, inSlot, isComponentFile, isSpecFile, kindOfFile, roleOfFile, tierOfFile } from "./lib/scope.mjs"
17
17
 
18
- /** Tiers whose folder holds exactly the two halves of one surface. */
19
- // `features/` is the current root for route-facing surfaces (knowledge FE-FOLDER-1); `components/` is the
20
- // earlier Academy layout, still accepted so existing trees stay valid.
21
- const TWO_FILE_TIERS = /\/src\/(?:components|features)\/(pages|layouts)\/([^/]+)\/(.+)$/
22
-
23
- /** Overlays carry a feature category between the tier and the surface. */
24
- 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
+ }
25
24
 
26
- /** The two halves, and the twin test of each, are the whole of what a surface folder may hold. */
27
- 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
+ }
28
41
 
29
- /** Folders that are not component code, whatever they are nested inside. */
30
- 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"])
31
44
 
32
- /** Whether the path sits in ONE surface folder, and what that folder is named. */
33
- const surfaceFolder = (filename) => {
34
- const file = normalizePath(filename)
35
- const hit = file.match(TWO_FILE_TIERS) || file.match(TWO_FILE_TIERS_WITH_CATEGORY)
36
- 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("/") }
37
56
  }
38
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
+
39
62
  // -- FILE-2 --------------------------------------------------------------------------------------
40
63
 
41
64
  /** A page, layout or overlay folder holds its two halves and its colocated class-name module. */
@@ -50,8 +73,8 @@ export const surfaceFolderTwoFilesOnly = {
50
73
  },
51
74
  },
52
75
  create(context) {
53
- const folder = surfaceFolder(context.filename || context.getFilename())
54
- if (!folder || ALLOWED_IN_SURFACE_FOLDER.test(folder.rest)) return {}
76
+ const folder = surfaceFolder(context)
77
+ if (!folder || belongsInSurfaceFolder(context, folder)) return {}
55
78
  return {
56
79
  Program(node) {
57
80
  context.report({ node, messageId: "extra", data: folder })
@@ -70,15 +93,19 @@ export const noHelperFolderInComponents = {
70
93
  schema: [],
71
94
  messages: {
72
95
  helper:
73
- "`{{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.",
74
97
  },
75
98
  },
76
99
  create(context) {
77
- const hit = normalizePath(context.filename || context.getFilename()).match(NON_COMPONENT_FOLDERS)
78
- 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 {}
79
106
  return {
80
107
  Program(node) {
81
- context.report({ node, messageId: "helper", data: { kind: hit[1] } })
108
+ context.report({ node, messageId: "helper", data: { kind: folder } })
82
109
  },
83
110
  }
84
111
  },
@@ -98,9 +125,9 @@ export const exportMatchesFolder = {
98
125
  },
99
126
  },
100
127
  create(context) {
101
- const hit = normalizePath(context.filename || context.getFilename()).match(/\/([A-Z][A-Za-z0-9]*)\/index\.tsx?$/)
102
- if (!hit) return {}
103
- 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 {}
104
131
  const names = new Set()
105
132
  const belongsToFamily = (name) =>
106
133
  name === folder || (name.startsWith(folder) && name.length > folder.length && /^[A-Z]/.test(name.slice(folder.length)))
@@ -162,18 +189,28 @@ export const noRuntimeNamespace = {
162
189
 
163
190
  // -- FILE-5 --------------------------------------------------------------------------------------
164
191
 
165
- /** Tiers that know a feature, and therefore belong to the app that owns it. */
166
- 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
+ }
167
200
 
168
- /** Tiers that know no feature, and therefore belong to the shared package. */
169
- 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
+ }
170
206
 
171
207
  /**
172
208
  * In a monorepo, the shared package stops below the block.
173
209
  *
174
- * 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
175
211
  * no domain and a block is a domain sentence, so which side of the workspace each belongs on
176
- * 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.
177
214
  *
178
215
  * WHAT IT COST TO LEARN. A shared package carried one `blocks/FleetRow/` while its own header
179
216
  * insisted that "a block carries feature meaning and therefore belongs to the app that owns the
@@ -196,16 +233,18 @@ export const monorepoTierBelongsToItsSide = {
196
233
  },
197
234
  },
198
235
  create(context) {
199
- const file = normalizePath(context.filename || context.getFilename())
200
- const inPackage = file.match(FEATURE_TIERS)
201
- 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
202
241
  return {
203
242
  Program(node) {
204
243
  if (inPackage) {
205
- context.report({ node, messageId: "featureInPackage", data: { tier: inPackage[1] } })
244
+ context.report({ node, messageId: "featureInPackage", data: { tier: inPackage } })
206
245
  return
207
246
  }
208
- if (inApp) context.report({ node, messageId: "vocabularyInApp", data: { tier: inApp[1] } })
247
+ if (inApp) context.report({ node, messageId: "vocabularyInApp", data: { tier: inApp } })
209
248
  },
210
249
  }
211
250
  },
@@ -213,25 +252,6 @@ export const monorepoTierBelongsToItsSide = {
213
252
 
214
253
  // -- FILE-6 --------------------------------------------------------------------------------------
215
254
 
216
- /** Anything under a routing tree, in either workspace shape. */
217
- const ROUTE_TREE = /\/src\/app\/(.+)$/
218
-
219
- /**
220
- * What a routing tree is allowed to contain.
221
- *
222
- * Next decides these names, not this law: `page`, `layout`, `template`, `loading`, `error`,
223
- * `not-found`, `global-error`, `default`, `route`, `sitemap`, `robots`, `manifest`, `opengraph-image`
224
- * and `icon` are framework slots. Two more are admitted because the root layout mounts them and
225
- * there is nowhere else they could go: `providers`, the client boundary above every route, and
226
- * `globals`, the stylesheet the document imports. Everything else in that folder is a file somebody
227
- * put there.
228
- */
229
- const ROUTE_TREE_FILES =
230
- /^(?: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)$/
231
-
232
- /** `app/api/**` is server code, not a screen; `_private/**` is Next's own opt-out folder. */
233
- const ROUTE_TREE_EXEMPT = /^(?:api\/|_)/
234
-
235
255
  /**
236
256
  * A test sitting beside the thing it tests.
237
257
  *
@@ -242,10 +262,9 @@ const ROUTE_TREE_EXEMPT = /^(?:api\/|_)/
242
262
  *
243
263
  * The name is deliberately not required to match `page` or `layout`. A route's tests split by
244
264
  * CONCERN - what the screen renders, who may reach it, where the boundary sits - and forcing them
245
- * 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.
246
267
  */
247
- const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
248
-
249
268
  /**
250
269
  * A file under `app/` names which page renders at which URL, and does nothing else.
251
270
  *
@@ -255,9 +274,9 @@ const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
255
274
  * typecheck, four sealed screenshots and an approval, and arrived at the edge of a production write
256
275
  * with every gate green - because every gate was reading rules, and this one was only prose.
257
276
  *
258
- * 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
259
278
  * route that mounts one component and a route that arranges six both return JSX. What CAN be
260
- * 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
261
280
  * owner is a component, a component has a name, and a named component belongs in the tier that
262
281
  * groups it with its siblings - `components/pages/<Name>/`, where the next author looks for it.
263
282
  *
@@ -267,7 +286,7 @@ const ROUTE_TREE_TEST = /\.spec\.(?:tsx?|jsx?)$/
267
286
  export const routeTreeHoldsRoutesOnly = {
268
287
  meta: {
269
288
  type: "problem",
270
- 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>/`." },
271
290
  schema: [],
272
291
  messages: {
273
292
  stray:
@@ -275,14 +294,11 @@ export const routeTreeHoldsRoutesOnly = {
275
294
  },
276
295
  },
277
296
  create(context) {
278
- const file = normalizePath(context.filename || context.getFilename())
279
- const hit = file.match(ROUTE_TREE)
280
- if (!hit) return {}
281
- const rest = hit[1]
282
- 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
283
301
  const basename = rest.slice(rest.lastIndexOf("/") + 1)
284
- if (ROUTE_TREE_TEST.test(basename)) return {}
285
- if (ROUTE_TREE_FILES.test(basename)) return {}
286
302
  return {
287
303
  Program(node) {
288
304
  context.report({ node, messageId: "stray", data: { rest, basename } })
@@ -291,9 +307,6 @@ export const routeTreeHoldsRoutesOnly = {
291
307
  },
292
308
  }
293
309
 
294
- /** The rules this law contributes to the plugin. */
295
- const TIER_PATH = /\/(?:src\/components|src\/features|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/|features\/)?)\/(leaves|composites|branches|blocks|layouts|overlays|pages)\//
296
-
297
310
  function propertyName(node) {
298
311
  if (node.key?.type === "Identifier") return node.key.name
299
312
  if (node.key?.type === "Literal") return node.key.value
@@ -312,8 +325,8 @@ export const sourceTierMarkerMatchesFolder = {
312
325
  },
313
326
  },
314
327
  create(context) {
315
- const hit = normalizePath(context.filename || context.getFilename()).match(TIER_PATH)
316
- if (!hit) return {}
328
+ const tier = kindOfFile(context)
329
+ if (tier === null) return {}
317
330
  const expected = {
318
331
  blocks: "block",
319
332
  branches: "branch",
@@ -322,7 +335,7 @@ export const sourceTierMarkerMatchesFolder = {
322
335
  leaves: "leaf",
323
336
  overlays: "overlay",
324
337
  pages: "page",
325
- }[hit[1]]
338
+ }[tier]
326
339
  return {
327
340
  ExportNamedDeclaration(node) {
328
341
  const declaration = node.declaration
@@ -336,7 +349,7 @@ export const sourceTierMarkerMatchesFolder = {
336
349
  property.type === "Property" && !property.computed && propertyName(property) === "shape")
337
350
  const shape = shapeProperty?.value?.type === "Literal" ? shapeProperty.value.value : null
338
351
  if (typeof shape === "string" && shape !== expected) {
339
- context.report({ node: shapeProperty.value, messageId: "mismatch", data: { tier: hit[1], shape } })
352
+ context.report({ node: shapeProperty.value, messageId: "mismatch", data: { tier, shape } })
340
353
  }
341
354
  }
342
355
  },
@@ -344,8 +357,6 @@ export const sourceTierMarkerMatchesFolder = {
344
357
  },
345
358
  }
346
359
 
347
- const SHELL_TIER = /\/(?:src\/components|packages\/[^/]+\/src|apps\/[^/]+\/src\/(?:components\/)?)\/shells\//
348
-
349
360
  /** `shells/` no longer exists; vendor mechanics are closed named branches. */
350
361
  export const noShellTier = {
351
362
  meta: {
@@ -358,16 +369,13 @@ export const noShellTier = {
358
369
  },
359
370
  },
360
371
  create(context) {
361
- if (!SHELL_TIER.test(normalizePath(context.filename || context.getFilename()))) return {}
372
+ if (unknownLayerFolder(context) !== "shells") return {}
362
373
  return { Program(node) { context.report({ node, messageId: "shell" }) } }
363
374
  },
364
375
  }
365
376
 
366
377
  // -- FILE-9 --------------------------------------------------------------------------------------
367
378
 
368
- const UNIT_TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
369
- const SEPARATE_FRONTEND_TEST_TREE = /\/(?:src\/tests|tests?\/unit|e2e)(?:\/|$)/
370
-
371
379
  /** Frontend units are `.spec.` twins beside the source they exercise. */
372
380
  export const unitTestColocated = {
373
381
  meta: {
@@ -380,13 +388,13 @@ export const unitTestColocated = {
380
388
  },
381
389
  },
382
390
  create(context) {
383
- const file = normalizePath(context.filename || context.getFilename())
384
- const match = file.match(UNIT_TEST_FILE)
385
- 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)
386
394
  return {
387
395
  Program(node) {
388
- if (match[1] === "test") context.report({ node, messageId: "suffix", data: { name: file.slice(file.lastIndexOf("/") + 1) } })
389
- 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) } })
390
398
  },
391
399
  }
392
400
  },
@@ -417,10 +425,6 @@ const ROUTE_SLOT_NAME = {
417
425
  "not-found": "NotFound",
418
426
  }
419
427
 
420
- const ROUTE_SLOT_FILE = new RegExp(
421
- `^(?:${Object.keys(ROUTE_SLOT_NAME).join("|")})(?:\\.[a-z-]+)?\\.tsx$`,
422
- )
423
-
424
428
  export const routeSlotFixedName = {
425
429
  meta: {
426
430
  type: "problem",
@@ -436,14 +440,11 @@ export const routeSlotFixedName = {
436
440
  },
437
441
  },
438
442
  create(context) {
439
- const file = normalizePath(context.filename || context.getFilename())
440
- const hit = file.match(ROUTE_TREE)
441
- if (!hit) return {}
442
- const basename = hit[1].slice(hit[1].lastIndexOf("/") + 1)
443
- if (ROUTE_TREE_TEST.test(basename)) return {}
444
- const slot = basename.match(ROUTE_SLOT_FILE)
445
- if (!slot) return {}
446
- 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)
447
448
  let seen = false
448
449
  const report = (node, actual) =>
449
450
  context.report({
package/formatting.mjs ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The rule that holds `formatting.md` (HFS R59 `FE_I18N_PLACEMENT`, sub-check `FE_I18N_FORMATTER`).
3
+ *
4
+ * THE LOCALE HAS ONE OWNER. `next-intl` knows the reader's locale and time zone and hands them to
5
+ * `useFormatter()` / `getFormatter()`. Every other way of turning a number or a date into text either
6
+ * guesses the locale (`toLocaleString()` with no argument uses the SERVER's on the server and the
7
+ * browser's on the client, which is the hydration mismatch that makes a price flicker), hard-codes
8
+ * one (`"en-US"` in a Vietnamese product), or builds a private formatter that ignores the app's
9
+ * currency and date styles. Money and dates are the two places a wrong format is a wrong fact.
10
+ *
11
+ * WHAT IS REFUSED. `toLocaleString`, `toLocaleDateString`, `toLocaleTimeString`; `new Intl.*Format`;
12
+ * `toFixed` when the result is shown (inside JSX or a template); a currency symbol glued to a
13
+ * substitution in a template; and the date libraries (`moment`, `dayjs`, `date-fns`, `luxon`).
14
+ * `modules/i18n/**` is exempt: it is where the formatters are configured.
15
+ */
16
+
17
+ import { fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
18
+
19
+ /** The locale-sensitive `Date` and `Number` methods. */
20
+ const LOCALE_METHODS = new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"])
21
+
22
+ /** The `Intl` formatters a product must not build itself. */
23
+ const INTL_FORMATTERS = new Set(["NumberFormat", "DateTimeFormat", "RelativeTimeFormat", "ListFormat", "PluralRules"])
24
+
25
+ /** Date libraries that format on their own locale. */
26
+ const DATE_LIBRARIES = /^(?:moment(?:-timezone)?|dayjs|date-fns(?:\/.+)?|luxon)$/
27
+
28
+ /** A currency mark or code beside a substitution. */
29
+ const CURRENCY = /[₫€£¥]|\b(?:VND|USD|EUR)\b/
30
+
31
+ /** True when `node` is displayed: it sits in a JSX expression or a template literal. */
32
+ const isDisplayed = (node) => {
33
+ for (let current = node.parent; current; current = current.parent) {
34
+ if (current.type === "JSXExpressionContainer" || current.type === "TemplateLiteral") return true
35
+ if (current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression" || current.type === "FunctionDeclaration") return false
36
+ }
37
+ return false
38
+ }
39
+
40
+ /** Numbers, money and dates are formatted by the `next-intl` formatter. */
41
+ export const useIntlFormatter = {
42
+ meta: {
43
+ type: "problem",
44
+ docs: { description: "Format numbers, money and dates with `useFormatter` / `getFormatter` from `next-intl`." },
45
+ schema: [],
46
+ messages: {
47
+ method:
48
+ "`{{name}}` formats with a locale this file does not own: with no argument it is the server's on the server and the browser's in the browser (a price that flickers on hydration), with a string it is one language for every reader. Use `useFormatter().number(...)` / `.dateTime(...)` (or `getFormatter()` on the server).",
49
+ intl:
50
+ "`new Intl.{{name}}` builds a private formatter that ignores the app's locale, time zone and named formats. Use the `next-intl` formatter, and put a shared format in `modules/i18n` once.",
51
+ fixed:
52
+ "`.toFixed()` in displayed text writes a number with a dot decimal separator and no grouping to every reader. Use `useFormatter().number(value, { maximumFractionDigits })`.",
53
+ currency:
54
+ "A currency mark glued to a substitution in a template writes the symbol on the wrong side for half of the locales and skips grouping. Use `useFormatter().number(value, { style: \"currency\", currency })`.",
55
+ library:
56
+ "`{{name}}` formats dates on its own locale data, next to `next-intl`'s. One library decides the language of a date: use the `next-intl` formatter (`dateTime`, `relativeTime`).",
57
+ },
58
+ },
59
+ create(context) {
60
+ if (isSpecFile(fileOf(context)) || inSlot(context, "fe.modules.i18n")) return {}
61
+ return {
62
+ ImportDeclaration(node) {
63
+ const source = String(node.source.value)
64
+ if (DATE_LIBRARIES.test(source)) context.report({ node, messageId: "library", data: { name: source } })
65
+ },
66
+ CallExpression(node) {
67
+ const callee = node.callee
68
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
69
+ const name = callee.property.name
70
+ if (LOCALE_METHODS.has(name)) context.report({ node, messageId: "method", data: { name } })
71
+ else if (name === "toFixed" && isDisplayed(node)) context.report({ node, messageId: "fixed" })
72
+ },
73
+ NewExpression(node) {
74
+ const callee = node.callee
75
+ if (
76
+ callee.type === "MemberExpression" &&
77
+ callee.object.type === "Identifier" &&
78
+ callee.object.name === "Intl" &&
79
+ callee.property.type === "Identifier" &&
80
+ INTL_FORMATTERS.has(callee.property.name)
81
+ ) {
82
+ context.report({ node, messageId: "intl", data: { name: callee.property.name } })
83
+ }
84
+ },
85
+ TemplateLiteral(node) {
86
+ if (node.expressions.length === 0) return
87
+ const glued = node.quasis.some((quasi, index) => {
88
+ const text = quasi.value.cooked ?? ""
89
+ if (index < node.expressions.length && text.endsWith("$")) return true
90
+ return CURRENCY.test(text)
91
+ })
92
+ if (glued) context.report({ node, messageId: "currency" })
93
+ },
94
+ }
95
+ },
96
+ }
97
+
98
+ /** The rules this law contributes to the plugin. */
99
+ export const rules = {
100
+ "use-intl-formatter": useIntlFormatter,
101
+ }
102
+
103
+ /** Every rule is an error. */
104
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -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"]))