@estiva-app/ui 0.19.0 → 0.21.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.
Files changed (112) hide show
  1. package/README.md +38 -2
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Breadcrumb.d.ts.map +1 -1
  4. package/dist/Button.d.ts +6 -1
  5. package/dist/Button.d.ts.map +1 -1
  6. package/dist/Card.d.ts +6 -1
  7. package/dist/Card.d.ts.map +1 -1
  8. package/dist/DialogShell.d.ts.map +1 -1
  9. package/dist/Divider.d.ts +7 -0
  10. package/dist/Divider.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts +12 -2
  12. package/dist/IconButton.d.ts.map +1 -1
  13. package/dist/IdentityMenu.d.ts.map +1 -1
  14. package/dist/Link.d.ts +6 -1
  15. package/dist/Link.d.ts.map +1 -1
  16. package/dist/Menu.d.ts.map +1 -1
  17. package/dist/Popover.d.ts.map +1 -1
  18. package/dist/ReactionPicker.d.ts.map +1 -1
  19. package/dist/SectionLabel.d.ts +6 -1
  20. package/dist/SectionLabel.d.ts.map +1 -1
  21. package/dist/Tooltip.d.ts.map +1 -1
  22. package/dist/eslint/index.d.ts +2 -0
  23. package/dist/eslint/index.d.ts.map +1 -1
  24. package/dist/eslint/index.js +514 -6
  25. package/dist/eslint/index.js.map +4 -4
  26. package/dist/eslint/no-restyled-part.d.ts +81 -0
  27. package/dist/eslint/no-restyled-part.d.ts.map +1 -0
  28. package/dist/gates/app-checks.d.ts +40 -0
  29. package/dist/gates/app-checks.d.ts.map +1 -0
  30. package/dist/gates/chunk-AWO7SZSA.js +477 -0
  31. package/dist/gates/chunk-AWO7SZSA.js.map +7 -0
  32. package/dist/gates/chunk-ZGJ2J5NU.js +754 -0
  33. package/dist/gates/chunk-ZGJ2J5NU.js.map +7 -0
  34. package/dist/gates/cli.d.ts +2 -0
  35. package/dist/gates/cli.d.ts.map +1 -0
  36. package/dist/gates/cli.js +46 -0
  37. package/dist/gates/cli.js.map +7 -0
  38. package/dist/gates/count.d.ts +20 -0
  39. package/dist/gates/count.d.ts.map +1 -0
  40. package/dist/gates/create-app-cli.d.ts +2 -0
  41. package/dist/gates/create-app-cli.d.ts.map +1 -0
  42. package/dist/gates/create-app.d.ts +23 -0
  43. package/dist/gates/create-app.d.ts.map +1 -0
  44. package/dist/gates/create-app.js +30 -0
  45. package/dist/gates/create-app.js.map +7 -0
  46. package/dist/gates/gate-config.d.ts +44 -0
  47. package/dist/gates/gate-config.d.ts.map +1 -0
  48. package/dist/gates/hook.d.ts +18 -0
  49. package/dist/gates/hook.d.ts.map +1 -0
  50. package/dist/gates/index.d.ts +30 -0
  51. package/dist/gates/index.d.ts.map +1 -0
  52. package/dist/gates/index.js +415 -0
  53. package/dist/gates/index.js.map +7 -0
  54. package/dist/gates/status.d.ts +84 -0
  55. package/dist/gates/status.d.ts.map +1 -0
  56. package/dist/gates/token-lint.d.ts +26 -0
  57. package/dist/gates/token-lint.d.ts.map +1 -0
  58. package/dist/index.js +290 -266
  59. package/dist/index.js.map +4 -4
  60. package/package.json +30 -4
  61. package/src/AttachmentCard.tsx +7 -2
  62. package/src/Banner.tsx +2 -2
  63. package/src/Breadcrumb.tsx +9 -4
  64. package/src/Button.mdx +7 -0
  65. package/src/Button.stories.tsx +4 -1
  66. package/src/Button.tsx +10 -2
  67. package/src/Card.mdx +7 -0
  68. package/src/Card.stories.tsx +11 -0
  69. package/src/Card.tsx +8 -0
  70. package/src/CommandPalette.tsx +1 -1
  71. package/src/ConfirmDialog.tsx +2 -2
  72. package/src/DialogShell.tsx +6 -7
  73. package/src/Divider.mdx +3 -0
  74. package/src/Divider.tsx +12 -2
  75. package/src/EditableText.mdx +5 -0
  76. package/src/IconButton.mdx +9 -0
  77. package/src/IconButton.stories.tsx +10 -1
  78. package/src/IconButton.tsx +22 -3
  79. package/src/IdentityMenu.tsx +4 -1
  80. package/src/Link.mdx +6 -0
  81. package/src/Link.stories.tsx +12 -0
  82. package/src/Link.tsx +7 -2
  83. package/src/Menu.stories.tsx +1 -1
  84. package/src/Menu.test.tsx +2 -2
  85. package/src/Menu.tsx +6 -3
  86. package/src/Person.stories.tsx +5 -5
  87. package/src/Popover.test.tsx +2 -2
  88. package/src/Popover.tsx +3 -2
  89. package/src/PreviewCard.tsx +1 -1
  90. package/src/Property.stories.tsx +2 -2
  91. package/src/ReactionPicker.tsx +6 -3
  92. package/src/ScrollArea.stories.tsx +21 -7
  93. package/src/SectionLabel.mdx +3 -2
  94. package/src/SectionLabel.stories.tsx +3 -0
  95. package/src/SectionLabel.tsx +7 -2
  96. package/src/Tooltip.tsx +12 -14
  97. package/src/eslint/index.test.ts +32 -7
  98. package/src/eslint/index.ts +10 -1
  99. package/src/eslint/no-restyled-part.test.ts +301 -0
  100. package/src/eslint/no-restyled-part.ts +647 -0
  101. package/src/gates/app-checks.ts +224 -0
  102. package/src/gates/cli.ts +55 -0
  103. package/src/gates/count.ts +76 -0
  104. package/src/gates/create-app-cli.ts +25 -0
  105. package/src/gates/create-app.test.ts +72 -0
  106. package/src/gates/create-app.ts +797 -0
  107. package/src/gates/gate-config.ts +78 -0
  108. package/src/gates/gates.test.ts +183 -0
  109. package/src/gates/hook.ts +111 -0
  110. package/src/gates/index.ts +30 -0
  111. package/src/gates/status.ts +532 -0
  112. package/src/gates/token-lint.ts +231 -0
