@estiva-app/ui 0.33.0 → 0.35.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 (126) hide show
  1. package/README.md +8 -1
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Button.d.ts.map +1 -1
  4. package/dist/Card.d.ts.map +1 -1
  5. package/dist/Chip.d.ts.map +1 -1
  6. package/dist/ChipInput.d.ts.map +1 -1
  7. package/dist/CommandPalette.d.ts.map +1 -1
  8. package/dist/DialogShell.d.ts.map +1 -1
  9. package/dist/Lightbox.d.ts.map +1 -1
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/NavItem.d.ts.map +1 -1
  12. package/dist/PersonTrigger.d.ts.map +1 -1
  13. package/dist/Reaction.d.ts.map +1 -1
  14. package/dist/SearchInput.d.ts.map +1 -1
  15. package/dist/SectionLabel.d.ts +9 -2
  16. package/dist/SectionLabel.d.ts.map +1 -1
  17. package/dist/Select.d.ts.map +1 -1
  18. package/dist/TextInput.d.ts.map +1 -1
  19. package/dist/Textarea.d.ts.map +1 -1
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/eslint/index.d.ts +2 -0
  22. package/dist/eslint/index.d.ts.map +1 -1
  23. package/dist/eslint/index.js +392 -14
  24. package/dist/eslint/index.js.map +4 -4
  25. package/dist/eslint/looks-of.d.ts +54 -0
  26. package/dist/eslint/looks-of.d.ts.map +1 -0
  27. package/dist/eslint/no-copied-look.d.ts +3 -0
  28. package/dist/eslint/no-copied-look.d.ts.map +1 -0
  29. package/dist/gates/app-checks.d.ts +1 -1
  30. package/dist/gates/app-checks.d.ts.map +1 -1
  31. package/dist/gates/{chunk-JZE7TIQY.js → chunk-D466XXVL.js} +12 -6
  32. package/dist/gates/chunk-D466XXVL.js.map +7 -0
  33. package/dist/gates/{chunk-XAPFC37F.js → chunk-JWOU4UQZ.js} +43 -8
  34. package/dist/gates/chunk-JWOU4UQZ.js.map +7 -0
  35. package/dist/gates/cli.js +12 -4
  36. package/dist/gates/cli.js.map +2 -2
  37. package/dist/gates/create-app.d.ts.map +1 -1
  38. package/dist/gates/create-app.js +1 -1
  39. package/dist/gates/gate-config.d.ts +1 -1
  40. package/dist/gates/hook.d.ts +1 -1
  41. package/dist/gates/hook.d.ts.map +1 -1
  42. package/dist/gates/index.js +3 -3
  43. package/dist/gates/index.js.map +2 -2
  44. package/dist/gates/status.d.ts +9 -3
  45. package/dist/gates/status.d.ts.map +1 -1
  46. package/dist/index.js +171 -133
  47. package/dist/index.js.map +3 -3
  48. package/dist/looks.d.ts +34 -0
  49. package/dist/looks.d.ts.map +1 -0
  50. package/dist/registry/app-3ALZE6XO.js +10 -0
  51. package/dist/registry/app.d.ts.map +1 -1
  52. package/dist/registry/{build-LGFCCOLR.js → build-NDXMVFQ5.js} +3 -3
  53. package/dist/registry/build.d.ts.map +1 -1
  54. package/dist/registry/{chunk-XCBN2EKW.js → chunk-3WJ7ZZ7Z.js} +8 -4
  55. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +7 -0
  56. package/dist/registry/{chunk-E4JNV7PC.js → chunk-PZO4BVV3.js} +2 -1
  57. package/dist/registry/chunk-PZO4BVV3.js.map +7 -0
  58. package/dist/registry/chunk-XVII7P6G.js +595 -0
  59. package/dist/registry/chunk-XVII7P6G.js.map +7 -0
  60. package/dist/registry/cli.js +106 -18
  61. package/dist/registry/cli.js.map +4 -4
  62. package/dist/registry/index.js +3 -3
  63. package/dist/registry/schema.d.ts +8 -0
  64. package/dist/registry/schema.d.ts.map +1 -1
  65. package/dist/registry/storymap.d.ts +12 -0
  66. package/dist/registry/storymap.d.ts.map +1 -0
  67. package/package.json +1 -1
  68. package/registry.json +347 -90
  69. package/src/AttachmentCard.tsx +2 -0
  70. package/src/Button.tsx +1 -0
  71. package/src/Card.stories.tsx +2 -2
  72. package/src/Card.tsx +1 -0
  73. package/src/Chip.tsx +2 -1
  74. package/src/ChipInput.tsx +4 -2
  75. package/src/CommandPalette.tsx +2 -0
  76. package/src/ContainerHeader.stories.tsx +2 -1
  77. package/src/DialogShell.tsx +1 -0
  78. package/src/Divider.stories.tsx +12 -10
  79. package/src/Lightbox.tsx +1 -0
  80. package/src/Link.stories.tsx +5 -4
  81. package/src/Menu.tsx +3 -1
  82. package/src/NavItem.tsx +1 -0
  83. package/src/PersonTrigger.tsx +1 -0
  84. package/src/Reaction.tsx +3 -1
  85. package/src/ReactionPicker.stories.tsx +3 -2
  86. package/src/ScrollArea.stories.tsx +13 -12
  87. package/src/SearchInput.tsx +5 -4
  88. package/src/SectionLabel.mdx +4 -2
  89. package/src/SectionLabel.stories.tsx +13 -0
  90. package/src/SectionLabel.tsx +19 -2
  91. package/src/Select.tsx +6 -4
  92. package/src/TextInput.tsx +6 -6
  93. package/src/Textarea.tsx +6 -4
  94. package/src/Tooltip.stories.tsx +3 -2
  95. package/src/Tooltip.tsx +2 -1
  96. package/src/eslint/index.test.ts +12 -8
  97. package/src/eslint/index.ts +13 -5
  98. package/src/eslint/looks-of.ts +302 -0
  99. package/src/eslint/no-copied-look.test.ts +155 -0
  100. package/src/eslint/no-copied-look.ts +203 -0
  101. package/src/eslint/no-restyled-part.ts +1 -1
  102. package/src/gates/app-checks.ts +2 -2
  103. package/src/gates/cli.ts +15 -4
  104. package/src/gates/create-app.test.ts +57 -2
  105. package/src/gates/create-app.ts +11 -5
  106. package/src/gates/gate-config.ts +1 -1
  107. package/src/gates/gates.test.ts +36 -3
  108. package/src/gates/hook.ts +1 -1
  109. package/src/gates/status.ts +62 -17
  110. package/src/looks.ts +42 -0
  111. package/src/registry/app.ts +3 -0
  112. package/src/registry/build.ts +8 -0
  113. package/src/registry/cli.ts +21 -3
  114. package/src/registry/registry.test.ts +1 -1
  115. package/src/registry/schema.ts +9 -0
  116. package/src/registry/storymap.test.ts +96 -0
  117. package/src/registry/storymap.ts +107 -0
  118. package/dist/gates/chunk-JZE7TIQY.js.map +0 -7
  119. package/dist/gates/chunk-XAPFC37F.js.map +0 -7
  120. package/dist/registry/app-FXN4EI34.js +0 -10
  121. package/dist/registry/chunk-E4JNV7PC.js.map +0 -7
  122. package/dist/registry/chunk-NJN4MQAM.js +0 -397
  123. package/dist/registry/chunk-NJN4MQAM.js.map +0 -7
  124. package/dist/registry/chunk-XCBN2EKW.js.map +0 -7
  125. /package/dist/registry/{app-FXN4EI34.js.map → app-3ALZE6XO.js.map} +0 -0
  126. /package/dist/registry/{build-LGFCCOLR.js.map → build-NDXMVFQ5.js.map} +0 -0
