@starci/eslint-canon-fe 8.4.39 → 8.4.41

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 (67) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -0
  3. package/app-shell.mjs +161 -0
  4. package/callback-shape.mjs +1 -13
  5. package/docs/app-shell.md +50 -0
  6. package/docs/grammar-boundary.md +25 -1
  7. package/docs/surface-rules.md +41 -0
  8. package/grammar-boundary.mjs +112 -0
  9. package/hooks-folder.mjs +9 -3
  10. package/icon.mjs +31 -1
  11. package/index.mjs +6 -0
  12. package/lib/app-shell-cluster.mjs +66 -0
  13. package/lib/app-shell-create.mjs +33 -0
  14. package/lib/app-shell-owner-source.mjs +124 -0
  15. package/lib/app-shell-owner.mjs +65 -0
  16. package/lib/app-shell-scope.mjs +79 -0
  17. package/lib/app-shell-visible.mjs +92 -0
  18. package/lib/app-shell.mjs +148 -0
  19. package/lib/bindings.mjs +15 -0
  20. package/lib/config.mjs +1 -0
  21. package/lib/fe-stack.mjs +8 -4
  22. package/lib/scope.mjs +2 -2
  23. package/lib/typed-class-values.mjs +72 -0
  24. package/lib/why.mjs +42 -2
  25. package/lint-escape-hatch.mjs +2 -2
  26. package/menu-entries.mjs +133 -0
  27. package/package.json +1 -1
  28. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  29. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  30. package/runtime/knowledge/hfs/slots.yaml +113 -53
  31. package/runtime/knowledge/sonar-gate.yaml +1 -1
  32. package/runtime/modules/goal/source-phrases.yaml +95 -0
  33. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  34. package/runtime/modules/models/registry.yaml +3 -1
  35. package/runtime/modules/models/runtimes.yaml +2 -0
  36. package/runtime/scripts/api/git/ls-tree.mjs +5 -0
  37. package/runtime/scripts/api/git/show.mjs +6 -0
  38. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  39. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  41. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  42. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  43. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  44. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  45. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  46. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  47. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
  48. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  49. package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
  50. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  51. package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
  52. package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
  53. package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
  54. package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
  55. package/runtime/scripts/hfs/slots.mjs +2 -1
  56. package/runtime/scripts/hfs/view.mjs +2 -1
  57. package/runtime/scripts/lib/in-order.mjs +71 -0
  58. package/runtime/scripts/lib/language.mjs +94 -1
  59. package/runtime/scripts/lib/package-at.mjs +62 -0
  60. package/runtime/scripts/lib/same-text.mjs +5 -0
  61. package/runtime/scripts/lib/secret-patterns.mjs +58 -9
  62. package/runtime/scripts/lib/source-phrases.mjs +34 -0
  63. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  64. package/size-and-state-budget.mjs +3 -3
  65. package/surface-rules.mjs +230 -0
  66. package/swr-family.mjs +136 -7
  67. package/transport.mjs +2 -1
package/lib/bindings.mjs CHANGED
@@ -6,6 +6,8 @@
6
6
  * written.
7
7
  */
8
8
 
9
+ import { stringOf } from "./ast.mjs"
10
+
9
11
  /** The globals that all name the browser's global object. */
10
12
  const GLOBAL_OBJECT = new Set(["window", "globalThis", "self"])
11
13
 
@@ -72,6 +74,19 @@ export const importOf = (context, identifier) => {
72
74
  return { source, imported: null }
73
75
  }
74
76
 
77
+ /** The property path a destructured binding selects from its initializer; null for an unselected or rest binding. */
78
+ export const patternPath = (pattern, name) => {
79
+ if (pattern.type === "Identifier") return pattern.name === name ? [] : null
80
+ if (pattern.type === "AssignmentPattern") return patternPath(pattern.left, name)
81
+ if (pattern.type !== "ObjectPattern") return null
82
+ for (const property of pattern.properties) {
83
+ if (property.type !== "Property") continue
84
+ const nested = patternPath(property.value, name)
85
+ if (nested !== null) return [property.computed ? stringOf(property.key) : property.key.name ?? property.key.value, ...nested]
86
+ }
87
+ return null
88
+ }
89
+
75
90
  /**
76
91
  * The function an expression stands for: the function itself, or the function a same-file variable or declaration holds.
77
92
  *
package/lib/config.mjs CHANGED
@@ -100,6 +100,7 @@ export const SLOT_FREE_RULES = Object.freeze([
100
100
  "no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "status-text-uses-soft-foreground", "no-copy-in-call", "no-alert", "no-failure-collapse",
101
101
  "outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
102
102
  "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
103
+ "nested-control-variant", "no-list-panel-padding",
103
104
  ])
104
105
 
105
106
  /** The source of a plain front-end tree: its `src/`. */
package/lib/fe-stack.mjs CHANGED
@@ -11,16 +11,17 @@ import { readFileSync } from "node:fs"
11
11
  import { dirname, join } from "node:path"
12
12
  import { fileURLToPath } from "node:url"