@@ -0,0 +1,78 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * Where the UI Guardrails' rules apply, written once (docs/GATES.md §23).
4
+ *
5
+ * Until UIG-10 every repo wrote this itself: `eslint.gates.js` (the rules and
6
+ * where they apply) and `eslint.gates.config.js` (the gate: those rules alone)
7
+ * in Peek, in Ship's `web/`, and here pointed inward. The rules were always the
8
+ * package's; these two files were copies. Now they are two functions.
9
+ *
10
+ * Where: every `.ts` and `.tsx` under `src`, stories included (a story is what
11
+ * people read and copy), tests not (a test mounts markup to test it) —
12
+ * Katerina's rulings of 13 September; `.ts` since UIG-8. This package's own
13
+ * inward set reads `.tsx` only, as it always has.
14
+ *
15
+ * A place that keeps something says why, on the line above:
16
+ * `// @estiva-escape: <reason>`, or `{/* @estiva-escape: <reason> *\/}` as a
17
+ * JSX child. Never `eslint-disable`: the count refuses it.
18
+ */
19
+ import type { ESLint, Linter } from 'eslint'
20
+ import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
21
+ import { parser as typescriptParser } from 'typescript-eslint'
22
+ import estiva from '../eslint/index'
23
+ import { TOKEN_LINT_IGNORES } from './token-lint'
24
+
25
+ export interface GateConfigOptions {
26
+ /** `app` (the default) runs `configs.recommended`; `package` runs this package's inward set. */
27
+ audience?: 'app' | 'package'
28
+ /**
29
+ * Plugins whose `eslint-disable` directives appear in the source, registered
30
+ * here with every rule off. ESLint refuses a directive for a rule it does not
31
+ * know, so without them the gate would fail on another lint's comments.
32
+ * The token lint's names are registered already.
33
+ */
34
+ quiet?: Record<string, ESLint.Plugin>
35
+ /** Folders the gate never reads, beside build output. */
36
+ ignores?: string[]
37
+ }
38
+
39
+ /**
40
+ * The rules and where they apply — one config object, to add beside
41
+ * everything else an app's `eslint.config.js` runs, so an editor shows a
42
+ * refusal where the element is typed.
43
+ */
44
+ export function gateLint({ audience = 'app' }: Pick<GateConfigOptions, 'audience'> = {}): Linter.Config {
45
+ return {
46
+ ...(audience === 'package' ? estiva.configs.package : estiva.configs.recommended),
47
+ files: audience === 'package' ? ['src/**/*.tsx'] : ['src/**/*.{ts,tsx}'],
48
+ ignores: ['**/*.test.ts', '**/*.test.tsx'],
49
+ languageOptions: {
50
+ parser: typescriptParser,
51
+ parserOptions: { ecmaFeatures: { jsx: true } },
52
+ },
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The gate: the rules on their own — `npm run lint:rules`, CI's job `gate`, and
58
+ * the config the editor hook lints a proposed write with. It fails on a gate
59
+ * rule and on nothing else, so a backlog in the full lint never blocks it.
60
+ */
61
+ export function gateConfig({ audience = 'app', quiet = {}, ignores = [] }: GateConfigOptions = {}): Linter.Config[] {
62
+ return [
63
+ { ignores: [...TOKEN_LINT_IGNORES, ...ignores] },
64
+ {
65
+ plugins: {
66
+ ...quiet,
67
+ 'better-tailwindcss': betterTailwindcss,
68
+ 'token-values': betterTailwindcss,
69
+ 'token-spacing': betterTailwindcss,
70
+ },
71
+ // Every other config's directives are unused here, because their rules are
72
+ // off here. Reporting them would be this config complaining about the
73
+ // others' business.
74
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
75
+ },
76
+ gateLint({ audience }),
77
+ ]
78
+ }
@@ -0,0 +1,183 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { ESLint, type Linter } from 'eslint'
5
+ import { afterAll, describe, expect, it } from 'vitest'
6
+ import { writeGateCount } from './count'
7
+ import { gateConfig, gateLint } from './gate-config'
8
+ import { runHook } from './hook'
9
+ import { helpers, runStatus } from './status'
10
+ import { tokenLint, tokenValues } from './token-lint'
11
+
12
+ /**
13
+ * The gate pieces, as an app and this package use them (UIG-10, docs/GATES.md
14
+ * §23). Their equality with what Peek, Ship and this package ran before the
15
+ * move was proved by comparing the configs and every lint message; these tests
16
+ * hold the behaviour from here on.
17
+ *
18
+ * A config *file* is needed where ESLint loads one (the count, the hook): it
19
+ * imports the built `dist/gates`, which `npm test` builds first (`pretest`):
20
+ * the release job runs the tests with no lint before them. The app folder sits inside this repository, so it resolves ESLint and
21
+ * the plugins from this repository's `node_modules`, as an app resolves its own.
22
+ */
23
+ const root = process.cwd()
24
+ const built = pathToFileURL(join(root, 'dist', 'gates', 'index.js')).href
25
+ const scratch = mkdtempSync(join(root, 'node_modules', '.gates-test-'))
26
+ afterAll(() => rmSync(scratch, { recursive: true, force: true }))
27
+
28
+ function app(name: string, files: Record<string, string>) {
29
+ const dir = join(scratch, name)
30
+ mkdirSync(join(dir, 'src'), { recursive: true })
31
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, private: true, type: 'module' }))
32
+ writeFileSync(join(dir, 'eslint.gates.config.js'), `import { gateConfig } from '${built}'\nexport default gateConfig()\n`)
33
+ for (const [rel, text] of Object.entries(files)) {
34
+ mkdirSync(join(dir, rel, '..'), { recursive: true })
35
+ writeFileSync(join(dir, rel), text)
36
+ }
37
+ return dir
38
+ }
39
+
40
+ async function lintWith(config: Linter.Config[], code: string, filePath: string) {
41
+ const eslint = new ESLint({ cwd: root, overrideConfigFile: true, overrideConfig: config })
42
+ const [result] = await eslint.lintText(code, { filePath: join(root, filePath) })
43
+ return result.messages
44
+ }
45
+
46
+ const component = (body: string) => `export function Probe() {\n return ${body}\n}\n`
47
+
48
+ describe('tokenLint and tokenValues', () => {
49
+ it('refuse a hand-written text size and name the token, in the words for an app', async () => {
50
+ const messages = await lintWith([tokenLint(), tokenValues()], component('<div className="text-[14px]">x</div>'), 'src/Probe.tsx')
51
+ const error = messages.find((m) => m.ruleId === 'token-values/no-restricted-classes')
52
+ expect(error?.severity).toBe(2)
53
+ expect(error?.message).toContain('text-body-2')
54
+ })
55
+
56
+ it('speak to the package when the package runs them', async () => {
57
+ const app = await lintWith([tokenLint(), tokenValues()], component('<div className="bg-gray-100 rounded-[3px]">x</div>'), 'src/Probe.tsx')
58
+ const pkg = await lintWith([tokenLint({ audience: 'package' }), tokenValues({ audience: 'package' })], component('<div className="bg-gray-100 rounded-[3px]">x</div>'), 'src/Probe.tsx')
59
+ expect(app.map((m) => m.message).join('\n')).toContain('from the package')
60
+ expect(pkg.map((m) => m.message).join('\n')).toContain('tailwind-preset.js')
61
+ expect(pkg.map((m) => m.ruleId)).toEqual(app.map((m) => m.ruleId))
62
+ })
63
+
64
+ it('report a hand-written height as a warning, never an error', async () => {
65
+ const messages = await lintWith([tokenLint(), tokenValues()], component('<div className="h-[240px]">x</div>'), 'src/Probe.tsx')
66
+ expect(messages.filter((m) => m.ruleId?.startsWith('token-'))).toEqual([expect.objectContaining({ ruleId: 'token-spacing/no-restricted-classes', severity: 1 })])
67
+ })
68
+
69
+ it('leave a test file to its test', async () => {
70
+ const messages = await lintWith([tokenLint(), tokenValues()], component('<div style={{ color: "red" }}>x</div>'), 'src/Probe.test.tsx')
71
+ expect(messages.filter((m) => m.severity === 2)).toEqual([])
72
+ })
73
+ })
74
+
75
+ describe('gateConfig', () => {
76
+ it('refuses a raw button in an app, in a .tsx and in a story, naming Button', async () => {
77
+ for (const file of ['src/Probe.tsx', 'src/stories/Probe.stories.tsx']) {
78
+ const messages = await lintWith(gateConfig(), component('<button type="button">x</button>'), file)
79
+ expect(messages).toEqual([expect.objectContaining({ ruleId: 'estiva/no-raw-element', severity: 2, message: expect.stringContaining('Button') })])
80
+ }
81
+ })
82
+
83
+ it('reads .ts for an app (UIG-8), and .tsx only for the package', async () => {
84
+ const listener = "export function listen() {\n window.addEventListener('keydown', () => {})\n}\n"
85
+ expect(await lintWith(gateConfig(), listener, 'src/lib/probe.ts')).toEqual([expect.objectContaining({ ruleId: 'estiva/no-rebuilt-behaviour' })])
86
+ expect(gateLint({ audience: 'package' }).files).toEqual(['src/**/*.tsx'])
87
+ })
88
+
89
+ it('leaves tests alone', async () => {
90
+ const messages = await lintWith(gateConfig(), component('<button type="button">x</button>'), 'src/Probe.test.tsx')
91
+ expect(messages.filter((m) => m.ruleId)).toEqual([])
92
+ })
93
+
94
+ it('knows the token lint and quiet plugins, so their directives do not break the gate', async () => {
95
+ const code = `// eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: a size the probe keeps on purpose\n${component('<div className="text-[14px]">x</div>')}`
96
+ expect(await lintWith(gateConfig(), code, 'src/Probe.tsx')).toEqual([])
97
+ const quiet = { rules: { 'some-rule': { create: () => ({}) } } }
98
+ const directive = `// eslint-disable-next-line house/some-rule\n${component('<div>x</div>')}`
99
+ expect(await lintWith(gateConfig({ quiet: { house: quiet } }), directive, 'src/Probe.tsx')).toEqual([])
100
+ })
101
+ })
102
+
103
+ describe('writeGateCount', () => {
104
+ it('writes the count once, leaves it alone when nothing changed, and fails on a rule switched off', async () => {
105
+ const dir = app('count', {
106
+ 'src/Kept.tsx': component('(\n // @estiva-escape: a probe that keeps its element on purpose\n <form />\n )'),
107
+ })
108
+ const first = await writeGateCount({ root: dir, repo: 'probe' })
109
+ expect(first.changed).toBe(true)
110
+ const written = JSON.parse(readFileSync(join(dir, '.gates-count.json'), 'utf8'))
111
+ expect(written).toMatchObject({ schemaVersion: 1, repo: 'probe' })
112
+ expect(written.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 1 })
113
+ expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part'])
114
+
115
+ expect((await writeGateCount({ root: dir, repo: 'probe' })).changed).toBe(false)
116
+
117
+ writeFileSync(join(dir, 'src', 'Off.tsx'), 'export function Probe() {\n // eslint-disable-next-line estiva/no-raw-element\n return <form />\n}\n')
118
+ const off = await writeGateCount({ root: dir, repo: 'probe' })
119
+ expect(off.failures.join('\n')).toContain('src/Off.tsx:3 estiva/no-raw-element'.replace('/', process.platform === 'win32' ? '\\' : '/'))
120
+ })
121
+ })
122
+
123
+ describe('runHook', () => {
124
+ const dir = app('hook', { 'src/Page.tsx': 'export function Page() {\n return <div>x</div>\n}\n' })
125
+ const write = (file_path: string, content: string) => ({ tool_name: 'Write', tool_input: { file_path, content } })
126
+
127
+ it('refuses a raw button with exit 2 and the message naming Button', async () => {
128
+ const result = await runHook({ root: dir, input: write('src/Probe.tsx', component('<button type="button">x</button>')) })
129
+ expect(result.code).toBe(2)
130
+ expect(result.message).toContain('src/Probe.tsx was not written: the UI Guardrails refuse it (eslint.gates.config.js).')
131
+ expect(result.message).toContain('Button')
132
+ })
133
+
134
+ it('applies an Edit to the file on disk before judging it', async () => {
135
+ const edit = { tool_name: 'Edit', tool_input: { file_path: 'src/Page.tsx', old_string: '<div>x</div>', new_string: '<a href="/x">x</a>' } }
136
+ expect((await runHook({ root: dir, input: edit })).code).toBe(2)
137
+ const fine = { tool_name: 'Edit', tool_input: { file_path: 'src/Page.tsx', old_string: '<div>x</div>', new_string: '<div>y</div>' } }
138
+ expect((await runHook({ root: dir, input: fine })).code).toBe(0)
139
+ })
140
+
141
+ it('lets through a test, a declaration file, and anything outside src', async () => {
142
+ for (const path of ['src/Probe.test.tsx', 'src/types.d.ts', 'scripts/probe.tsx']) {
143
+ expect((await runHook({ root: dir, input: write(path, component('<button type="button">x</button>')) })).code).toBe(0)
144
+ }
145
+ })
146
+
147
+ it('finds the app in a folder of its own, as Ship keeps it in web/', async () => {
148
+ const top = join(scratch, 'top')
149
+ mkdirSync(top, { recursive: true })
150
+ const web = app('top/web', {})
151
+ expect(web).toBe(join(top, 'web'))
152
+ const result = await runHook({ root: top, app: 'web', input: write('web/src/Probe.tsx', component('<button type="button">x</button>')) })
153
+ expect(result.code).toBe(2)
154
+ expect(result.message).toContain('web/src/Probe.tsx was not written: the UI Guardrails refuse it (web/eslint.gates.config.js).')
155
+ })
156
+ })
157
+
158
+ describe('gates:status', () => {
159
+ it('reads a CI job by its id, a committed hook, and a script', () => {
160
+ const dir = app('status-helpers', {
161
+ '.github/workflows/deploy.yml': 'jobs:\n check:\n steps:\n - run: npm test\n gate:\n steps:\n # - run: npm run lint:rules\n - run: npm run lint:rules\n',
162
+ '.claude/settings.json': JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook' }] }] } }),
163
+ })
164
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'status-helpers', scripts: { 'gates:status': 'estiva-gates status' } }))
165
+ const h = helpers(dir)
166
+ expect(h.ciJob('gate', 'lint:rules').result).toBe('pass')
167
+ expect(h.ciJob('check', 'lint:rules').result).toBe('fail')
168
+ expect(h.hook('.claude/settings.json', 'gates').result).toBe('pass')
169
+ expect(h.script('package.json', 'gates:status').result).toBe('pass')
170
+ })
171
+
172
+ it('prints a repo on its own: its own rows, then its parts', async () => {
173
+ const dir = app('status-run', {
174
+ 'scripts/gates-checks.mjs': "export default (h) => ({ repo: 'probe', tickets: [\n { ref: 'UIG-2', title: 'Rails', owner: false, checks: [{ what: 'wired', run: () => h.PASS('yes') }] },\n { ref: 'UIG-9', title: 'Allow-list', owner: false, checks: [{ what: 'a look is refused', run: () => h.FAIL('no') }] },\n] })\n",
175
+ })
176
+ const out = await runStatus({ root: dir })
177
+ expect(out).toContain('Tickets probe owns (0):')
178
+ expect(out).toContain('✅ UIG-2')
179
+ expect(out).toContain('⬜ UIG-9')
180
+ const json = JSON.parse(await runStatus({ root: dir, json: true }))
181
+ expect(json.engine).toMatch(/^@estiva-app\/ui@\d+\.\d+\.\d+$/)
182
+ })
183
+ })
@@ -0,0 +1,111 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * The editor gate: before Claude writes a `.ts` or `.tsx` under `src/`, lint the
4
+ * text it is about to write with the gate, and refuse the write on an error.
5
+ * Written once (docs/GATES.md §23): until UIG-10 Peek, Ship and this package
6
+ * each carried their own copy of this script.
7
+ *
8
+ * A `PreToolUse` hook in the repository's committed `.claude/settings.json`
9
+ * runs it. Claude Code documents exit 2 as blocking the tool call, with stderr
10
+ * handed back to Claude; exit 0 raises no objection. It reads
11
+ * `$CLAUDE_PROJECT_DIR`, the project root where the session started — a session
12
+ * started in a folder above or below the repository gets no hook (UIG-3,
13
+ * UIG-4 findings).
14
+ *
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. Tests are not checked
17
+ * (Katerina, 13 September); stories are. This package's inward gate reads `.tsx`
18
+ * only, as its lint does.
19
+ *
20
+ * Input, from Claude Code on stdin: `tool_name` and `tool_input` — `file_path`
21
+ * and `content` for Write; `file_path`, `old_string`, `new_string` and
22
+ * `replace_all` for Edit. An Edit carries no whole file, so the edit is applied
23
+ * to the file on disk here. Anything it cannot read passes, and the tool itself
24
+ * reports its own failure.
25
+ */
26
+ import { existsSync, readFileSync } from 'node:fs'
27
+ import { createRequire } from 'node:module'
28
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
29
+ import { pathToFileURL } from 'node:url'
30
+ import type { ESLint as ESLintClass } from 'eslint'
31
+ import { countGates } from '../eslint/index'
32
+
33
+ export interface HookOptions {
34
+ /** The repository's top folder. Defaults to `$CLAUDE_PROJECT_DIR`, then the tool call's `cwd`. */
35
+ root?: string
36
+ /** The folder that holds the app, relative to `root`: `web` in Ship, `.` elsewhere. */
37
+ app?: string
38
+ /** `package` gates `.tsx` only, as this package's inward lint does. */
39
+ audience?: 'app' | 'package'
40
+ /** The hook's input, when not read from stdin (tests). */
41
+ input?: unknown
42
+ }
43
+
44
+ export interface HookResult {
45
+ /** 0 lets the write through; 2 refuses it. */
46
+ code: 0 | 2
47
+ /** What goes to stderr when refused. */
48
+ message: string
49
+ }
50
+
51
+ interface ToolCall {
52
+ tool_name?: string
53
+ cwd?: string
54
+ tool_input?: { file_path?: unknown; content?: unknown; old_string?: unknown; new_string?: unknown; replace_all?: unknown }
55
+ }
56
+
57
+ const PASS: HookResult = { code: 0, message: '' }
58
+
59
+ export async function runHook({ root, app = '.', audience = 'app', input }: HookOptions = {}): Promise<HookResult> {
60
+ let call: ToolCall
61
+ try {
62
+ call = (input ?? JSON.parse(readFileSync(0, 'utf8'))) as ToolCall
63
+ } catch {
64
+ return PASS
65
+ }
66
+ const args = call.tool_input ?? {}
67
+ if (typeof args.file_path !== 'string') return PASS
68
+
69
+ const top = root ?? process.env.CLAUDE_PROJECT_DIR ?? call.cwd ?? process.cwd()
70
+ const appDir = resolve(top, app)
71
+ const file = isAbsolute(args.file_path) ? args.file_path : resolve(call.cwd ?? top, args.file_path)
72
+ const rel = relative(appDir, file).split(sep).join('/')
73
+ const source = audience === 'package' ? /^src\/.+\.tsx$/ : /^src\/.+\.tsx?$/
74
+ if (rel.startsWith('../') || isAbsolute(rel) || !source.test(rel) || /\.test\.tsx?$/.test(rel) || /\.d\.ts$/.test(rel)) return PASS
75
+
76
+ const text = proposed(call.tool_name, args, file)
77
+ if (text === null) return PASS
78
+
79
+ // ESLint from the app's own install: in Ship the top folder has none.
80
+ const resolved = createRequire(join(appDir, 'package.json')).resolve('eslint')
81
+ const { ESLint } = (await import(pathToFileURL(resolved).href)) as { ESLint: typeof ESLintClass }
82
+ const config = join(appDir, 'eslint.gates.config.js')
83
+ const eslint = new ESLint({ cwd: appDir, overrideConfigFile: config })
84
+ const results = await eslint.lintText(text, { filePath: file })
85
+ // A rule's errors only. Code that does not parse yet (one edit of several) is
86
+ // the typecheck's to judge; the gate looks again at the edit that completes it.
87
+ const errors = results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId))
88
+ const { disabled } = countGates(results)
89
+ if (errors.length === 0 && disabled.length === 0) return PASS
90
+
91
+ const shown = (p: string) => relative(top, p).split(sep).join('/')
92
+ const lines = [`${shown(file)} was not written: the UI Guardrails refuse it (${shown(config)}).`]
93
+ for (const m of errors) lines.push(` ${m.line}:${m.column} ${m.message}${m.ruleId ? ` (${m.ruleId})` : ''}`)
94
+ const keep = audience === 'package' ? 'Keep it only' : 'Keep the element only'
95
+ 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>`)
96
+ return { code: 2, message: `${lines.join('\n')}\n` }
97
+ }
98
+
99
+ /** The file as it will be after this tool call, or null when that cannot be known. */
100
+ function proposed(tool: string | undefined, args: NonNullable<ToolCall['tool_input']>, file: string): string | null {
101
+ if (tool === 'Write') return typeof args.content === 'string' ? args.content : null
102
+ if (tool !== 'Edit' || typeof args.old_string !== 'string' || typeof args.new_string !== 'string') return null
103
+ const { old_string: from, new_string: to } = args
104
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : ''
105
+ // A checkout on Windows has CRLF on disk; an edit is written with LF.
106
+ for (const text of [current, current.replace(/\r\n/g, '\n')]) {
107
+ if (!text.includes(from)) continue
108
+ return args.replace_all === true ? text.split(from).join(to) : text.replace(from, () => to)
109
+ }
110
+ return null
111
+ }
@@ -0,0 +1,30 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * `@estiva-app/ui/gates` — everything an app needs to run the UI Guardrails'
4
+ * gates, shipped once (docs/GATES.md §23, Katerina's ruling of 17 September).
5
+ *
6
+ * The rules were always here, in `@estiva-app/ui/eslint`. The pieces that
7
+ * switch them on were copied into every repo from UIG-3 on; UIG-10 moved them
8
+ * here, and an app imports them:
9
+ *
10
+ * - `tokenLint`, `tokenValues` — the token contract's lint settings (UIG-28).
11
+ * - `gateLint`, `gateConfig` — where the rules apply, and the gate that runs them alone.
12
+ * - `writeGateCount` — `.gates-count.json` (seam S3).
13
+ * - `runHook` — the editor gate a `PreToolUse` hook runs.
14
+ * - `runStatus`, `helpers` — gates:status and the helpers a checks file is written with.
15
+ *
16
+ * - `appChecks` — the gate checks every app runs, for its `scripts/gates-checks.mjs`.
17
+ * - `appFiles`, `createApp`, `themes` — a new app with every gate on (`create-estiva-app`).
18
+ *
19
+ * `estiva-gates` (`cli.ts`) runs the count, the hook and the status as one command.
20
+ *
21
+ * Built by build.mjs into `dist/gates/`, for Node. Nothing here reaches the
22
+ * components' browser bundle.
23
+ */
24
+ export { TOKEN_LINT_IGNORES, tokenLint, tokenValues, type TokenAudience, type TokenLintOptions } from './token-lint'
25
+ export { gateConfig, gateLint, type GateConfigOptions } from './gate-config'
26
+ export { writeGateCount, type CountOptions, type CountResult } from './count'
27
+ export { runHook, type HookOptions, type HookResult } from './hook'
28
+ export { ENGINE, helpers, runStatus, type CheckResult, type GateCheck, type GateHelpers, type GateSpec, type GateTicket, type LintProbe, type StatusOptions, type TicketListEntry } from './status'
29
+ export { APP_TICKET_TITLES, appChecks, type AppCheckOptions } from './app-checks'
30
+ export { appFiles, askNpm, createApp, themes, type CreateAppOptions } from './create-app'