@@ -0,0 +1,203 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { dirname, join, relative, resolve } from 'node:path'
3
+ import type { Rule } from 'eslint'
4
+ import type ts from 'typescript'
5
+ import { ESCAPE_MESSAGES, isEscaped } from './escape'
6
+ import { classUnits, componentsOf, copies, looksOf, parseForLooks, sharedLooksOf, type ClassUnit } from './looks-of'
7
+
8
+ /**
9
+ * UIG-25: a component's look typed again by hand. **A warning, never an error**
10
+ * (Katerina, 13 September): a copy is found by how alike two lists are, and
11
+ * likeness is a judgement — a warning gets read, a guess that blocks gets
12
+ * switched off.
13
+ *
14
+ * Every class list in the file (see `looks-of.ts` for where one is read) is
15
+ * compared, by its look words alone, against the looks of every catalogued
16
+ * part in another file. A list is a copy when it shares `MIN_SHARED` look words
17
+ * with one of a part's looks, covering `MIN_COVER` of the shorter of the two.
18
+ *
19
+ * - **In an app**, the parts are the package's — read from the catalogue the
20
+ * installed package ships (`@estiva-app/ui/registry.json`, its `looks`) — and
21
+ * the app's own exported components, read from its `src`. So a part added to
22
+ * either is covered the day it is added, with no list kept here. The fix:
23
+ * use the part.
24
+ * - **In the package**, the parts are its own. The fix: write the look once in
25
+ * `src/looks.ts` and use it in both.
26
+ *
27
+ * Left alone: a part's look in its own file, and a component named like the
28
+ * part it wraps (Peek's `Avatar` over the package's). A constant imported from
29
+ * another file is a look written once, not a copy. Peek and Ship copying each
30
+ * other is not seen here — each app knows only itself and the package — and
31
+ * is counted by `gates:status`.
32
+ */
33
+
34
+ interface Look {
35
+ part: string
36
+ file: string
37
+ look: string[]
38
+ /** The installed package's part, this repo's own part, or a shared look (`…_CLASSES`) this repo writes once. */
39
+ from: 'package' | 'own' | 'shared'
40
+ }
41
+
42
+ const packageDirCache = new Map<string, string | null>()
43
+ function packageDirOf(file: string): string | null {
44
+ const start = dirname(resolve(file))
45
+ if (packageDirCache.has(start)) return packageDirCache.get(start) ?? null
46
+ let dir = start
47
+ for (;;) {
48
+ if (existsSync(join(dir, 'package.json'))) break
49
+ const up = dirname(dir)
50
+ if (up === dir) {
51
+ dir = ''
52
+ break
53
+ }
54
+ dir = up
55
+ }
56
+ packageDirCache.set(start, dir || null)
57
+ return dir || null
58
+ }
59
+
60
+ const PACKAGE = '@estiva-app/ui'
61
+ const nameOf = (dir: string) => {
62
+ try {
63
+ return (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string }).name
64
+ } catch {
65
+ return undefined
66
+ }
67
+ }
68
+
69
+ /** The installed package's parts and their looks, from the catalogue it ships. */
70
+ const shippedCache = new Map<string, { mtime: number; looks: Look[] }>()
71
+ function shippedLooks(appDir: string): Look[] {
72
+ for (let dir = appDir; ; dir = dirname(dir)) {
73
+ const file = join(dir, 'node_modules', PACKAGE, 'registry.json')
74
+ if (existsSync(file)) {
75
+ const mtime = statSync(file).mtimeMs
76
+ const cached = shippedCache.get(file)
77
+ if (cached && cached.mtime === mtime) return cached.looks
78
+ let looks: Look[] = []
79
+ try {
80
+ const registry = JSON.parse(readFileSync(file, 'utf8')) as { entries: { name: string; sourceFile: string; looks?: string[] }[] }
81
+ looks = registry.entries.flatMap((e) => (e.looks ?? []).map((l) => ({ part: e.name, file: e.sourceFile, look: l.split(' '), from: 'package' as const })))
82
+ } catch {
83
+ // A catalogue that does not parse is its own check's business.
84
+ }
85
+ shippedCache.set(file, { mtime, looks })
86
+ return looks
87
+ }
88
+ if (dirname(dir) === dir) return []
89
+ }
90
+ }
91
+
92
+ /** The names a file exports. */
93
+ function exportedNames(source: ts.SourceFile): Set<string> {
94
+ const names = new Set<string>()
95
+ const text = source.text
96
+ for (const m of text.matchAll(/export\s+(?:default\s+)?(?:function|const)\s+([A-Z]\w*)/g)) names.add(m[1])
97
+ for (const m of text.matchAll(/export\s*\{([^}]*)\}/g)) for (const part of m[1].split(',')) {
98
+ const name = part.trim().split(/\s+as\s+/).pop()
99
+ if (name && /^[A-Z]/.test(name)) names.add(name)
100
+ }
101
+ return names
102
+ }
103
+
104
+ /** A repo's own exported components and their looks, from its `src`. */
105
+ const ownCache = new Map<string, { mtime: number; looks: Look[] }>()
106
+ function ownLooks(repoDir: string): Look[] {
107
+ const out: Look[] = []
108
+ const walk = (dir: string) => {
109
+ let names: string[] = []
110
+ try {
111
+ names = readdirSync(dir)
112
+ } catch {
113
+ return
114
+ }
115
+ for (const name of names) {
116
+ if (name === 'node_modules' || name === 'dist') continue
117
+ const path = join(dir, name)
118
+ if (statSync(path).isDirectory()) walk(path)
119
+ else if (/\.tsx?$/.test(name) && !/\.(?:stories|test)\.tsx?$/.test(name) && !name.endsWith('.d.ts')) {
120
+ const mtime = statSync(path).mtimeMs
121
+ let cached = ownCache.get(path)
122
+ if (!cached || cached.mtime !== mtime) {
123
+ const text = readFileSync(path, 'utf8')
124
+ const source = parseForLooks(path, text)
125
+ const exported = name.endsWith('.tsx') ? [...componentsOf(source).keys()].filter((n) => exportedNames(source).has(n)) : []
126
+ const file = relative(repoDir, path).replace(/\\/g, '/')
127
+ const looks: Look[] = [...looksOf(source, exported)].flatMap(([part, lists]) => lists.map((l) => ({ part, file, look: l.split(' '), from: 'own' as const })))
128
+ // A shared look written once (`…_CLASSES`): a copy of it is told to use it.
129
+ if (text.includes('_CLASSES')) for (const [part, lists] of sharedLooksOf(source)) for (const l of lists) looks.push({ part, file, look: l.split(' '), from: 'shared' })
130
+ cached = { mtime, looks }
131
+ ownCache.set(path, cached)
132
+ }
133
+ out.push(...cached.looks)
134
+ }
135
+ }
136
+ }
137
+ walk(join(repoDir, 'src'))
138
+ return out
139
+ }
140
+
141
+ export const noCopiedLook: Rule.RuleModule = {
142
+ meta: {
143
+ type: 'suggestion',
144
+ docs: { description: "A part's look typed again by hand, where the part (or a shared look) should be used" },
145
+ schema: [],
146
+ messages: {
147
+ usePart:
148
+ "This class list is `{{part}}`'s look, typed again ({{count}} words: {{words}}). Use `{{part}}` from {{from}}: a copy looks right today and stays behind the day `{{part}}` changes.",
149
+ shareIt:
150
+ 'This look is also written in `{{part}}` ({{file}}): {{words}}. Write it once in src/looks.ts and use it in both, so the two cannot drift apart.',
151
+ useShared:
152
+ 'This class list is `{{part}}` ({{file}}), typed again: {{words}}. Use `{{part}}`: a look written once cannot drift apart.',
153
+ ...ESCAPE_MESSAGES,
154
+ },
155
+ },
156
+ create(context) {
157
+ const file = context.filename
158
+ const repoDir = packageDirOf(file)
159
+ if (!repoDir) return {}
160
+ const inPackage = nameOf(repoDir) === PACKAGE
161
+ return {
162
+ Program() {
163
+ const here = relative(repoDir, file).replace(/\\/g, '/')
164
+ const source = parseForLooks(file, context.sourceCode.text)
165
+ const declared = new Set(componentsOf(source).keys())
166
+ const looks = [...(inPackage ? [] : shippedLooks(repoDir)), ...ownLooks(repoDir)].filter((l) => l.file !== here && !declared.has(l.part))
167
+ if (!looks.length) return
168
+ const units = classUnits(source)
169
+ // A string inside a className is read with it; report the className once.
170
+ const within = (u: ClassUnit) => units.some((o) => o !== u && o.via === 'attribute' && u.via === 'literal' && u.start >= o.start && u.end <= o.end)
171
+ const reported = new Set<number>()
172
+ for (const unit of units) {
173
+ if (within(unit) || reported.has(unit.start)) continue
174
+ let best: { look: Look; shared: string[] } | null = null
175
+ for (const look of looks) {
176
+ // A shared look is compared with the package's parts only: the parts here that use it are not its copies.
177
+ if (unit.shared && look.from !== 'package') continue
178
+ const shared = copies(unit.look, look.look)
179
+ // On a tie, the shared look: using it is the whole fix.
180
+ if (shared && (!best || shared.length > best.shared.length || (shared.length === best.shared.length && look.from === 'shared'))) best = { look, shared }
181
+ }
182
+ if (!best) continue
183
+ // A className built from a constant of this file is reported once, at the constant.
184
+ const found = best
185
+ if (unit.via === 'attribute' && units.some((o) => o.via === 'literal' && !within(o) && found.shared.every((w) => o.look.includes(w)) && looks.some((l) => copies(o.look, l.look)))) continue
186
+ const loc = { start: context.sourceCode.getLocFromIndex(unit.start), end: context.sourceCode.getLocFromIndex(unit.end) }
187
+ const at = { start: context.sourceCode.getLocFromIndex(unit.anchor), end: loc.end }
188
+ if (isEscaped(context, { loc: at, range: [unit.anchor, unit.end] })) continue
189
+ reported.add(unit.start)
190
+ const words = best.shared.map((w) => `\`${w}\``).join(' ')
191
+ if (best.look.from === 'shared') context.report({ loc, messageId: 'useShared', data: { part: best.look.part, file: best.look.file, words } })
192
+ else if (inPackage) context.report({ loc, messageId: 'shareIt', data: { part: best.look.part, file: best.look.file, words } })
193
+ else
194
+ context.report({
195
+ loc,
196
+ messageId: 'usePart',
197
+ data: { part: best.look.part, count: String(best.shared.length), words, from: best.look.from === 'package' ? PACKAGE : `\`${best.look.file}\`` },
198
+ })
199
+ }
200
+ },
201
+ }
202
+ },
203
+ }
@@ -119,7 +119,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
119
119
  Reaction: ['pressed'],
