@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.
Files changed (143) hide show
  1. package/dist/Panel.d.ts +44 -0
  2. package/dist/Panel.d.ts.map +1 -0
  3. package/dist/ScrollArea.d.ts +7 -2
  4. package/dist/ScrollArea.d.ts.map +1 -1
  5. package/dist/eslint/escape.d.ts +23 -6
  6. package/dist/eslint/escape.d.ts.map +1 -1
  7. package/dist/eslint/index.js +86 -12
  8. package/dist/eslint/index.js.map +3 -3
  9. package/dist/eslint/no-handmade-header.d.ts +5 -0
  10. package/dist/eslint/no-handmade-header.d.ts.map +1 -1
  11. package/dist/eslint/no-native-title.d.ts +1 -1
  12. package/dist/eslint/no-native-title.d.ts.map +1 -1
  13. package/dist/eslint/no-raw-element.d.ts +7 -0
  14. package/dist/eslint/no-raw-element.d.ts.map +1 -1
  15. package/dist/eslint/no-restyled-part.d.ts +8 -0
  16. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  17. package/dist/gates/app-checks.d.ts +1 -1
  18. package/dist/gates/app-checks.d.ts.map +1 -1
  19. package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
  20. package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
  21. package/dist/gates/chunk-SYVKGOWM.js +1131 -0
  22. package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
  23. package/dist/gates/ci.d.ts +18 -0
  24. package/dist/gates/ci.d.ts.map +1 -0
  25. package/dist/gates/cli.js +20 -3
  26. package/dist/gates/cli.js.map +2 -2
  27. package/dist/gates/create-app.d.ts.map +1 -1
  28. package/dist/gates/create-app.js +1 -1
  29. package/dist/gates/escapes.d.ts +23 -0
  30. package/dist/gates/escapes.d.ts.map +1 -0
  31. package/dist/gates/gate-config.d.ts.map +1 -1
  32. package/dist/gates/hook.d.ts +2 -0
  33. package/dist/gates/hook.d.ts.map +1 -1
  34. package/dist/gates/index.d.ts +5 -1
  35. package/dist/gates/index.d.ts.map +1 -1
  36. package/dist/gates/index.js +42 -185
  37. package/dist/gates/index.js.map +4 -4
  38. package/dist/gates/name-escapes.d.ts +14 -0
  39. package/dist/gates/name-escapes.d.ts.map +1 -0
  40. package/dist/gates/status.d.ts +3 -1
  41. package/dist/gates/status.d.ts.map +1 -1
  42. package/dist/gates/token-lint.d.ts.map +1 -1
  43. package/dist/gates/token-style.d.ts +21 -0
  44. package/dist/gates/token-style.d.ts.map +1 -0
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +76 -49
  48. package/dist/index.js.map +4 -4
  49. package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
  50. package/dist/registry/app.d.ts.map +1 -1
  51. package/dist/registry/behaviours.d.ts +71 -0
  52. package/dist/registry/behaviours.d.ts.map +1 -0
  53. package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
  54. package/dist/registry/build.d.ts.map +1 -1
  55. package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
  56. package/dist/registry/chunk-EVJW26UR.js.map +7 -0
  57. package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
  58. package/dist/registry/chunk-SIB44CES.js.map +7 -0
  59. package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
  60. package/dist/registry/chunk-XK747STJ.js.map +7 -0
  61. package/dist/registry/cli.js +7 -6
  62. package/dist/registry/cli.js.map +2 -2
  63. package/dist/registry/contract.d.ts +13 -1
  64. package/dist/registry/contract.d.ts.map +1 -1
  65. package/dist/registry/index.d.ts +1 -1
  66. package/dist/registry/index.d.ts.map +1 -1
  67. package/dist/registry/index.js +11 -5
  68. package/package.json +4 -1
  69. package/registry.json +369 -19
  70. package/skill/estiva-ui.md +2 -1
  71. package/src/AppShell.mdx +2 -1
  72. package/src/AttachmentCard.tsx +7 -7
  73. package/src/Button.tsx +1 -1
  74. package/src/Card.tsx +2 -2
  75. package/src/ChipInput.mdx +2 -0
  76. package/src/ChipInput.tsx +1 -1
  77. package/src/CommandPalette.tsx +3 -3
  78. package/src/DialogShell.tsx +1 -1
  79. package/src/EmptyState.mdx +4 -3
  80. package/src/Lightbox.tsx +1 -1
  81. package/src/NavItem.tsx +1 -1
  82. package/src/Panel.mdx +78 -0
  83. package/src/Panel.stories.tsx +98 -0
  84. package/src/Panel.tsx +80 -0
  85. package/src/PersonTrigger.tsx +1 -1
  86. package/src/Reaction.tsx +1 -1
  87. package/src/ScrollArea.mdx +6 -2
  88. package/src/ScrollArea.stories.tsx +1 -1
  89. package/src/ScrollArea.tsx +9 -4
  90. package/src/Select.tsx +1 -1
  91. package/src/TopBar.mdx +1 -1
  92. package/src/eslint/escape.ts +43 -14
  93. package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
  94. package/src/eslint/index.test.ts +2 -2
  95. package/src/eslint/no-copied-look.test.ts +1 -1
  96. package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
  97. package/src/eslint/no-handmade-empty-state.test.ts +2 -2
  98. package/src/eslint/no-handmade-header.test.ts +11 -2
  99. package/src/eslint/no-handmade-header.ts +24 -2
  100. package/src/eslint/no-native-title.test.ts +2 -2
  101. package/src/eslint/no-native-title.ts +1 -1
  102. package/src/eslint/no-raw-element.test.ts +47 -15
  103. package/src/eslint/no-raw-element.ts +53 -3
  104. package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
  105. package/src/eslint/no-restyled-part.test.ts +21 -2
  106. package/src/eslint/no-restyled-part.ts +30 -0
  107. package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
  108. package/src/gates/app-checks.ts +9 -6
  109. package/src/gates/ci.ts +152 -0
  110. package/src/gates/cli.ts +19 -3
  111. package/src/gates/count.ts +1 -1
  112. package/src/gates/create-app.test.ts +2 -2
  113. package/src/gates/create-app.ts +22 -16
  114. package/src/gates/escapes.ts +111 -0
  115. package/src/gates/gate-config.ts +2 -0
  116. package/src/gates/gates.test.ts +86 -1
  117. package/src/gates/hook.ts +38 -4
  118. package/src/gates/index.ts +5 -1
  119. package/src/gates/name-escapes.ts +77 -0
  120. package/src/gates/status.ts +45 -2
  121. package/src/gates/token-lint.ts +11 -4
  122. package/src/gates/token-style.ts +79 -0
  123. package/src/how.test.ts +282 -0
  124. package/src/index.ts +1 -0
  125. package/src/pages.test.ts +6 -0
  126. package/src/registry/app.test.ts +37 -1
  127. package/src/registry/app.ts +52 -2
  128. package/src/registry/behaviours.ts +223 -0
  129. package/src/registry/build.ts +36 -0
  130. package/src/registry/cli.ts +5 -3
  131. package/src/registry/contract.test.ts +27 -2
  132. package/src/registry/contract.ts +28 -3
  133. package/src/registry/index.ts +1 -1
  134. package/src/registry/registry.test.ts +18 -4
  135. package/stories/Choosing.mdx +4 -3
  136. package/dist/gates/chunk-DBMUQAR6.js +0 -607
  137. package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
  138. package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
  139. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
  140. package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
  141. package/dist/registry/chunk-XVII7P6G.js.map +0 -7
  142. /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
  143. /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
