@estiva-app/ui 0.30.0 → 0.32.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/README.md +12 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +1289 -961
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-handmade-empty-state.d.ts +25 -0
- package/dist/eslint/no-handmade-empty-state.d.ts.map +1 -0
- package/dist/eslint/no-handmade-header.d.ts +17 -0
- package/dist/eslint/no-handmade-header.d.ts.map +1 -0
- package/dist/eslint/no-restyled-part.d.ts +20 -1
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/index.js +4 -2
- package/dist/gates/index.js.map +2 -2
- package/dist/index.js +142 -123
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/CommandPalette.tsx +1 -0
- package/src/ContainerHeader.mdx +3 -2
- package/src/DialogShell.tsx +22 -17
- package/src/EmptyState.mdx +5 -2
- package/src/FilePicker.stories.tsx +5 -1
- package/src/ReactionPicker.stories.tsx +2 -1
- package/src/eslint/index.test.ts +17 -9
- package/src/eslint/index.ts +12 -2
- package/src/eslint/no-handmade-empty-state.test.ts +151 -0
- package/src/eslint/no-handmade-empty-state.ts +344 -0
- package/src/eslint/no-handmade-header.test.ts +89 -0
- package/src/eslint/no-handmade-header.ts +153 -0
- package/src/eslint/no-restyled-part.ts +11 -0
- package/src/gates/app-checks.ts +5 -3
- package/src/gates/create-app.test.ts +1 -1
- package/src/gates/gates.test.ts +1 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
|
+
import type { Rule } from 'eslint'
|
|
3
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
4
|
+
import {
|
|
5
|
+
bindingOfTag,
|
|
6
|
+
classesOf,
|
|
7
|
+
functionComponents,
|
|
8
|
+
parseFile,
|
|
9
|
+
partsOfFile,
|
|
10
|
+
resolveModule,
|
|
11
|
+
walk,
|
|
12
|
+
type Node,
|
|
13
|
+
type Parser,
|
|
14
|
+
type PartBinding,
|
|
15
|
+
} from './no-restyled-part'
|
|
16
|
+
|
|
17
|
+
type Locals = Map<string, PartBinding | 'namespace'>
|
|
18
|
+
|
|
19
|
+
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
20
|
+
const children = (node: Node, key: string) => ((node[key] as (Node | null)[] | undefined) ?? []).filter((n): n is Node => !!n)
|
|
21
|
+
const nameOf = (node: Node | null | undefined): string | undefined =>
|
|
22
|
+
node?.type === 'Identifier' || node?.type === 'JSXIdentifier' ? (node.name as string) : undefined
|
|
23
|
+
const tagName = (element: Node) => nameOf(child(child(element, 'openingElement') as Node, 'name'))
|
|
24
|
+
/**
|
|
25
|
+
* Whether a name is the part itself: imported, re-exported, or an app wrapper
|
|
26
|
+
* that hands everything on (Peek's `EmptyState`). A component that only hands
|
|
27
|
+
* its `className` to one (Ship's `Activity`) is the app's own, and is followed.
|
|
28
|
+
*/
|
|
29
|
+
const isPart = (binding: PartBinding | undefined, part: string) => binding?.part === part && binding.props === 'all'
|
|
30
|
+
const attributeOf = (opening: Node, name: string) => children(opening, 'attributes').find((a) => a.type === 'JSXAttribute' && nameOf(child(a, 'name')) === name)
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------- shape 1: a line drawn by hand
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An empty state is a line, not a paragraph of prose. The words are not read:
|
|
36
|
+
* UIG-1's pass on words alone fired on a hint and a status note, and words
|
|
37
|
+
* missed four of Peek's dialogs ("There is nowhere else to move it…", "Every
|
|
38
|
+
* topic is already in your Open work."). Where the line sits decides.
|
|
39
|
+
*/
|
|
40
|
+
const SHORT = 120
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The element's words, when words are all it holds: text, a string, a name
|
|
44
|
+
* dropped into a sentence ("There is nowhere else in {teamName} to put it.").
|
|
45
|
+
* A line with no words of its own — a count, a message's body — is not one.
|
|
46
|
+
*/
|
|
47
|
+
function onlyWords(element: Node): string | undefined {
|
|
48
|
+
let line = ''
|
|
49
|
+
let own = ''
|
|
50
|
+
for (const kid of children(element, 'children')) {
|
|
51
|
+
let piece: string
|
|
52
|
+
if (kid.type === 'JSXText') piece = String(kid.value)
|
|
53
|
+
else if (kid.type === 'JSXExpressionContainer') {
|
|
54
|
+
const e = child(kid, 'expression') as Node
|
|
55
|
+
if (e.type === 'JSXEmptyExpression') continue
|
|
56
|
+
if (e.type === 'Literal' && typeof e.value === 'string') piece = e.value
|
|
57
|
+
else if (e.type === 'TemplateLiteral' && children(e, 'expressions').length === 0) piece = children(e, 'quasis').map((q) => (q.value as { cooked: string }).cooked).join('')
|
|
58
|
+
else if (e.type === 'Identifier' || e.type === 'MemberExpression') {
|
|
59
|
+
line += 'x'
|
|
60
|
+
continue
|
|
61
|
+
} else return undefined
|
|
62
|
+
} else return undefined
|
|
63
|
+
own += piece
|
|
64
|
+
line += piece
|
|
65
|
+
}
|
|
66
|
+
if (!/[a-z]{2,}/i.test(own)) return undefined
|
|
67
|
+
return line.replace(/\s+/g, ' ').trim()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isCount = (node: Node | null | undefined): boolean => {
|
|
71
|
+
if (node?.type === 'ChainExpression') return isCount(child(node, 'expression'))
|
|
72
|
+
return node?.type === 'MemberExpression' && !node.computed && /^(?:length|size)$/.test(nameOf(child(node, 'property')) ?? '')
|
|
73
|
+
}
|
|
74
|
+
const isNumber = (node: Node | null | undefined, n: number) => node?.type === 'Literal' && node.value === n
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What a condition says about a list: `empty` when it is true for an empty one
|
|
78
|
+
* (`rows.length === 0`, `!rows.length`, `isEmpty(rows)`), `full` when it is
|
|
79
|
+
* true for a list with rows (`rows.length > 0`, `rows.length`), nothing when it
|
|
80
|
+
* does not ask. `a && b` says what either side says.
|
|
81
|
+
*/
|
|
82
|
+
function whenTrue(test: Node | null | undefined): 'empty' | 'full' | undefined {
|
|
83
|
+
if (!test) return undefined
|
|
84
|
+
if (isCount(test)) return 'full'
|
|
85
|
+
switch (test.type) {
|
|
86
|
+
case 'UnaryExpression': {
|
|
87
|
+
if (test.operator !== '!') return undefined
|
|
88
|
+
const inner = whenTrue(child(test, 'argument'))
|
|
89
|
+
return inner === 'empty' ? 'full' : inner === 'full' ? 'empty' : undefined
|
|
90
|
+
}
|
|
91
|
+
case 'LogicalExpression':
|
|
92
|
+
if (test.operator !== '&&') return undefined
|
|
93
|
+
return whenTrue(child(test, 'left')) ?? whenTrue(child(test, 'right'))
|
|
94
|
+
case 'BinaryExpression': {
|
|
95
|
+
let [count, other, op] = [child(test, 'left'), child(test, 'right'), String(test.operator)]
|
|
96
|
+
if (!isCount(count)) [count, other, op] = [other, count, ({ '<': '>', '>': '<', '<=': '>=', '>=': '<=' } as Record<string, string>)[op] ?? op]
|
|
97
|
+
if (!isCount(count)) return undefined
|
|
98
|
+
if (isNumber(other, 0)) return op === '===' || op === '==' || op === '<=' ? 'empty' : op === '>' || op === '!==' || op === '!=' ? 'full' : undefined
|
|
99
|
+
if (isNumber(other, 1)) return op === '<' ? 'empty' : op === '>=' ? 'full' : undefined
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
case 'CallExpression': {
|
|
103
|
+
const callee = child(test, 'callee')
|
|
104
|
+
const name = callee?.type === 'MemberExpression' ? nameOf(child(callee, 'property')) : nameOf(callee)
|
|
105
|
+
return name && /^is(?:Empty|Blank)$/.test(name) ? 'empty' : undefined
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isMapCall = (node: Node | null | undefined) => {
|
|
113
|
+
const callee = node?.type === 'CallExpression' ? child(node, 'callee') : undefined
|
|
114
|
+
return callee?.type === 'MemberExpression' && nameOf(child(callee, 'property')) === 'map'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether a branch **is** a list: a `.map(…)`, or a box that holds only that —
|
|
119
|
+
* `<ul>{rows.map(…)}</ul>`. A branch that shows something else and lists a few
|
|
120
|
+
* things inside it (an answer with its sources) is not the list the line stands
|
|
121
|
+
* in for.
|
|
122
|
+
*/
|
|
123
|
+
function isAList(branch: Node | null | undefined, depth = 0): boolean {
|
|
124
|
+
if (!branch || depth > 2) return false
|
|
125
|
+
if (isMapCall(branch)) return true
|
|
126
|
+
if (branch.type !== 'JSXElement' && branch.type !== 'JSXFragment') return false
|
|
127
|
+
if (branch.type === 'JSXElement' && !/^[a-z]/.test(tagName(branch) ?? '')) return false
|
|
128
|
+
const kids = children(branch, 'children').filter((k) => !(k.type === 'JSXText' && !String(k.value).trim()) && !(k.type === 'JSXExpressionContainer' && (child(k, 'expression') as Node).type === 'JSXEmptyExpression'))
|
|
129
|
+
return kids.length === 1 && (kids[0].type === 'JSXExpressionContainer' ? isMapCall(child(kids[0], 'expression')) : isAList(kids[0], depth + 1))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A box around one thing and nothing else: a wrapper that pads or places the line. */
|
|
133
|
+
function soleChildOf(parent: Node | undefined, element: Node): boolean {
|
|
134
|
+
if (parent?.type !== 'JSXElement' || !/^[a-z]/.test(tagName(parent) ?? '')) return false
|
|
135
|
+
const kids = children(parent, 'children').filter((k) => !(k.type === 'JSXText' && !String(k.value).trim()) && !(k.type === 'JSXExpressionContainer' && (child(k, 'expression') as Node).type === 'JSXEmptyExpression'))
|
|
136
|
+
return kids.length === 1 && kids[0] === element
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether the element stands where a list's rows would be: one side of a
|
|
141
|
+
* condition whose other side maps a list, or under a condition that asks if
|
|
142
|
+
* something is empty — `cond && …`, `cond ? … : …`, `if (cond) return …`.
|
|
143
|
+
*/
|
|
144
|
+
function standsInForAList(element: Node): boolean {
|
|
145
|
+
let at = element
|
|
146
|
+
for (let depth = 0; depth < 3 && soleChildOf(at.parent, at); depth += 1) at = at.parent as Node
|
|
147
|
+
const parent = at.parent
|
|
148
|
+
if (!parent) return false
|
|
149
|
+
if (parent.type === 'ConditionalExpression' && parent.test !== at) {
|
|
150
|
+
const consequent = parent.consequent === at
|
|
151
|
+
const other = child(parent, consequent ? 'alternate' : 'consequent')
|
|
152
|
+
return whenTrue(child(parent, 'test')) === (consequent ? 'empty' : 'full') || isAList(other)
|
|
153
|
+
}
|
|
154
|
+
if (parent.type === 'LogicalExpression' && parent.operator === '&&' && parent.right === at) return whenTrue(child(parent, 'left')) === 'empty'
|
|
155
|
+
if (parent.type === 'ReturnStatement') {
|
|
156
|
+
let holder = parent.parent
|
|
157
|
+
if (holder?.type === 'BlockStatement' && children(holder, 'body').length === 1) holder = holder.parent
|
|
158
|
+
return holder?.type === 'IfStatement' && child(holder, 'consequent') !== undefined && whenTrue(child(holder, 'test')) === 'empty'
|
|
159
|
+
}
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------- shape 2: the right part at the wrong scope
|
|
164
|
+
|
|
165
|
+
/** A content box that fills its viewport: `min-h-full` or `h-full` on `contentClassName`. */
|
|
166
|
+
const FILLS = /^(?:min-h-full|h-full)$/
|
|
167
|
+
|
|
168
|
+
function isPageScope(opening: Node): boolean {
|
|
169
|
+
const scope = attributeOf(opening, 'scope')
|
|
170
|
+
if (!scope) return true
|
|
171
|
+
const value = child(scope, 'value')
|
|
172
|
+
return value?.type === 'Literal' && value.value === 'page'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface FileFacts {
|
|
176
|
+
file: string
|
|
177
|
+
locals: Locals
|
|
178
|
+
components: Map<string, Node>
|
|
179
|
+
/** A local name imported from the app's own files: where from, and under what name. */
|
|
180
|
+
imports: Map<string, { file: string; name: string }>
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const factsCache = new Map<string, { mtime: number; facts: FileFacts | null }>()
|
|
184
|
+
|
|
185
|
+
function factsOf(file: string, program: Node, parser: Parser): FileFacts {
|
|
186
|
+
const imports = new Map<string, { file: string; name: string }>()
|
|
187
|
+
for (const statement of children(program, 'body')) {
|
|
188
|
+
if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type') continue
|
|
189
|
+
const target = resolveModule(file, String((child(statement, 'source') as Node).value))
|
|
190
|
+
if (!target) continue
|
|
191
|
+
for (const specifier of children(statement, 'specifiers')) {
|
|
192
|
+
if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue
|
|
193
|
+
const local = nameOf(child(specifier, 'local'))
|
|
194
|
+
const imported = nameOf(child(specifier, 'imported'))
|
|
195
|
+
if (local && imported) imports.set(local, { file: target, name: imported })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { file, locals: partsOfFile(file, program, parser), components: functionComponents(program), imports }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function factsOfFile(file: string, parser: Parser): FileFacts | null {
|
|
202
|
+
const mtime = statSync(file).mtimeMs
|
|
203
|
+
const cached = factsCache.get(file)
|
|
204
|
+
if (cached && cached.mtime === mtime) return cached.facts
|
|
205
|
+
factsCache.set(file, { mtime, facts: null })
|
|
206
|
+
const program = parseFile(parser, file)
|
|
207
|
+
const facts = program ? factsOf(file, program, parser) : null
|
|
208
|
+
factsCache.set(file, { mtime, facts })
|
|
209
|
+
return facts
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface PageState {
|
|
213
|
+
file: string
|
|
214
|
+
line: number
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Every page-scope `EmptyState` a component can draw, following the app's own
|
|
219
|
+
* components it draws in turn, in its file and in the files it imports from,
|
|
220
|
+
* four deep.
|
|
221
|
+
*/
|
|
222
|
+
function pageStatesOf(facts: FileFacts, name: string, parser: Parser, depth: number, seen: Set<string>): PageState[] {
|
|
223
|
+
const key = `${facts.file}#${name}`
|
|
224
|
+
if (depth > 4 || seen.has(key)) return []
|
|
225
|
+
seen.add(key)
|
|
226
|
+
const local = facts.components.get(name)
|
|
227
|
+
if (!local) {
|
|
228
|
+
const from = facts.imports.get(name)
|
|
229
|
+
const there = from ? factsOfFile(from.file, parser) : null
|
|
230
|
+
return there && from ? pageStatesOf(there, from.name, parser, depth + 1, seen) : []
|
|
231
|
+
}
|
|
232
|
+
const found: PageState[] = []
|
|
233
|
+
walk(local, (node) => {
|
|
234
|
+
if (node.type !== 'JSXOpeningElement') return
|
|
235
|
+
const tag = child(node, 'name')
|
|
236
|
+
const binding = bindingOfTag(tag, facts.locals)
|
|
237
|
+
if (isPart(binding, 'EmptyState')) {
|
|
238
|
+
if (isPageScope(node)) found.push({ file: facts.file, line: node.loc?.start.line ?? 0 })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
const component = binding?.props === 'all' ? undefined : nameOf(tag)
|
|
242
|
+
if (component && /^[A-Z]/.test(component)) found.push(...pageStatesOf(facts, component, parser, depth + 1, seen))
|
|
243
|
+
})
|
|
244
|
+
return found
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const fileName = (file: string) => file.replace(/\\/g, '/').split('/').pop()
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* UIG-23: an empty state drawn by hand, or the right part at the wrong scope.
|
|
251
|
+
*
|
|
252
|
+
* **A line drawn by hand.** A plain box whose only content is a short line
|
|
253
|
+
* saying there is nothing ("No tickets yet.") where a list's rows would be: one
|
|
254
|
+
* side of a condition whose other side maps the list, or under a condition that
|
|
255
|
+
* asks if something is empty. Both halves are needed. The words alone fire on a
|
|
256
|
+
* hint and a status note (UIG-1 measured it); the place alone fires on any
|
|
257
|
+
* line a list shows beside itself.
|
|
258
|
+
*
|
|
259
|
+
* **The wrong scope.** A `page`-scope `EmptyState` centres in the room a flex
|
|
260
|
+
* column gives it. Inside a `ScrollArea` the content box is only as tall as
|
|
261
|
+
* what is in it, so the state sits at the top of its pane. The rule looks at
|
|
262
|
+
* the `ScrollArea`'s content and at every component of the app's own placed in
|
|
263
|
+
* it, following them into their files, because that is how four of them
|
|
264
|
+
* arrived on Folders in one merge (peek `7f22e5e`): the state was drawn in one
|
|
265
|
+
* file and scrolled in another. A `contentClassName` with `min-h-full` or
|
|
266
|
+
* `h-full` gives the state its room, and passes.
|
|
267
|
+
*
|
|
268
|
+
* Not judged: the package's `EmptyState` and a part's own lines (a
|
|
269
|
+
* `FieldLine`, a palette's empty row) — a part is already a part.
|
|
270
|
+
*/
|
|
271
|
+
export const noHandmadeEmptyState: Rule.RuleModule = {
|
|
272
|
+
meta: {
|
|
273
|
+
type: 'problem',
|
|
274
|
+
docs: { description: 'An empty state drawn by hand, or a page-scope EmptyState that cannot centre' },
|
|
275
|
+
schema: [],
|
|
276
|
+
messages: {
|
|
277
|
+
handmade:
|
|
278
|
+
'A line saying there is nothing is an EmptyState. Use `EmptyState` from @estiva-app/ui: `scope="section"` inside a section, `scope="page"` for a whole page.',
|
|
279
|
+
wrongScope:
|
|
280
|
+
'A page-scope EmptyState inside a ScrollArea sits at the top of its pane, not the middle: the ScrollArea\'s content is only as tall as what is in it. Use `scope="section"` when the pane has anything else on it; when the whole pane is empty, draw the page EmptyState in place of the ScrollArea, not inside it.',
|
|
281
|
+
wrongScopeVia:
|
|
282
|
+
'`{{component}}` draws a page-scope EmptyState ({{where}}) inside this ScrollArea, where it sits at the top of its pane, not the middle: the ScrollArea\'s content is only as tall as what is in it. Use `scope="section"` there when the pane has anything else on it; when the whole pane is empty, draw the page EmptyState in place of the ScrollArea, not inside it.',
|
|
283
|
+
...ESCAPE_MESSAGES,
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
create(context) {
|
|
287
|
+
const parser = (context.languageOptions?.parser ?? {}) as Parser
|
|
288
|
+
const file = context.filename
|
|
289
|
+
let facts: FileFacts | undefined
|
|
290
|
+
// A ScrollArea inside another is walked by both; each state is reported once.
|
|
291
|
+
const reported = new Set<Node>()
|
|
292
|
+
const scopeOf = (node: Node) => context.sourceCode.getScope(node as unknown as Parameters<typeof context.sourceCode.getScope>[0])
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
Program(program) {
|
|
296
|
+
facts = factsOf(file, program as unknown as Node, parser)
|
|
297
|
+
},
|
|
298
|
+
JSXElement(ruleNode: Rule.Node) {
|
|
299
|
+
const element = ruleNode as unknown as Node
|
|
300
|
+
const opening = child(element, 'openingElement') as Node
|
|
301
|
+
const tag = tagName(element)
|
|
302
|
+
|
|
303
|
+
// Shape 1: a plain box holding only a "nothing" line, where a list would be.
|
|
304
|
+
if (tag && /^[a-z]/.test(tag)) {
|
|
305
|
+
const words = onlyWords(element)
|
|
306
|
+
if (!words || words.length > SHORT) return
|
|
307
|
+
if (!standsInForAList(element)) return
|
|
308
|
+
if (isEscaped(context, opening)) return
|
|
309
|
+
context.report({ loc: opening.loc as NonNullable<Rule.Node['loc']>, messageId: 'handmade' })
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Shape 2: a page-scope EmptyState in a ScrollArea that does not fill.
|
|
314
|
+
if (!facts || !isPart(bindingOfTag(child(opening, 'name'), facts.locals), 'ScrollArea')) return
|
|
315
|
+
const content = attributeOf(opening, 'contentClassName')
|
|
316
|
+
if (content && classesOf(child(content, 'value'), scopeOf).some((c) => FILLS.test(c.replace(/^(?:[^:\s[\]]+:)+/, '')))) return
|
|
317
|
+
const here = facts
|
|
318
|
+
for (const kid of children(element, 'children')) {
|
|
319
|
+
walk(kid, (node) => {
|
|
320
|
+
if (node.type !== 'JSXOpeningElement') return
|
|
321
|
+
const name = child(node, 'name')
|
|
322
|
+
const binding = bindingOfTag(name, here.locals)
|
|
323
|
+
if (isPart(binding, 'EmptyState')) {
|
|
324
|
+
if (!isPageScope(node) || reported.has(node) || isEscaped(context, node)) return
|
|
325
|
+
reported.add(node)
|
|
326
|
+
context.report({ loc: node.loc as NonNullable<Rule.Node['loc']>, messageId: 'wrongScope' })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const component = binding?.props === 'all' ? undefined : nameOf(name)
|
|
330
|
+
if (!component || !/^[A-Z]/.test(component)) return
|
|
331
|
+
const states = pageStatesOf(here, component, parser, 0, new Set())
|
|
332
|
+
if (!states.length || reported.has(node) || isEscaped(context, node)) return
|
|
333
|
+
reported.add(node)
|
|
334
|
+
// In reading order: file, then line.
|
|
335
|
+
for (const state of [...states].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)) {
|
|
336
|
+
const where = state.file === file ? `line ${state.line}` : `${fileName(state.file)}:${state.line}`
|
|
337
|
+
context.report({ loc: node.loc as NonNullable<Rule.Node['loc']>, messageId: 'wrongScopeVia', data: { component, where } })
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { RuleTester } from 'eslint'
|
|
2
|
+
import { parser } from 'typescript-eslint'
|
|
3
|
+
import { describe, it } from 'vitest'
|
|
4
|
+
import { noHandmadeHeader } from './no-handmade-header'
|
|
5
|
+
|
|
6
|
+
RuleTester.describe = describe
|
|
7
|
+
RuleTester.it = it
|
|
8
|
+
RuleTester.itOnly = it.only
|
|
9
|
+
|
|
10
|
+
const tester = new RuleTester({
|
|
11
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
12
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const component = (body: string) => `export function Probe({ title, open }: { title: string; open: boolean }) {\n return (\n${body}\n )\n}\n`
|
|
16
|
+
const handmade = [{ messageId: 'handmade' }]
|
|
17
|
+
|
|
18
|
+
tester.run('no-handmade-header', noHandmadeHeader, {
|
|
19
|
+
valid: [
|
|
20
|
+
{ name: 'the part itself', code: component('<div className="flex h-full flex-col">\n<ContainerHeader title="Files" />\n<div />\n</div>') },
|
|
21
|
+
{
|
|
22
|
+
name: "a group heading inside a list: SectionHeader's row, not a column's bar (Ship's issue groups)",
|
|
23
|
+
code: component('<section className="flex flex-col gap-px">\n<h3 className="flex h-8 items-center gap-2 px-3"><StatusLabel value="todo" /><span>{3}</span></h3>\n<div />\n</section>'),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "a label strip on a card (Peek's huddle card)",
|
|
27
|
+
code: component('<Card className="flex flex-col h-[130px]">\n<div className="h-6 bg-bg-inset px-2 flex items-center shrink-0"><span className="text-caption">Huddle</span></div>\n<div />\n</Card>'),
|
|
28
|
+
},
|
|
29
|
+
{ name: 'a row a person presses is a row, not a header', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center px-3 py-2" onClick={() => {}}><span>{title}</span></div>\n<div />\n</div>') },
|
|
30
|
+
{ name: 'a row with no title: a toolbar, a field', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center gap-1 px-3"><Button>One</Button><Button>Two</Button></div>\n<div />\n</div>') },
|
|
31
|
+
{ name: 'a row with nothing after it heads nothing', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n</div>') },
|
|
32
|
+
{ name: 'a row with no side padding of its own', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center py-2"><span>{title}</span></div>\n<div />\n</div>') },
|
|
33
|
+
{ name: 'a column of rows', code: component('<div className="flex h-full flex-col">\n<div className="flex flex-col px-3"><span>{title}</span></div>\n<div />\n</div>') },
|
|
34
|
+
{
|
|
35
|
+
name: 'an escape with its reason',
|
|
36
|
+
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: a print header that must not follow the theme */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
invalid: [
|
|
40
|
+
{
|
|
41
|
+
// The Folders page's right pane, at the commit before 3dc663b.
|
|
42
|
+
name: 'a pane that heads itself around an editable name',
|
|
43
|
+
code: component('<div className="flex h-full flex-col">\n{/* ContainerHeader draws a static title, so this pane heads itself. */}\n<div className="flex items-center gap-2 px-3 py-2">\n<div className="min-w-0 flex-1"><EditableText value={title} label="Folder name" /></div>\n<IconButton icon={<IconX />} label="Close" />\n</div>\n<div className="min-h-0 flex-1" />\n</div>'),
|
|
44
|
+
errors: handmade,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
// The Folders page's thread pane, at the commit before 3dc663b.
|
|
48
|
+
name: 'a pane that heads itself with a title and a caption',
|
|
49
|
+
code: component('<div className="flex h-full flex-col">\n<div className="flex items-center gap-2 px-3 py-2">\n<div className="min-w-0 flex-1">\n<p className="truncate text-body-2-strong text-text-primary">{title ?? "Conversation"}</p>\n{open && <p className="truncate text-caption text-text-muted">In Ship</p>}\n</div>\n</div>\n<div />\n</div>'),
|
|
50
|
+
errors: handmade,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "ContainerHeader's exact class list, typed by hand (Peek's FilesPanel and ThreadPanel)",
|
|
54
|
+
code: component('<div className="flex flex-col h-full">\n<div className="h-12 shrink-0 flex items-center justify-between pl-5 pr-4 py-2 border-b border-border-subtle">\n<span className="text-body-2-strong text-text-primary">Files</span>\n</div>\n<div />\n</div>'),
|
|
55
|
+
errors: handmade,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'the shape, not the class names: other padding, other gap, a heading, a column that grows',
|
|
59
|
+
code: component('<aside className="flex grow flex-col">\n<header className="flex items-baseline gap-4 pl-6 pr-2 py-3"><h2>{title}</h2></header>\n<ul />\n</aside>'),
|
|
60
|
+
errors: handmade,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "a header bar's height and hairline, whatever the column is",
|
|
64
|
+
code: component('<section className="flex flex-col">\n<div className="flex h-11 items-center border-b px-4"><span>{title}</span></div>\n<div />\n</section>'),
|
|
65
|
+
errors: handmade,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'a bar shown only while something is open',
|
|
69
|
+
code: component('<div className="flex h-full flex-col">\n{open && (\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n)}\n<div />\n</div>'),
|
|
70
|
+
errors: handmade,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'classes put together with cn() and a variant',
|
|
74
|
+
code: component('<div className={cn("flex flex-col", "h-full")}>\n<div className={cn("flex items-center", "md:px-4 py-2")}><span>{title}</span></div>\n<div />\n</div>'),
|
|
75
|
+
errors: handmade,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
// DialogShell's bar, before it drew ContainerHeader: a part's title, named as one.
|
|
79
|
+
name: "a bar whose title is a part's Title",
|
|
80
|
+
code: component('<div className="flex flex-col">\n<div className="h-12 flex items-center justify-between pl-5 pr-4 border-b shrink-0">\n{open ?? <Parts.Title render={<span />}>{title}</Parts.Title>}\n</div>\n<div />\n</div>'),
|
|
81
|
+
errors: handmade,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'an escape with no reason escapes nothing',
|
|
85
|
+
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
86
|
+
errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'handmade' }],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Rule } from 'eslint'
|
|
2
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
3
|
+
|
|
4
|
+
/** The JSX an element is made of, as far as this rule reads it. */
|
|
5
|
+
interface Node {
|
|
6
|
+
type: string
|
|
7
|
+
[key: string]: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface JSXElement extends Node {
|
|
11
|
+
type: 'JSXElement'
|
|
12
|
+
openingElement: {
|
|
13
|
+
name: { type: string; name?: string; object?: { name?: string }; property?: { name?: string } }
|
|
14
|
+
attributes: { type: string; name?: { name?: string }; value?: Node | null }[]
|
|
15
|
+
loc: NonNullable<Rule.Node['loc']>
|
|
16
|
+
range: [number, number]
|
|
17
|
+
}
|
|
18
|
+
children: Node[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isElement = (n: unknown): n is JSXElement => typeof n === 'object' && n !== null && (n as Node).type === 'JSXElement'
|
|
22
|
+
|
|
23
|
+
function tagOf(el: JSXElement): string {
|
|
24
|
+
const n = el.openingElement.name
|
|
25
|
+
if (n.type === 'JSXIdentifier') return n.name ?? ''
|
|
26
|
+
if (n.type === 'JSXMemberExpression') return `${n.object?.name ?? ''}.${n.property?.name ?? ''}`
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function attribute(el: JSXElement, name: string) {
|
|
31
|
+
return el.openingElement.attributes.find((a) => a.type === 'JSXAttribute' && a.name?.name === name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every string literal under a node: a `cn()` call, a template, a ternary. */
|
|
35
|
+
function strings(node: Node | null | undefined, out: string[] = []): string[] {
|
|
36
|
+
if (!node || typeof node !== 'object') return out
|
|
37
|
+
if (node.type === 'Literal' && typeof node.value === 'string') out.push(node.value)
|
|
38
|
+
else if (node.type === 'TemplateLiteral') for (const q of node.quasis as { value: { cooked: string | null } }[]) out.push(q.value.cooked ?? '')
|
|
39
|
+
for (const [key, value] of Object.entries(node)) {
|
|
40
|
+
if (key === 'parent' || key === 'loc' || key === 'range') continue
|
|
41
|
+
if (Array.isArray(value)) for (const v of value) strings(v as Node, out)
|
|
42
|
+
else if (value && typeof value === 'object' && (value as Node).type) strings(value as Node, out)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The classes an element is given, variants taken off: `md:px-3` is `px-3`. */
|
|
48
|
+
function classesOf(el: JSXElement): string[] {
|
|
49
|
+
const value = attribute(el, 'className')?.value
|
|
50
|
+
if (!value) return []
|
|
51
|
+
const text = value.type === 'Literal' ? String(value.value) : strings(value.expression as Node).join(' ')
|
|
52
|
+
return text
|
|
53
|
+
.split(/\s+/)
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((c) => c.replace(/^(?:[^:\s[\]]+:)+/, ''))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An element's children as a reader sees them: whitespace and comments gone,
|
|
60
|
+
* `{open && <X />}` read as `<X />`.
|
|
61
|
+
*/
|
|
62
|
+
function meaningful(el: JSXElement): Node[] {
|
|
63
|
+
const out: Node[] = []
|
|
64
|
+
for (const child of el.children) {
|
|
65
|
+
if (child.type === 'JSXText') {
|
|
66
|
+
if (String(child.value).trim()) out.push(child)
|
|
67
|
+
} else if (child.type === 'JSXExpressionContainer') {
|
|
68
|
+
const e = child.expression as Node
|
|
69
|
+
if (e.type === 'JSXEmptyExpression') continue
|
|
70
|
+
if (e.type === 'LogicalExpression' && isElement(e.right)) out.push(e.right as Node)
|
|
71
|
+
else if (isElement(e)) out.push(e)
|
|
72
|
+
else out.push(child)
|
|
73
|
+
} else if (child.type === 'JSXElement' || child.type === 'JSXFragment') out.push(child)
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parts that draw a line of text: a title made of one is still a title. */
|
|
79
|
+
const TEXT_PARTS = new Set(['EditableText', 'Person', 'Breadcrumb', 'Link', 'SectionLabel', 'InlineChip'])
|
|
80
|
+
/** What a title written as an expression looks like: a name, a field, a template, a fallback. */
|
|
81
|
+
const VALUE_EXPRESSIONS = new Set(['Identifier', 'MemberExpression', 'TemplateLiteral', 'Literal', 'LogicalExpression', 'ConditionalExpression', 'ChainExpression', 'CallExpression'])
|
|
82
|
+
|
|
83
|
+
/** Whether the row carries a title: text, a value, a heading, or a part that draws text, three boxes deep at most. */
|
|
84
|
+
function carriesTitle(el: JSXElement, depth = 0): boolean {
|
|
85
|
+
if (depth > 3) return false
|
|
86
|
+
for (const child of meaningful(el)) {
|
|
87
|
+
if (child.type === 'JSXText') return true
|
|
88
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
89
|
+
if (VALUE_EXPRESSIONS.has((child.expression as Node).type)) return true
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (!isElement(child)) continue
|
|
93
|
+
const tag = tagOf(child)
|
|
94
|
+
// `Dialog.Title`, `Parts.Title`: a part's title, named as one.
|
|
95
|
+
if (/^h[1-6]$/.test(tag) || TEXT_PARTS.has(tag) || /\.(?:Title|Heading)$/.test(tag)) return true
|
|
96
|
+
// A part's own words are its label, a Button's "Save": only plain boxes are looked into.
|
|
97
|
+
if (/^[a-z]/.test(tag) && carriesTitle(child, depth + 1)) return true
|
|
98
|
+
}
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ROW = (c: string[]) => c.includes('flex') && !c.includes('flex-col')
|
|
103
|
+
const OWN_SIDE_PADDING = (c: string[]) => c.some((x) => /^-?(?:p|px|pl|pr|ps|pe)-/.test(x))
|
|
104
|
+
/** A column that takes the height it is given: the pane a header bar tops. */
|
|
105
|
+
const FILLS = (c: string[]) => c.includes('flex-col') && c.some((x) => /^(?:h-full|h-screen|flex-1|grow|min-h-0|h-dvh)$/.test(x))
|
|
106
|
+
/** A header bar's own geometry: a column bar's height, or the hairline under it. */
|
|
107
|
+
const BAR = (c: string[]) => c.some((x) => /^(?:h|min-h)-(?:10|11|12|13|14|\[(?:4[0-9]|5[0-6])px\])$/.test(x)) || c.some((x) => /^border-b(?:-|$)/.test(x))
|
|
108
|
+
/** A row a person presses is a row of its own kind, not a header. */
|
|
109
|
+
const INTERACTIVE = ['onClick', 'href', 'role', 'onKeyDown', 'onPointerDown']
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* UIG-22: a column's header bar built by hand.
|
|
113
|
+
*
|
|
114
|
+
* The general rules cannot see this one: a hand-made header is made entirely of
|
|
115
|
+
* legal parts, a box, some text, some padding. So this rule reads the shape.
|
|
116
|
+
* A row, first in a column, with side padding of its own and a title in it,
|
|
117
|
+
* where the column fills the height it is given, or the row has a header bar's
|
|
118
|
+
* height or the hairline under one. `ContainerHeader` is that bar: 48px, the
|
|
119
|
+
* title, the column's buttons at the right edge, a hairline under it.
|
|
120
|
+
*
|
|
121
|
+
* What it leaves alone, on purpose: a group heading inside a list (the row one
|
|
122
|
+
* section starts with), a label strip on a card, and a row a person presses.
|
|
123
|
+
* Only a lowercase element is judged: a part is already a part.
|
|
124
|
+
*/
|
|
125
|
+
export const noHandmadeHeader: Rule.RuleModule = {
|
|
126
|
+
meta: {
|
|
127
|
+
type: 'problem',
|
|
128
|
+
docs: { description: "A column's header bar drawn by hand where ContainerHeader is the part" },
|
|
129
|
+
schema: [],
|
|
130
|
+
messages: {
|
|
131
|
+
handmade:
|
|
132
|
+
"A column's header bar drawn by hand. Use `ContainerHeader` from @estiva-app/ui: 48px, the title, the column's buttons at the right edge (`actions`), a hairline under it.",
|
|
133
|
+
...ESCAPE_MESSAGES,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
create(context) {
|
|
137
|
+
return {
|
|
138
|
+
JSXElement(node: Rule.Node) {
|
|
139
|
+
const column = node as unknown as JSXElement
|
|
140
|
+
const kids = meaningful(column)
|
|
141
|
+
const first = kids[0]
|
|
142
|
+
if (kids.length < 2 || !isElement(first) || !/^[a-z]/.test(tagOf(first))) return
|
|
143
|
+
const own = classesOf(first)
|
|
144
|
+
if (!ROW(own) || !OWN_SIDE_PADDING(own)) return
|
|
145
|
+
if (INTERACTIVE.some((name) => attribute(first, name))) return
|
|
146
|
+
if (!FILLS(classesOf(column)) && !BAR(own)) return
|
|
147
|
+
if (!carriesTitle(first)) return
|
|
148
|
+
if (isEscaped(context, first.openingElement)) return
|
|
149
|
+
context.report({ loc: first.openingElement.loc, messageId: 'handmade' })
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
@@ -399,6 +399,17 @@ function moduleParts(file: string, program: Node, parser: Parser, stack: Set<str
|
|
|
399
399
|
return { locals, exports }
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
/**
|
|
403
|
+
* What a file's names stand for, found the way this rule finds them: for a rule
|
|
404
|
+
* that asks whether a tag is one particular part, through an app's wrapper too
|
|
405
|
+
* (UIG-23 asks for `EmptyState` and `ScrollArea`).
|
|
406
|
+
*/
|
|
407
|
+
export function partsOfFile(file: string, program: Node, parser: Parser): Map<string, PartBinding | 'namespace'> {
|
|
408
|
+
return moduleParts(file, program, parser, new Set([file])).locals
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export { bindingOfTag, functionComponents, parseFile, resolveModule, walk, type Node, type Parser }
|
|
412
|
+
|
|
402
413
|
function moduleExports(file: string, parser: Parser, stack: Set<string>): Map<string, PartBinding> {
|
|
403
414
|
const mtime = statSync(file).mtimeMs
|
|
404
415
|
const cached = moduleCache.get(file)
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface AppCheckOptions {
|
|
|
31
31
|
chain?: { ref: string; owner: boolean }
|
|
32
32
|
/** The token lint configs the app has, relative to the app. */
|
|
33
33
|
tokenConfigs?: string[]
|
|
34
|
-
/** The
|
|
34
|
+
/** The part a hand-made column header should name (UIG-22): `ContainerHeader`. */
|
|
35
35
|
headers?: string[]
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -70,7 +70,7 @@ function all(h: GateHelpers, steps: (() => CheckResult | Promise<CheckResult>)[]
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['ContainerHeader'
|
|
73
|
+
export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['ContainerHeader'] }: AppCheckOptions): GateTicket[] {
|
|
74
74
|
const WEB = app === '.' ? '' : `${app.replace(/\/$/, '')}/`
|
|
75
75
|
const cwd = app === '.' ? undefined : app
|
|
76
76
|
const PROBE = 'src/components/__gates_probe__.tsx'
|
|
@@ -219,7 +219,9 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
219
219
|
{ what: "a plain tailwind-merge is an error naming the package's cn()", run: tokenProbe("import { twMerge } from 'tailwind-merge'\nexport const merged = twMerge('p-2', 'p-3')\n", 'error', 'cn()', 'src/lib/__gates_probe__.ts') },
|
|
220
220
|
]),
|
|
221
221
|
ticket('UIG-22', [
|
|
222
|
-
|
|
222
|
+
// The Folders pane's own row, at the commit before peek 3dc663b.
|
|
223
|
+
{ what: 'a hand-made column header is an error naming ContainerHeader', run: all(h, headers.map((name) => probe(component('<div className="flex h-full flex-col"><div className="flex items-center gap-2 px-3 py-2"><span>Folders</span></div><div /></div>'), 'error', name)), 'a hand-made column header is an error naming ContainerHeader') },
|
|
224
|
+
{ what: 'a group heading inside a list is left alone', run: probe(component('<section className="flex flex-col gap-px"><h3 className="flex h-8 items-center gap-2 px-3"><span>Todo</span></h3><div /></section>'), 'none') },
|
|
223
225
|
]),
|
|
224
226
|
ticket('UIG-23', [
|
|
225
227
|
{ what: 'a hand-made empty line is an error naming EmptyState', run: probe("export function Probe({ items }: { items: string[] }) {\n return <div>{items.length === 0 ? <p className=\"text-text-secondary\">Nothing here</p> : items.map((i) => <span key={i}>{i}</span>)}</div>\n}\n", 'error', 'EmptyState') },
|
|
@@ -59,7 +59,7 @@ describe('create-estiva-app', () => {
|
|
|
59
59
|
|
|
60
60
|
it('starts the count at zero and keeps the CI job named gate', () => {
|
|
61
61
|
const count = JSON.parse(files['.gates-count.json'])
|
|
62
|
-
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
62
|
+
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 }])
|
|
63
63
|
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run lint:rules/)
|
|
64
64
|
expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
|
|
65
65
|
})
|