120
120
  ReactionPicker: ['surface'],
121
121
  SectionHeader: ['hover'],
122
- SectionLabel: ['tone'],
122
+ SectionLabel: ['tone', 'truncate'],
123
123
  Select: ['size'],
124
124
  Tabs: ['size'],
125
125
  TextInput: ['size'],
@@ -23,7 +23,7 @@
23
23
  import type { CheckResult, GateCheck, GateHelpers, GateTicket } from './status'
24
24
 
25
25
  export interface AppCheckOptions {
26
- /** Where the app is inside the repository: `web` in Ship, `.` elsewhere. */
26
+ /** Where the app is inside the repository: `.` unless it sits in a folder of its own. */
27
27
  app?: string
28
28
  /** A real page of the app, relative to the app, full of elements that are not controls. */
29
29
  page: string
@@ -230,7 +230,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
230
230
  { what: 'a title= is an error naming WithTooltip', run: probe(`import { Button } from '@estiva-app/ui'\n${component('<Button title="Delete">x</Button>')}`, 'error', 'WithTooltip') },
231
231
  ]),
232
232
  ticket('UIG-25', [
233
- { what: "SectionLabel's class list, typed by hand, is a warning", run: probe(component('<span className="text-[10px] uppercase tracking-wide text-text-muted">Label</span>'), 'warning', 'SectionLabel') },
233
+ { what: "SectionLabel's class list, typed by hand, is a warning", run: probe(component('<span className="text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-secondary">Label</span>'), 'warning', 'SectionLabel') },
234
234
  ]),