@@ -35,7 +35,7 @@ tester.run('raw-element-outside-a-wrapper', rawElementOutsideAWrapper, {
35
35
  { name: 'layout inside a component', code: component(' <div>\n <span>Issues</span>\n </div>') },
36
36
  {
37
37
  name: 'a nested element with its reason on the line above',
38
- code: component(' <div>\n {/* @estiva-escape: the trail is one line of text and an anchor keeps it */}\n <a href="/x">Issues</a>\n </div>'),
38
+ code: component(' <div>\n {/* @estiva-escape(raw-element-outside-a-wrapper): the trail is one line of text and an anchor keeps it */}\n <a href="/x">Issues</a>\n </div>'),
39
39
  },
40
40
  ],
41
41
  invalid: [
@@ -66,14 +66,14 @@ tester.run('raw-element-outside-a-wrapper', rawElementOutsideAWrapper, {
66
66
  },
67
67
  {
68
68
  name: 'a reason too short to be a reason',
69
- code: component(' <div>\n {/* @estiva-escape: short */}\n <a href="/x">Issues</a>\n </div>'),
69
+ code: component(' <div>\n {/* @estiva-escape(raw-element-outside-a-wrapper): short */}\n <a href="/x">Issues</a>\n </div>'),
70
70
  errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'nested', data: { name: 'a' } }],
71
71
  },
72
72
  {
73
73
  name: 'an eslint-disable instead of a reason',
74
74
  // The tester runs the rule under its bare name; in a real lint the plugin
75
75
  // prefixes it, and `isEscaped` compares against whichever the rule has.
76
- code: component(' <div>\n {/* eslint-disable-next-line rule-to-test/raw-element-outside-a-wrapper -- @estiva-escape: the trail keeps its anchor */}\n <a href="/x">Issues</a>\n </div>'),
76
+ code: component(' <div>\n {/* eslint-disable-next-line rule-to-test/raw-element-outside-a-wrapper -- @estiva-escape(raw-element-outside-a-wrapper): the trail keeps its anchor */}\n <a href="/x">Issues</a>\n </div>'),
77
77
  // The directive silences the rule's own report, which is exactly why it is
78
78
  // refused: `countGates` sees the silenced report and the gate fails on it.
79
79
  errors: [{ messageId: 'escapeInDirective' }],
@@ -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 part a hand-made column header should name (UIG-22): `ContainerHeader`. */
34
+ /** The part a hand-made column header should name (UIG-22): `Panel` since 0.37.0, `ContainerHeader` before. */
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'] }: AppCheckOptions): GateTicket[] {
73
+ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['Panel'] }: 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'
@@ -114,6 +114,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
114
114
  ]),