13
13
  import { parseYaml } from "../runtime/engine/yaml.mjs"
14
+ import { globExpression } from "../runtime/scripts/lib/glob.mjs"
14
15
 
15
16
  const CATALOG = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime", "knowledge", "hfs", "fe-stack.yaml")
16
17
 
17
- /** @type {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object> } | null} */
18
+ /** @type {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object>, patterns: ReadonlyArray<object> } | null} */
18
19
  let loaded = null
19
20
 
20
21
  /**
21
22
  * The parsed catalog: its concerns, and every forbidden package mapped to the concern that forbids it.
22
23
  *
23
- * @returns {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object> }} The catalog.
24
+ * @returns {{ concerns: ReadonlyArray<object>, forbidden: ReadonlyMap<string, object>, patterns: ReadonlyArray<object> }} The catalog.
24
25
  */
25
26
  export const feStack = () => {
26
27
  if (loaded) return loaded
@@ -34,7 +35,8 @@ export const feStack = () => {
34
35
  if (!Array.isArray(concerns) || concerns.length === 0) throw new Error("runtime/knowledge/hfs/fe-stack.yaml declares no concerns: the fe-stack catalog is malformed")
35
36
  const forbidden = new Map()
36
37
  for (const concern of concerns) for (const name of concern.forbids ?? []) forbidden.set(String(name), concern)
37
- loaded = Object.freeze({ concerns: Object.freeze(concerns), forbidden })
38
+ const patterns = [...forbidden].filter(([name]) => name.includes("*")).map(([name, concern]) => ({ expression: globExpression(name), concern }))
39
+ loaded = Object.freeze({ concerns: Object.freeze(concerns), forbidden, patterns: Object.freeze(patterns) })
38
40
  return loaded
39
41
  }
40
42
 
@@ -61,7 +63,9 @@ export const packageNameOf = (specifier) => {
61
63
  */
62
64
  export const forbiddingConcern = (specifier) => {
63
65
  const name = packageNameOf(specifier)
64
- return name === null ? null : feStack().forbidden.get(name) ?? null
66
+ if (name === null) return null
67
+ const { forbidden, patterns } = feStack()
68
+ return forbidden.get(name) ?? patterns.find(({ expression }) => expression.test(name))?.concern ?? null
65
69
  }
66
70
 
67
71
  /**
package/lib/scope.mjs CHANGED
@@ -67,8 +67,8 @@ export const isConfigModule = (context) => inSlot(context, "fe.modules.config")
67
67
  */
68
68
  export const isApiClient = (context) => inSlot(context, "fe.transport.client", "fe.package.api.client")
69
69
 
70
- /** The slots of the API layer: the app's `modules/api` (with its clients folder and its GraphQLResponse file) and the shared api package. */
71
- const API_LAYER_SLOTS = Object.freeze(["fe.modules.api", "fe.transport.client", "fe.transport.outcome", "fe.package.api", "fe.package.api.client", "fe.package.api.outcome"])
70
+ /** The slots of the API layer: the app's `modules/api` (with its clients folder, its stream reader and its GraphQLResponse file) and the shared api package. */
71
+ const API_LAYER_SLOTS = Object.freeze(["fe.modules.api", "fe.transport.client", "fe.transport.stream", "fe.transport.outcome", "fe.package.api", "fe.package.api.client", "fe.package.api.outcome"])
72
72
 
73
73
  /** True when the linted file sits in the API layer: the only place that talks to the back end (Apollo, axios, `fetch`). */
74
74
  export const isApiLayer = (context) => inSlot(context, ...API_LAYER_SLOTS)
@@ -0,0 +1,72 @@
1
+ /** Static class declarations in imported colocated modules, resolved by the consuming TypeScript program. */
2
+ import ts from "typescript"
3
+
4
+ /** The expression's literal fragments, following imported const/property declarations without executing code. */
5
+ const fragments = (checker, node, seen, mode) => {
6
+ if (!node || seen.has(node)) return []
7
+ seen.add(node)
8
+ if (ts.isStringLiteralLike(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) return [node.text]
9
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) return fragments(checker, node.expression, seen, mode)
10
+ if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
11
+ const symbol = ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)
12
+ ? checker.getTypeAtLocation(node.expression).getProperty(node.argumentExpression.text)
13
+ : checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node)
14
+ return symbolFragments(checker, symbol, seen, mode)
15
+ }
16
+ return fragmentChildren(node, mode).flatMap((child) => fragments(checker, child, seen, mode))
17
+ }
18
+
19
+ /** An object binding reads only its selected property's symbol, including aliases and nested patterns. */
20
+ const bindingProperty = (checker, declaration) => {
21
+ const source = declaration.parent.parent
22
+ const owner = ts.isBindingElement(source) ? bindingProperty(checker, source) : null
23
+ const type = owner ? checker.getTypeOfSymbolAtLocation(owner, source) : source.initializer ? checker.getTypeAtLocation(source.initializer) : null
24
+ const name = declaration.propertyName ?? declaration.name
25
+ return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? type?.getProperty(name.text) : null
26
+ }
27
+
28
+ /** Alias and destructuring symbols retain their declaration values without reading sibling properties. */
29
+ const symbolFragments = (checker, input, seen, mode) => {
30
+ let symbol = input
31
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol)
32
+ return (symbol?.declarations ?? []).flatMap((declaration) => {
33
+ const selected = ts.isBindingElement(declaration) ? bindingProperty(checker, declaration) : null
34
+ return selected ? symbolFragments(checker, selected, seen, mode) : fragments(checker, declaration.initializer, seen, mode)
35
+ })
36
+ }
37
+
38
+ /** Class-producing positions inside a declaration, including cn dictionaries and conditional recipes. */
39
+ const fragmentChildren = (node, mode) => {
40
+ if (ts.isCallExpression(node)) return mode === "classes" ? node.arguments : []
41
+ if (ts.isArrayLiteralExpression(node)) return node.elements
42
+ if (ts.isConditionalExpression(node)) return [node.whenTrue, node.whenFalse]
43
+ if (ts.isBinaryExpression(node)) {
44
+ if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) return [node.right]
45
+ return [ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(node.operatorToken.kind) ? [node.left, node.right] : []
46
+ }
47
+ if (ts.isObjectLiteralExpression(node)) return node.properties.flatMap((property) => ts.isPropertyAssignment(property) ? [property.name, property.initializer] : [])
48
+ if (ts.isTemplateExpression(node) && mode === "classes") return [node.head, ...node.templateSpans.flatMap((span) => [span.expression, span.literal])]
49
+ return []
50
+ }
51
+
52
+ /**
53
+ * Imported class values are reported at their use in the linted file, while their text comes from the typed declaration.
54
+ * Syntax-only rule consumers have no imported-file program and retain same-file inspection.
55
+ */
56
+ export const typedClassStrings = (context, node, mode) => {
57
+ const services = context.sourceCode.parserServices
58
+ if (!services?.program || !services.esTreeNodeToTSNodeMap) return []
59
+ const source = services.esTreeNodeToTSNodeMap.get(node)
60
+ const checker = services.program.getTypeChecker()
61
+ return fragments(checker, source, new Set(), mode).map((text) => ({ node, text }))
62
+ }
63
+
64
+ /** Known imported props spreads expose the same static property values as named JSX attributes. */
65
+ export const typedPropertyStrings = (context, node, key, mode) => {
66
+ const services = context.sourceCode.parserServices
67
+ if (!services?.program || !services.esTreeNodeToTSNodeMap) return []
68
+ const source = services.esTreeNodeToTSNodeMap.get(node)
69
+ const checker = services.program.getTypeChecker()
70
+ const symbol = checker.getTypeAtLocation(source).getProperty(key)
71
+ return symbolFragments(checker, symbol, new Set(), mode).map((text) => ({ node, text }))
72
+ }
package/lib/why.mjs CHANGED
@@ -19,6 +19,41 @@
19
19
 