235
235
  ticket('UIG-27', [
236
236
  { what: 'the installed package has Link', run: () => h.contains(installed, /\bLink\b/, 'the installed @estiva-app/ui exports Link') },
package/src/gates/cli.ts CHANGED
@@ -13,7 +13,8 @@
13
13
  * node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook
14
14
  *
15
15
  * A repo whose app sits in a folder of its own runs both by path, from the
16
- * repo's top folder, and says where the app is — Ship (UIG-32):
16
+ * repo's top folder, and says where the app is (Ship did, from UIG-32 until
17
+ * PER-19 moved its app to the top folder):
17
18
  *
18
19
  * node "$CLAUDE_PROJECT_DIR/web/node_modules/@estiva-app/ui/dist/gates/cli.js" hook --app web
19
20
  * node web/node_modules/@estiva-app/ui/dist/gates/cli.js status --app web
@@ -44,9 +45,19 @@ async function main(): Promise<number> {
44
45
  return result.failures.length ? 1 : 0
45
46
  }
46
47
  case 'hook': {
47
- const result = await runHook({ app: value('app'), audience })
48
- if (result.code === 2) process.stderr.write(result.message)
49
- return result.code
48
+ // A hook that fails open guards nothing: Claude Code lets a write through
49
+ // on any exit but 2, and a crash exits 1 (audit A2). So a gate that cannot
50
+ // run — no ESLint in the app's install, a config that does not load —
51
+ // refuses the write and says why, and the session fixes the install.
52
+ try {
53
+ const result = await runHook({ app: value('app'), audience })
54
+ if (result.code === 2) process.stderr.write(result.message)
55
+ return result.code
56
+ } catch (error) {
57
+ const why = error instanceof Error ? error.message.split('\n')[0] : String(error)
58
+ process.stderr.write(`The UI Guardrails could not check this write, so it was not made: ${why}\nRun \`npm ci\` in the app's folder, then try again.\n`)
59
+ return 2
60
+ }
50
61
  }
51
62
  case 'status': {
52
63
  process.stdout.write(`${await runStatus({ app: value('app'), json: flag('json'), detail: flag('detail') })}${flag('json') ? '' : '\n'}`)
@@ -1,9 +1,12 @@
1
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
1
+ import { execFileSync } from 'node:child_process'
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
2
3
  import { dirname, join } from 'node:path'
3
4
  import { afterAll, describe, expect, it } from 'vitest'
4
5
  import { buildAppRegistry } from '../registry/app'
5
6
  import { validateRegistry } from '../registry/schema'
7
+ import { appChecks } from './app-checks'
6
8
  import { appFiles, ASKED_OF_NPM, createApp, themes } from './create-app'
9
+ import { helpers, type GateHelpers } from './status'
7
10
 
8
11
  /**
9
12
  * `create-estiva-app` (UIG-10). What the app does when it runs is proved on a
@@ -59,7 +62,7 @@ describe('create-estiva-app', () => {
59
62
 
60
63
  it('starts the count at zero and keeps the CI job named gate', () => {
61
64
  const count = JSON.parse(files['.gates-count.json'])
62
- expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
65
+ 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 }])
63
66
  expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run lint:rules/)
64
67
  expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
65
68
  })
@@ -148,3 +151,55 @@ describe('create-estiva-app', () => {
148
151
  expect(() => createApp({ name: 'once', parent: scratch, versions })).toThrow(/already exists/)
149
152
  })
150
153
  })
154
+
155
+ /**
156
+ * The made app, checked the way gates:status checks Peek and Ship (audit B5).
157
+ * The tests above pin the starter's files one by one; they could not see that
158
+ * its required job `gate` skipped the token lint, which every app check since
159
+ * 0.30.0 asks for. This runs every check an app runs on a freshly made app, for
160
+ * real: the package is linked in as the app's install, and ESLint, TypeScript
161
+ * and the plugins resolve from this repository's own, as they do for the gate
162
+ * tests. Only what needs GitHub or a commit is left out.
163
+ */
164
+ describe('a made app, under the checks every app runs', () => {
165
+ it('passes every one on its first commit, but the tickets not built yet', async () => {
166
+ const dir = join(scratch, 'checked')
167
+ for (const [path, text] of Object.entries(files)) {
168
+ mkdirSync(dirname(join(dir, path)), { recursive: true })
169
+ writeFileSync(join(dir, path), text)
170
+ }
171
+ mkdirSync(join(dir, 'node_modules', '@estiva-app'), { recursive: true })
172
+ symlinkSync(process.cwd(), join(dir, 'node_modules', '@estiva-app', 'ui'), 'junction')
173
+ // The one plugin the app asks npm for that this repository does not use: the
174
+ // gate only needs its name known, so a stand-in with no rules does.
175
+ const hooks = join(dir, 'node_modules', 'eslint-plugin-react-hooks')
176
+ mkdirSync(hooks, { recursive: true })
177
+ writeFileSync(join(hooks, 'package.json'), JSON.stringify({ name: 'eslint-plugin-react-hooks', type: 'module', main: 'index.js' }))
178
+ writeFileSync(join(hooks, 'index.js'), 'export default { rules: {}, configs: { flat: { recommended: { rules: {} } } } }\n')
179
+ // Its first commit, as the ticket asks: the count file must be committed.
180
+ const git = (...a: string[]) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.autocrlf=false', ...a], { cwd: dir, stdio: 'pipe' })
181
+ git('init', '-q', '-b', 'main')
182
+ git('add', '-A')
183
+ git('commit', '-q', '-m', 'made by create-estiva-app')
184
+ const real = helpers(dir)
185
+ // "The installed package exports Link" reads its declarations, which CI's
186
+ // test step does not build (`pretest` bundles only): the same exports are read
187
+ // from their source when the declarations are not there.
188
+ const exportsFrom = (rel: string) => (rel.endsWith('dist/index.d.ts') && !real.exists(rel) ? join(process.cwd(), 'src', 'index.ts') : rel)
189
+ const h: GateHelpers = {
190
+ ...real,
191
+ contains: (rel, needle, label) => real.contains(exportsFrom(rel), needle, label),
192
+ protectedBranch: () => real.UNKNOWN('a rule on GitHub'),
193
+ gh: () => real.UNKNOWN('a question for GitHub'),
194
+ }
195
+ const failed: string[] = []
196
+ for (const ticket of appChecks(h, { page: 'src/pages/HomePage.tsx' })) {
197
+ for (const check of ticket.checks) {
198
+ const result = await check.run()
199
+ if (result.result === 'fail' || result.result === 'part') failed.push(`${ticket.ref} ${check.what}: ${result.detail}`)
200
+ }
201
+ }
202
+ // RichText is UIG-30's, not built yet: no app passes that one.
203
+ expect(failed.filter((f) => !f.startsWith('UIG-30 '))).toEqual([])
204
+ }, 300_000)
205
+ })
@@ -365,9 +365,10 @@ jobs:
365
365
  exit 1
366
366
  fi
367
367
 
368
- # The UI Guardrails' rules, on their own: a raw element, behaviour a part owns
369
- # written by hand, a part restyled. \`npm run lint:rules\` also writes
370
- # .gates-count.json and fails on an eslint-disable that switches a rule off.
368
+ # The UI Guardrails, the one check a merge requires: the package's gate rules
369
+ # (each refusal names what to use instead), the token contract, and the
370
+ # catalogue. \`npm run lint:rules\` also writes .gates-count.json and fails on
371
+ # an eslint-disable that switches a rule off.
371
372
  #
372
373
  # A job of its own, because GitHub can require only a whole job, by its name:
373
374
  # the rule on main requires \`gate\`. Renaming it leaves every pull request
@@ -383,6 +384,10 @@ jobs:
383
384
  - run: npm ci
384
385
  - name: Gate lint
385
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
386
391
  # Every part of the app says in one line what it is for, so the catalogue
387
392
  # (\`npm run ui:find\`) can offer it before someone builds it again.
388
393
  - name: Every part is described
@@ -1011,8 +1016,9 @@ UIG-6 as not done.
1011
1016
  ${title} is an Estiva app. Its parts, tokens and gates come from \`@estiva-app/ui\`.
1012
1017
 
1013
1018
  **Use the package's parts, never a raw element or a hand-built look.** The gate
1014
- refuses a raw control, behaviour a part owns written by hand, and a part restyled
1015
- through \`className\`, and names what to use instead. It runs before you write
1019
+ refuses code that reaches past the package, and each refusal names what to use
1020
+ instead. What it refuses is the package's to say, and grows with it: the
1021
+ \`estiva-ui\` skill lists it. It runs before you write
1016
1022
  (the hook in \`.claude/settings.json\`, for a session started in this folder), in
1017
1023
  \`npm run lint:rules\` and in CI. A session started elsewhere: run
1018
1024
  \`npm run lint:rules\` after changing \`src/\`, and fix what it reports. Keep something
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Until UIG-10 every repo wrote this itself: `eslint.gates.js` (the rules and
6
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
7
+ * in Peek, in Ship, and here pointed inward. The rules were always the
8
8
  * package's; these two files were copies. Now they are two functions.
9
9
  *
10
10
  * Where: every `.ts` and `.tsx` under `src`, stories included (a story is what
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'node:child_process'
1
2
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
3
  import { join } from 'node:path'
3
4
  import { pathToFileURL } from 'node:url'
@@ -6,7 +7,7 @@ import { afterAll, describe, expect, it } from 'vitest'
6
7
  import { writeGateCount } from './count'
7
8
  import { gateConfig, gateLint } from './gate-config'
8
9
  import { runHook } from './hook'
9
- import { helpers, runStatus } from './status'
10
+ import { behindMain, helpers, runStatus } from './status'
10
11
  import { tokenConfig, tokenLint, tokenValues } from './token-lint'
11
12
 
12
13
  /**
@@ -153,7 +154,7 @@ describe('writeGateCount', () => {
153
154
  const written = JSON.parse(readFileSync(join(dir, '.gates-count.json'), 'utf8'))
154
155
  expect(written).toMatchObject({ schemaVersion: 1, repo: 'probe' })
155
156
  expect(written.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 1 })
156
- expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title'])
157
+ expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look'])
157
158
 
158
159
  expect((await writeGateCount({ root: dir, repo: 'probe' })).changed).toBe(false)
159
160
 
@@ -233,10 +234,42 @@ describe('gates:status', () => {
233
234
  const h = helpers(dir)
234
235
  expect(h.ciJob('gate', 'lint:rules').result).toBe('pass')
235
236
  expect(h.ciJob('check', 'lint:rules').result).toBe('fail')
236
- expect(h.hook('.claude/settings.json', 'gates').result).toBe('pass')
237
237
  expect(h.script('package.json', 'gates:status').result).toBe('pass')
238
238
  })
239
239
 
240
+ // Audit A2: a hook is judged by running it, the way Claude Code does, on a raw
241
+ // <button>. Only exit 2 blocks a write; a hook that cannot run exits 1, and
242
+ // the write goes through.
243
+ it('runs the committed hook and passes only when it refuses the write', () => {
244
+ const settings = (matcher: string, command: string) => ({ '.claude/settings.json': JSON.stringify({ hooks: { PreToolUse: [{ matcher, hooks: [{ type: 'command', command }] }] } }) })
245
+ const refuses = 'node -e "process.exit(2)" -- gates'
246
+ expect(helpers(app('hook-refuses', settings('Edit|Write', refuses))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'pass' })
247
+ const broken = helpers(app('hook-broken', settings('Edit|Write', 'node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook'))).hook('.claude/settings.json', 'gates')
248
+ expect(broken).toMatchObject({ result: 'fail', detail: expect.stringContaining('let a raw <button> through (exit 1)') })
249
+ expect(helpers(app('hook-waves', settings('Edit|Write', 'node -e "process.exit(0)" -- gates'))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'fail' })
250
+ expect(helpers(app('hook-never', settings('Read', refuses))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'fail', detail: expect.stringContaining('never for a Write') })
251
+ // The hook is handed the real project folder, as Claude Code hands it.
252
+ const seen = 'node -e "process.exit(String(process.env.CLAUDE_PROJECT_DIR).endsWith(process.argv[1]) ? 2 : 1)" hook-dir gates'
253
+ expect(helpers(app('hook-dir', settings('Edit|Write', seen))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'pass' })
254
+ })
255
+
256
+ // Audit A4: a checkout seven commits behind reported a finished ticket as not started.
257
+ it('says how far a checkout is behind main, after fetching it', () => {
258
+ const base = app('behind', {})
259
+ const git = (cwd: string, ...a: string[]) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { cwd, stdio: 'pipe' }).toString().trim()
260
+ git(base, 'init', '-q', '--bare', '-b', 'main', 'origin.git')
261
+ git(base, 'clone', '-q', 'origin.git', 'writer')
262
+ const writer = join(base, 'writer')
263
+ git(writer, 'commit', '-q', '--allow-empty', '-m', 'one')
264
+ git(writer, 'push', '-q', 'origin', 'HEAD:main')
265
+ git(base, 'clone', '-q', 'origin.git', 'reader')
266
+ const reader = join(base, 'reader')
267
+ expect(behindMain(reader)).toBe('')
268
+ git(writer, 'commit', '-q', '--allow-empty', '-m', 'two')
269
+ git(writer, 'push', '-q', 'origin', 'HEAD:main')
270
+ expect(behindMain(reader)).toContain('1 commit behind origin/main')
271
+ })
272
+
240
273
  it('counts what loads into every session, imports included, and refuses a rule file with no paths (UIG-21)', () => {
241
274
  const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\n') + '\n'
242
275
  const scoped = '---\npaths:\n - "src/**"\n---\n\nA rule.\n'
package/src/gates/hook.ts CHANGED
@@ -44,7 +44,7 @@ import { repositoryOf } from '../registry/skill'
44
44
  export interface HookOptions {
45
45
  /** The repository's top folder. Defaults to `$CLAUDE_PROJECT_DIR`, then the tool call's `cwd`. */
46
46
  root?: string
47
- /** The folder that holds the app, relative to `root`: `web` in Ship, `.` elsewhere. */
47
+ /** The folder that holds the app, relative to `root`: `.` unless it sits in a folder of its own. */
48
48
  app?: string
49
49
  /** `package` gates `.tsx` only, as this package's inward lint does. */
50
50
  audience?: 'app' | 'package'
@@ -5,7 +5,7 @@
5
5
  * estiva-gates status one row per ticket
6
6
  * estiva-gates status --detail every check under every row
7
7
  * estiva-gates status --json machine output, read by estiva-ui's run
8
- * estiva-gates status --app web the app sits in `web/`, as Ship's does
8
+ * estiva-gates status --app web the app sits in `web/` (Ship's did, until PER-19)
9
9
  *
10
10
  * Every row is decided by checks on real files, a real lint run or a real
11
11
  * GitHub setting. Nothing here reads a list that someone ticks by hand.
@@ -19,13 +19,13 @@
19
19
  *
20
20
  * What differs per repo is `scripts/gates-checks.mjs`, beside the app: its
21
21
  * checks, or the checks every app runs (`appChecks`) plus its own. It sits with
22
- * the app because it imports the package — `web/scripts/` in Ship, whose app,
23
- * and whose install, are in `web/` (UIG-32).
22
+ * the app because it imports the package: in an app that sits in a folder of
23
+ * its own, the checks file sits there too (UIG-32).
24
24
  *
25
25
  * The rows and the reasons for each check are in estiva-ui docs/GATES.md §15
26
26
  * and §17.
27
27
  */
28
- import { execFileSync } from 'node:child_process'
28
+ import { execFileSync, spawnSync } from 'node:child_process'
29
29
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
30
30
  import { createRequire } from 'node:module'
31
31
  import { join, relative, resolve } from 'node:path'
@@ -36,7 +36,7 @@ export type CheckResult = { result: 'pass' | 'fail' | 'part' | 'unknown'; detail
36
36
  type Maybe<T> = T | Promise<T>
37
37
 
38
38
  export interface LintProbe {
39
- /** The folder ESLint runs from, relative to the repo: `web` in Ship. */
39
+ /** The folder ESLint runs from, relative to the repo: `.` unless the app sits in a folder of its own. */
40
40
  cwd?: string
41
41
  /** The config file, relative to `cwd`. */
42
42
  config: string
@@ -106,7 +106,7 @@ export interface GateSpec {
106
106
  tickets: GateTicket[]
107
107
  /** estiva-ui only: every ticket, and the repos it joins in. */
108
108
  all?: TicketListEntry[]
109
- /** estiva-ui only: the repos beside it. `app` is the folder its app sits in: `web` in Ship. */
109
+ /** estiva-ui only: the repos beside it. `app` is the folder its app sits in, when not the top. */
110
110
  siblings?: { name: string; path: string; env: string; app?: string }[]
111
111
  }
112
112
 
@@ -292,16 +292,39 @@ export function helpers(ROOT: string): GateHelpers {
292
292
 
293
293
  hook(settingsRel, needle) {
294
294
  if (!exists(settingsRel)) return FAIL(`${settingsRel} does not exist`)
295
- let s: { hooks?: { PreToolUse?: { hooks?: { command?: string }[] }[] } }
295
+ let s: { hooks?: { PreToolUse?: { matcher?: string; hooks?: { command?: string }[] }[] } }
296
296
  try {
297
297
  s = JSON.parse(read(settingsRel))
298
298
  } catch {
299
299
  return FAIL(`${settingsRel} is not valid JSON`)
300
300
  }
301
- const commands = (s.hooks?.PreToolUse ?? []).flatMap((m) => (m.hooks ?? []).map((x) => x.command ?? ''))
302
- const hit = commands.find((c) => c.includes(needle))
303
- if (hit) return PASS(`${settingsRel} has a PreToolUse hook running ${needle}`)
304
- return FAIL(commands.length ? `${settingsRel} has PreToolUse hooks, none running ${needle}` : `${settingsRel} has no PreToolUse hook`)
301
+ const entries = (s.hooks?.PreToolUse ?? []).flatMap((m) => (m.hooks ?? []).map((x) => ({ matcher: m.matcher ?? '', command: x.command ?? '' })))
302
+ const hit = entries.find((e) => e.command.includes(needle))
303
+ if (!hit) return FAIL(entries.length ? `${settingsRel} has PreToolUse hooks, none running ${needle}` : `${settingsRel} has no PreToolUse hook`)
304
+ // A hook that is written but never fires, or fires and cannot run, guards
305
+ // nothing — and Claude Code lets a write through on any exit but 2 (audit
306
+ // A2: Ship's hook pointed at an install its checkout did not have). So the
307
+ // hook is run, as Claude Code would run it, on a raw <button>.
308
+ let matches: boolean
309
+ try {
310
+ matches = hit.matcher === '' || hit.matcher === '*' || new RegExp(`^(?:${hit.matcher})$`).test('Write')
311
+ } catch {
312
+ return FAIL(`${settingsRel}: the hook's matcher "${hit.matcher}" is not a pattern`)
313
+ }
314
+ if (!matches) return FAIL(`${settingsRel}: the hook runs only for "${hit.matcher}", never for a Write`)
315
+ const top = ROOT.split('\\').join('/')
316
+ const app = /--app\s+("?)([^"\s]+)\1/.exec(hit.command)?.[2] ?? '.'
317
+ const input = JSON.stringify({
318
+ hook_event_name: 'PreToolUse',
319
+ cwd: top,
320
+ tool_name: 'Write',
321
+ tool_input: { file_path: `${resolve(ROOT, app, 'src', '__gates_hook_probe__.tsx').split('\\').join('/')}`, content: 'export function Probe() {\n return <button type="button">x</button>\n}\n' },
322
+ })
323
+ const command = hit.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, top)
324
+ const run = spawnSync(command, { cwd: ROOT, input, shell: true, encoding: 'utf8', timeout: 120_000, env: { ...process.env, CLAUDE_PROJECT_DIR: top } })
325
+ if (run.status === 2) return PASS(`${settingsRel}: the hook ran and refused a raw <button> (exit 2)`)
326
+ const why = `${run.stderr ?? ''}${run.stdout ?? ''}`.trim().split(/\r?\n/)[0] || (run.error ? String(run.error.message) : 'no output')
327
+ return FAIL(`${settingsRel}: the hook ran and let a raw <button> through (exit ${run.status ?? 'none'}): ${why}`)
305
328
  },