115
115
  ticket('UIG-7', [
116
116
  { what: 'a raw <input> is an error naming TextInput', run: probe(component('<input />'), 'error', 'TextInput') },
117
+ { what: 'a clickable <div> is an error naming Button (R5)', run: probe(component('<div onClick={() => history.back()}>Back</div>'), 'error', '`Button`') },
117
118
  { what: 'a raw <a> is an error naming Link', run: all(h, [probe(component('<a href="/topics">Topics</a>'), 'error', 'Link'), probe(component('<a href="/issues">Issues</a>'), 'error', 'Link')], 'a raw <a> is an error naming Link') },
118
119
  { what: 'a raw <form> is an error naming Form, and a file input one naming FilePicker', run: async () => {
119
120
  const form = await probe(component('<form />'), 'error', '`Form`')()
@@ -131,7 +132,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
131
132
  { what: 'an escaped element is not an error, while the same element is', run: async () => {
132
133
  const raw = await probe(component('<form />'), 'error')()
133
134
  if (raw.result !== 'pass') return h.FAIL(`a raw <form> is not caught yet, so this proves nothing: ${raw.detail}`)
134
- return probe('export function Probe() {\n return (\n // @estiva-escape: a probe that keeps its element on purpose\n <form />\n )\n}\n', 'none')()
135
+ return probe('export function Probe() {\n return (\n // @estiva-escape(no-raw-element): a probe that keeps its element on purpose\n <form />\n )\n}\n', 'none')()
135
136
  } },
136
137
  { what: 'a real page, full of elements that are not controls, gets no error', run: realPage },
137
138
  ]),
@@ -154,7 +155,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
154
155
  { what: 'an escaped listener is not an error, while the same listener is', run: async () => {
155
156
  const raw = await probe("export function listen() {\n document.addEventListener('mousedown', () => {})\n}\n", 'error', undefined, 'src/lib/__gates_probe__.ts')()
156
157
  if (raw.result !== 'pass') return h.FAIL(`a page listener is not caught yet, so this proves nothing: ${raw.detail}`)
157
- return probe("export function listen() {\n // @estiva-escape: a probe that keeps its listener on purpose\n document.addEventListener('mousedown', () => {})\n}\n", 'none', undefined, 'src/lib/__gates_probe__.ts')()
158
+ return probe("export function listen() {\n // @estiva-escape(no-rebuilt-behaviour): a probe that keeps its listener on purpose\n document.addEventListener('mousedown', () => {})\n}\n", 'none', undefined, 'src/lib/__gates_probe__.ts')()
158
159
  } },
159
160
  { what: 'a real page gets no error', run: realPage },
160
161
  ]),
@@ -164,7 +165,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
164
165
  { what: 'an escaped look is not an error, while the same look is', run: async () => {
165
166
  const raw = await probe(`import { Link } from '@estiva-app/ui'\n${component('<Link href="/x" className="after:absolute after:inset-0">x</Link>')}`, 'error', '`Link`')()
166
167
  if (raw.result !== 'pass') return h.FAIL(`a restyled part is not caught yet, so this proves nothing: ${raw.detail}`)
167
- return probe(`import { Link } from '@estiva-app/ui'\nexport function Probe() {\n return (\n // @estiva-escape: a probe that keeps its row link on purpose\n <Link href="/x" className="after:absolute after:inset-0">x</Link>\n )\n}\n`, 'none')()
168
+ return probe(`import { Link } from '@estiva-app/ui'\nexport function Probe() {\n return (\n // @estiva-escape(no-restyled-part): a probe that keeps its row link on purpose\n <Link href="/x" className="after:absolute after:inset-0">x</Link>\n )\n}\n`, 'none')()
168
169
  } },
169
170
  { what: 'a real page gets no error', run: realPage },
170
171
  ]),
@@ -220,7 +221,9 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
220
221
  ]),
