@estiva-app/ui 0.35.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +70 -9
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-raw-element.d.ts +7 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -1
- package/dist/eslint/no-restyled-part.d.ts +8 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
- package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
- package/dist/gates/chunk-SYVKGOWM.js +1131 -0
- package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
- package/dist/gates/ci.d.ts +18 -0
- package/dist/gates/ci.d.ts.map +1 -0
- package/dist/gates/cli.js +20 -3
- package/dist/gates/cli.js.map +2 -2
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/escapes.d.ts +23 -0
- package/dist/gates/escapes.d.ts.map +1 -0
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts +2 -0
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.d.ts +5 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +41 -185
- package/dist/gates/index.js.map +4 -4
- package/dist/gates/name-escapes.d.ts +14 -0
- package/dist/gates/name-escapes.d.ts.map +1 -0
- package/dist/gates/status.d.ts +3 -1
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/gates/token-style.d.ts +21 -0
- package/dist/gates/token-style.d.ts.map +1 -0
- package/dist/index.js +13 -13
- package/dist/index.js.map +1 -1
- package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/behaviours.d.ts +71 -0
- package/dist/registry/behaviours.d.ts.map +1 -0
- package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
- package/dist/registry/chunk-EVJW26UR.js.map +7 -0
- package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
- package/dist/registry/chunk-SIB44CES.js.map +7 -0
- package/dist/registry/{chunk-KMZIMB3G.js → chunk-XK747STJ.js} +46 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +93 -34
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/contract.d.ts +27 -1
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -5
- package/dist/registry/siblings.d.ts +9 -0
- package/dist/registry/siblings.d.ts.map +1 -1
- package/dist/registry/storymap.d.ts +19 -5
- package/dist/registry/storymap.d.ts.map +1 -1
- package/package.json +4 -1
- package/registry.json +293 -17
- package/skill/estiva-ui.md +2 -1
- package/src/AppShell.mdx +2 -1
- package/src/AttachmentCard.tsx +7 -7
- package/src/Button.tsx +1 -1
- package/src/Card.tsx +2 -2
- package/src/ChipInput.mdx +2 -0
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.tsx +3 -3
- package/src/DialogShell.tsx +1 -1
- package/src/EmptyState.mdx +4 -3
- package/src/Lightbox.tsx +1 -1
- package/src/NavItem.tsx +1 -1
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +4 -0
- package/src/Select.tsx +1 -1
- package/src/TopBar.mdx +1 -1
- package/src/eslint/escape.ts +43 -14
- package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
- package/src/eslint/index.test.ts +2 -2
- package/src/eslint/no-copied-look.test.ts +1 -1
- package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
- package/src/eslint/no-handmade-empty-state.test.ts +2 -2
- package/src/eslint/no-handmade-header.test.ts +2 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-raw-element.test.ts +47 -15
- package/src/eslint/no-raw-element.ts +53 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
- package/src/eslint/no-restyled-part.test.ts +21 -2
- package/src/eslint/no-restyled-part.ts +30 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
- package/src/gates/app-checks.ts +4 -3
- package/src/gates/ci.ts +152 -0
- package/src/gates/cli.ts +19 -3
- package/src/gates/count.ts +1 -1
- package/src/gates/create-app.test.ts +2 -2
- package/src/gates/create-app.ts +22 -16
- package/src/gates/escapes.ts +111 -0
- package/src/gates/gate-config.ts +6 -2
- package/src/gates/gates.test.ts +93 -1
- package/src/gates/hook.ts +44 -6
- package/src/gates/index.ts +5 -1
- package/src/gates/name-escapes.ts +77 -0
- package/src/gates/status.ts +45 -2
- package/src/gates/token-lint.ts +11 -4
- package/src/gates/token-style.ts +79 -0
- package/src/how.test.ts +282 -0
- package/src/pages.test.ts +17 -0
- package/src/registry/app.test.ts +34 -0
- package/src/registry/app.ts +52 -2
- package/src/registry/behaviours.ts +223 -0
- package/src/registry/build.ts +36 -0
- package/src/registry/cli.ts +37 -18
- package/src/registry/contract.test.ts +63 -3
- package/src/registry/contract.ts +75 -2
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +14 -0
- package/src/registry/siblings.test.ts +24 -1
- package/src/registry/siblings.ts +23 -0
- package/src/registry/storymap.test.ts +52 -31
- package/src/registry/storymap.ts +76 -37
- package/stories/Choosing.mdx +8 -2
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/gates/chunk-GI6M4P7H.js +0 -606
- package/dist/gates/chunk-GI6M4P7H.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-KMZIMB3G.js.map +0 -7
- package/dist/registry/chunk-XVII7P6G.js.map +0 -7
- /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
- /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
|
@@ -111,7 +111,43 @@ function isOn(element: JSXOpeningElement, name: string): boolean {
|
|
|
111
111
|
return attr !== undefined && literal(attr) !== false
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | null
|
|
114
|
+
type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | { report: 'clickable'; element: string } | null
|
|
115
|
+
|
|
116
|
+
/** The pointer's press, as a handler on a plain element makes it a control (R5). */
|
|
117
|
+
const CLICK_HANDLERS = ['onClick', 'onMouseDown', 'onMouseUp', 'onPointerDown', 'onPointerUp', 'onDoubleClick']
|
|
118
|
+
|
|
119
|
+
interface Expression {
|
|
120
|
+
type: string
|
|
121
|
+
[key: string]: unknown
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `e.stopPropagation()` or `e.preventDefault()`: a handler that only keeps the click from what is behind. */
|
|
125
|
+
function onlyStops(handler: Expression | undefined): boolean {
|
|
126
|
+
if (!handler || (handler.type !== 'ArrowFunctionExpression' && handler.type !== 'FunctionExpression')) return false
|
|
127
|
+
const body = handler.body as Expression
|
|
128
|
+
const calls = body.type === 'BlockStatement' ? (body.body as Expression[]).map((s) => (s.type === 'ExpressionStatement' ? (s.expression as Expression) : s)) : [body]
|
|
129
|
+
return (
|
|
130
|
+
calls.length > 0 &&
|
|
131
|
+
calls.every((call) => {
|
|
132
|
+
if (call.type !== 'CallExpression') return false
|
|
133
|
+
const callee = call.callee as Expression
|
|
134
|
+
const property = callee.type === 'MemberExpression' ? (callee.property as Expression) : undefined
|
|
135
|
+
return property?.type === 'Identifier' && (property.name === 'stopPropagation' || property.name === 'preventDefault')
|
|
136
|
+
})
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A plain element made clickable by hand: the first press handler that does more than stop the click. */
|
|
141
|
+
function clickHandler(element: JSXOpeningElement): string | undefined {
|
|
142
|
+
for (const name of CLICK_HANDLERS) {
|
|
143
|
+
const attr = attribute(element, name)
|
|
144
|
+
if (!attr) continue
|
|
145
|
+
const value = attr.value
|
|
146
|
+
const handler = value?.type === 'JSXExpressionContainer' ? (value.expression as Expression | undefined) : undefined
|
|
147
|
+
if (!onlyStops(handler)) return name
|
|
148
|
+
}
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
115
151
|
|
|
116
152
|
/** What this element is to the rule: a raw element with a part, one with none yet, or none of its business. */
|
|
117
153
|
export function classify(element: JSXOpeningElement): Found {
|
|
@@ -131,7 +167,14 @@ export function classify(element: JSXOpeningElement): Found {
|
|
|
131
167
|
return part ? { report: 'raw', element: shown, part } : { report: 'noPart', element: shown }
|
|
132
168
|
}
|
|
133
169
|
|
|
134
|
-
if (!Object.hasOwn(RAW_ELEMENT_PARTS, name))
|
|
170
|
+
if (!Object.hasOwn(RAW_ELEMENT_PARTS, name)) {
|
|
171
|
+
// A plain element with a press handler is a button drawn by hand (Katerina's ruling R5,
|
|
172
|
+
// 25 September): no focus, no keys, no look anybody keeps in step. `<option>` and
|
|
173
|
+
// `<summary>` live inside a control the rule already reads.
|
|
174
|
+
if (!/^[a-z][a-z0-9]*$/.test(name) || name === 'option' || name === 'summary') return null
|
|
175
|
+
const handler = clickHandler(element)
|
|
176
|
+
return handler ? { report: 'clickable', element: `<${name} ${handler}>` } : null
|
|
177
|
+
}
|
|
135
178
|
if ((name === 'audio' || name === 'video') && !isOn(element, 'controls')) return null
|
|
136
179
|
if (name === 'img' && !attribute(element, 'usemap') && !attribute(element, 'useMap')) return null
|
|
137
180
|
|
|
@@ -150,6 +193,10 @@ export function classify(element: JSXOpeningElement): Found {
|
|
|
150
193
|
* that says so: the part is made in the package, not by hand in an app
|
|
151
194
|
* (Katerina, 16 September).
|
|
152
195
|
*
|
|
196
|
+
* A plain element with a press handler (`<div onClick>`) is a control too, and
|
|
197
|
+
* is refused naming `Button` (R5, 25 September); a handler that only stops the
|
|
198
|
+
* click reaching what is behind passes.
|
|
199
|
+
*
|
|
153
200
|
* Only a JSX element written with a lowercase name. `<Button>`,
|
|
154
201
|
* `<Foo.button>` and `createElement('button')` are not this rule's business.
|
|
155
202
|
*/
|
|
@@ -160,6 +207,8 @@ export const noRawElement: Rule.RuleModule = {
|
|
|
160
207
|
schema: [],
|
|
161
208
|
messages: {
|
|
162
209
|
raw: 'Use `{{use}}` from @estiva-app/ui instead of a raw {{element}}.{{more}}',
|
|
210
|
+
clickable:
|
|
211
|
+
'A clickable {{element}} is a button made by hand: it takes no focus and answers no key. Use `Button` from @estiva-app/ui; for a whole card that opens something, `Card` with `href`. A handler that only stops the click (`e.stopPropagation()`) is fine.',
|
|
163
212
|
noPart: '@estiva-app/ui has no part for a raw {{element}} yet. Do not build one here: ask Katerina, and it gets made in @estiva-app/ui.',
|
|
164
213
|
...ESCAPE_MESSAGES,
|
|
165
214
|
},
|
|
@@ -172,7 +221,8 @@ export const noRawElement: Rule.RuleModule = {
|
|
|
172
221
|
const found = classify(element)
|
|
173
222
|
if (!found) return
|
|
174
223
|
if (isEscaped(context, element)) return
|
|
175
|
-
if (found.report === '
|
|
224
|
+
if (found.report === 'clickable') context.report({ loc: element.loc, messageId: 'clickable', data: { element: found.element } })
|
|
225
|
+
else if (found.report === 'noPart') context.report({ loc: element.loc, messageId: 'noPart', data: { element: found.element } })
|
|
176
226
|
else context.report({ loc: element.loc, messageId: 'raw', data: { element: found.element, use: found.part.use, more: found.part.more ?? '' } })
|
|
177
227
|
},
|
|
178
228
|
}
|
|
@@ -65,27 +65,27 @@ tester.run('no-rebuilt-behaviour', noRebuiltBehaviour, {
|
|
|
65
65
|
// escapes
|
|
66
66
|
{
|
|
67
67
|
name: 'an escape above the statement keeps a page listener',
|
|
68
|
-
code: effect(" // @estiva-escape: an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)"),
|
|
68
|
+
code: effect(" // @estiva-escape(no-rebuilt-behaviour): an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)"),
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
71
|
name: 'an escape above the element keeps a hand-written role',
|
|
72
|
-
code: component(' // @estiva-escape: a list the editor drives from its caret\n <MenuItem role="option" aria-selected label="Item" />'),
|
|
72
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour): a list the editor drives from its caret\n <MenuItem role="option" aria-selected label="Item" />'),
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
name: 'an escape above the object property keeps its arrow keys',
|
|
76
|
-
code: "useImperativeHandle(ref, () => ({\n // @estiva-escape: a list the editor drives from its caret\n onKeyDown: ({ event }) => {\n if (event.key === 'ArrowDown') next()\n if (event.key === 'ArrowUp') previous()\n return false\n },\n}))",
|
|
76
|
+
code: "useImperativeHandle(ref, () => ({\n // @estiva-escape(no-rebuilt-behaviour): a list the editor drives from its caret\n onKeyDown: ({ event }) => {\n if (event.key === 'ArrowDown') next()\n if (event.key === 'ArrowUp') previous()\n return false\n },\n}))",
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
name: 'an escape above the call keeps a portal',
|
|
80
|
-
code: "import { createPortal } from 'react-dom'\nfunction Viewer() {\n // @estiva-escape: becomes the package Lightbox at stage 7\n return createPortal(<div />, document.body)\n}",
|
|
80
|
+
code: "import { createPortal } from 'react-dom'\nfunction Viewer() {\n // @estiva-escape(no-rebuilt-behaviour): becomes the package Lightbox at stage 7\n return createPortal(<div />, document.body)\n}",
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
name: 'an escape above an array item keeps its class',
|
|
84
|
-
code: "const CLASSES = [\n 'flex',\n // @estiva-escape: a code block the editor draws, which nothing can wrap\n '[&_pre]:overflow-x-auto',\n]",
|
|
84
|
+
code: "const CLASSES = [\n 'flex',\n // @estiva-escape(no-rebuilt-behaviour): a code block the editor draws, which nothing can wrap\n '[&_pre]:overflow-x-auto',\n]",
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
87
|
name: 'one escape above an element covers everything found on it',
|
|
88
|
-
code: component(' // @estiva-escape: a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} className="overflow-auto" />'),
|
|
88
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour): a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} className="overflow-auto" />'),
|
|
89
89
|
},
|
|
90
90
|
],
|
|
91
91
|
invalid: [
|
|
@@ -175,17 +175,17 @@ tester.run('no-rebuilt-behaviour', noRebuiltBehaviour, {
|
|
|
175
175
|
// escapes
|
|
176
176
|
{
|
|
177
177
|
name: 'an escape with no reason hides nothing',
|
|
178
|
-
code: effect(" // @estiva-escape:\n document.addEventListener('mousedown', close)"),
|
|
178
|
+
code: effect(" // @estiva-escape(no-rebuilt-behaviour):\n document.addEventListener('mousedown', close)"),
|
|
179
179
|
errors: [{ messageId: 'escapeWithoutReason', line: 2 }, { messageId: 'press', line: 3 }],
|
|
180
180
|
},
|
|
181
181
|
{
|
|
182
182
|
name: 'an escape above one statement does not reach the next',
|
|
183
|
-
code: effect(" // @estiva-escape: an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)\n document.addEventListener('keydown', onKey)"),
|
|
183
|
+
code: effect(" // @estiva-escape(no-rebuilt-behaviour): an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)\n document.addEventListener('keydown', onKey)"),
|
|
184
184
|
errors: [{ messageId: 'key', line: 4 }],
|
|
185
185
|
},
|
|
186
186
|
{
|
|
187
187
|
name: 'with reportEscapes on, one escape over two findings is counted once',
|
|
188
|
-
code: component(' // @estiva-escape: a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} />'),
|
|
188
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour): a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} />'),
|
|
189
189
|
settings: { estiva: { reportEscapes: true } },
|
|
190
190
|
errors: [{ messageId: 'escaped', line: 3 }],
|
|
191
191
|
},
|
|
@@ -51,6 +51,8 @@ const local = (imports: string, from: string, body: string) => `import { ${impor
|
|
|
51
51
|
const USE_VARIANT_OR_SIZE = ' Use its `variant` or `size`.'
|
|
52
52
|
const restyled = (classes: string, where: string, use: string) =>
|
|
53
53
|
`${classes} on ${where} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in.${use} A look for what is around it goes on your own element around it.`
|
|
54
|
+
const restyledStyle = (keys: string, where: string, use: string) =>
|
|
55
|
+
`${keys} in the \`style\` of ${where} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled, by class or by style: only space, size, flex and grid, and position pass in.${use} A look for what is around it goes on your own element around it.`
|
|
54
56
|
const noProp = (part: string) => ` \`${part}\` has no prop for this yet: ask Katerina, and it gets made in @estiva-app/ui.`
|
|
55
57
|
|
|
56
58
|
tester.run('no-restyled-part', noRestyledPart, {
|
|
@@ -66,14 +68,31 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
66
68
|
{ name: 'a raw element is not a part', filename: app, code: ui('Button', ' <div className="bg-bg-surface text-h2 rounded-lg" />') },
|
|
67
69
|
{ name: 'an app component of the same name is not a part', filename: app, code: local('Button', './components/Button', ' <Button className="text-h2" />') },
|
|
68
70
|
{ name: 'a style prop is not a class prop', filename: app, code: ui('Button', ' <Button style={{ width: 20 }} data-class="text-h2">x</Button>') },
|
|
71
|
+
// style on a part: placement keys pass (B6)
|
|
72
|
+
{ name: 'style: space, size, position and a move', filename: app, code: ui('Card', " <Card style={{ marginTop: 4, 'paddingInline': 8, width: '50%', maxHeight: 240, flexGrow: 1, gridColumn: '1 / 3', position: 'absolute', top: 0, zIndex: 2, transform: `translateY(${y}px)` }} />") },
|
|
73
|
+
{ name: 'style: a const of placement, and a custom property', filename: app, code: "import { Card } from '@estiva-app/ui'\nconst at = { top: 0, left: 0 }\nexport function Probe() {\n return <Card style={at}><Card style={{ '--row': 2 }} /></Card>\n}\n" },
|
|
74
|
+
{ name: 'style the code works out while it runs passes', filename: app, code: ui('Card', ' <Card style={props.style} />') },
|
|
75
|
+
{ name: 'style on a component that hands on only its className is not read', filename: app, code: local('ConversationCard', './components/ConversationCard', ' <ConversationCard title="x" style={{ opacity: 0.5 }} />') },
|
|
69
76
|
{ name: 'EmptyState placed', filename: app, code: ui('EmptyState', ' <EmptyState className="mt-2 flex-1" message="Nothing yet" />') },
|
|
70
77
|
// escapes
|
|
71
|
-
{ name: 'escaped, with its reason', filename: app, code: ui('Link', ' // @estiva-escape: the whole row is the link, and no part does that yet\n <Link href="/x" className="after:absolute after:inset-0">x</Link>') },
|
|
78
|
+
{ name: 'escaped, with its reason', filename: app, code: ui('Link', ' // @estiva-escape(no-restyled-part): the whole row is the link, and no part does that yet\n <Link href="/x" className="after:absolute after:inset-0">x</Link>') },
|
|
72
79
|
// the package itself
|
|
73
80
|
{ name: 'inside the package: a name index.ts does not export', filename: pkg, code: local('Base', './Base', ' <Base className="text-h2" />') },
|
|
74
81
|
{ name: 'inside the package: a raw element in a part', filename: pkg, code: 'export function Card() {\n return <div className="rounded-lg border" />\n}\n' },
|
|
75
82
|
],
|
|
76
83
|
invalid: [
|
|
84
|
+
{
|
|
85
|
+
name: 'style: a corner, an opacity, a weight and a line height (B6)',
|
|
86
|
+
filename: app,
|
|
87
|
+
code: ui('Button', ' <Button style={{ borderRadius: 999, opacity: 0.4, fontWeight: 700, lineHeight: 2, marginTop: 4 }}>x</Button>'),
|
|
88
|
+
errors: [{ message: restyledStyle('`borderRadius` `opacity` `fontWeight` `lineHeight`', '`Button`', USE_VARIANT_OR_SIZE) }],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'style: quoted keys, a const, and a re-export',
|
|
92
|
+
filename: app,
|
|
93
|
+
code: "import { Button } from './components/ui'\nconst look = { 'background': 'red' } as const\nexport function Probe() {\n return <Button style={look}>x</Button>\n}\n",
|
|
94
|
+
errors: [{ message: restyledStyle('`background`', '`Button`', USE_VARIANT_OR_SIZE) }],
|
|
95
|
+
},
|
|
77
96
|
{
|
|
78
97
|
name: 'a colour and a text size, naming the props that carry a look',
|
|
79
98
|
filename: app,
|
|
@@ -176,7 +195,7 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
176
195
|
{
|
|
177
196
|
name: 'an escape with no reason is reported, and so is the class',
|
|
178
197
|
filename: app,
|
|
179
|
-
code: ui('Link', ' // @estiva-escape:\n <Link href="/x" className="text-h2">x</Link>'),
|
|
198
|
+
code: ui('Link', ' // @estiva-escape(no-restyled-part):\n <Link href="/x" className="text-h2">x</Link>'),
|
|
180
199
|
errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant` or `truncate`.') }],
|
|
181
200
|
},
|
|
182
201
|
{
|
|
@@ -53,6 +53,16 @@ export const PLACEMENT: { what: string; pattern: RegExp }[] = [
|
|
|
53
53
|
{ what: 'a name for hover', pattern: /^(?:group|peer)(?:\/[\w-]+)?$/ },
|
|
54
54
|
]
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The same, as `style` keys (Katerina's ruling B6, 25 September): `style` on a
|
|
58
|
+
* part is read like its `className`, so a radius, a weight or an opacity cannot
|
|
59
|
+
* go in by the other door. CSS's own names for space, size, flex and grid, and
|
|
60
|
+
* position; `transform` and `translate` move a part without changing its look.
|
|
61
|
+
* A custom property (`--name`) passes: the part decides what it means.
|
|
62
|
+
*/
|
|
63
|
+
export const PLACEMENT_STYLE =
|
|
64
|
+
/^(?:margin|padding|inset|scrollMargin)(?:Top|Right|Bottom|Left|Block|Inline|BlockStart|BlockEnd|InlineStart|InlineEnd)?$|^(?:width|height|minWidth|minHeight|maxWidth|maxHeight|inlineSize|blockSize|minInlineSize|minBlockSize|maxInlineSize|maxBlockSize)$|^(?:display|flex|flexGrow|flexShrink|flexBasis|order|gap|rowGap|columnGap|alignSelf|justifySelf|placeSelf|gridArea|gridColumn|gridColumnStart|gridColumnEnd|gridRow|gridRowStart|gridRowEnd)$|^(?:position|top|right|bottom|left|zIndex|transform|translate)$/
|
|
65
|
+
|
|
56
66
|
/**
|
|
57
67
|
* A variant that draws another box or reaches inside the part: `before:`,
|
|
58
68
|
* `after:`, `[&>*]:`, `*:`… A placement class behind one of these is not the
|
|
@@ -592,6 +602,8 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
592
602
|
messages: {
|
|
593
603
|
restyled:
|
|
594
604
|
'{{classes}} on {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.',
|
|
605
|
+
restyledStyle:
|
|
606
|
+
'{{keys}} in the `style` of {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled, by class or by style: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.',
|
|
595
607
|
emptyStatePadding:
|
|
596
608
|
'{{classes}} pads {{where}}. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.',
|
|
597
609
|
...ESCAPE_MESSAGES,
|
|
@@ -628,6 +640,23 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
628
640
|
if (padding.length) context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'emptyStatePadding', data: { classes: code(padding), where } })
|
|
629
641
|
}
|
|
630
642
|
|
|
643
|
+
/** `style` on a part: every key it can read must be placement (B6). */
|
|
644
|
+
const checkStyle = (element: Node, binding: PartBinding, value: Node | null | undefined, at: Node) => {
|
|
645
|
+
if (binding.props !== 'all' || value?.type !== 'JSXExpressionContainer') return
|
|
646
|
+
let object = child(value, 'expression')
|
|
647
|
+
if (object?.type === 'Identifier') object = constantOf(object, scopeOf(object))
|
|
648
|
+
while (object?.type === 'TSAsExpression' || object?.type === 'TSSatisfiesExpression') object = child(object, 'expression')
|
|
649
|
+
if (object?.type !== 'ObjectExpression') return
|
|
650
|
+
const keys = children(object, 'properties')
|
|
651
|
+
.map((property) => (property?.type === 'Property' && !property.computed ? nameOf(child(property, 'key')) : undefined))
|
|
652
|
+
.filter((key): key is string => !!key && !key.startsWith('--') && !PLACEMENT_STYLE.test(key))
|
|
653
|
+
if (!keys.length || escaped(element)) return
|
|
654
|
+
const props = PART_LOOK_PROPS[binding.part]
|
|
655
|
+
const use = props ? ` Use its ${or(props)}.` : ` \`${binding.part}\` has no prop for this yet: ask Katerina, and it gets made in @estiva-app/ui.`
|
|
656
|
+
const where = binding.via && binding.via !== binding.part ? `\`${binding.via}\` (it hands its props to \`${binding.part}\`)` : `\`${binding.part}\``
|
|
657
|
+
context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'restyledStyle', data: { keys: code([...new Set(keys)]), where, use } })
|
|
658
|
+
}
|
|
659
|
+
|
|
631
660
|
return {
|
|
632
661
|
Program(program) {
|
|
633
662
|
locals = moduleParts(file, program as unknown as Node, parser, new Set([file])).locals
|
|
@@ -641,6 +670,7 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
641
670
|
if (attribute.type === 'JSXAttribute') {
|
|
642
671
|
const prop = nameOf(child(attribute, 'name'))
|
|
643
672
|
if (prop && CLASS_PROP.test(prop)) check(element, binding, prop, child(attribute, 'value'), attribute)
|
|
673
|
+
else if (prop === 'style') checkStyle(element, binding, child(attribute, 'value'), attribute)
|
|
644
674
|
continue
|
|
645
675
|
}
|
|
646
676
|
// `{...{ className }}`, or a `const` object spread onto the part.
|
|
@@ -35,7 +35,7 @@ tester.run('raw-element-outside-a-wrapper', rawElementOutsideAWrapper, {
|
|
|
35
35
|
{ name: 'layout inside a component', code: component(' <div>\n <span>Issues</span>\n </div>') },
|
|
36
36
|
{
|
|
37
37
|
name: 'a nested element with its reason on the line above',
|
|
38
|
-
code: component(' <div>\n {/* @estiva-escape: the trail is one line of text and an anchor keeps it */}\n <a href="/x">Issues</a>\n </div>'),
|
|
38
|
+
code: component(' <div>\n {/* @estiva-escape(raw-element-outside-a-wrapper): the trail is one line of text and an anchor keeps it */}\n <a href="/x">Issues</a>\n </div>'),
|
|
39
39
|
},
|
|
40
40
|
],
|
|
41
41
|
invalid: [
|
|
@@ -66,14 +66,14 @@ tester.run('raw-element-outside-a-wrapper', rawElementOutsideAWrapper, {
|
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
name: 'a reason too short to be a reason',
|
|
69
|
-
code: component(' <div>\n {/* @estiva-escape: short */}\n <a href="/x">Issues</a>\n </div>'),
|
|
69
|
+
code: component(' <div>\n {/* @estiva-escape(raw-element-outside-a-wrapper): short */}\n <a href="/x">Issues</a>\n </div>'),
|
|
70
70
|
errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'nested', data: { name: 'a' } }],
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
name: 'an eslint-disable instead of a reason',
|
|
74
74
|
// The tester runs the rule under its bare name; in a real lint the plugin
|
|
75
75
|
// prefixes it, and `isEscaped` compares against whichever the rule has.
|
|
76
|
-
code: component(' <div>\n {/* eslint-disable-next-line rule-to-test/raw-element-outside-a-wrapper -- @estiva-escape: the trail keeps its anchor */}\n <a href="/x">Issues</a>\n </div>'),
|
|
76
|
+
code: component(' <div>\n {/* eslint-disable-next-line rule-to-test/raw-element-outside-a-wrapper -- @estiva-escape(raw-element-outside-a-wrapper): the trail keeps its anchor */}\n <a href="/x">Issues</a>\n </div>'),
|
|
77
77
|
// The directive silences the rule's own report, which is exactly why it is
|
|
78
78
|
// refused: `countGates` sees the silenced report and the gate fails on it.
|
|
79
79
|
errors: [{ messageId: 'escapeInDirective' }],
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -114,6 +114,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
114
114
|
]),
|
|
115
115
|
ticket('UIG-7', [
|
|
116
116
|
{ what: 'a raw <input> is an error naming TextInput', run: probe(component('<input />'), 'error', 'TextInput') },
|
|
117
|
+
{ what: 'a clickable <div> is an error naming Button (R5)', run: probe(component('<div onClick={() => history.back()}>Back</div>'), 'error', '`Button`') },
|
|
117
118
|
{ what: 'a raw <a> is an error naming Link', run: all(h, [probe(component('<a href="/topics">Topics</a>'), 'error', 'Link'), probe(component('<a href="/issues">Issues</a>'), 'error', 'Link')], 'a raw <a> is an error naming Link') },
|
|
118
119
|
{ what: 'a raw <form> is an error naming Form, and a file input one naming FilePicker', run: async () => {
|
|
119
120
|
const form = await probe(component('<form />'), 'error', '`Form`')()
|
|
@@ -131,7 +132,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
131
132
|
{ what: 'an escaped element is not an error, while the same element is', run: async () => {
|
|
132
133
|
const raw = await probe(component('<form />'), 'error')()
|
|
133
134
|
if (raw.result !== 'pass') return h.FAIL(`a raw <form> is not caught yet, so this proves nothing: ${raw.detail}`)
|
|
134
|
-
return probe('export function Probe() {\n return (\n // @estiva-escape: a probe that keeps its element on purpose\n <form />\n )\n}\n', 'none')()
|
|
135
|
+
return probe('export function Probe() {\n return (\n // @estiva-escape(no-raw-element): a probe that keeps its element on purpose\n <form />\n )\n}\n', 'none')()
|
|
135
136
|
} },
|
|
136
137
|
{ what: 'a real page, full of elements that are not controls, gets no error', run: realPage },
|
|
137
138
|
]),
|
|
@@ -154,7 +155,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
154
155
|
{ what: 'an escaped listener is not an error, while the same listener is', run: async () => {
|
|
155
156
|
const raw = await probe("export function listen() {\n document.addEventListener('mousedown', () => {})\n}\n", 'error', undefined, 'src/lib/__gates_probe__.ts')()
|
|
156
157
|
if (raw.result !== 'pass') return h.FAIL(`a page listener is not caught yet, so this proves nothing: ${raw.detail}`)
|
|
157
|
-
return probe("export function listen() {\n // @estiva-escape: a probe that keeps its listener on purpose\n document.addEventListener('mousedown', () => {})\n}\n", 'none', undefined, 'src/lib/__gates_probe__.ts')()
|
|
158
|
+
return probe("export function listen() {\n // @estiva-escape(no-rebuilt-behaviour): a probe that keeps its listener on purpose\n document.addEventListener('mousedown', () => {})\n}\n", 'none', undefined, 'src/lib/__gates_probe__.ts')()
|
|
158
159
|
} },
|
|
159
160
|
{ what: 'a real page gets no error', run: realPage },
|
|
160
161
|
]),
|
|
@@ -164,7 +165,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
164
165
|
{ what: 'an escaped look is not an error, while the same look is', run: async () => {
|
|
165
166
|
const raw = await probe(`import { Link } from '@estiva-app/ui'\n${component('<Link href="/x" className="after:absolute after:inset-0">x</Link>')}`, 'error', '`Link`')()
|
|
166
167
|
if (raw.result !== 'pass') return h.FAIL(`a restyled part is not caught yet, so this proves nothing: ${raw.detail}`)
|
|
167
|
-
return probe(`import { Link } from '@estiva-app/ui'\nexport function Probe() {\n return (\n // @estiva-escape: a probe that keeps its row link on purpose\n <Link href="/x" className="after:absolute after:inset-0">x</Link>\n )\n}\n`, 'none')()
|
|
168
|
+
return probe(`import { Link } from '@estiva-app/ui'\nexport function Probe() {\n return (\n // @estiva-escape(no-restyled-part): a probe that keeps its row link on purpose\n <Link href="/x" className="after:absolute after:inset-0">x</Link>\n )\n}\n`, 'none')()
|
|
168
169
|
} },
|
|
169
170
|
{ what: 'a real page gets no error', run: realPage },
|
|
170
171
|
]),
|
package/src/gates/ci.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* `estiva-gates ci` — CI's job `gate`, as one command (Katerina's ruling R13,
|
|
4
|
+
* 25 September). Until 0.36.0 each repo listed the gate's steps in its own
|
|
5
|
+
* workflow, and the lists had drifted: Peek ran `lint:tokens`, Ship the whole
|
|
6
|
+
* `lint`, and no repo checked the two things below. Now the steps live here,
|
|
7
|
+
* and a repo's `gate` job runs this and nothing else of the gate's.
|
|
8
|
+
*
|
|
9
|
+
* 1. The gate lint (`npm run lint:rules`), which writes `.gates-count.json`.
|
|
10
|
+
* 2. The count is committed (B2): a pull request that changes what the gate
|
|
11
|
+
* finds and leaves the old count behind fails, so the file is the truth.
|
|
12
|
+
* 3. The token contract (`npm run lint:tokens`, or `npm run lint` where a repo
|
|
13
|
+
* has no token lint of its own).
|
|
14
|
+
* 4. The catalogue (`npm run registry:check`).
|
|
15
|
+
* 5. The wall's own settings (B13): the committed `.claude/settings.json` runs
|
|
16
|
+
* the editor hook before every Write and Edit, and that hook refuses a raw
|
|
17
|
+
* button; every folder the gate skips is named on the debt page, with why.
|
|
18
|
+
*
|
|
19
|
+
* Every step runs, so one red step does not hide the others.
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from 'node:child_process'
|
|
22
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { pathToFileURL } from 'node:url'
|
|
25
|
+
import { TOKEN_LINT_IGNORES } from './token-lint'
|
|
26
|
+
|
|
27
|
+
export interface CiOptions {
|
|
28
|
+
/** The repository's top folder, where the gate lints from. */
|
|
29
|
+
root?: string
|
|
30
|
+
/** `package` is this package's own gate: its skipped folders are named in docs/GATES.md. */
|
|
31
|
+
audience?: 'app' | 'package'
|
|
32
|
+
/** Print as the steps run. Defaults to stdout. */
|
|
33
|
+
print?: (line: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The part a CI run writes to the probe: a raw button, which every gate refuses. */
|
|
37
|
+
const PROBE = '/** A probe CI writes to check the editor hook; never on disk. */\nexport function GateProbe() {\n return <button type="button">x</button>\n}\n'
|
|
38
|
+
|
|
39
|
+
const npm = (root: string, script: string) => spawnSync('npm', ['run', script], { cwd: root, stdio: 'inherit', shell: true }).status === 0
|
|
40
|
+
|
|
41
|
+
/** `.gates-count.json` as the gate lint left it matches the committed file; the lines that say otherwise. */
|
|
42
|
+
export function countCommitted(root: string): string[] {
|
|
43
|
+
const run = spawnSync('git', ['status', '--porcelain', '--', '.gates-count.json'], { cwd: root, encoding: 'utf8' })
|
|
44
|
+
if (run.status !== 0) return [`git could not read .gates-count.json's state: ${run.stderr.trim()}`]
|
|
45
|
+
if (!run.stdout.trim()) return []
|
|
46
|
+
const diff = spawnSync('git', ['diff', '--', '.gates-count.json'], { cwd: root, encoding: 'utf8' }).stdout.trim()
|
|
47
|
+
return [
|
|
48
|
+
'The gate found something other than the committed count: run `npm run lint:rules` and commit `.gates-count.json` with the change that moved it.',
|
|
49
|
+
...(diff ? diff.split('\n').filter((l) => /^[+-]\s/.test(l)).map((l) => ` ${l}`) : [' .gates-count.json is not committed at all']),
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface HookEntry {
|
|
54
|
+
matcher?: string
|
|
55
|
+
hooks?: { type?: string; command?: string }[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The committed hook runs before every Write and Edit and refuses a raw button; the lines that say otherwise. */
|
|
59
|
+
export function hookRuns(root: string): string[] {
|
|
60
|
+
const file = join(root, '.claude', 'settings.json')
|
|
61
|
+
if (!existsSync(file)) return ['.claude/settings.json is missing: the editor hook is not switched on (docs/GATES.md §23).']
|
|
62
|
+
let entries: HookEntry[]
|
|
63
|
+
try {
|
|
64
|
+
entries = ((JSON.parse(readFileSync(file, 'utf8')) as { hooks?: { PreToolUse?: HookEntry[] } }).hooks?.PreToolUse ?? [])
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return [`.claude/settings.json does not read as JSON: ${error instanceof Error ? error.message : String(error)}`]
|
|
67
|
+
}
|
|
68
|
+
const covers = (matcher: string | undefined, tool: string) => {
|
|
69
|
+
if (!matcher || matcher === '*') return true
|
|
70
|
+
try {
|
|
71
|
+
return new RegExp(`^(?:${matcher})$`).test(tool)
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const input = JSON.stringify({ tool_name: 'Write', session_id: 'estiva-gates-ci', cwd: root, tool_input: { file_path: join(root, 'src', 'GateProbe.tsx'), content: PROBE } })
|
|
77
|
+
const tried: string[] = []
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (!covers(entry.matcher, 'Write') || !covers(entry.matcher, 'Edit')) continue
|
|
80
|
+
for (const hook of entry.hooks ?? []) {
|
|
81
|
+
if (hook.type !== 'command' || !hook.command) continue
|
|
82
|
+
// The project folder, written in: a Windows shell does not expand `$VAR`.
|
|
83
|
+
const command = hook.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, root.split('\\').join('/'))
|
|
84
|
+
const run = spawnSync(command, { cwd: root, input, encoding: 'utf8', shell: true, env: { ...process.env, CLAUDE_PROJECT_DIR: root }, timeout: 120_000 })
|
|
85
|
+
if (run.status === 2 && /UI Guardrails/.test(run.stderr)) return []
|
|
86
|
+
tried.push(` ${hook.command} → exit ${run.status ?? 'none'}${run.stderr.trim() ? `: ${run.stderr.trim().split('\n')[0]}` : ''}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [
|
|
90
|
+
tried.length
|
|
91
|
+
? 'The editor hook let a raw button through: no PreToolUse hook in .claude/settings.json refused it.'
|
|
92
|
+
: 'No PreToolUse hook in .claude/settings.json runs before both Write and Edit: the editor gate is off.',
|
|
93
|
+
...tried,
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A glob as the words a page would name it by: `demo-scenarios/**` → `demo-scenarios`. */
|
|
98
|
+
const plain = (glob: string) => glob.replace(/\/\*\*.*$|\/$/, '')
|
|
99
|
+
|
|
100
|
+
/** The folders a gate config skips beyond the package's own three, from the config itself. */
|
|
101
|
+
export async function skippedFolders(root: string): Promise<string[]> {
|
|
102
|
+
const found = new Set<string>()
|
|
103
|
+
for (const name of ['eslint.gates.config.js', 'eslint.tokens.config.js']) {
|
|
104
|
+
const file = join(root, name)
|
|
105
|
+
if (!existsSync(file)) continue
|
|
106
|
+
// The file as it is now: Node keeps a module it has imported once, by its URL.
|
|
107
|
+
const loaded = (await import(`${pathToFileURL(file).href}?at=${statSync(file).mtimeMs}`)) as { default?: unknown }
|
|
108
|
+
const configs = [loaded.default].flat(Infinity) as { ignores?: string[]; [key: string]: unknown }[]
|
|
109
|
+
for (const c of configs) {
|
|
110
|
+
// Only a config with nothing but `ignores` skips a folder for every rule.
|
|
111
|
+
if (!c || typeof c !== 'object' || !Array.isArray(c.ignores) || Object.keys(c).some((k) => k !== 'ignores' && k !== 'name')) continue
|
|
112
|
+
for (const glob of c.ignores) if (!TOKEN_LINT_IGNORES.includes(glob)) found.add(glob)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return [...found]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Every skipped folder is named on the debt page; the lines that say otherwise. */
|
|
119
|
+
export async function skipsListed(root: string, audience: 'app' | 'package'): Promise<string[]> {
|
|
120
|
+
const page = audience === 'package' ? 'docs/GATES.md' : 'docs/GATES-DEBT.md'
|
|
121
|
+
const skipped = await skippedFolders(root)
|
|
122
|
+
if (!skipped.length) return []
|
|
123
|
+
const text = existsSync(join(root, page)) ? readFileSync(join(root, page), 'utf8') : ''
|
|
124
|
+
const missing = skipped.filter((glob) => !text.includes(plain(glob)))
|
|
125
|
+
if (!missing.length) return []
|
|
126
|
+
return [`The gate skips folders ${page} does not name. Name each there, with why it is not checked (B11):`, ...missing.map((g) => ` ${g}`)]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function runCi({ root = process.cwd(), audience = 'app', print = (line) => process.stdout.write(`${line}\n`) }: CiOptions = {}): Promise<number> {
|
|
130
|
+
const scripts = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}
|
|
131
|
+
const tokens = scripts['lint:tokens'] ? 'lint:tokens' : 'lint'
|
|
132
|
+
const failed: string[] = []
|
|
133
|
+
const step = async (title: string, run: () => boolean | string[] | Promise<string[]>) => {
|
|
134
|
+
print(`\n▸ ${title}`)
|
|
135
|
+
const result = await run()
|
|
136
|
+
const problems = result === true ? [] : result === false ? [`${title}: failed (above)`] : result
|
|
137
|
+
if (problems.length) {
|
|
138
|
+
failed.push(title)
|
|
139
|
+
for (const line of problems) print(process.env.GITHUB_ACTIONS && !line.startsWith(' ') ? `::error::${line}` : line)
|
|
140
|
+
} else print(' ✓')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await step('The gate lint (npm run lint:rules)', () => npm(root, 'lint:rules'))
|
|
144
|
+
await step('The count is committed (.gates-count.json)', () => countCommitted(root))
|
|
145
|
+
await step(`The token contract (npm run ${tokens})`, () => npm(root, tokens))
|
|
146
|
+
await step('The catalogue (npm run registry:check)', () => npm(root, 'registry:check'))
|
|
147
|
+
await step('The editor hook refuses a raw button (.claude/settings.json)', () => hookRuns(root))
|
|
148
|
+
await step('Every folder the gate skips is named on the debt page', () => skipsListed(root, audience))
|
|
149
|
+
|
|
150
|
+
print(failed.length ? `\nThe gate failed: ${failed.join('; ')}.` : '\nThe gate passed.')
|
|
151
|
+
return failed.length ? 1 : 0
|
|
152
|
+
}
|
package/src/gates/cli.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* `estiva-gates` — the gate pieces as one command (docs/GATES.md §23).
|
|
4
4
|
*
|
|
5
|
+
* estiva-gates ci [--package] CI's job `gate`: every step of the gate (R13)
|
|
5
6
|
* estiva-gates count --repo <name> [--package] write .gates-count.json (postlint:rules)
|
|
6
7
|
* estiva-gates hook [--app <dir>] [--package] the editor gate, run by a PreToolUse hook
|
|
7
|
-
* estiva-gates
|
|
8
|
+
* estiva-gates name-escapes [--config <file>] once: name the rule in every escape marker (B3)
|
|
9
|
+
* estiva-gates status [--app <dir>] [--json] [--detail] [--escapes] gates:status
|
|
8
10
|
*
|
|
9
11
|
* An app's `package.json` runs the first and the last through npm, which finds
|
|
10
12
|
* the command in `node_modules/.bin`. The hook is run by path, from the
|
|
@@ -19,8 +21,10 @@
|
|
|
19
21
|
* node "$CLAUDE_PROJECT_DIR/web/node_modules/@estiva-app/ui/dist/gates/cli.js" hook --app web
|
|
20
22
|
* node web/node_modules/@estiva-app/ui/dist/gates/cli.js status --app web
|
|
21
23
|
*/
|
|
24
|
+
import { runCi } from './ci'
|
|
22
25
|
import { writeGateCount } from './count'
|
|
23
26
|
import { runHook } from './hook'
|
|
27
|
+
import { nameEscapes } from './name-escapes'
|
|
24
28
|
import { runStatus } from './status'
|
|
25
29
|
|
|
26
30
|
const [command, ...rest] = process.argv.slice(2)
|
|
@@ -33,6 +37,8 @@ const audience = flag('package') ? 'package' : 'app'
|
|
|
33
37
|
|
|
34
38
|
async function main(): Promise<number> {
|
|
35
39
|
switch (command) {
|
|
40
|
+
case 'ci':
|
|
41
|
+
return runCi({ audience })
|
|
36
42
|
case 'count': {
|
|
37
43
|
const repo = value('repo')
|
|
38
44
|
if (!repo) {
|
|
@@ -44,6 +50,13 @@ async function main(): Promise<number> {
|
|
|
44
50
|
if (result.failures.length) process.stderr.write(`${result.failures.join('\n')}\n`)
|
|
45
51
|
return result.failures.length ? 1 : 0
|
|
46
52
|
}
|
|
53
|
+
case 'name-escapes': {
|
|
54
|
+
// Once, after taking 0.36.0: every unnamed marker gets the rules it keeps off (B3).
|
|
55
|
+
const result = await nameEscapes({ config: value('config') })
|
|
56
|
+
process.stdout.write(`${result.named.length} markers named:\n ${result.named.join('\n ')}\n`)
|
|
57
|
+
if (result.unreached.length) process.stdout.write(`${result.unreached.length} unnamed markers no rule reaches, left as they are:\n ${result.unreached.join('\n ')}\n`)
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
47
60
|
case 'hook': {
|
|
48
61
|
// A hook that fails open guards nothing: Claude Code lets a write through
|
|
49
62
|
// on any exit but 2, and a crash exits 1 (audit A2). So a gate that cannot
|
|
@@ -52,6 +65,9 @@ async function main(): Promise<number> {
|
|
|
52
65
|
try {
|
|
53
66
|
const result = await runHook({ app: value('app'), audience })
|
|
54
67
|
if (result.code === 2) process.stderr.write(result.message)
|
|
68
|
+
// A note on a write let through reaches Claude as additional context: plain
|
|
69
|
+
// stdout on exit 0 does not, for a PreToolUse hook (code.claude.com/docs/en/hooks-guide).
|
|
70
|
+
else if (result.note) process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `A copied look, let through (it only warns):\n${result.note}` } })}\n`)
|
|
55
71
|
return result.code
|
|
56
72
|
} catch (error) {
|
|
57
73
|
const why = error instanceof Error ? error.message.split('\n')[0] : String(error)
|
|
@@ -60,11 +76,11 @@ async function main(): Promise<number> {
|
|
|
60
76
|
}
|
|
61
77
|
}
|
|
62
78
|
case 'status': {
|
|
63
|
-
process.stdout.write(`${await runStatus({ app: value('app'), json: flag('json'), detail: flag('detail') })}${flag('json') ? '' : '\n'}`)
|
|
79
|
+
process.stdout.write(`${await runStatus({ app: value('app'), json: flag('json'), detail: flag('detail'), escapes: flag('escapes') })}${flag('json') ? '' : '\n'}`)
|
|
64
80
|
return 0
|
|
65
81
|
}
|
|
66
82
|
default:
|
|
67
|
-
process.stderr.write('estiva-gates <count | hook | status>\n')
|
|
83
|
+
process.stderr.write('estiva-gates <ci | count | hook | status | name-escapes>\n')
|
|
68
84
|
return 1
|
|
69
85
|
}
|
|
70
86
|
}
|
package/src/gates/count.ts
CHANGED
|
@@ -69,7 +69,7 @@ export async function writeGateCount({ root = process.cwd(), repo, audience = 'a
|
|
|
69
69
|
|
|
70
70
|
const failures: string[] = []
|
|
71
71
|
if (disabled.length > 0) {
|
|
72
|
-
failures.push('', 'An eslint-disable switched a gate rule off. Write the reason above the element instead: // @estiva-escape: <reason>')
|
|
72
|
+
failures.push('', 'An eslint-disable switched a gate rule off. Write the reason above the element instead, naming the rule: // @estiva-escape(<rule>): <reason>')
|
|
73
73
|
for (const d of disabled) failures.push(` ${relative(root, d.filePath)}:${d.line} ${d.ruleId}`)
|
|
74
74
|
}
|
|
75
75
|
return { rules, disabled, changed, lines, failures }
|
|
@@ -71,7 +71,7 @@ describe('create-estiva-app', () => {
|
|
|
71
71
|
it('starts the count at zero and keeps the CI job named gate', () => {
|
|
72
72
|
const count = JSON.parse(files['.gates-count.json'])
|
|
73
73
|
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
74
|
-
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*
|
|
74
|
+
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*run: npx estiva-gates ci/)
|
|
75
75
|
expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
|
|
76
76
|
})
|
|
77
77
|
|
|
@@ -131,7 +131,7 @@ describe('create-estiva-app', () => {
|
|
|
131
131
|
expect(registry.filesWithoutParts.map((file) => file.file)).toEqual(['src/main.tsx'])
|
|
132
132
|
const scripts = JSON.parse(files['package.json']).scripts
|
|
133
133
|
expect(scripts).toMatchObject({ 'ui:find': 'estiva-ui find', registry: 'estiva-ui build', 'registry:check': 'estiva-ui check' })
|
|
134
|
-
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*
|
|
134
|
+
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*run: npx estiva-gates ci/)
|
|
135
135
|
expect(files['.gitignore'].split('\n')).toContain('registry.json')
|
|
136
136
|
})
|
|
137
137
|
|