306
329
 
307
330
  json(rel, test = () => true, label) {
@@ -418,8 +441,8 @@ function statusOf(checks: CheckResult[]) {
418
441
 
419
442
  async function runRepo(ROOT: string, app: string, h: GateHelpers): Promise<{ spec: GateSpec; report: Report }> {
420
443
  // The checks file sits with the app, because it imports `appChecks` from the
421
- // package: in Ship that install is `web/node_modules`, not the repo's top
422
- // folder (UIG-32). Everything it names is still read from the repo's top.
444
+ // package: for an app in a folder of its own, that install is there, not in
445
+ // the repo's top folder (UIG-32). Everything it names is still read from the top.
423
446
  const checksFile = join(ROOT, app, 'scripts', 'gates-checks.mjs')
424
447
  const define = ((await import(pathToFileURL(checksFile).href)) as { default: (h: GateHelpers) => GateSpec }).default
425
448
  const spec = define(h)
@@ -452,7 +475,7 @@ async function runRepo(ROOT: string, app: string, h: GateHelpers): Promise<{ spe
452
475
  * Another repository's report. A repo on the package's engine runs it from its
453
476
  * own install; a repo still carrying `scripts/gates-status.mjs` runs that copy.
454
477
  *
455
- * `app` is the folder that repo's app sits in — `web` in Ship — where its
478
+ * `app` is the folder that repo's app sits in, when not the top, where its
456
479
  * install and its checks file are. The report is still of the whole repo.
457
480
  */
458
481
  function runSibling(dir: string, app: string, shown: string): { report?: Report; error?: string } {
@@ -479,6 +502,28 @@ function runSibling(dir: string, app: string, shown: string): { report?: Report;
479
502
  }
480
503
  }
481
504
 
505
+ /**
506
+ * How far a checkout is behind its remote's main, after fetching it. Status reads
507
+ * the checkout, not main: a copy seven commits behind once reported a finished
508
+ * ticket as not started (audit A4). Empty when it is level; a warning otherwise.
509
+ */
510
+ export function behindMain(dir: string): string {
511
+ const git = (...a: string[]) => {
512
+ try {
513
+ return execFileSync('git', a, { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'], timeout: 30000 }).toString().trim()
514
+ } catch {
515
+ return null
516
+ }
517
+ }
518
+ if (git('rev-parse', '--is-inside-work-tree') !== 'true') return ''
519
+ const fetched = git('fetch', '-q', 'origin') !== null
520
+ const main = (git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD') ?? 'origin/main').trim()
521
+ const count = Number(git('rev-list', '--count', `HEAD..${main}`))
522
+ const stale = fetched ? '' : ' (could not fetch: compared with the last fetch)'
523
+ if (!Number.isFinite(count) || count === 0) return stale ? ` ·${stale}` : ''
524
+ return ` · ⚠️ ${count} commit${count === 1 ? '' : 's'} behind ${main}${stale}: pull, or this reads old code`
525
+ }
526
+
482
527
  function pad(s: string, n: number) {
483
528
  const len = [...s].length
484
529
  return len >= n ? s : s + ' '.repeat(n - len)
@@ -509,7 +554,7 @@ function summary(rows: Row[]) {
509
554
  export interface StatusOptions {
510
555
  /** The repository to report on. */
511
556
  root?: string
512
- /** The folder that holds the app, relative to `root`: `web` in Ship, `.` elsewhere. */
557
+ /** The folder that holds the app, relative to `root`: `.` unless it sits in a folder of its own. */
513
558
  app?: string
514
559
  json?: boolean
515
560
  detail?: boolean
@@ -526,7 +571,7 @@ export async function runStatus({ root = process.cwd(), app = '.', json = false,
526
571
 
527
572
  const out: string[] = []
528
573
  out.push('UI Guardrails · gates:status', 'Read from the code. Nothing here is a hand-ticked list.', '')
529
- out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}`)
574
+ out.push(`${pad(spec.repo, 10)} ${report.branch} @ ${report.commit}${behindMain(ROOT)}`)
530
575
 
531
576
  if (!spec.all) {
532
577
  // A sibling repo on its own: its own rows, then its parts of rows owned elsewhere.
@@ -553,7 +598,7 @@ export async function runStatus({ root = process.cwd(), app = '.', json = false,
553
598
  continue
554
599
  }
555
600
  const copy = r.report.engine !== ENGINE ? (r.report.engine.startsWith('@estiva-app/ui@') ? ` · on ${r.report.engine}'s engine` : ' · runs its own copy of the status engine; UIG-32 moves it onto the package') : ''
556
- found.push({ name: s.name, note: `${r.report.branch} @ ${r.report.commit}, found at ${shown(dir)}${copy}` })
601
+ found.push({ name: s.name, note: `${r.report.branch} @ ${r.report.commit}, found at ${shown(dir)}${copy}${behindMain(dir)}` })
557
602
  reports.push(r.report)
558
603
  }
559
604
  for (const f of found) out.push(`${pad(f.name, 10)} ${f.note}`)