20
20
  /** @type {Record<string, Why>} */
21
21
  export const why = {
22
+ "nested-control-variant": {
23
+ code: "FE_NESTED_VARIANT",
24
+ en: "`<file>` forces a primary field inside a Grammar surface, or paints a raw bg-surface in app CSS.",
25
+ fix: "Compose a Grammar surface and omit the nested field variant so its surface context supplies secondary.",
26
+ },
27
+ "no-list-panel-padding": {
28
+ code: "FE_LIST_PANEL_PADDING",
29
+ en: "`<file>` adds horizontal wrapper padding to a Grammar list or its section caption, stacking the content inset.",
30
+ fix: "Remove the wrapper padding and use ListBox inset=\"panel\" so headers, captions and rows share the panel content edge.",
31
+ },
32
+ "app-shell-frame": {
33
+ code: "FE_APP_SHELL_FRAME",
34
+ en: "The app frame must use Grammar WorkspaceShell with its fixed viewport.",
35
+ fix: "Compose WorkspaceShell viewport=\"fixed\" and supply its header, navigation and primary slots.",
36
+ },
37
+ "no-shell-bottom-tabs": {
38
+ code: "FE_APP_SHELL_BOTTOM_TABS",
39
+ en: "Bottom tab navigation replaces the required mobile sidebar drawer.",
40
+ fix: "Use WorkspaceShell navigationDrawer and Sidebar presentation=\"drawer\".",
41
+ },
42
+ "navbar-control-size": {
43
+ code: "FE_NAVBAR_CONTROL_SIZE",
44
+ en: "A navbar control does not use Grammar's shared control height.",
45
+ fix: "Set size=\"control\" on every visible bar control.",
46
+ },
47
+ "navbar-cluster": {
48
+ code: "FE_NAVBAR_CLUSTER",
49
+ en: "Navbar action component kinds or order differ from the app-shell canon.",
50
+ fix: "Compose Language IconButton, theme Switch, notification IconButton and account AvatarButton in that order.",
51
+ },
52
+ "no-navbar-create": {
53
+ code: "FE_NAVBAR_CREATE",
54
+ en: "A create action is composed into the global navbar.",
55
+ fix: "Move the create action to the page that owns it.",
56
+ },
22
57
  "no-env-outside-config": {
23
58
  code: "FE_ENV_OWNER",
24
59
  en: "`<file>` reads an environment variable. Only `modules/config` may read env.",
@@ -279,10 +314,15 @@ export const why = {
279
314
  en: "Raw `<tag>` in `<file>`. Page structure and text are composed from grammar components, not hand-written HTML.",
280
315
  fix: "Replace it with the matching grammar component (`Heading`, `Text`, `SurfaceCard`, ...); if grammar lacks it, add it to grammar or the ui package instead of drawing it in place.",
281
316
  },
317
+ "no-app-relative-clock": {
318
+ code: "FE_SIZE_AND_STATE_BUDGET",
319
+ en: "`<file>` starts a recurring timer that reads the wall clock for live time labels.",
320
+ fix: "Render Grammar RelativeTime with the target dateTime, locale and mode; its shared ticker owns live relative time and countdown updates.",
321
+ },
282
322
  "fe-stack-import": {
283
323
  code: "FE_STACK_FORBIDDEN",
284
- en: "`<file>` imports `<what>`, a library the front-end stack forbids (a second library for a concern that has one), or a Supabase package outside `modules/auth`.",
285
- fix: "Use the concern's canonical library from `knowledge/hfs/fe-stack.yaml` (the message names it); Supabase on the front end is the auth client in `modules/auth` only, data goes through `modules/api`.",
324
+ en: "`<file>` imports `<what>`, a package or scoped package family the front-end stack forbids, or a Supabase package outside `modules/auth`.",
325
+ fix: "Use the concern's canonical library from `knowledge/hfs/fe-stack.yaml` (the message names it). For @dicebear/*, pass the identity id as seed on Grammar Avatar, AvatarButton or AvatarGroup members. Supabase on the front end is the auth client in `modules/auth` only, data goes through `modules/api`.",
286
326
  },
287
327
  "file-size-growth": {
288
328
  code: "HFS_SIZE_GROWTH",
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * eslint-disable / -next-line / -line / -enable / -env, and `/* eslint rule: "off" *\/`
9
9
  * @ts-ignore, @ts-expect-error, @ts-nocheck
10
- * NOSONAR, @sonar-ignore - Sonar's own switches
10
+ * the no-sonar word, @sonar-ignore - Sonar's own switches
11
11
  * istanbul ignore, c8 ignore, v8 ignore - a line taken out of the coverage the gate reads
12
12
  * prettier-ignore - a region taken out of the formatter
13
13
  * stylelint-disable / -enable - a stylelint switch that reached a TS file
@@ -39,7 +39,7 @@ const ESLINT_DIRECTIVE = /^\s*eslint-(?:disable(?:-next-line|-line)?|enable|env)
39
39
  const TS_DIRECTIVE = /^\s*(?:\/\s*)?@ts-(?:ignore|expect-error|nocheck)\b/
40
40
 
41
41
  /**
42
- * A quality-tool switch other than ESLint's: Sonar honours `NOSONAR` anywhere in a comment (case-insensitively), so it is
42
+ * A quality-tool switch other than ESLint's: Sonar honours its no-sonar word anywhere in a comment (case-insensitively), so it is
43
43
  * matched anywhere; the others are read by their tool from the start of the comment, so they are anchored there too.
44
44
  */
45
45
  const TOOL_DIRECTIVE =
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The menu entries a product hands to Grammar's `DropdownMenu`.
3
+ *
4
+ * HeroUI colours a destructive menu item through its `danger` variant, which Grammar exposes as `tone: "danger"`. An entry the app
5
+ * forgets to tone reads like any other command: sign out and delete sit beside "Settings" in the same ink, and the one click that
6
+ * cannot be taken back gets no mark. The decision is the entry's meaning, so it is checked where the entry is written: an entry whose
7
+ * id or label says it signs out, deletes, removes, leaves or revokes carries `tone: "danger"`.
8
+ *
9
+ * The terms are this canon's own list, English and Vietnamese. They are compared as whole words after the text is lower-cased, split
10
+ * at `-`, `_` and camelCase, and stripped of diacritics (the Vietnamese for "sign out" with its tone marks is `dang xuat`), so `signOut`, `sign-out`,
11
+ * `Sign out` and the tone-marked Vietnamese spelling are one term.
12
+ *
13
+ * WHERE IT LOOKS. A menu entry is an object literal in the `entries` attribute of a `DropdownMenu`, or in a declaration typed as
14
+ * `DropdownMenuEntry`, `DropdownMenuItem` or `DropdownMenuSection` (an array of them too), including the `items` of a section and the
15
+ * `submenu` of an item. Anything it cannot see through (a spread, a call) it leaves alone: the rule refuses to guess.
16
+ */
17
+
18
+ import { stringOf } from "./lib/ast.mjs"
19
+ import { isProductSource } from "./lib/scope.mjs"
20
+
21
+ /** The English terms of a destructive entry. */
22
+ const DESTRUCTIVE_TERMS_EN = Object.freeze([
23
+ "sign out", "log out", "logout", "signout",
24
+ "delete", "remove", "erase", "destroy", "discard", "purge",
25
+ "leave workspace", "leave team", "leave group", "leave organization", "leave project", "leave channel", "leave course", "revoke", "deactivate", "disconnect", "unlink", "unsubscribe", "terminate",
26
+ "close account", "cancel subscription", "cancel plan",
27
+ ])
28
+
29
+ /** The Vietnamese terms, written without diacritics (the text is compared after the diacritics are stripped). */
30
+ const DESTRUCTIVE_TERMS_VI = Object.freeze([
31
+ "dang xuat", "xoa", "xoa bo", "go bo", "loai bo", "roi khoi", "roi nhom", "thu hoi",
32
+ "vo hieu hoa", "ngat ket noi", "huy dang ky", "huy goi", "cham dut", "dong tai khoan",
33
+ ])
34
+
35
+ /** A destructive term as a whole word or phrase of the normalized text. */
36
+ const DESTRUCTIVE_PATTERN = new RegExp(String.raw`(?:^|\s)(?:${[...DESTRUCTIVE_TERMS_EN, ...DESTRUCTIVE_TERMS_VI].map((term) => term.replaceAll(" ", String.raw`\s+`)).join("|")})(?=\s|$)`)
37
+
38
+ /** The text lower-cased, split at separators and camelCase, with diacritics stripped. */
39
+ export const normalizeText = (text) =>
40
+ text
41
+ .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
42
+ .replaceAll(/[-_./:]+/g, " ")
43
+ .toLowerCase()
44
+ .replaceAll("\u0111", "d")
45
+ .normalize("NFD")
46
+ .replaceAll(/\p{M}+/gu, "")
47
+ .replaceAll(/\s+/g, " ")
48
+ .trim()
49
+
50
+ /** True when the id or label text names a destructive command. */
51
+ export const isDestructiveText = (text) => DESTRUCTIVE_PATTERN.test(normalizeText(text))
52
+
53
+ const MENU_TYPES = new Set(["DropdownMenuEntry", "DropdownMenuItem", "DropdownMenuSection"])
54
+
55
+ /** The value node of the property called `name` in an object literal, or null. */
56
+ const propertyValue = (object, name) =>
57
+ object.properties.find((entry) => entry.type === "Property" && !entry.computed && (entry.key.name === name || entry.key.value === name))?.value ?? null
58
+
59
+ /** The element type of an array type (`T[]`, `readonly T[]`, `ReadonlyArray<T>`), else the type itself. */
60
+ const elementTypeOf = (type) => {
61
+ if (type.type === "TSTypeOperator") return elementTypeOf(type.typeAnnotation)
62
+ if (type.type === "TSArrayType") return type.elementType
63
+ const isArrayName = type.type === "TSTypeReference" && type.typeName.type === "Identifier" && (type.typeName.name === "ReadonlyArray" || type.typeName.name === "Array")
64
+ return isArrayName ? (type.typeArguments ?? type.typeParameters)?.params[0] ?? type : type
65
+ }
66
+
67
+ /** True when the type annotation names a menu entry type or an array of one. */
68
+ const isMenuType = (annotation) => {
69
+ const type = annotation?.typeAnnotation
70
+ if (!type) return false
71
+ const element = elementTypeOf(type)
72
+ return element.type === "TSTypeReference" && element.typeName.type === "Identifier" && MENU_TYPES.has(element.typeName.name)
73
+ }
74
+
75
+ /** Report an entry (an object literal), then walk the `items` of a section and the `submenu` of an item. */
76
+ function visitEntries(node, report) {
77
+ if (!node) return
78
+ if (node.type === "ArrayExpression") {
79
+ for (const element of node.elements) visitEntries(element, report)
80
+ return
81
+ }
82
+ if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
83
+ visitEntries(node.expression, report)
84
+ return
85
+ }
86
+ if (node.type !== "ObjectExpression") return
87
+ const kind = stringOf(propertyValue(node, "kind"))
88
+ if (kind === "section") {
89
+ visitEntries(propertyValue(node, "items"), report)
90
+ return
91
+ }
92
+ const text = [stringOf(propertyValue(node, "id")), stringOf(propertyValue(node, "label"))].filter((value) => value !== null)
93
+ const tone = propertyValue(node, "tone")
94
+ const toned = tone !== null && (stringOf(tone) === "danger" || stringOf(tone) === null)
95
+ if (!toned && text.some(isDestructiveText)) report(node, text.find(isDestructiveText))
96
+ visitEntries(propertyValue(node, "submenu"), report)
97
+ }
98
+
99
+ /** A destructive menu entry carries `tone: "danger"`. */
100
+ export const destructiveMenuEntryNeedsDangerTone = {
101
+ meta: {
102
+ type: "problem",
103
+ docs: { description: "A menu entry whose id or label signs out, deletes, removes, leaves or revokes carries `tone: \"danger\"`." },
104
+ schema: [],
105
+ messages: {
106
+ tone:
107
+ "The menu entry `{{text}}` is destructive but has no `tone: \"danger\"`. HeroUI colours the label and glyph of a danger item so the one click that cannot be taken back is marked; without the tone it reads like \"Settings\". Add `tone: \"danger\"` to the entry.",
108
+ },
109
+ },
110
+ create(context) {
111
+ if (!isProductSource(context)) return {}
112
+ const report = (node, text) => context.report({ node, messageId: "tone", data: { text } })
113
+ return {
114
+ JSXAttribute(node) {
115
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "entries") return
116
+ const element = node.parent?.name
117
+ if (element?.type !== "JSXIdentifier" || element.name !== "DropdownMenu") return
118
+ visitEntries(node.value?.type === "JSXExpressionContainer" ? node.value.expression : null, report)
119
+ },
120
+ VariableDeclarator(node) {
121
+ if (isMenuType(node.id.typeAnnotation)) visitEntries(node.init, report)
122
+ },
123
+ }
124
+ },
125
+ }
126
+
127
+ /** The rules this law contributes to the plugin. */
128
+ export const rules = {
129
+ "destructive-menu-entry-needs-danger-tone": destructiveMenuEntryNeedsDangerTone,
130
+ }
131
+
132
+ /** Every rule is an error. */
133
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "8.4.39",
3
+ "version": "8.4.41",
4
4
  "description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin and attached by one factory.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  schema: starci/canon-pins@1
2
2
  purpose: >-
3
- The exact version of every dependency a StarCi product repository pins. One version per dependency for the whole
3
+ The exact version of every shared dependency in the StarCi family, including package-owned runtime dependencies. One version per dependency for the whole
4
4
  family. Raising a version is a change to this file first (with the package bump when it is a @starci package),
5
5
  then the repositories' own upgrade lane (each sets the exact version and reinstalls; nothing else installs a @starci package). Read on 2026-09-29 from the packages under
6
6
  .claude/packages and from the installed node_modules of the six product repositories; where they disagreed the
@@ -10,7 +10,8 @@ purpose: >-
10
10
  two, pinned here with a why (2026-10-01: every such conflict but graphql was already pinned). The runtime's example apps
11
11
  merged the same way on 2026-10-01: no dependency outside this file was declared at two versions across their sides, so
12
12
  every choice is a pin (each side's older @starci pins and graphql moved to the version here). `side` names the side whose code uses
13
- the dependency; the app root package.json carries the pins of both.
13
+ the dependency; the app root package.json declares the direct dependencies and tools its sides need. Package-owned
14
+ runtime dependencies are declared by their package, not by the app.
14
15
  pins:
15
16
  # --- @starci packages. Every @starci package is a PUBLISHED npm package (install: registry): a product repository
16
17
  # installs the exact pinned version from the npm registry in the root and every workspace package.json, never `file:`.
@@ -22,28 +23,28 @@ pins:
22
23
  source: packages/heroicons/package.json
23
24
  why: 'StarCi custom Heroicons-compatible cuts; eslint-canon-fe (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
24
25
  '@starci/grammar':
25
- version: 0.11.0
26
+ version: 0.12.1
26
27
  group: starci
27
28
  install: registry
28
29
  side: fe
29
30
  source: packages/grammar/package.json
30
31
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
32
  '@starci/eslint-canon-be':
32
- version: 3.4.40
33
+ version: 3.4.41
33
34
  group: starci
34
35
  install: registry
35
36
  side: be
36
37
  source: packages/eslint/be/package.json
37
38
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
39
  '@starci/eslint-canon-fe':
39
- version: 8.4.39
40
+ version: 8.4.41
40
41
  group: starci
41
42
  install: registry
42
43
  side: fe
43
44
  source: packages/eslint/fe/package.json
44
45
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
45
46
  '@starci/stylelint-canon':
46
- version: 2.0.10
47
+ version: 2.0.11
47
48
  group: starci
48
49
  install: registry
49
50
  side: fe
@@ -62,7 +63,7 @@ pins:
62
63
  side: both
63
64
  source: packages/prettier-config/package.json
64
65
  '@starci/jest-preset':
65
- version: 2.3.5
66
+ version: 2.3.6
66
67
  group: starci
67
68
  install: registry
68
69
  side: be
@@ -76,14 +77,14 @@ pins:
76
77
  source: packages/test-world/package.json
77
78
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
79
  '@starci/cli':
79
- version: 1.7.32
80
+ version: 1.7.33
80
81
  group: starci
81
82
  install: registry
82
83
  side: both
83
84
  source: packages/cli/package.json
84
85
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
86
  '@starci/hfs':
86
- version: 4.7.32
87
+ version: 4.7.33
87
88
  group: starci
88
89
  install: registry
89
90
  side: both
@@ -238,6 +239,11 @@ pins:
238
239
  group: framework
239
240
  side: be
240
241
  why: 'the Postgres driver behind TypeORM; the reference app declares ^8.12.0 and resolves 8.23.1.'
242
+ bullmq:
243
+ version: 6.3.11
244
+ group: framework
245
+ side: be
246
+ why: 'the queue transport of both editions behind platform/queue (ruling:R212/case-10); starci app add queue|job declares it in a lite app together with its Redis, and both example apps resolve this version.'
241
247
  reflect-metadata:
242
248
  version: 0.2.2
243
249
  group: framework
@@ -305,6 +311,11 @@ pins:
305
311
  group: framework
306
312
  side: fe
307
313
  why: 'the GraphQL client of every front end (fe-stack graphql): auth and no-auth clients with the link chain in modules/api/graphql; version read from the starci-academy package-lock on 2026-10-13.'
314
+ graphql-ws:
315
+ version: 6.2.1
316
+ group: framework
317
+ side: both
318
+ why: 'the subscription transport: the front end opens it only in the Apollo clients folder (fe-stack graphql, GraphQLWsLink of @apollo/client/link/subscriptions) and the back end serves it only from platform/realtime; the version resolved by the full-app lockfile on 2026-10-24.'
308
319
  '@graphql-codegen/cli':
309
320
  version: 7.4.6
310
321
  group: tooling
@@ -370,6 +381,16 @@ pins:
370
381
  group: ui
371
382
  side: fe
372
383
  why: 'the base of the one application icon set (fe-stack icons, with @starci/heroicons for the glyphs it lacks); eslint-canon-fe (icon.mjs) admits @heroicons/react/24/outline and /16/solid.'
384
+ '@dicebear/core':
385
+ version: 10.4.0
386
+ group: ui
387
+ side: fe
388
+ why: 'runtime dependency of @starci/grammar for its seeded circle avatars; apps use the Avatar seed prop and never declare or import @dicebear/*; pinned with @dicebear/styles.'
389
+ '@dicebear/styles':
390
+ version: 10.4.0
391
+ group: ui
392
+ side: fe
393
+ why: 'runtime dependency of @starci/grammar supplying its fixed avatar DNA style; apps use the Avatar seed prop and never declare or import @dicebear/*; pinned with @dicebear/core.'
373
394
  'next-themes':
374
395
  version: 0.4.6
375
396
  group: ui
@@ -481,6 +502,16 @@ pins:
481
502
  group: framework
482
503
  side: be
483
504
  why: 'the Apollo driver of @nestjs/graphql; the full-app resolution on 2026-10-13. Stays on 13.x with @nestjs/graphql (accepted advisory, major deferred to alpha.19).'
505
+ '@aws-sdk/client-s3':
506
+ version: 3.1150.0
507
+ group: framework
508
+ side: be
509
+ why: 'the S3 transport of integrations/object-storage, the one ObjectStorage adapter of both editions (MinIO, S3, Spaces, Supabase Storage); 3.1150.0 is the release of 2026-10-12, chosen over newer ones that had not aged two weeks.'
510
+ '@aws-sdk/s3-request-presigner':
511
+ version: 3.1150.0
512
+ group: framework
513
+ side: be
514
+ why: 'signs the presigned PUT and GET of integrations/object-storage locally, the same release as client-s3.'
484
515
  '@apollo/server':
485
516
  version: 5.5.1
486
517
  group: framework
@@ -496,6 +527,31 @@ pins:
496
527
  group: framework
497
528
  side: be
498
529
  why: 'the socket.io platform of @nestjs/websockets; the @nestjs/core pin line.'
530
+ socket.io:
531
+ version: 4.8.3
532
+ group: framework
533
+ side: be
534
+ why: 'the socket.io server the IoAdapter of platform/realtime builds (types and the adapter contract); the exact version @nestjs/platform-socket.io 11.2.5 depends on, so the workspace keeps one copy (4.8.4 is the latest on 2026-10-24).'
535
+ '@socket.io/postgres-adapter':
536
+ version: 0.5.0
537
+ group: framework
538
+ side: be
539
+ why: 'the stateless socket.io adapter of a back end without Redis that runs several api replicas (LISTEN/NOTIFY, no sticky sessions, websocket only), imported only by platform/realtime (knowledge/patterns/be/realtime.yaml BE-REALTIME-7); the latest registry version on 2026-10-24.'
540
+ '@socket.io/redis-adapter':
541
+ version: 8.3.0
542
+ group: framework
543
+ side: be
544
+ why: 'the sharded Redis adapter (createShardedAdapter, Redis 7 or later) of a back end with Redis declared that runs several api replicas, imported only by platform/realtime; the latest registry version on 2026-10-24.'
545
+ ioredis:
546
+ version: 5.11.1
547
+ group: framework
548
+ side: be
549
+ why: 'the one Redis client of a back end: the cache, the optional peer bullmq loads for the queue of platform/queue, and the Redis hub and sharded socket.io adapter of platform/realtime (its docs recommend ioredis over redis); the latest 5.x on 2026-10-24, because typeorm 0.3.31 declares ioredis ^5.0.4 as a peer and ioredis 6.0.0 (the latest) does not satisfy it.'
550
+ '@types/pg':
551
+ version: 8.23.1
552
+ group: framework
553
+ side: be
554
+ why: 'the types of the pg driver the Postgres hub of platform/realtime opens a LISTEN connection with; the pg pin line, the latest registry version on 2026-10-24.'
499
555
  '@as-integrations/express5':
500
556
  version: 1.1.2
501
557
  group: framework
@@ -19,7 +19,14 @@ concerns:
19
19
  home: [fe.modules.api, fe.transport.client, fe.transport.outcome]
20
20
  pattern: FE-API-GRAPHQL
21
21
  forbids: [urql, graphql-request, '@urql/core', graphql-hooks, apollo-boost]
22
- why: one file per operation under modules/api/graphql/{queries,mutations}, run by the auth or no-auth Apollo client with the link chain.
22
+ why: one file per operation under modules/api/graphql/{queries,mutations,subscriptions}, run by the auth or no-auth Apollo client with the link chain.
23
+ - id: graphql-subscriptions
24
+ tier: opt
25
+ packages: [graphql-ws]
26
+ home: [fe.transport.client, fe.package.api.client]
27
+ pattern: FE-REALTIME
28
+ forbids: []
29
+ why: the subscription transport of a GraphQL subscription door (be.feature.realtime.graphql); only the Apollo clients folder imports it (GraphQLWsLink of @apollo/client/link/subscriptions), a subscription operation runs on that client and a useSubscribe hook of hooks/swr reads it.
23
30
  - id: codegen
24
31
  tier: core
25
32
  packages: ['@graphql-codegen/cli', '@graphql-codegen/client-preset']
@@ -74,8 +81,8 @@ concerns:
74
81
  packages: [socket.io-client, eventemitter2]
75
82
  home: [fe.hooks.socketio]
76
83
  pattern: FE-REALTIME
77
- forbids: [graphql-ws, subscriptions-transport-ws, '@supabase/realtime-js', pusher-js, ably]
78
- why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; lite runs the gateway in the be api process. A one-way channel served as server-sent events needs no package, only a fetch reader in modules/api/sse and a use<Channel>Stream hook (FE-REALTIME).
84
+ forbids: [subscriptions-transport-ws, '@supabase/realtime-js', pusher-js, ably]
85
+ why: one socket.io-client singleton per namespace and one side-effect component that dispatches to redux and SWR; both editions run the gateway in the be api process. A one-way channel served as server-sent events needs no package, only a stream reader in modules/api/sse/clients, a stream operation in modules/api/sse and a useSubscribe<Channel>Swr hook (FE-REALTIME).
79
86
  - id: auth
80
87
  tier: core
81
88
  packages: []
@@ -89,7 +96,7 @@ concerns:
89
96
  home: [fe.modules.i18n]
90
97
  pattern: FE-I18N
91
98
  forbids: [i18next, react-i18next, next-i18next, react-intl, '@lingui/react']
92
- why: useTranslations for copy and the next-intl formatter for numbers, dates and currency.
99
+ why: useTranslations for copy and the next-intl formatter for numbers, fixed dates and currency; FE-DATE owns live time display.
93
100
  - id: ui
94
101
  tier: core
95
102
  packages: ['@heroui/react', '@heroui/styles', tailwindcss, '@starci/grammar']
@@ -107,6 +114,17 @@ concerns:
107
114
  one application icon set, the heroicons family (owner ruling 2026-10-13), on the 24/outline and 16/solid cuts. @heroicons/react
108
115
  is the base set and is used first; @starci/heroicons holds only the glyphs heroicons lacks (brand or domain glyphs drawn in
109
116
  heroicons style), never duplicates a heroicons glyph, and a missing glyph is added to it.
117
+ - id: avatar-illustration
118
+ tier: opt
119
+ packages: ['@starci/grammar']
120
+ home: [fe.components]
121
+ pattern: FE-UI
122
+ forbids: ['@dicebear/*', boring-avatars, 'react-nice-avatar', multiavatar]
123
+ why: >-
124
+ Grammar owns circle avatars: pass the uploaded avatar as src, otherwise pass the user or identity id as seed on
125
+ Avatar, AvatarButton or AvatarGroup members. Grammar renders deterministic DiceBear images with its fixed DNA style;
126
+ @dicebear/core and @dicebear/styles are Grammar runtime dependencies pinned in canon-pins.yaml. Apps declare and
127
+ import @starci/grammar, never @dicebear/*, and draw no avatar visuals. With neither src nor seed, initials remain.
110
128
  - id: theme
111
129
  tier: core
112
130
  packages: [next-themes]
@@ -127,7 +145,7 @@ concerns:
127
145
  home: [fe.modules]
128
146
  pattern: FE-DATE
129
147
  forbids: [moment, moment-timezone, date-fns, luxon]
130
- why: dayjs (modules/dayjs) for date math and parsing; display through the next-intl formatter.
148
+ why: FE-DATE owns application date math, fixed date formatting and Grammar's live time display.
131
149
  - id: numbers
132
150
  tier: opt
133
151
  packages: [decimal.js]