221
222
  ticket('UIG-22', [
222
223
  // 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 hand-made column header is an error naming ${headers.join(' or ')}`, 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 ${headers.join(' or ')}`) },
225
+ // 26 September: the package's header on its own is a panel made by hand, whose box forgot to scroll.
226
+ { what: "the package's ContainerHeader on its own is an error naming Panel", run: probe(`import { ContainerHeader } from '@estiva-app/ui'\n${component('<div className="flex flex-col"><ContainerHeader title="Details" /><div className="px-4">x</div></div>')}`, 'error', '`Panel`') },
224
227
  { 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') },
225
228
  ]),
226
229
  ticket('UIG-23', [
@@ -0,0 +1,152 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * `estiva-gates ci` — CI's job `gate`, as one command (Katerina's ruling R13,
4
+ * 25 September). Until 0.36.0 each repo listed the gate's steps in its own
5
+ * workflow, and the lists had drifted: Peek ran `lint:tokens`, Ship the whole
6
+ * `lint`, and no repo checked the two things below. Now the steps live here,
7
+ * and a repo's `gate` job runs this and nothing else of the gate's.
8
+ *
9
+ * 1. The gate lint (`npm run lint:rules`), which writes `.gates-count.json`.
10
+ * 2. The count is committed (B2): a pull request that changes what the gate
11
+ * finds and leaves the old count behind fails, so the file is the truth.
12
+ * 3. The token contract (`npm run lint:tokens`, or `npm run lint` where a repo
13
+ * has no token lint of its own).
14
+ * 4. The catalogue (`npm run registry:check`).
15
+ * 5. The wall's own settings (B13): the committed `.claude/settings.json` runs
16
+ * the editor hook before every Write and Edit, and that hook refuses a raw
17
+ * button; every folder the gate skips is named on the debt page, with why.
18
+ *
19
+ * Every step runs, so one red step does not hide the others.
20
+ */
21
+ import { spawnSync } from 'node:child_process'
22
+ import { existsSync, readFileSync, statSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import { pathToFileURL } from 'node:url'
25
+ import { TOKEN_LINT_IGNORES } from './token-lint'
26
+
27
+ export interface CiOptions {
28
+ /** The repository's top folder, where the gate lints from. */
29
+ root?: string
30
+ /** `package` is this package's own gate: its skipped folders are named in docs/GATES.md. */
31
+ audience?: 'app' | 'package'
32
+ /** Print as the steps run. Defaults to stdout. */
33
+ print?: (line: string) => void
34
+ }
35
+
36
+ /** The part a CI run writes to the probe: a raw button, which every gate refuses. */
37
+ const PROBE = '/** A probe CI writes to check the editor hook; never on disk. */\nexport function GateProbe() {\n return <button type="button">x</button>\n}\n'
38
+
39
+ const npm = (root: string, script: string) => spawnSync('npm', ['run', script], { cwd: root, stdio: 'inherit', shell: true }).status === 0
40
+
41
+ /** `.gates-count.json` as the gate lint left it matches the committed file; the lines that say otherwise. */
42
+ export function countCommitted(root: string): string[] {
43
+ const run = spawnSync('git', ['status', '--porcelain', '--', '.gates-count.json'], { cwd: root, encoding: 'utf8' })
44
+ if (run.status !== 0) return [`git could not read .gates-count.json's state: ${run.stderr.trim()}`]
45
+ if (!run.stdout.trim()) return []
46
+ const diff = spawnSync('git', ['diff', '--', '.gates-count.json'], { cwd: root, encoding: 'utf8' }).stdout.trim()
47
+ return [
48
+ 'The gate found something other than the committed count: run `npm run lint:rules` and commit `.gates-count.json` with the change that moved it.',
49
+ ...(diff ? diff.split('\n').filter((l) => /^[+-]\s/.test(l)).map((l) => ` ${l}`) : [' .gates-count.json is not committed at all']),
50
+ ]
51
+ }
52
+
53
+ interface HookEntry {
54
+ matcher?: string
55
+ hooks?: { type?: string; command?: string }[]
56
+ }
57
+
58
+ /** The committed hook runs before every Write and Edit and refuses a raw button; the lines that say otherwise. */
59
+ export function hookRuns(root: string): string[] {
60
+ const file = join(root, '.claude', 'settings.json')
61
+ if (!existsSync(file)) return ['.claude/settings.json is missing: the editor hook is not switched on (docs/GATES.md §23).']
62
+ let entries: HookEntry[]
63
+ try {
64
+ entries = ((JSON.parse(readFileSync(file, 'utf8')) as { hooks?: { PreToolUse?: HookEntry[] } }).hooks?.PreToolUse ?? [])
65
+ } catch (error) {
66
+ return [`.claude/settings.json does not read as JSON: ${error instanceof Error ? error.message : String(error)}`]
67
+ }
68
+ const covers = (matcher: string | undefined, tool: string) => {
69
+ if (!matcher || matcher === '*') return true
70
+ try {
71
+ return new RegExp(`^(?:${matcher})$`).test(tool)
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+ const input = JSON.stringify({ tool_name: 'Write', session_id: 'estiva-gates-ci', cwd: root, tool_input: { file_path: join(root, 'src', 'GateProbe.tsx'), content: PROBE } })
77
+ const tried: string[] = []
78
+ for (const entry of entries) {
79
+ if (!covers(entry.matcher, 'Write') || !covers(entry.matcher, 'Edit')) continue
80
+ for (const hook of entry.hooks ?? []) {
81
+ if (hook.type !== 'command' || !hook.command) continue
82
+ // The project folder, written in: a Windows shell does not expand `$VAR`.
83
+ const command = hook.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, root.split('\\').join('/'))
84
+ const run = spawnSync(command, { cwd: root, input, encoding: 'utf8', shell: true, env: { ...process.env, CLAUDE_PROJECT_DIR: root }, timeout: 120_000 })
85
+ if (run.status === 2 && /UI Guardrails/.test(run.stderr)) return []
86
+ tried.push(` ${hook.command} → exit ${run.status ?? 'none'}${run.stderr.trim() ? `: ${run.stderr.trim().split('\n')[0]}` : ''}`)
87
+ }
88
+ }
89
+ return [
90
+ tried.length
91
+ ? 'The editor hook let a raw button through: no PreToolUse hook in .claude/settings.json refused it.'
92
+ : 'No PreToolUse hook in .claude/settings.json runs before both Write and Edit: the editor gate is off.',
93
+ ...tried,
94
+ ]
95
+ }
96
+
97
+ /** A glob as the words a page would name it by: `demo-scenarios/**` → `demo-scenarios`. */
98
+ const plain = (glob: string) => glob.replace(/\/\*\*.*$|\/$/, '')
99
+
100
+ /** The folders a gate config skips beyond the package's own three, from the config itself. */
101
+ export async function skippedFolders(root: string): Promise<string[]> {
102
+ const found = new Set<string>()
103
+ for (const name of ['eslint.gates.config.js', 'eslint.tokens.config.js']) {
104
+ const file = join(root, name)
105
+ if (!existsSync(file)) continue
106
+ // The file as it is now: Node keeps a module it has imported once, by its URL.
107
+ const loaded = (await import(`${pathToFileURL(file).href}?at=${statSync(file).mtimeMs}`)) as { default?: unknown }
108
+ const configs = [loaded.default].flat(Infinity) as { ignores?: string[]; [key: string]: unknown }[]
109
+ for (const c of configs) {
110
+ // Only a config with nothing but `ignores` skips a folder for every rule.
111
+ if (!c || typeof c !== 'object' || !Array.isArray(c.ignores) || Object.keys(c).some((k) => k !== 'ignores' && k !== 'name')) continue
112
+ for (const glob of c.ignores) if (!TOKEN_LINT_IGNORES.includes(glob)) found.add(glob)
113
+ }
114
+ }
115
+ return [...found]
116
+ }
117
+
118
+ /** Every skipped folder is named on the debt page; the lines that say otherwise. */
119
+ export async function skipsListed(root: string, audience: 'app' | 'package'): Promise<string[]> {
120
+ const page = audience === 'package' ? 'docs/GATES.md' : 'docs/GATES-DEBT.md'
121
+ const skipped = await skippedFolders(root)
122
+ if (!skipped.length) return []
123
+ const text = existsSync(join(root, page)) ? readFileSync(join(root, page), 'utf8') : ''
124
+ const missing = skipped.filter((glob) => !text.includes(plain(glob)))
125
+ if (!missing.length) return []
126
+ return [`The gate skips folders ${page} does not name. Name each there, with why it is not checked (B11):`, ...missing.map((g) => ` ${g}`)]
127
+ }
128
+
129
+ export async function runCi({ root = process.cwd(), audience = 'app', print = (line) => process.stdout.write(`${line}\n`) }: CiOptions = {}): Promise<number> {
130
+ const scripts = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}
131
+ const tokens = scripts['lint:tokens'] ? 'lint:tokens' : 'lint'
132
+ const failed: string[] = []
133
+ const step = async (title: string, run: () => boolean | string[] | Promise<string[]>) => {
134
+ print(`\n▸ ${title}`)
135
+ const result = await run()
136
+ const problems = result === true ? [] : result === false ? [`${title}: failed (above)`] : result
137
+ if (problems.length) {
138
+ failed.push(title)
139
+ for (const line of problems) print(process.env.GITHUB_ACTIONS && !line.startsWith(' ') ? `::error::${line}` : line)
140
+ } else print(' ✓')
141
+ }
142
+
143
+ await step('The gate lint (npm run lint:rules)', () => npm(root, 'lint:rules'))
144
+ await step('The count is committed (.gates-count.json)', () => countCommitted(root))
145
+ await step(`The token contract (npm run ${tokens})`, () => npm(root, tokens))
146
+ await step('The catalogue (npm run registry:check)', () => npm(root, 'registry:check'))
147
+ await step('The editor hook refuses a raw button (.claude/settings.json)', () => hookRuns(root))
148
+ await step('Every folder the gate skips is named on the debt page', () => skipsListed(root, audience))
149
+
150
+ print(failed.length ? `\nThe gate failed: ${failed.join('; ')}.` : '\nThe gate passed.')
151
+ return failed.length ? 1 : 0
152
+ }
package/src/gates/cli.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  /**
3
3
  * `estiva-gates` — the gate pieces as one command (docs/GATES.md §23).
4
4
  *
5
+ * estiva-gates ci [--package] CI's job `gate`: every step of the gate (R13)
5
6
  * estiva-gates count --repo <name> [--package] write .gates-count.json (postlint:rules)
6
7
  * estiva-gates hook [--app <dir>] [--package] the editor gate, run by a PreToolUse hook
7
- * estiva-gates status [--app <dir>] [--json] [--detail] gates:status
8
+ * estiva-gates name-escapes [--config <file>] once: name the rule in every escape marker (B3)
9
+ * estiva-gates status [--app <dir>] [--json] [--detail] [--escapes] gates:status
8
10
  *
9
11
  * An app's `package.json` runs the first and the last through npm, which finds
10
12
  * the command in `node_modules/.bin`. The hook is run by path, from the
@@ -19,8 +21,10 @@
19
21
  * node "$CLAUDE_PROJECT_DIR/web/node_modules/@estiva-app/ui/dist/gates/cli.js" hook --app web
20
22
  * node web/node_modules/@estiva-app/ui/dist/gates/cli.js status --app web
21
23
  */
24
+ import { runCi } from './ci'
22
25
  import { writeGateCount } from './count'
23
26
  import { runHook } from './hook'
27
+ import { nameEscapes } from './name-escapes'
24
28
  import { runStatus } from './status'
25
29
 
26
30
  const [command, ...rest] = process.argv.slice(2)
@@ -33,6 +37,8 @@ const audience = flag('package') ? 'package' : 'app'
33
37
 
34
38
  async function main(): Promise<number> {
35
39
  switch (command) {
40
+ case 'ci':
41
+ return runCi({ audience })
36
42
  case 'count': {
37
43
  const repo = value('repo')
38
44
  if (!repo) {
@@ -44,6 +50,13 @@ async function main(): Promise<number> {
44
50
  if (result.failures.length) process.stderr.write(`${result.failures.join('\n')}\n`)
45
51
  return result.failures.length ? 1 : 0
46
52
  }
53
+ case 'name-escapes': {
54
+ // Once, after taking 0.36.0: every unnamed marker gets the rules it keeps off (B3).
55
+ const result = await nameEscapes({ config: value('config') })
56
+ process.stdout.write(`${result.named.length} markers named:\n ${result.named.join('\n ')}\n`)
57
+ if (result.unreached.length) process.stdout.write(`${result.unreached.length} unnamed markers no rule reaches, left as they are:\n ${result.unreached.join('\n ')}\n`)
58
+ return 0
59
+ }
47
60
  case 'hook': {
48
61
  // A hook that fails open guards nothing: Claude Code lets a write through
49
62
  // on any exit but 2, and a crash exits 1 (audit A2). So a gate that cannot
@@ -52,6 +65,9 @@ async function main(): Promise<number> {
52
65
  try {
53
66
  const result = await runHook({ app: value('app'), audience })
54
67
  if (result.code === 2) process.stderr.write(result.message)
68
+ // A note on a write let through reaches Claude as additional context: plain
69
+ // stdout on exit 0 does not, for a PreToolUse hook (code.claude.com/docs/en/hooks-guide).
70
+ else if (result.note) process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `A copied look, let through (it only warns):\n${result.note}` } })}\n`)
55
71
  return result.code
56
72
  } catch (error) {
57
73
  const why = error instanceof Error ? error.message.split('\n')[0] : String(error)
@@ -60,11 +76,11 @@ async function main(): Promise<number> {
60
76
  }
61
77
  }
62
78
  case 'status': {
63
- process.stdout.write(`${await runStatus({ app: value('app'), json: flag('json'), detail: flag('detail') })}${flag('json') ? '' : '\n'}`)
79
+ process.stdout.write(`${await runStatus({ app: value('app'), json: flag('json'), detail: flag('detail'), escapes: flag('escapes') })}${flag('json') ? '' : '\n'}`)
64
80
  return 0
65
81
  }
66
82
  default:
67
- process.stderr.write('estiva-gates <count | hook | status>\n')
83
+ process.stderr.write('estiva-gates <ci | count | hook | status | name-escapes>\n')
68
84
  return 1
69
85
  }
70
86
  }
@@ -69,7 +69,7 @@ export async function writeGateCount({ root = process.cwd(), repo, audience = 'a
69
69
 
70
70
  const failures: string[] = []
71
71
  if (disabled.length > 0) {
72
- failures.push('', 'An eslint-disable switched a gate rule off. Write the reason above the element instead: // @estiva-escape: <reason>')
72
+ failures.push('', 'An eslint-disable switched a gate rule off. Write the reason above the element instead, naming the rule: // @estiva-escape(<rule>): <reason>')
73
73
  for (const d of disabled) failures.push(` ${relative(root, d.filePath)}:${d.line} ${d.ruleId}`)
74
74
  }
75
75
  return { rules, disabled, changed, lines, failures }
@@ -71,7 +71,7 @@ describe('create-estiva-app', () => {
71
71
  it('starts the count at zero and keeps the CI job named gate', () => {
72
72
  const count = JSON.parse(files['.gates-count.json'])
73
73
  expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
74
- expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run lint:rules/)
74
+ expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*run: npx estiva-gates ci/)
75
75
  expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
76
76
  })
77
77
 
@@ -131,7 +131,7 @@ describe('create-estiva-app', () => {
131
131
  expect(registry.filesWithoutParts.map((file) => file.file)).toEqual(['src/main.tsx'])
132
132
  const scripts = JSON.parse(files['package.json']).scripts
133
133
  expect(scripts).toMatchObject({ 'ui:find': 'estiva-ui find', registry: 'estiva-ui build', 'registry:check': 'estiva-ui check' })
134
- expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run registry:check/)
134
+ expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*run: npx estiva-gates ci/)
135
135
  expect(files['.gitignore'].split('\n')).toContain('registry.json')
136
136
  })
137
137
 
@@ -284,7 +284,8 @@ import reactHooks from 'eslint-plugin-react-hooks'
284
284
  * TypeScript's the package names itself.
285
285
  *
286
286
  * A place that keeps something the gate refuses says why, on the line above:
287
- * \`// @estiva-escape: <reason>\`. Never \`eslint-disable\`: the count refuses it.
287
+ * \`// @estiva-escape(<rule>): <reason>\`, naming the rule it keeps off. Never
288
+ * \`eslint-disable\`: the count refuses it.
288
289
  */
289
290
  export default gateConfig({ quiet: { 'react-hooks': reactHooks } })
290
291
  `,
@@ -316,8 +317,14 @@ export default function define(h) {
316
317
  Nothing. ${title} was made with every gate on, at zero.
317
318
 
318
319
  It should stay that way. A place that keeps something the gate refuses says why on
319
- the line above it, \`// @estiva-escape: <reason>\`, and the count lists it; a whole
320
- file that cannot pass yet goes here, with its reason.
320
+ the line above it, \`// @estiva-escape(<rule>): <reason>\`. This page does not list those:
321
+ \`npm run gates:status -- --escapes\` does, from the code, with each one's age, and
322
+ \`npm run gates:status\` shows the ones older than 30 days, to keep with a new reason or fix.
323
+
324
+ ## Folders the gate does not read
325
+
326
+ None. A folder the gate skips is named here, with why; CI's job \`gate\` fails on one
327
+ that is not.
321
328
  `,
322
329
 
323
330
  '.github/workflows/deploy.yml': `name: deploy
@@ -382,16 +389,14 @@ jobs:
382
389
  node-version: 24
383
390
  cache: npm
384
391
  - run: npm ci
385
- - name: Gate lint
386
- run: npm run lint:rules
387
- # Colours, type, corners and shadows from the preset only. In \`check\` too,
388
- # inside \`npm run lint\`, but only \`gate\` blocks a merge.
389
- - name: Token lint
390
- run: npm run lint:tokens
391
- # Every part of the app says in one line what it is for, so the catalogue
392
- # (\`npm run ui:find\`) can offer it before someone builds it again.
393
- - name: Every part is described
394
- run: npm run registry:check
392
+ # Every step of the gate, the same command Peek and Ship run: the gate
393
+ # lint and \`.gates-count.json\` (committed, or this fails), the token
394
+ # contract, the catalogue (every part says in one line what it is for),
395
+ # and the wall's own settings: the editor hook in .claude/settings.json
396
+ # refuses a raw button, and every folder the gate skips is named in
397
+ # docs/GATES-DEBT.md.
398
+ - name: The gate (estiva-gates ci)
399
+ run: npx estiva-gates ci
395
400
  `,
396
401
 
397
402
  'src/index.css': `@import '@estiva-app/ui/tokens.css';
@@ -996,12 +1001,13 @@ nothing arrives.
996
1001
  |---|---|
997
1002
  | \`npm run typecheck\` | TypeScript, the app and its settings files |
998
1003
  | \`npm run lint\` | everything: TypeScript's and React's rules, the token contract, the gate |
999
- | \`npm run lint:rules\` | the gate alone, and \`.gates-count.json\` — CI's job \`gate\` |
1004
+ | \`npm run lint:rules\` | the gate alone, and \`.gates-count.json\` |
1000
1005
  | \`npm test\` | the tests |
1001
1006
  | \`npm run build\` | the build |
1002
1007
  | \`npm run gates:status\` | which gates are on, read from the code |
1003
1008
  | \`npm run ui:find <words>\` | what the package and this app already have for it |
1004
- | \`npm run registry:check\` | every part says what it is for — CI's job \`gate\` |
1009
+ | \`npm run registry:check\` | every part says what it is for |
1010
+ | \`npx estiva-gates ci\` | every step of CI's job \`gate\`: the gate, its count committed, the tokens, the catalogue, the hook |
1005
1011
  | \`npm run storybook\` | the stories |
1006
1012
 
1007
1013
  The gates are the package's, imported rather than copied, so a rule written later
@@ -1026,7 +1032,7 @@ instead. What it refuses is the package's to say, and grows with it: the
1026
1032
  (the hook in \`.claude/settings.json\`, for a session started in this folder), in
1027
1033
  \`npm run lint:rules\` and in CI. A session started elsewhere: run
1028
1034
  \`npm run lint:rules\` after changing \`src/\`, and fix what it reports. Keep something
1029
- only with its reason on the line above, \`// @estiva-escape: <reason>\`, never with
1035
+ only with its reason on the line above, \`// @estiva-escape(<rule>): <reason>\`, never with
1030
1036
  \`eslint-disable\`.
1031
1037
 
1032
1038
  **Tokens only.** Colours, type, corners and shadows come from the package's preset.
@@ -0,0 +1,111 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * Every escape in a repository, listed (Katerina's ruling B1, 25 September):
4
+ * where it is, which rules it keeps off, why, and how old it is.
5
+ *
6
+ * The count (`.gates-count.json`) holds numbers per rule; the plan asked for
7
+ * escapes "listed in a report, reviewed", and the hand-written debt pages had
8
+ * drifted from the count in all three repos. This reads the markers themselves,
9
+ * both forms:
10
+ *
11
+ * - the gate's, `// @estiva-escape(<rule>): <reason>` (or in a JSX comment);
12
+ * - the token lint's, `// eslint-disable-next-line <rule> -- @estiva-escape: <reason>`
13
+ * (ruling A2, 15 September), which no count sees.
14
+ *
15
+ * Age comes from git (the day the marker's line was last written), so it cannot
16
+ * be forgotten or faked. Review is by age, monthly: `gates:status` shows every
17
+ * escape older than {@link REVIEW_AFTER_DAYS} days, and Katerina keeps it (with
18
+ * a new reason) or it gets fixed.
19
+ */
20
+ import { execFileSync } from 'node:child_process'
21
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
22
+ import { join, relative } from 'node:path'
23
+ import { readMarker } from '../eslint/escape'
24
+
25
+ /** An escape older than this many days is shown for review. */
26
+ export const REVIEW_AFTER_DAYS = 30
27
+
28
+ export interface Escape {
29
+ file: string
30
+ line: number
31
+ /** The rules it keeps off, as the marker names them. */
32
+ rules: string[]
33
+ reason: string
34
+ /** Whole days since the marker's line was written, or null outside git. */
35
+ days: number | null
36
+ /** `gate` for the gate's marker, `token` for the token lint's directive. */
37
+ kind: 'gate' | 'token'
38
+ }
39
+
40
+ const SOURCE = /\.[cm]?[jt]sx?$/
41
+ const SKIP = new Set(['node_modules', 'dist', '.git', 'storybook-static', 'coverage'])
42
+ const DIRECTIVE = /eslint-disable(?:-next-line|-line)?\s+(.*?)\s+--\s+@estiva-escape\s*:?\s*(.*)$/
43
+
44
+ function sourceFiles(dir: string, out: string[] = []): string[] {
45
+ if (!existsSync(dir)) return out
46
+ for (const name of readdirSync(dir)) {
47
+ if (SKIP.has(name)) continue
48
+ const path = join(dir, name)
49
+ if (statSync(path).isDirectory()) sourceFiles(path, out)
50
+ else if (SOURCE.test(name) && !/\.test\.[cm]?[jt]sx?$/.test(name)) out.push(path)
51
+ }
52
+ return out
53
+ }
54
+
55
+ /** The day each line of a file was last written, from `git blame`, in seconds since 1970. */
56
+ function lineTimes(root: string, file: string): Map<number, number> {
57
+ const times = new Map<number, number>()
58
+ try {
59
+ const out = execFileSync('git', ['blame', '--line-porcelain', '--', relative(root, file)], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20 }).toString()
60
+ let line = 0
61
+ for (const row of out.split('\n')) {
62
+ const header = /^[0-9a-f]{40} \d+ (\d+)/.exec(row)
63
+ if (header) line = Number(header[1])
64
+ else if (row.startsWith('author-time ')) times.set(line, Number(row.slice(12)))
65
+ }
66
+ } catch {
67
+ // Not in git, or a file git does not track yet: its escapes have no age.
68
+ }
69
+ return times
70
+ }
71
+
72
+ /** Every escape under `src`, and in any other folder named, oldest first. */
73
+ export function listEscapes(root: string, { folders = ['src'], now = Date.now() }: { folders?: string[]; now?: number } = {}): Escape[] {
74
+ const found: Escape[] = []
75
+ for (const file of folders.flatMap((f) => sourceFiles(join(root, f)))) {
76
+ const lines = readFileSync(file, 'utf8').split(/\r?\n/)
77
+ const hits: Omit<Escape, 'days'>[] = []
78
+ lines.forEach((text, i) => {
79
+ if (!text.includes('@estiva-escape')) return
80
+ const rel = relative(root, file).split('\\').join('/')
81
+ // A real marker opens its comment; documentation mentions one mid-sentence, or with a placeholder.
82
+ if (/<(reason|why|rule)\b/.test(text)) return
83
+ const directive = /^\s*(?:\/\/|\{?\/\*)\s*eslint-disable/.test(text) ? DIRECTIVE.exec(text) : null
84
+ if (directive) {
85
+ hits.push({ file: rel, line: i + 1, rules: directive[1].split(/[\s,]+/).filter(Boolean), reason: directive[2].replace(/\*\/\s*}?\s*$/, '').trim(), kind: 'token' })
86
+ return
87
+ }
88
+ if (!/^\s*(?:\/\/+|\{?\/\*+)?\s*@estiva-escape(?:\s*\(|\s*:)/.test(text)) return
89
+ const marker = readMarker(text)
90
+ if (!marker) return
91
+ hits.push({ file: rel, line: i + 1, rules: marker.rules, reason: marker.reason.replace(/\*\/\s*}?\s*$/, '').trim(), kind: 'gate' })
92
+ })
93
+ if (!hits.length) continue
94
+ const times = lineTimes(root, file)
95
+ for (const hit of hits) {
96
+ const at = times.get(hit.line)
97
+ found.push({ ...hit, days: at === undefined ? null : Math.floor((now - at * 1000) / 86_400_000) })
98
+ }
99
+ }
100
+ return found.sort((a, b) => (b.days ?? -1) - (a.days ?? -1) || a.file.localeCompare(b.file) || a.line - b.line)
101
+ }
102
+
103
+ /** The lines `gates:status` prints: a total, and every escape due for review (or every one, with `all`). */
104
+ export function escapeLines(escapes: Escape[], { all = false }: { all?: boolean } = {}): string[] {
105
+ const due = escapes.filter((e) => (e.days ?? 0) > REVIEW_AFTER_DAYS)
106
+ const shown = all ? escapes : due
107
+ const gate = escapes.filter((e) => e.kind === 'gate').length
108
+ const out = [`Escapes: ${escapes.length} (${gate} of the gate's, ${escapes.length - gate} of the token lint's) · ${due.length} older than ${REVIEW_AFTER_DAYS} days, to review: keep with a new reason, or fix${all ? '' : ' · every escape: add -- --escapes'}`]
109
+ for (const e of shown) out.push(` ${e.file}:${e.line} ${e.rules.join(', ') || '(no rule named)'} · ${e.days === null ? 'not in git yet' : `${e.days} days`}\n ${e.reason}`)
110
+ return out
111
+ }
@@ -21,6 +21,7 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
21
21
  import { parser as typescriptParser } from 'typescript-eslint'
22
22
  import estiva from '../eslint/index'
23
23
  import { KNOWN_PLUGINS, TOKEN_LINT_IGNORES } from './token-lint'
24
+ import { tokenStylePlugin } from './token-style'
24
25
 
25
26
  export interface GateConfigOptions {
26
27
  /** `app` (the default) runs `configs.recommended`; `package` runs this package's inward set. */
@@ -70,6 +71,7 @@ export function gateConfig({ audience = 'app', quiet = {}, ignores = [] }: GateC
70
71
  'better-tailwindcss': betterTailwindcss,
71
72
  'token-values': betterTailwindcss,
72
73
  'token-spacing': betterTailwindcss,
74
+ 'token-style': tokenStylePlugin,
73
75
  },
74
76
  // Every other config's directives are unused here, because their rules are
75
77
  // off here. Reporting them would be this config complaining about the