@estiva-app/ui 0.35.2 → 0.37.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/Panel.d.ts +44 -0
- package/dist/Panel.d.ts.map +1 -0
- package/dist/ScrollArea.d.ts +7 -2
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +86 -12
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-handmade-header.d.ts +5 -0
- package/dist/eslint/no-handmade-header.d.ts.map +1 -1
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- 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 +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 +42 -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.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -49
- package/dist/index.js.map +4 -4
- 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-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +7 -6
- package/dist/registry/cli.js.map +2 -2
- package/dist/registry/contract.d.ts +13 -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/package.json +4 -1
- package/registry.json +369 -19
- 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/Panel.mdx +78 -0
- package/src/Panel.stories.tsx +98 -0
- package/src/Panel.tsx +80 -0
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +6 -2
- package/src/ScrollArea.stories.tsx +1 -1
- package/src/ScrollArea.tsx +9 -4
- 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 +11 -2
- package/src/eslint/no-handmade-header.ts +24 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-native-title.ts +1 -1
- 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 +9 -6
- 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 +2 -0
- package/src/gates/gates.test.ts +86 -1
- package/src/gates/hook.ts +38 -4
- 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/index.ts +1 -0
- package/src/pages.test.ts +6 -0
- package/src/registry/app.test.ts +37 -1
- 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 +5 -3
- package/src/registry/contract.test.ts +27 -2
- package/src/registry/contract.ts +28 -3
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +18 -4
- package/stories/Choosing.mdx +4 -3
- package/dist/gates/chunk-DBMUQAR6.js +0 -607
- package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-5K2TRR4X.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
package/src/gates/gates.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
5
5
|
import { ESLint, type Linter } from 'eslint'
|
|
6
6
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
7
|
+
import { countCommitted, hookRuns, skippedFolders, skipsListed } from './ci'
|
|
7
8
|
import { writeGateCount } from './count'
|
|
8
9
|
import { gateConfig, gateLint } from './gate-config'
|
|
9
10
|
import { runHook } from './hook'
|
|
@@ -78,6 +79,18 @@ describe('tokenLint and tokenValues', () => {
|
|
|
78
79
|
expect(await lintWith([tokenLint()], "import { cn } from '@estiva-app/ui'\nexport const merged = cn('p-2', 'p-3')\n", 'src/lib/probe.ts')).toEqual([])
|
|
79
80
|
})
|
|
80
81
|
|
|
82
|
+
it('refuse a colour in a style with its key in quotes, or kept in a const (R9)', async () => {
|
|
83
|
+
const quoted = await lintWith([tokenLint(), tokenValues()], component(`<div style={{ 'background': 'red' }}>x</div>`), 'src/Probe.tsx')
|
|
84
|
+
expect(quoted.filter((m) => m.severity === 2).map((m) => m.ruleId)).toEqual(['no-restricted-syntax'])
|
|
85
|
+
const kept = `const look = { background: 'red', width: 20 } as const\n${component('<div style={look}>x</div>')}`
|
|
86
|
+
const constant = await lintWith([tokenLint(), tokenValues()], kept, 'src/Probe.tsx')
|
|
87
|
+
expect(constant.filter((m) => m.severity === 2)).toEqual([expect.objectContaining({ ruleId: 'token-style/no-token-style', line: 1 })])
|
|
88
|
+
// A size in a const, and a style worked out while it runs, pass.
|
|
89
|
+
expect((await lintWith([tokenLint(), tokenValues()], `const at = { width: 20 }\n${component('<div style={at}><div style={props.style} /></div>')}`, 'src/Probe.tsx')).filter((m) => m.severity === 2)).toEqual([])
|
|
90
|
+
// Its escape works in the gate too: the gate knows the rule's name.
|
|
91
|
+
expect(await lintWith(gateConfig(), `// eslint-disable-next-line token-style/no-token-style -- @estiva-escape: a colour the probe keeps\n${kept}`, 'src/Probe.tsx')).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
81
94
|
it('leave a test file to its test', async () => {
|
|
82
95
|
const messages = await lintWith([tokenLint(), tokenValues()], component('<div style={{ color: "red" }}>x</div>'), 'src/Probe.test.tsx')
|
|
83
96
|
expect(messages.filter((m) => m.severity === 2)).toEqual([])
|
|
@@ -147,7 +160,7 @@ describe('tokenConfig', () => {
|
|
|
147
160
|
describe('writeGateCount', () => {
|
|
148
161
|
it('writes the count once, leaves it alone when nothing changed, and fails on a rule switched off', async () => {
|
|
149
162
|
const dir = app('count', {
|
|
150
|
-
'src/Kept.tsx': component('(\n // @estiva-escape: a probe that keeps its element on purpose\n <form />\n )'),
|
|
163
|
+
'src/Kept.tsx': component('(\n // @estiva-escape(no-raw-element): a probe that keeps its element on purpose\n <form />\n )'),
|
|
151
164
|
})
|
|
152
165
|
const first = await writeGateCount({ root: dir, repo: 'probe' })
|
|
153
166
|
expect(first.changed).toBe(true)
|
|
@@ -164,6 +177,47 @@ describe('writeGateCount', () => {
|
|
|
164
177
|
})
|
|
165
178
|
})
|
|
166
179
|
|
|
180
|
+
describe('estiva-gates ci: the steps no lint runs (B2, B13)', () => {
|
|
181
|
+
const git = (dir: string, ...args: string[]) => execFileSync('git', ['-c', 'user.name=probe', '-c', 'user.email=probe@example.com', ...args], { cwd: dir, stdio: 'ignore' })
|
|
182
|
+
|
|
183
|
+
it('fails when the count the gate writes is not the committed one', async () => {
|
|
184
|
+
const dir = app('ci-count', { 'src/Plain.tsx': component('<div>x</div>') })
|
|
185
|
+
git(dir, 'init', '-q')
|
|
186
|
+
await writeGateCount({ root: dir, repo: 'probe' })
|
|
187
|
+
git(dir, 'add', '.')
|
|
188
|
+
git(dir, 'commit', '-q', '-m', 'count')
|
|
189
|
+
expect(countCommitted(dir)).toEqual([])
|
|
190
|
+
writeFileSync(join(dir, 'src', 'Kept.tsx'), component('(\n // @estiva-escape(no-raw-element): a probe that keeps its element on purpose\n <form />\n )'))
|
|
191
|
+
await writeGateCount({ root: dir, repo: 'probe' })
|
|
192
|
+
const moved = countCommitted(dir)
|
|
193
|
+
expect(moved[0]).toContain('commit `.gates-count.json`')
|
|
194
|
+
expect(moved.join('\n')).toContain('"escapes": 1')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('fails when no hook runs before both Write and Edit, or the hook lets a raw button through', () => {
|
|
198
|
+
const cli = join(root, 'dist', 'gates', 'cli.js').split('\\').join('/')
|
|
199
|
+
const settings = (matcher: string, command: string) => JSON.stringify({ hooks: { PreToolUse: [{ matcher, hooks: [{ type: 'command', command }] }] } })
|
|
200
|
+
const dir = app('ci-hook', { '.claude/settings.json': settings('Edit|Write', `node "${cli}" hook`) })
|
|
201
|
+
expect(hookRuns(dir)).toEqual([])
|
|
202
|
+
writeFileSync(join(dir, '.claude', 'settings.json'), settings('Write', `node "${cli}" hook`))
|
|
203
|
+
expect(hookRuns(dir)[0]).toContain('before both Write and Edit')
|
|
204
|
+
writeFileSync(join(dir, '.claude', 'settings.json'), settings('Edit|Write', 'node -e "process.exit(0)"'))
|
|
205
|
+
expect(hookRuns(dir)[0]).toContain('let a raw button through')
|
|
206
|
+
rmSync(join(dir, '.claude'), { recursive: true })
|
|
207
|
+
expect(hookRuns(dir)[0]).toContain('missing')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('fails when the gate skips a folder the debt page does not name', async () => {
|
|
211
|
+
const dir = app('ci-skips', { 'docs/GATES-DEBT.md': '# Debt\n\nNothing skipped.\n' })
|
|
212
|
+
expect(await skipsListed(dir, 'app')).toEqual([])
|
|
213
|
+
writeFileSync(join(dir, 'eslint.gates.config.js'), `import { gateConfig } from '${built}'\nexport default gateConfig({ ignores: ['demo-scenarios/**'] })\n`)
|
|
214
|
+
expect(await skippedFolders(dir)).toEqual(['demo-scenarios/**'])
|
|
215
|
+
expect((await skipsListed(dir, 'app')).join('\n')).toContain('demo-scenarios/**')
|
|
216
|
+
writeFileSync(join(dir, 'docs', 'GATES-DEBT.md'), '# Debt\n\n`demo-scenarios` is not checked: fixtures for the demo, never shipped.\n')
|
|
217
|
+
expect(await skipsListed(dir, 'app')).toEqual([])
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
167
221
|
describe('runHook', () => {
|
|
168
222
|
const dir = app('hook', { 'src/Page.tsx': 'export function Page() {\n return <div>x</div>\n}\n' })
|
|
169
223
|
const write = (file_path: string, content: string) => ({ tool_name: 'Write', tool_input: { file_path, content } })
|
|
@@ -182,6 +236,29 @@ describe('runHook', () => {
|
|
|
182
236
|
expect((await runHook({ root: dir, input: fine })).code).toBe(0)
|
|
183
237
|
})
|
|
184
238
|
|
|
239
|
+
// B4 (Katerina, 25 September): the token contract is refused before the write, not only in CI.
|
|
240
|
+
it('refuses a hand-written size and a raw colour before the write, naming the token', async () => {
|
|
241
|
+
const result = await runHook({ root: dir, input: write('src/Probe.tsx', component('<div className="text-[13px] bg-[#ff0000]">x</div>')) })
|
|
242
|
+
expect(result.code).toBe(2)
|
|
243
|
+
expect(result.message).toContain('token-values/no-restricted-classes')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// R10: a copied look only warns, so the write goes through, with a note Claude sees.
|
|
247
|
+
it('lets a copied look through with a note, and no note when nothing is copied', async () => {
|
|
248
|
+
const own = app('hook-copied', {
|
|
249
|
+
// An app's Tailwind config, on the package preset: the token lint knows its classes from it.
|
|
250
|
+
'tailwind.config.js': `import estiva from '${pathToFileURL(join(root, 'tailwind-preset.js')).href}'\nexport default { presets: [estiva], content: ['./src/**/*.tsx'] }\n`,
|
|
251
|
+
'src/Badge.tsx': '/** A badge. */\nexport function Badge() {\n return <span className="inline-flex items-center rounded-md border border-border-default bg-bg-elevated px-2 text-caption text-text-secondary">x</span>\n}\n',
|
|
252
|
+
// Already there, so the write is an edit of a part, not a new one the search stops.
|
|
253
|
+
'src/Probe.tsx': component('<span>y</span>'),
|
|
254
|
+
})
|
|
255
|
+
const copied = await runHook({ root: own, input: write('src/Probe.tsx', component('<span className="inline-flex items-center rounded-md border border-border-default bg-bg-elevated px-2 text-caption text-text-secondary">y</span>')) })
|
|
256
|
+
expect(copied.code).toBe(0)
|
|
257
|
+
expect(copied.note).toContain('Badge')
|
|
258
|
+
const plain = await runHook({ root: own, input: write('src/Probe.tsx', component('<span className="mt-2">y</span>')) })
|
|
259
|
+
expect(plain).toEqual({ code: 0, message: '' })
|
|
260
|
+
})
|
|
261
|
+
|
|
185
262
|
// The re-review after the audit before UIG-26: only .ts and .tsx were read, so these passed.
|
|
186
263
|
it('reads every script type under src: a raw button in a .jsx, a page listener in a .mts', async () => {
|
|
187
264
|
expect((await runHook({ root: dir, input: write('src/Probe.jsx', component('<button type="button">x</button>')) })).code).toBe(2)
|
|
@@ -242,6 +319,14 @@ describe('gates:status', () => {
|
|
|
242
319
|
expect(h.ciJob('gate', 'lint:rules').result).toBe('pass')
|
|
243
320
|
expect(h.ciJob('check', 'lint:rules').result).toBe('fail')
|
|
244
321
|
expect(h.script('package.json', 'gates:status').result).toBe('pass')
|
|
322
|
+
// A job that runs `estiva-gates ci` runs the gate's scripts (R13), and nothing else.
|
|
323
|
+
writeFileSync(join(dir, '.github', 'workflows', 'deploy.yml'), 'jobs:\n gate:\n steps:\n - run: npx estiva-gates ci\n')
|
|
324
|
+
expect(h.ciJob('gate', 'lint:rules').result).toBe('pass')
|
|
325
|
+
expect(h.ciJob('gate', 'registry:check').result).toBe('pass')
|
|
326
|
+
expect(h.ci('lint:tokens').result).toBe('pass')
|
|
327
|
+
expect(h.ciJob('gate', 'test').result).toBe('fail')
|
|
328
|
+
writeFileSync(join(dir, '.github', 'workflows', 'deploy.yml'), 'jobs:\n gate:\n steps:\n # - run: npx estiva-gates ci\n - run: npm test\n')
|
|
329
|
+
expect(h.ciJob('gate', 'lint:rules').result).toBe('fail')
|
|
245
330
|
})
|
|
246
331
|
|
|
247
332
|
// Audit A2: a hook is judged by running it, the way Claude Code does, on a raw
|
package/src/gates/hook.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* UIG-4 findings).
|
|
14
14
|
*
|
|
15
15
|
* It runs the gate's own config and reimplements nothing: a hook and a lint
|
|
16
|
-
* that could disagree would be worse than neither.
|
|
16
|
+
* that could disagree would be worse than neither. Since 0.36.0 it runs the
|
|
17
|
+
* token contract too, keeping only that contract's errors (B4), and a copied
|
|
18
|
+
* look, which only warns, reaches Claude as a note on a write let through (R10). Tests are not checked
|
|
17
19
|
* (Katerina, 13 September); stories are. This package's inward gate reads `.tsx`
|
|
18
20
|
* only, as its lint does.
|
|
19
21
|
*
|
|
@@ -37,8 +39,9 @@ import { createRequire } from 'node:module'
|
|
|
37
39
|
import { tmpdir } from 'node:os'
|
|
38
40
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
39
41
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
40
|
-
import type { ESLint as ESLintClass } from 'eslint'
|
|
42
|
+
import type { ESLint as ESLintClass, Linter } from 'eslint'
|
|
41
43
|
import { countGates } from '../eslint/index'
|
|
44
|
+
import { tokenConfig } from './token-lint'
|
|
42
45
|
import { repositoryOf } from '../registry/skill'
|
|
43
46
|
|
|
44
47
|
export interface HookOptions {
|
|
@@ -59,6 +62,27 @@ export interface HookResult {
|
|
|
59
62
|
code: 0 | 2
|
|
60
63
|
/** What goes to stderr when refused. */
|
|
61
64
|
message: string
|
|
65
|
+
/** A note for Claude on a write let through: a copied look, which only warns. */
|
|
66
|
+
note?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The token contract's own rules: what the hook keeps from its lint, and nothing else. */
|
|
70
|
+
const TOKEN_RULE_IDS = new Set(tokenConfig().flatMap((c) => Object.keys(c.rules ?? {})))
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The token contract's errors for the text about to be written. The app's own
|
|
74
|
+
* token config when it has one (`eslint.tokens.config.js`, which knows the app's
|
|
75
|
+
* other plugins' names); otherwise the package's `tokenConfig`. Only the token
|
|
76
|
+
* rules' errors count, so an app's wider lint (TypeScript's, React's) never
|
|
77
|
+
* blocks a write that is still being made.
|
|
78
|
+
*/
|
|
79
|
+
async function tokenErrors(ESLint: typeof ESLintClass, appDir: string, file: string, text: string, audience: 'app' | 'package'): Promise<Linter.LintMessage[]> {
|
|
80
|
+
const own = join(appDir, 'eslint.tokens.config.js')
|
|
81
|
+
const eslint = existsSync(own)
|
|
82
|
+
? new ESLint({ cwd: appDir, overrideConfigFile: own })
|
|
83
|
+
: new ESLint({ cwd: appDir, overrideConfigFile: true, overrideConfig: tokenConfig({ audience }) })
|
|
84
|
+
const results = await eslint.lintText(text, { filePath: file })
|
|
85
|
+
return results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId && TOKEN_RULE_IDS.has(m.ruleId)))
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
interface ToolCall {
|
|
@@ -103,14 +127,24 @@ export async function runHook({ root, app = '.', audience = 'app', input, seen }
|
|
|
103
127
|
// A rule's errors only. Code that does not parse yet (one edit of several) is
|
|
104
128
|
// the typecheck's to judge; the gate looks again at the edit that completes it.
|
|
105
129
|
const errors = results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId))
|
|
130
|
+
// The token contract too (Katerina's ruling B4, 25 September): a colour or a size
|
|
131
|
+
// is refused before the write, as CI's gate refuses it, not only at the PR.
|
|
132
|
+
errors.push(...(await tokenErrors(ESLint, appDir, file, text, audience)))
|
|
106
133
|
const { disabled } = countGates(results)
|
|
107
|
-
if (errors.length === 0 && disabled.length === 0)
|
|
134
|
+
if (errors.length === 0 && disabled.length === 0) {
|
|
135
|
+
const stopped = searchFirst(call, file, text, appDir, top, seen)
|
|
136
|
+
if (stopped) return stopped
|
|
137
|
+
// A copied look is a warning (C2): it never blocks, and Claude hears of it now,
|
|
138
|
+
// not only in CI (R10, 25 September).
|
|
139
|
+
const copies = results.flatMap((r) => r.messages.filter((m) => m.severity === 1 && m.ruleId === 'estiva/no-copied-look'))
|
|
140
|
+
return copies.length ? { code: 0, message: '', note: copies.map((m) => `${relative(top, file).split(sep).join('/')}:${m.line} ${m.message}`).join('\n') } : PASS
|
|
141
|
+
}
|
|
108
142
|
|
|
109
143
|
const shown = (p: string) => relative(top, p).split(sep).join('/')
|
|
110
144
|
const lines = [`${shown(file)} was not written: the UI Guardrails refuse it (${shown(config)}).`]
|
|
111
145
|
for (const m of errors) lines.push(` ${m.line}:${m.column} ${m.message}${m.ruleId ? ` (${m.ruleId})` : ''}`)
|
|
112
146
|
const keep = audience === 'package' ? 'Keep it only' : 'Keep the element only'
|
|
113
|
-
for (const d of disabled) lines.push(` ${d.line} an eslint-disable switches ${d.ruleId} off. ${keep} with its reason on the line above: // @estiva-escape: <reason>`)
|
|
147
|
+
for (const d of disabled) lines.push(` ${d.line} an eslint-disable switches ${d.ruleId} off. ${keep} with its reason on the line above: // @estiva-escape(${d.ruleId.slice(d.ruleId.lastIndexOf('/') + 1)}): <reason>`)
|
|
114
148
|
return { code: 2, message: `${lines.join('\n')}\n` }
|
|
115
149
|
}
|
|
116
150
|
|
package/src/gates/index.ts
CHANGED
|
@@ -11,21 +11,25 @@
|
|
|
11
11
|
* - `tokenConfig` — the token lint on its own, as `lint:tokens` runs it (UIG-37).
|
|
12
12
|
* - `gateLint`, `gateConfig` — where the rules apply, and the gate that runs them alone.
|
|
13
13
|
* - `writeGateCount` — `.gates-count.json` (seam S3).
|
|
14
|
+
* - `runCi` — CI's job `gate`, every step of it (R13).
|
|
14
15
|
* - `runHook` — the editor gate a `PreToolUse` hook runs.
|
|
15
16
|
* - `runStatus`, `helpers` — gates:status and the helpers a checks file is written with.
|
|
16
17
|
*
|
|
17
18
|
* - `appChecks` — the gate checks every app runs, for its `scripts/gates-checks.mjs`.
|
|
18
19
|
* - `appFiles`, `createApp`, `themes` — a new app with every gate on (`create-estiva-app`).
|
|
19
20
|
*
|
|
20
|
-
* `estiva-gates` (`cli.ts`) runs the count, the hook and the status as one command.
|
|
21
|
+
* `estiva-gates` (`cli.ts`) runs the gate's CI, the count, the hook and the status as one command.
|
|
21
22
|
*
|
|
22
23
|
* Built by build.mjs into `dist/gates/`, for Node. Nothing here reaches the
|
|
23
24
|
* components' browser bundle.
|
|
24
25
|
*/
|
|
25
26
|
export { TOKEN_LINT_IGNORES, tokenConfig, tokenLint, tokenValues, type TokenAudience, type TokenConfigOptions, type TokenLintOptions } from './token-lint'
|
|
26
27
|
export { gateConfig, gateLint, type GateConfigOptions } from './gate-config'
|
|
28
|
+
export { countCommitted, hookRuns, runCi, skippedFolders, skipsListed, type CiOptions } from './ci'
|
|
27
29
|
export { writeGateCount, type CountOptions, type CountResult } from './count'
|
|
28
30
|
export { runHook, type HookOptions, type HookResult } from './hook'
|
|
31
|
+
export { nameEscapes, type NameEscapesOptions, type NameEscapesResult } from './name-escapes'
|
|
32
|
+
export { escapeLines, listEscapes, REVIEW_AFTER_DAYS, type Escape } from './escapes'
|
|
29
33
|
export { ENGINE, helpers, runStatus, type CheckResult, type GateCheck, type GateHelpers, type GateSpec, type GateTicket, type LintProbe, type StatusOptions, type TicketListEntry } from './status'
|
|
30
34
|
export { APP_TICKET_TITLES, appChecks, type AppCheckOptions } from './app-checks'
|
|
31
35
|
export { appFiles, askNpm, createApp, themes, type CreateAppOptions } from './create-app'
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* `estiva-gates name-escapes`: write into every escape marker the rules it keeps
|
|
4
|
+
* off, once (Katerina's ruling B3, 25 September).
|
|
5
|
+
*
|
|
6
|
+
* A marker used to keep off every rule on its element; now it keeps off only the
|
|
7
|
+
* rules it names, `// @estiva-escape(no-raw-element): <reason>`. So that nothing
|
|
8
|
+
* that was escaped breaks, this asks the gate itself which rules each unnamed
|
|
9
|
+
* marker stands for: linted with the new reader, an unnamed marker is reported
|
|
10
|
+
* once per rule that finds its element ("Name the rule … `@estiva-escape(<rule>)`"),
|
|
11
|
+
* and those names are written into the marker. Nothing is guessed, and a marker
|
|
12
|
+
* no rule reaches is left as it is and listed.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { createRequire } from 'node:module'
|
|
16
|
+
import { join, relative } from 'node:path'
|
|
17
|
+
import { pathToFileURL } from 'node:url'
|
|
18
|
+
import type { ESLint as ESLintClass } from 'eslint'
|
|
19
|
+
|
|
20
|
+
export interface NameEscapesOptions {
|
|
21
|
+
/** The folder the gate lints from. */
|
|
22
|
+
root?: string
|
|
23
|
+
/** The gate's config file, relative to `root`. */
|
|
24
|
+
config?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NameEscapesResult {
|
|
28
|
+
/** One line per marker rewritten: `file:line rule, rule`. */
|
|
29
|
+
named: string[]
|
|
30
|
+
/** Markers no rule reached, left unnamed. */
|
|
31
|
+
unreached: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const UNNAMED = /@estiva-escape(?!\s*\()/
|
|
35
|
+
const NAMED_IN_MESSAGE = /@estiva-escape\(([^)]+)\)/
|
|
36
|
+
|
|
37
|
+
export async function nameEscapes({ root = process.cwd(), config = 'eslint.gates.config.js' }: NameEscapesOptions = {}): Promise<NameEscapesResult> {
|
|
38
|
+
const resolved = createRequire(join(root, 'package.json')).resolve('eslint')
|
|
39
|
+
const { ESLint } = (await import(pathToFileURL(resolved).href)) as { ESLint: typeof ESLintClass }
|
|
40
|
+
const eslint = new ESLint({ cwd: root, overrideConfigFile: join(root, config) })
|
|
41
|
+
const results = await eslint.lintFiles(['.'])
|
|
42
|
+
|
|
43
|
+
const named: string[] = []
|
|
44
|
+
const unreached: string[] = []
|
|
45
|
+
for (const result of results) {
|
|
46
|
+
// The rules each unnamed marker stands for, by the line its comment starts on.
|
|
47
|
+
const byLine = new Map<number, Set<string>>()
|
|
48
|
+
for (const m of result.messages) {
|
|
49
|
+
if (m.messageId !== 'escapeWithoutRule') continue
|
|
50
|
+
const rule = NAMED_IN_MESSAGE.exec(m.message)?.[1]
|
|
51
|
+
if (!rule) continue
|
|
52
|
+
byLine.set(m.line, (byLine.get(m.line) ?? new Set()).add(rule))
|
|
53
|
+
}
|
|
54
|
+
const source = result.source ?? readFileSync(result.filePath, 'utf8')
|
|
55
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n'
|
|
56
|
+
const lines = source.split(/\r?\n/)
|
|
57
|
+
let changed = false
|
|
58
|
+
const file = relative(root, result.filePath).split('\\').join('/')
|
|
59
|
+
for (const [line, rules] of byLine) {
|
|
60
|
+
// The marker is on the comment's first line, or within the next few (a block comment).
|
|
61
|
+
for (let i = line - 1; i < Math.min(lines.length, line + 3); i++) {
|
|
62
|
+
if (!UNNAMED.test(lines[i])) continue
|
|
63
|
+
const names = [...rules].sort().join(', ')
|
|
64
|
+
lines[i] = lines[i].replace(UNNAMED, `@estiva-escape(${names})`)
|
|
65
|
+
named.push(`${file}:${i + 1} ${names}`)
|
|
66
|
+
changed = true
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (changed) writeFileSync(result.filePath, lines.join(eol), 'utf8')
|
|
71
|
+
// Unnamed markers the gate did not report: nothing of the gate's reaches that element.
|
|
72
|
+
lines.forEach((text, i) => {
|
|
73
|
+
if (UNNAMED.test(text) && !/eslint-disable/.test(text) && /\/\/|\/\*/.test(text)) unreached.push(`${file}:${i + 1}`)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
return { named, unreached }
|
|
77
|
+
}
|
package/src/gates/status.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { createRequire } from 'node:module'
|
|
|
31
31
|
import { join, relative, resolve } from 'node:path'
|
|
32
32
|
import { pathToFileURL } from 'node:url'
|
|
33
33
|
import type { ESLint as ESLintClass } from 'eslint'
|
|
34
|
+
import { escapeLines, listEscapes } from './escapes'
|
|
34
35
|
|
|
35
36
|
export type CheckResult = { result: 'pass' | 'fail' | 'part' | 'unknown'; detail: string }
|
|
36
37
|
type Maybe<T> = T | Promise<T>
|
|
@@ -141,6 +142,16 @@ const said = (e: unknown) => {
|
|
|
141
142
|
return { text: `${err.stdout ?? ''}${err.stderr ?? ''}`, message: String(err.message ?? e), code: err.code }
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** The scripts `estiva-gates ci` runs (R13): a job that runs it runs these. */
|
|
146
|
+
const GATE_CI_SCRIPTS = new Set(['lint:rules', 'lint:tokens', 'lint', 'registry:check'])
|
|
147
|
+
|
|
148
|
+
/** `estiva-gates ci`, as a workflow runs it, when it runs `script`; null otherwise. A comment line does not count. */
|
|
149
|
+
function runsGateCi(text: string, script: string | RegExp): string | null {
|
|
150
|
+
if (typeof script !== 'string' || !GATE_CI_SCRIPTS.has(script)) return null
|
|
151
|
+
const m = /^(?!\s*#).*?((?:npx )?estiva-gates ci\b|npm run gates:ci\b|cli\.js"? ci\b)/m.exec(text)
|
|
152
|
+
return m ? m[1] : null
|
|
153
|
+
}
|
|
154
|
+
|
|
144
155
|
/** The helpers, bound to one repository. */
|
|
145
156
|
export function helpers(ROOT: string): GateHelpers {
|
|
146
157
|
const abs = (rel: string) => resolve(ROOT, rel)
|
|
@@ -263,6 +274,8 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
263
274
|
for (const f of files) {
|
|
264
275
|
const m = read(f).match(step)
|
|
265
276
|
if (m) return PASS(`${f} runs npm run ${m[1]}`)
|
|
277
|
+
const all = runsGateCi(read(f), script)
|
|
278
|
+
if (all) return PASS(`${f} runs ${all}, which runs npm run ${script}`)
|
|
266
279
|
}
|
|
267
280
|
return FAIL(`no workflow runs npm run ${script}`)
|
|
268
281
|
},
|
|
@@ -285,7 +298,9 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
285
298
|
if (start === -1) continue
|
|
286
299
|
const end = lines.findIndex((l, i) => i > start && /^ {0,2}\S/.test(l))
|
|
287
300
|
const body = lines.slice(start + 1, end === -1 ? undefined : end).join('\n')
|
|
288
|
-
|
|
301
|
+
if (step.test(body)) return PASS(`${f}: the job ${job} runs npm run ${script}`)
|
|
302
|
+
const all = runsGateCi(body, script)
|
|
303
|
+
return all ? PASS(`${f}: the job ${job} runs ${all}, which runs npm run ${script}`) : FAIL(`${f}: the job ${job} does not run npm run ${script}`)
|
|
289
304
|
}
|
|
290
305
|
return FAIL(`no workflow has a job ${job}`)
|
|
291
306
|
},
|
|
@@ -560,10 +575,35 @@ export interface StatusOptions {
|
|
|
560
575
|
app?: string
|
|
561
576
|
json?: boolean
|
|
562
577
|
detail?: boolean
|
|
578
|
+
/** List every escape, not only those due for review (B1). */
|
|
579
|
+
escapes?: boolean
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* What an app's catalogue leaves for Katerina: the parts nothing uses, by name
|
|
584
|
+
* (her UIG-13 ruling: an unused part is listed for her, never deleted by the
|
|
585
|
+
* catalogue; R20), and the parts with no link into Storybook (R22). A reusable
|
|
586
|
+
* part with none fails `estiva-ui check`; a one-off needs none (B10), so its
|
|
587
|
+
* number is shown and nothing more.
|
|
588
|
+
*/
|
|
589
|
+
async function catalogueLines(appDir: string): Promise<string[]> {
|
|
590
|
+
try {
|
|
591
|
+
const { buildAppRegistry } = await import('../registry/index')
|
|
592
|
+
const entries = buildAppRegistry({ root: appDir }).entries
|
|
593
|
+
const unused = entries.filter((e) => e.app?.class === 'unused')
|
|
594
|
+
const oneOffs = entries.filter((e) => e.app?.class === 'one-off')
|
|
595
|
+
const unlinked = oneOffs.filter((e) => !e.storyId && !e.docsId)
|
|
596
|
+
return [
|
|
597
|
+
`Unused parts: ${unused.length}${unused.length ? ` · Katerina rules on each: ${unused.map((e) => `${e.name} (${e.sourceFile})`).join(', ')}` : ''}`,
|
|
598
|
+
`Storybook links: every reusable part has one (estiva-ui check holds it) · one-offs with none: ${unlinked.length} of ${oneOffs.length}, which need none`,
|
|
599
|
+
]
|
|
600
|
+
} catch (error) {
|
|
601
|
+
return [`Catalogue: could not be built here: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`]
|
|
602
|
+
}
|
|
563
603
|
}
|
|
564
604
|
|
|
565
605
|
/** Print where the project stands. Returns what was printed, or the JSON report. */
|
|
566
|
-
export async function runStatus({ root = process.cwd(), app = '.', json = false, detail = false }: StatusOptions = {}): Promise<string> {
|
|
606
|
+
export async function runStatus({ root = process.cwd(), app = '.', json = false, detail = false, escapes = false }: StatusOptions = {}): Promise<string> {
|
|
567
607
|
const ROOT = resolve(root)
|
|
568
608
|
const h = helpers(ROOT)
|
|
569
609
|
const shown = (dir: string) => (relative(ROOT, dir) || dir).replace(/\\/g, '/')
|
|
@@ -574,8 +614,11 @@ export async function runStatus({ root = process.cwd(), app = '.', json = false,
|
|
|
574
614
|
const out: string[] = []
|
|
575
615
|
out.push('UI Guardrails · gates:status', 'Read from the code. Nothing here is a hand-ticked list.', '')
|
|
576
616
|
out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}${behindMain(ROOT)}`)
|
|
617
|
+
// Every escape, listed and aged (Katerina's rulings B1, 25 September): the ones due for review always.
|
|
618
|
+
out.push('', ...escapeLines(listEscapes(join(ROOT, app)), { all: escapes }))
|
|
577
619
|
|
|
578
620
|
if (!spec.all) {
|
|
621
|
+
out.push('', ...(await catalogueLines(join(ROOT, app))))
|
|
579
622
|
// A sibling repo on its own: its own rows, then its parts of rows owned elsewhere.
|
|
580
623
|
const own = report.rows.filter((r) => r.owner).map((r) => ({ ...r, ownerRepo: spec.repo, status: statusOf(r.checks) }))
|
|
581
624
|
const parts = report.rows.filter((r) => !r.owner).map((r) => ({ ...r, ownerRepo: '', status: statusOf(r.checks) }))
|
package/src/gates/token-lint.ts
CHANGED
|
@@ -39,6 +39,7 @@ import type { ESLint, Linter } from 'eslint'
|
|
|
39
39
|
import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
|
|
40
40
|
import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults'
|
|
41
41
|
import { parser as typescriptParser, plugin as typescriptPlugin } from 'typescript-eslint'
|
|
42
|
+
import { INLINE_STYLE_MESSAGE, INLINE_STYLE_TOKEN_KEYS, tokenStylePlugin } from './token-style'
|
|
42
43
|
|
|
43
44
|
/** Who reads the messages. */
|
|
44
45
|
export type TokenAudience = 'app' | 'package'
|
|
@@ -162,8 +163,7 @@ const HAND_WRITTEN = {
|
|
|
162
163
|
width: `${V}(?:border(?:-[xytrblse])?|divide-[xy]|outline|ring|ring-offset)-\\[(?:length:)?-?[0-9.]+(?:px|rem|em)?\\]$`,
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
const INLINE_STYLE_TOKEN_PROPERTIES =
|
|
166
|
-
'/^(color|background|backgroundColor|backgroundImage|fill|stroke|fontSize|border|borderTop|borderRight|borderBottom|borderLeft|borderBlock|borderInline|borderStyle|boxShadow|textShadow)$|Color$/'
|
|
166
|
+
const INLINE_STYLE_TOKEN_PROPERTIES = String(INLINE_STYLE_TOKEN_KEYS)
|
|
167
167
|
|
|
168
168
|
interface Preset {
|
|
169
169
|
theme: { extend: { fontSize: Record<string, string | [string, unknown]>; borderRadius: Record<string, string> } }
|
|
@@ -204,8 +204,10 @@ export function tokenValues({ audience = 'app' }: Pick<TokenLintOptions, 'audien
|
|
|
204
204
|
return {
|
|
205
205
|
files: ['**/*.{ts,tsx}'],
|
|
206
206
|
ignores: ['**/*.test.ts', '**/*.test.tsx'],
|
|
207
|
-
plugins: { 'token-values': betterTailwindcss, 'token-spacing': betterTailwindcss },
|
|
207
|
+
plugins: { 'token-values': betterTailwindcss, 'token-spacing': betterTailwindcss, 'token-style': tokenStylePlugin },
|
|
208
208
|
rules: {
|
|
209
|
+
// A style kept in a const and passed by name (R9): the selector below cannot follow it.
|
|
210
|
+
'token-style/no-token-style': 'error',
|
|
209
211
|
'token-values/no-restricted-classes': [
|
|
210
212
|
'error',
|
|
211
213
|
{
|
|
@@ -252,7 +254,12 @@ export function tokenValues({ audience = 'app' }: Pick<TokenLintOptions, 'audien
|
|
|
252
254
|
'error',
|
|
253
255
|
{
|
|
254
256
|
selector: `JSXAttribute[name.name="style"] > JSXExpressionContainer > ObjectExpression > Property[key.name=${INLINE_STYLE_TOKEN_PROPERTIES}]`,
|
|
255
|
-
message:
|
|
257
|
+
message: INLINE_STYLE_MESSAGE,
|
|
258
|
+
},
|
|
259
|
+
// The same key in quotes, `style={{ 'background': 'red' }}` (R9).
|
|
260
|
+
{
|
|
261
|
+
selector: `JSXAttribute[name.name="style"] > JSXExpressionContainer > ObjectExpression > Property[key.value=${INLINE_STYLE_TOKEN_PROPERTIES}]`,
|
|
262
|
+
message: INLINE_STYLE_MESSAGE,
|
|
256
263
|
},
|
|
257
264
|
],
|
|
258
265
|
},
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* The token contract's inline-style check, where a selector cannot reach
|
|
4
|
+
* (the second review's R9, 25 September). `no-restricted-syntax` reads a
|
|
5
|
+
* `style={{ … }}` written in place; a style kept in a `const` and passed by name
|
|
6
|
+
* got through every lint in all three repos:
|
|
7
|
+
*
|
|
8
|
+
* const look = { background: 'red' }
|
|
9
|
+
* <div style={look} />
|
|
10
|
+
*
|
|
11
|
+
* This follows the name to its `const` in the same file and reads the keys
|
|
12
|
+
* there, quoted or not. It reports on the key, where the value is written, so
|
|
13
|
+
* an escape sits beside it. A style the code works out while it runs passes:
|
|
14
|
+
* nothing can read it.
|
|
15
|
+
*/
|
|
16
|
+
import type { ESLint, Rule, Scope } from 'eslint'
|
|
17
|
+
|
|
18
|
+
/** The style keys outside the token contract: colours, a font size, borders and shadows. */
|
|
19
|
+
export const INLINE_STYLE_TOKEN_KEYS = /^(color|background|backgroundColor|backgroundImage|fill|stroke|fontSize|border|borderTop|borderRight|borderBottom|borderLeft|borderBlock|borderInline|borderStyle|boxShadow|textShadow)$|Color$/
|
|
20
|
+
|
|
21
|
+
export const INLINE_STYLE_MESSAGE =
|
|
22
|
+
'An inline style that sets a colour, a font size, a border or a shadow is outside the token contract: use a token class. If the value is computed (a palette, a size from a prop), say why in an eslint-disable comment. Width, height and transforms are fine.'
|
|
23
|
+
|
|
24
|
+
interface Node {
|
|
25
|
+
type: string
|
|
26
|
+
[key: string]: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const keyOf = (property: Node): string | undefined => {
|
|
30
|
+
if (property.computed) return undefined
|
|
31
|
+
const key = property.key as Node
|
|
32
|
+
return key.type === 'Identifier' ? (key.name as string) : key.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The object a `const` of this name holds, in this scope or one around it. */
|
|
36
|
+
function constObject(name: string, scope: Scope.Scope | null): Node | undefined {
|
|
37
|
+
for (let current = scope; current; current = current.upper) {
|
|
38
|
+
const variable = current.set.get(name)
|
|
39
|
+
if (!variable) continue
|
|
40
|
+
const definition = variable.defs[0] as unknown as { type: string; node: Node; parent?: Node } | undefined
|
|
41
|
+
if (variable.defs.length !== 1 || definition?.type !== 'Variable' || definition.parent?.kind !== 'const') return undefined
|
|
42
|
+
let init = definition.node.init as Node | undefined
|
|
43
|
+
while (init && (init.type === 'TSAsExpression' || init.type === 'TSSatisfiesExpression')) init = init.expression as Node
|
|
44
|
+
return init?.type === 'ObjectExpression' ? init : undefined
|
|
45
|
+
}
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const noTokenStyle: Rule.RuleModule = {
|
|
50
|
+
meta: {
|
|
51
|
+
type: 'problem',
|
|
52
|
+
docs: { description: 'No colour, font size, border or shadow in a style kept in a const' },
|
|
53
|
+
schema: [],
|
|
54
|
+
messages: { token: INLINE_STYLE_MESSAGE },
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
const reported = new Set<unknown>()
|
|
58
|
+
return {
|
|
59
|
+
JSXAttribute(ruleNode: Rule.Node) {
|
|
60
|
+
const attribute = ruleNode as unknown as Node
|
|
61
|
+
if ((attribute.name as Node).name !== 'style') return
|
|
62
|
+
const value = attribute.value as Node | null
|
|
63
|
+
const expression = value?.type === 'JSXExpressionContainer' ? (value.expression as Node) : undefined
|
|
64
|
+
if (expression?.type !== 'Identifier') return
|
|
65
|
+
const object = constObject(expression.name as string, context.sourceCode.getScope(ruleNode))
|
|
66
|
+
for (const property of (object?.properties as Node[] | undefined) ?? []) {
|
|
67
|
+
if (property.type !== 'Property' || reported.has(property)) continue
|
|
68
|
+
const key = keyOf(property)
|
|
69
|
+
if (!key || !INLINE_STYLE_TOKEN_KEYS.test(key)) continue
|
|
70
|
+
reported.add(property)
|
|
71
|
+
context.report({ node: property as unknown as Rule.Node, messageId: 'token' })
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The plugin, as the token lint registers it: `token-style/no-token-style`. */
|
|
79
|
+
export const tokenStylePlugin: ESLint.Plugin = { rules: { 'no-token-style': noTokenStyle } }
|