@starci/eslint-canon-fe 9.0.0 → 9.1.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.
- package/CHANGELOG.md +6 -0
- package/README.md +5 -0
- package/button-intent.mjs +320 -0
- package/docs/button-intent.md +22 -0
- package/index.mjs +2 -0
- package/lib/config.mjs +1 -0
- package/lib/why.mjs +25 -0
- package/package.json +1 -1
- package/runtime/knowledge/grammars/index.yaml +10 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.1.1 - 2026-10-26
|
|
4
|
+
- Changed: the bundled runtime copies (canon pins) follow the stylelint 2.0.13, hfs 4.7.38 and cli 1.7.38 pins. No rule changes.
|
|
5
|
+
|
|
6
|
+
## 9.1.0 - 2026-10-26
|
|
7
|
+
- Added: universal CTA-6 button intent laws require `intent`, refuse app `variant`, contain destructive confirms in confirmation dialogs, keep one commit per render path and reserve commit for the view rather than collection items. Run `starci app codemod button-intent` to prepare migration and review its flagged cases.
|
|
8
|
+
|
|
3
9
|
## 9.0.0 - 2026-10-26
|
|
4
10
|
- Changed: concrete Grammar component, variant, token and vendor rules move to `@starci/eslint-grammar-starci` under the `starci-grammar` namespace. Consumers compose both flat-config factories for their selected family.
|
|
5
11
|
- Added: explicit shared infrastructure exports under `@starci/eslint-canon-fe/lib/*`; family packages reuse these readers without copying them.
|
package/README.md
CHANGED
|
@@ -40,4 +40,9 @@ variants on layer-aware fields and primary Tabs inside painted Grammar surfaces
|
|
|
40
40
|
within the nearest painted surface (`FE_LIST_PANEL_PADDING`). The R65 budget law's
|
|
41
41
|
`no-app-relative-clock` leaves live relative time and countdowns to Grammar's shared clock.
|
|
42
42
|
|
|
43
|
+
The [button intent law](docs/button-intent.md) (CTA-6) requires every app Button and IconButton to
|
|
44
|
+
declare intent and leave variants to Grammar. It contains destructive confirms in their same-file
|
|
45
|
+
dialog, counts mutually exclusive render paths separately and reserves commit for the view's
|
|
46
|
+
decision rather than repeated collection items.
|
|
47
|
+
|
|
43
48
|
Regression specs belong to this package. Application front ends and Grammar libraries have no specs.
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/** CTA-6: app buttons declare consequences; Grammar owns their visual variants. */
|
|
2
|
+
import { attributeValue, stringOf, walk } from "./lib/ast.mjs"
|
|
3
|
+
import { functionOf, importOf, isFn, patternPath, unwrap, variableOf } from "./lib/bindings.mjs"
|
|
4
|
+
import { typedPropertyStrings } from "./lib/typed-class-values.mjs"
|
|
5
|
+
import ts from "typescript"
|
|
6
|
+
|
|
7
|
+
const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|heritage|offset-pop))?$/
|
|
8
|
+
const BUTTONS = new Set(["Button", "IconButton"])
|
|
9
|
+
const DIALOGS = new Set(["AlertDialog", "ConfirmDialog"])
|
|
10
|
+
const ITEMS = new Set(["CollectionItem", "ItemActions", "ListItem", "ListRow", "ListBox.Item", "GridList.Item", "Table.Row", "TableRow"])
|
|
11
|
+
|
|
12
|
+
/** The consuming TypeScript program exposes literal intents and spread properties without executing app code. */
|
|
13
|
+
const typedInfo = (context, node, key) => {
|
|
14
|
+
const services = context.sourceCode.parserServices
|
|
15
|
+
if (!services?.program || !services.esTreeNodeToTSNodeMap || !node) return null
|
|
16
|
+
const checker = services.program.getTypeChecker()
|
|
17
|
+
const source = services.esTreeNodeToTSNodeMap.get(node)
|
|
18
|
+
const owner = checker.getTypeAtLocation(source)
|
|
19
|
+
const symbol = key === undefined ? null : owner.getProperty(key)
|
|
20
|
+
if (key !== undefined && !symbol) return null
|
|
21
|
+
const type = symbol ? checker.getTypeOfSymbolAtLocation(symbol, source) : owner
|
|
22
|
+
const members = type.isUnion() ? type.types : [type]
|
|
23
|
+
return { values: members.filter((member) => member.isStringLiteral()).map((member) => member.value), optional: Boolean(symbol && (symbol.flags & ts.SymbolFlags.Optional) !== 0) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve the imported Grammar role without confusing a shadowed local component with its namesake. */
|
|
27
|
+
const roleOf = (context, name) => {
|
|
28
|
+
const parts = []
|
|
29
|
+
let root = name
|
|
30
|
+
while (root.type === "JSXMemberExpression") {
|
|
31
|
+
parts.unshift(root.property.name)
|
|
32
|
+
root = root.object
|
|
33
|
+
}
|
|
34
|
+
if (root.type !== "JSXIdentifier") return null
|
|
35
|
+
const imported = importOf(context, root)
|
|
36
|
+
if (!imported || !GRAMMAR_ENTRY.test(imported.source)) return null
|
|
37
|
+
if (imported.imported) parts.unshift(imported.imported)
|
|
38
|
+
else if (!variableOf(context, root)?.defs.some((definition) => definition.node.type === "ImportNamespaceSpecifier")) return null
|
|
39
|
+
return parts.join(".")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Static object properties, including aliases and spreads, retain their button obligations. */
|
|
43
|
+
const objectProperty = (context, input, key, seen = new Set()) => {
|
|
44
|
+
const node = unwrap(input)
|
|
45
|
+
if (!node || seen.has(node)) return []
|
|
46
|
+
const next = new Set(seen).add(node)
|
|
47
|
+
if (node.type === "ObjectExpression") return node.properties.flatMap((property) => {
|
|
48
|
+
if (property.type === "SpreadElement") return objectProperty(context, property.argument, key, next)
|
|
49
|
+
const name = property.computed ? stringOf(property.key) : property.key.name ?? property.key.value
|
|
50
|
+
return name === key ? [property.value] : []
|
|
51
|
+
})
|
|
52
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch) => objectProperty(context, branch, key, next))
|
|
53
|
+
if (node.type === "MemberExpression") {
|
|
54
|
+
const member = node.computed ? stringOf(node.property) : node.property.name
|
|
55
|
+
return objectProperty(context, node.object, member, next).flatMap((value) => objectProperty(context, value, key, next))
|
|
56
|
+
}
|
|
57
|
+
if (node.type !== "Identifier") return []
|
|
58
|
+
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
59
|
+
if (!definition) return []
|
|
60
|
+
const path = patternPath(definition.node.id, node.name)
|
|
61
|
+
if (path === null) return []
|
|
62
|
+
const values = path.reduce((entries, name) => entries.flatMap((entry) => objectProperty(context, entry, name, next)), [definition.node.init])
|
|
63
|
+
return values.flatMap((value) => objectProperty(context, value, key, next))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Possible statically authored values of an intent or step; dynamic values remain TypeScript's responsibility. */
|
|
67
|
+
const valuesOf = (context, input, seen = new Set()) => {
|
|
68
|
+
const node = unwrap(input)
|
|
69
|
+
if (!node || seen.has(node)) return []
|
|
70
|
+
const next = new Set(seen).add(node)
|
|
71
|
+
const text = stringOf(node)
|
|
72
|
+
if (text !== null) return [text]
|
|
73
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch) => valuesOf(context, branch, next))
|
|
74
|
+
if (node.type === "LogicalExpression") return valuesOf(context, node.right, next)
|
|
75
|
+
if (node.type === "MemberExpression") {
|
|
76
|
+
const name = node.computed ? stringOf(node.property) : node.property.name
|
|
77
|
+
const values = objectProperty(context, node.object, name, next).flatMap((value) => valuesOf(context, value, next))
|
|
78
|
+
return values.length ? values : typedInfo(context, node)?.values ?? []
|
|
79
|
+
}
|
|
80
|
+
if (node.type !== "Identifier") return typedInfo(context, node)?.values ?? []
|
|
81
|
+
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
82
|
+
if (!definition) return typedInfo(context, node)?.values ?? []
|
|
83
|
+
const path = patternPath(definition.node.id, node.name)
|
|
84
|
+
if (path === null) return []
|
|
85
|
+
const values = path.reduce((entries, name) => entries.flatMap((entry) => objectProperty(context, entry, name, next)), [definition.node.init])
|
|
86
|
+
return values.flatMap((value) => valuesOf(context, value, next))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An explicit attribute or statically resolved spread exposing a prop. */
|
|
90
|
+
const propsOf = (context, opening, key) => opening.attributes.flatMap((entry) => {
|
|
91
|
+
if (entry.type === "JSXAttribute") return entry.name.name === key ? [{ node: entry, value: attributeValue(entry) }] : []
|
|
92
|
+
const values = objectProperty(context, entry.argument, key)
|
|
93
|
+
if (values.length) return values.map((value) => ({ node: entry, value }))
|
|
94
|
+
const authored = typedPropertyStrings(context, entry.argument, key, "variant")
|
|
95
|
+
if (authored.length) return authored.map((value) => ({ node: entry, text: value.text }))
|
|
96
|
+
const typed = typedInfo(context, entry.argument, key)
|
|
97
|
+
return typed ? [{ node: entry, texts: typed.values }] : []
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const hasValue = (context, opening, key, value) => propsOf(context, opening, key).some((prop) => prop.text === value || prop.texts?.includes(value) || valuesOf(context, prop.value).includes(value))
|
|
101
|
+
const isButton = (context, opening) => BUTTONS.has(roleOf(context, opening.name))
|
|
102
|
+
|
|
103
|
+
/** A conditional props spread supplies intent only when every branch supplies it. */
|
|
104
|
+
const suppliesIntent = (context, input, seen = new Set()) => {
|
|
105
|
+
const node = unwrap(input)
|
|
106
|
+
if (!node || seen.has(node)) return false
|
|
107
|
+
const next = new Set(seen).add(node)
|
|
108
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].every((branch) => suppliesIntent(context, branch, next))
|
|
109
|
+
if (node.type === "Identifier") {
|
|
110
|
+
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
111
|
+
if (definition?.node.id.type === "Identifier") return suppliesIntent(context, definition.node.init, next)
|
|
112
|
+
const typed = typedInfo(context, node, "intent")
|
|
113
|
+
return Boolean(typed && !typed.optional)
|
|
114
|
+
}
|
|
115
|
+
return objectProperty(context, node, "intent").length > 0 || typedPropertyStrings(context, node, "intent", "variant").length > 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const hasIntent = (context, opening) => opening.attributes.some((entry) => {
|
|
119
|
+
if (entry.type === "JSXSpreadAttribute") return suppliesIntent(context, entry.argument)
|
|
120
|
+
if (entry.name.name !== "intent") return false
|
|
121
|
+
const value = unwrap(attributeValue(entry))
|
|
122
|
+
return Boolean(value) && !(value.type === "Literal" && value.value === null) && !(value.type === "Identifier" && value.name === "undefined")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** JSX child render callbacks contribute content to their enclosing component render. */
|
|
126
|
+
const inlineRenderCallback = (node) => node.parent?.type === "JSXExpressionContainer" && node.parent.parent?.type !== "JSXAttribute"
|
|
127
|
+
|
|
128
|
+
const lexicalFunction = (node) => {
|
|
129
|
+
for (let current = node.parent; current; current = current.parent) if (isFn(current)) return current
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The nearest function owns a render; a component defined in a dialog is still its own subtree. */
|
|
134
|
+
const functionOwner = (node) => {
|
|
135
|
+
for (let current = node.parent; current; current = current.parent) if (isFn(current) && !inlineRenderCallback(current)) return current
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A trigger opens a dialog and is outside its confirmation subtree. */
|
|
140
|
+
const insideDialog = (context, opening) => {
|
|
141
|
+
const triggers = new Set()
|
|
142
|
+
for (let current = opening.parent; current; current = current.parent) {
|
|
143
|
+
if (isFn(current) && !inlineRenderCallback(current)) return false
|
|
144
|
+
if (current.type === "JSXAttribute" && current.name.name === "trigger") triggers.add(current.parent)
|
|
145
|
+
if (current.type === "JSXElement" && !triggers.has(current.openingElement) && DIALOGS.has(roleOf(context, current.openingElement.name))) return true
|
|
146
|
+
}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Normalize a condition so `ready` and `!ready` select opposite render paths. */
|
|
151
|
+
const guardOf = (context, input, positive) => {
|
|
152
|
+
const node = unwrap(input)
|
|
153
|
+
if (node.type === "UnaryExpression" && node.operator === "!") return guardOf(context, node.argument, !positive)
|
|
154
|
+
if (node.type === "BinaryExpression" && ["===", "==", "!==", "!="].includes(node.operator)) {
|
|
155
|
+
const literal = node.right.type === "Literal" ? node.right : node.left.type === "Literal" ? node.left : null
|
|
156
|
+
if (literal) {
|
|
157
|
+
const selector = context.sourceCode.getText(literal === node.right ? node.left : node.right).replace(/\s+/g, "")
|
|
158
|
+
const choice = JSON.stringify(literal.value)
|
|
159
|
+
const value = node.operator.startsWith("!") ? !positive : positive
|
|
160
|
+
return { key: `${selector}===${choice}`, value, selector, choice }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { key: context.sourceCode.getText(node).replace(/\s+/g, ""), value: positive }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const constrainPath = (context, path, test, positive) => {
|
|
167
|
+
const guard = guardOf(context, test, positive)
|
|
168
|
+
path.guards.set(guard.key, guard.value)
|
|
169
|
+
if (guard.selector && guard.value) path.choices.set(guard.selector, guard.choice)
|
|
170
|
+
return path
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Conditional intent values retain the guards selecting commit instead of flattening both alternatives. */
|
|
174
|
+
const valuePaths = (context, input, seen = new Set()) => {
|
|
175
|
+
const node = unwrap(input)
|
|
176
|
+
if (!node || seen.has(node)) return []
|
|
177
|
+
const next = new Set(seen).add(node)
|
|
178
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch, index) => valuePaths(context, branch, next).map((path) => constrainPath(context, path, node.test, index === 0)))
|
|
179
|
+
if (node.type === "Identifier") {
|
|
180
|
+
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
181
|
+
if (definition?.node.id.type === "Identifier") return valuePaths(context, definition.node.init, next)
|
|
182
|
+
}
|
|
183
|
+
return valuesOf(context, node).includes("commit") ? [{ guards: new Map(), choices: new Map() }] : []
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Branch guards and return ownership identify mutually exclusive render paths. */
|
|
187
|
+
const renderPath = (context, opening, owner) => {
|
|
188
|
+
const guards = new Map()
|
|
189
|
+
const choices = new Map()
|
|
190
|
+
const path = { guards, choices }
|
|
191
|
+
let returned = null
|
|
192
|
+
let child = opening
|
|
193
|
+
for (let current = opening.parent; current && current !== owner; current = current.parent) {
|
|
194
|
+
if (current.type === "ReturnStatement" && lexicalFunction(current) === owner) returned = current
|
|
195
|
+
if (current.type === "ConditionalExpression" && child !== current.test) constrainPath(context, path, current.test, child === current.consequent)
|
|
196
|
+
if (current.type === "IfStatement" && child !== current.test) constrainPath(context, path, current.test, child === current.consequent)
|
|
197
|
+
if (current.type === "LogicalExpression" && child === current.right && current.operator !== "??") constrainPath(context, path, current.left, current.operator === "&&")
|
|
198
|
+
child = current
|
|
199
|
+
}
|
|
200
|
+
return { guards, choices, returned }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const canCoexist = (left, right) => {
|
|
204
|
+
if (left.returned && right.returned && left.returned !== right.returned) return false
|
|
205
|
+
if ([...left.choices].some(([selector, choice]) => right.choices.has(selector) && right.choices.get(selector) !== choice)) return false
|
|
206
|
+
return [...left.guards].every(([condition, value]) => !right.guards.has(condition) || right.guards.get(condition) === value)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** No explicit visual prop: any authored variant, including a known props spread, is an app bypass. */
|
|
210
|
+
const buttonNoVariant = {
|
|
211
|
+
meta: { type: "problem", docs: { description: "CTA-6: app buttons never choose a variant." }, schema: [], messages: {
|
|
212
|
+
FE_BUTTON_NO_VARIANT: "FE_BUTTON_NO_VARIANT: declare Button intent instead of variant; Grammar resolves the variant from intent and placement.",
|
|
213
|
+
} },
|
|
214
|
+
create(context) {
|
|
215
|
+
return { JSXOpeningElement(opening) {
|
|
216
|
+
if (!isButton(context, opening)) return
|
|
217
|
+
for (const node of new Set(propsOf(context, opening, "variant").map((prop) => prop.node))) context.report({ node, messageId: "FE_BUTTON_NO_VARIANT" })
|
|
218
|
+
} }
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Every app button states its command's intent explicitly. */
|
|
223
|
+
const buttonIntentRequired = {
|
|
224
|
+
meta: { type: "problem", docs: { description: "CTA-6: every app Button and IconButton declares intent." }, schema: [], messages: {
|
|
225
|
+
FE_BUTTON_INTENT_REQUIRED: "FE_BUTTON_INTENT_REQUIRED: Button and IconButton require intent (commit, alternative, dismiss, neutral or destructive). Choose it from the command's consequence and rank.",
|
|
226
|
+
} },
|
|
227
|
+
create(context) {
|
|
228
|
+
return { JSXOpeningElement(opening) {
|
|
229
|
+
if (isButton(context, opening) && !hasIntent(context, opening)) context.report({ node: opening, messageId: "FE_BUTTON_INTENT_REQUIRED" })
|
|
230
|
+
} }
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A destructive final press is contained by its same-file confirmation dialog. */
|
|
235
|
+
const destructiveConfirmInDialog = {
|
|
236
|
+
meta: { type: "problem", docs: { description: "CTA-6: destructive confirm buttons stay inside AlertDialog or ConfirmDialog." }, schema: [], messages: {
|
|
237
|
+
FE_DESTRUCTIVE_CONFIRM_IN_DIALOG: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG: a destructive confirm belongs inside the same-file AlertDialog or ConfirmDialog subtree. The trigger uses step=\"entry\".",
|
|
238
|
+
} },
|
|
239
|
+
create(context) {
|
|
240
|
+
return { JSXOpeningElement(opening) {
|
|
241
|
+
if (!isButton(context, opening) || !hasValue(context, opening, "intent", "destructive") || !hasValue(context, opening, "step", "confirm")) return
|
|
242
|
+
if (!insideDialog(context, opening)) context.report({ node: opening, messageId: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG" })
|
|
243
|
+
} }
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A component render exposes one next action, with exclusive branches counted separately. */
|
|
248
|
+
const singleCommitPerRender = {
|
|
249
|
+
meta: { type: "problem", docs: { description: "CTA-6: at most one commit is visible in a component render." }, schema: [], messages: {
|
|
250
|
+
FE_SINGLE_COMMIT_PER_RENDER: "FE_SINGLE_COMMIT_PER_RENDER: this render contains another commit action. Keep the one next action as commit and classify its alternatives by consequence.",
|
|
251
|
+
} },
|
|
252
|
+
create(context) {
|
|
253
|
+
const owners = new Map()
|
|
254
|
+
return { JSXOpeningElement(opening) {
|
|
255
|
+
if (!isButton(context, opening) || !hasValue(context, opening, "intent", "commit")) return
|
|
256
|
+
const owner = functionOwner(opening) ?? context.sourceCode.ast
|
|
257
|
+
const base = renderPath(context, opening, owner)
|
|
258
|
+
const paths = propsOf(context, opening, "intent").flatMap((prop) => prop.value ? valuePaths(context, prop.value) : prop.text === "commit" || prop.texts?.includes("commit") ? [{ guards: new Map(), choices: new Map() }] : [])
|
|
259
|
+
.map((path) => ({ returned: base.returned, guards: new Map([...base.guards, ...path.guards]), choices: new Map([...base.choices, ...path.choices]) }))
|
|
260
|
+
const prior = owners.get(owner) ?? []
|
|
261
|
+
if (paths.some((path) => prior.some((entry) => canCoexist(entry, path)))) context.report({ node: opening, messageId: "FE_SINGLE_COMMIT_PER_RENDER" })
|
|
262
|
+
prior.push(...paths)
|
|
263
|
+
owners.set(owner, prior)
|
|
264
|
+
} }
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A repeated row's next action is alternative; the enclosing view keeps the commit. */
|
|
269
|
+
const noCommitInCollectionItem = {
|
|
270
|
+
meta: { type: "problem", docs: { description: "CTA-6: collection item renders cannot expose commit actions." }, schema: [], messages: {
|
|
271
|
+
FE_NO_COMMIT_IN_COLLECTION_ITEM: "FE_NO_COMMIT_IN_COLLECTION_ITEM: collection items cannot use commit. Use alternative for an item's next action and reserve commit for the view's decision.",
|
|
272
|
+
} },
|
|
273
|
+
create(context) {
|
|
274
|
+
const reported = new Set()
|
|
275
|
+
const report = (opening) => {
|
|
276
|
+
if (!reported.has(opening) && isButton(context, opening) && hasValue(context, opening, "intent", "commit")) {
|
|
277
|
+
reported.add(opening)
|
|
278
|
+
context.report({ node: opening, messageId: "FE_NO_COMMIT_IN_COLLECTION_ITEM" })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const scanCallback = (callback) => {
|
|
282
|
+
if (!callback) return
|
|
283
|
+
walk(context.sourceCode, callback.body, (node) => {
|
|
284
|
+
if (isFn(node) && !inlineRenderCallback(node)) return false
|
|
285
|
+
if (node.type === "JSXOpeningElement") report(node)
|
|
286
|
+
return undefined
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
JSXAttribute(attribute) {
|
|
291
|
+
if (attribute.name.name === "renderCell" && roleOf(context, attribute.parent.name) === "DataTable") scanCallback(functionOf(context, attributeValue(attribute)))
|
|
292
|
+
},
|
|
293
|
+
JSXOpeningElement(opening) {
|
|
294
|
+
for (let current = opening.parent; current; current = current.parent) {
|
|
295
|
+
if (isFn(current) && !inlineRenderCallback(current)) return
|
|
296
|
+
if (current.type === "JSXElement" && ITEMS.has(roleOf(context, current.openingElement.name))) return report(opening)
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
CallExpression(call) {
|
|
300
|
+
const callee = unwrap(call.callee)
|
|
301
|
+
if (callee.type !== "MemberExpression") return
|
|
302
|
+
const name = callee.computed ? stringOf(callee.property) : callee.property.name
|
|
303
|
+
if (name !== "map") return
|
|
304
|
+
scanCallback(functionOf(context, call.arguments[0]))
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Every intent law is universal across Grammar families and app editions. */
|
|
311
|
+
export const rules = {
|
|
312
|
+
"button-intent-required": buttonIntentRequired,
|
|
313
|
+
"button-no-variant": buttonNoVariant,
|
|
314
|
+
"destructive-confirm-in-dialog": destructiveConfirmInDialog,
|
|
315
|
+
"single-commit-per-render": singleCommitPerRender,
|
|
316
|
+
"no-commit-in-collection-item": noCommitInCollectionItem,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** All five laws reach consumers at error. */
|
|
320
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Button intent (CTA-6)
|
|
2
|
+
|
|
3
|
+
Owner: knowledge/ui/composition/cta.yaml
|
|
4
|
+
|
|
5
|
+
App `Button` and `IconButton` commands declare `intent`. Grammar derives their variant from that intent and its placement context. These laws apply to every Grammar family in both app editions; aliases and namespace imports retain the same obligations.
|
|
6
|
+
|
|
7
|
+
| Rule | Finding code | Required behavior |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `starci-fe/button-intent-required` | `FE_BUTTON_INTENT_REQUIRED` | Pass the command's `intent`. A known props spread may supply it. |
|
|
10
|
+
| `starci-fe/button-no-variant` | `FE_BUTTON_NO_VARIANT` | Omit `variant`, including variants in known props spreads. |
|
|
11
|
+
| `starci-fe/destructive-confirm-in-dialog` | `FE_DESTRUCTIVE_CONFIRM_IN_DIALOG` | Put `intent="destructive" step="confirm"` inside the same-file `AlertDialog` or `ConfirmDialog` content subtree. Its trigger uses `step="entry"`. A separate component's definition does not inherit its caller's JSX ancestry. |
|
|
12
|
+
| `starci-fe/single-commit-per-render` | `FE_SINGLE_COMMIT_PER_RENDER` | Render at most one commit per component path. Different return paths, opposite conditional arms and opposite guards are mutually exclusive. |
|
|
13
|
+
| `starci-fe/no-commit-in-collection-item` | `FE_NO_COMMIT_IN_COLLECTION_ITEM` | Use `alternative` for an item's next action. A `.map` callback or explicit Grammar collection item context cannot expose commit. |
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
<AlertDialog>
|
|
17
|
+
<Button intent="dismiss">Cancel</Button>
|
|
18
|
+
<Button intent="destructive" step="confirm">Delete record</Button>
|
|
19
|
+
</AlertDialog>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`starci app codemod button-intent` defaults to a dry run. Use `--write` to apply deterministic mappings, then resolve every case in its JSON and Markdown report from the command's actual consequence. A destructive-looking label is a review signal rather than authority to choose an intent.
|
package/index.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { createRequire } from "node:module"
|
|
|
12
12
|
import { join } from "node:path"
|
|
13
13
|
import { pathToFileURL } from "node:url"
|
|
14
14
|
import { recommended as brandValuesRecommended, rules as brandValuesRules } from "./brand-values.mjs"
|
|
15
|
+
import { recommended as buttonIntentRecommended, rules as buttonIntentRules } from "./button-intent.mjs"
|
|
15
16
|
import { recommended as clientBoundaryRecommended, rules as clientBoundaryRules } from "./client-boundary.mjs"
|
|
16
17
|
import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
|
|
17
18
|
import { recommended as classNamesRecommended, rules as classNamesRules } from "./class-names.mjs"
|
|
@@ -48,6 +49,7 @@ import { why } from "./lib/why.mjs"
|
|
|
48
49
|
const CONTRIBUTIONS = [
|
|
49
50
|
{ law: "project-graph", rules: projectGraphRules, recommended: projectGraphRecommended },
|
|
50
51
|
{ law: "brand-values", rules: brandValuesRules, recommended: brandValuesRecommended },
|
|
52
|
+
{ law: "button-intent", rules: buttonIntentRules, recommended: buttonIntentRecommended },
|
|
51
53
|
{ law: "client-boundary", rules: clientBoundaryRules, recommended: clientBoundaryRecommended },
|
|
52
54
|
{ law: "comments", rules: commentsRules, recommended: commentsRecommended },
|
|
53
55
|
{
|
package/lib/config.mjs
CHANGED
|
@@ -99,6 +99,7 @@ export const SLOT_FREE_RULES = Object.freeze([
|
|
|
99
99
|
"outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
|
|
100
100
|
"prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
|
|
101
101
|
"nested-control-variant", "no-list-panel-padding", "no-app-surface-paint",
|
|
102
|
+
"button-intent-required", "button-no-variant", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
|
|
102
103
|
])
|
|
103
104
|
|
|
104
105
|
/** The source of a plain front-end tree: its `src/`. */
|
package/lib/why.mjs
CHANGED
|
@@ -19,6 +19,31 @@
|
|
|
19
19
|
|
|
20
20
|
/** @type {Record<string, Why>} */
|
|
21
21
|
export const why = {
|
|
22
|
+
"button-intent-required": {
|
|
23
|
+
code: "FE_BUTTON_INTENT_REQUIRED",
|
|
24
|
+
en: "`<file>` renders a Button or IconButton without the command's intent.",
|
|
25
|
+
fix: "Declare intent from the command's consequence and rank so Grammar supplies its variant.",
|
|
26
|
+
},
|
|
27
|
+
"button-no-variant": {
|
|
28
|
+
code: "FE_BUTTON_NO_VARIANT",
|
|
29
|
+
en: "`<file>` chooses a Button or IconButton variant directly.",
|
|
30
|
+
fix: "Replace variant with intent and let Grammar resolve placement and visual treatment.",
|
|
31
|
+
},
|
|
32
|
+
"destructive-confirm-in-dialog": {
|
|
33
|
+
code: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG",
|
|
34
|
+
en: "`<file>` renders a final destructive press outside its confirmation dialog.",
|
|
35
|
+
fix: "Place the destructive confirm inside the same-file AlertDialog or ConfirmDialog; use entry on its trigger.",
|
|
36
|
+
},
|
|
37
|
+
"single-commit-per-render": {
|
|
38
|
+
code: "FE_SINGLE_COMMIT_PER_RENDER",
|
|
39
|
+
en: "`<file>` renders more than one commit in the same component render path.",
|
|
40
|
+
fix: "Keep the next action as commit and assign the other commands their consequence-based intents.",
|
|
41
|
+
},
|
|
42
|
+
"no-commit-in-collection-item": {
|
|
43
|
+
code: "FE_NO_COMMIT_IN_COLLECTION_ITEM",
|
|
44
|
+
en: "`<file>` repeats a commit inside a collection item render.",
|
|
45
|
+
fix: "Use alternative for the item's next action and keep the enclosing view's commit unique.",
|
|
46
|
+
},
|
|
22
47
|
"nested-control-variant": {
|
|
23
48
|
code: "FE_NESTED_VARIANT",
|
|
24
49
|
en: "`<file>` sets a static Grammar field variant, or forces primary Tabs inside a painted surface.",
|
package/package.json
CHANGED
|
@@ -61,14 +61,23 @@ topics:
|
|
|
61
61
|
shadow, one pink decision accent with a text-safe accent for text — with DNA, idioms, a
|
|
62
62
|
mobile-first playbook for playful learning or consumer apps, and family boundaries including two
|
|
63
63
|
decisions flagged for the family owner.
|
|
64
|
+
- id: heritage
|
|
65
|
+
path: heritage/DNA.yaml
|
|
66
|
+
title: Heritage Grammar
|
|
67
|
+
summary: |
|
|
68
|
+
Generated snapshot of the Heritage family's parity declaration, arrangement, scoped tokens and Common renderers.
|
|
64
69
|
families:
|
|
70
|
+
- id: heritage
|
|
71
|
+
title: heritage
|
|
72
|
+
contractRef: common
|
|
73
|
+
packageSnapshot: heritage/DNA.yaml
|
|
65
74
|
- id: common
|
|
66
75
|
title: common
|
|
67
76
|
componentContract:
|
|
68
77
|
package: '@starci/grammar'
|
|
69
78
|
entry: '@starci/grammar/common'
|
|
70
79
|
styleEntry: '@starci/grammar/common.css'
|
|
71
|
-
peers: [react, '@heroui/react', '@xyflow/react']
|
|
80
|
+
peers: [react, '@heroui/react', '@xyflow/react', react-aria-components]
|
|
72
81
|
packageSnapshot: common/DNA.yaml
|
|
73
82
|
scope: |
|
|
74
83
|
DNA snapshot only. This catalog carries no Common-side family boundary, idiom set or composition
|
|
@@ -23,42 +23,42 @@ pins:
|
|
|
23
23
|
source: packages/heroicons/package.json
|
|
24
24
|
why: 'StarCi custom Heroicons-compatible cuts; eslint-grammar-starci (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
|
|
25
25
|
'@starci/grammar':
|
|
26
|
-
version: 0.12.
|
|
26
|
+
version: 0.12.5
|
|
27
27
|
group: starci
|
|
28
28
|
install: registry
|
|
29
29
|
side: fe
|
|
30
30
|
source: packages/grammar/package.json
|
|
31
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.'
|
|
32
32
|
'@starci/eslint-canon-be':
|
|
33
|
-
version: 3.4.
|
|
33
|
+
version: 3.4.46
|
|
34
34
|
group: starci
|
|
35
35
|
install: registry
|
|
36
36
|
side: be
|
|
37
37
|
source: packages/eslint/be/package.json
|
|
38
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.'
|
|
39
39
|
'@starci/eslint-canon-fe':
|
|
40
|
-
version: 9.
|
|
40
|
+
version: 9.1.1
|
|
41
41
|
group: starci
|
|
42
42
|
install: registry
|
|
43
43
|
side: fe
|
|
44
44
|
source: packages/eslint/fe/package.json
|
|
45
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.'
|
|
46
46
|
'@starci/eslint-grammar-starci':
|
|
47
|
-
version: 0.12.
|
|
47
|
+
version: 0.12.5
|
|
48
48
|
group: starci
|
|
49
49
|
install: registry
|
|
50
50
|
side: fe
|
|
51
51
|
source: packages/eslint/grammar-starci/package.json
|
|
52
52
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
53
53
|
'@starci/eslint-grammar-offset-pop':
|
|
54
|
-
version: 0.12.
|
|
54
|
+
version: 0.12.5
|
|
55
55
|
group: starci
|
|
56
56
|
install: registry
|
|
57
57
|
side: fe
|
|
58
58
|
source: packages/eslint/grammar-offset-pop/package.json
|
|
59
59
|
why: 'the Offset Pop Grammar component API lint, versioned with @starci/grammar; its shared component contract comes from the StarCi family lint without copying rules.'
|
|
60
60
|
'@starci/stylelint-canon':
|
|
61
|
-
version: 2.0.
|
|
61
|
+
version: 2.0.13
|
|
62
62
|
group: starci
|
|
63
63
|
install: registry
|
|
64
64
|
side: fe
|
|
@@ -91,14 +91,14 @@ pins:
|
|
|
91
91
|
source: packages/test-world/package.json
|
|
92
92
|
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.'
|
|
93
93
|
'@starci/cli':
|
|
94
|
-
version: 1.7.
|
|
94
|
+
version: 1.7.38
|
|
95
95
|
group: starci
|
|
96
96
|
install: registry
|
|
97
97
|
side: both
|
|
98
98
|
source: packages/cli/package.json
|
|
99
99
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
100
100
|
'@starci/hfs':
|
|
101
|
-
version: 4.7.
|
|
101
|
+
version: 4.7.38
|
|
102
102
|
group: starci
|
|
103
103
|
install: registry
|
|
104
104
|
side: both
|
|
@@ -299,6 +299,11 @@ pins:
|
|
|
299
299
|
group: ui
|
|
300
300
|
side: fe
|
|
301
301
|
why: 'Grammar FlowGraph imports React Flow from @xyflow/react; every Grammar consumer declares this required peer at the version used by the Grammar package.'
|
|
302
|
+
react-aria-components:
|
|
303
|
+
version: 1.21.1
|
|
304
|
+
group: ui
|
|
305
|
+
side: fe
|
|
306
|
+
why: 'Grammar imports React Aria Components directly (Sidebar, Field); every Grammar consumer declares this required peer at the version HeroUI resolves.'
|
|
302
307
|
'@heroui/react':
|
|
303
308
|
version: 3.2.6
|
|
304
309
|
group: ui
|
|
@@ -99,7 +99,7 @@ concerns:
|
|
|
99
99
|
why: useTranslations for copy and the next-intl formatter for numbers, fixed dates and currency; FE-DATE owns live time display.
|
|
100
100
|
- id: ui
|
|
101
101
|
tier: core
|
|
102
|
-
packages: ['@heroui/react', '@heroui/styles', tailwindcss, '@starci/grammar']
|
|
102
|
+
packages: ['@heroui/react', '@heroui/styles', tailwindcss, '@starci/grammar', react-aria-components]
|
|
103
103
|
home: [fe.components, fe.feature]
|
|
104
104
|
pattern: FE-UI
|
|
105
105
|
forbids: ['@mantine/core', '@mui/material', '@chakra-ui/react', antd, styled-components, '@emotion/react', '@emotion/styled']
|
|
@@ -121,10 +121,11 @@ concerns:
|
|
|
121
121
|
pattern: FE-UI
|
|
122
122
|
forbids: ['@dicebear/*', boring-avatars, 'react-nice-avatar', multiavatar]
|
|
123
123
|
why: >-
|
|
124
|
-
Grammar owns
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
Grammar owns avatars: pass the uploaded avatar as src and the user or identity id as seed on Avatar, AvatarButton
|
|
125
|
+
or AvatarGroup members. Without src, Grammar renders bundled DiceBear initials using the first and last words of
|
|
126
|
+
name, with colour seeded by seed when present and by name otherwise. kind="agent" adds the Grammar-owned bot badge
|
|
127
|
+
without changing the name rule. @dicebear/core and @dicebear/styles are Grammar runtime dependencies pinned in
|
|
128
|
+
canon-pins.yaml. Apps declare and import @starci/grammar, never @dicebear/*, and draw no avatar visuals.
|
|
128
129
|
- id: theme
|
|
129
130
|
tier: core
|
|
130
131
|
packages: [next-themes]
|