@estiva-app/ui 0.35.0 → 0.35.2

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 (42) hide show
  1. package/dist/gates/{chunk-JWOU4UQZ.js → chunk-DBMUQAR6.js} +6 -5
  2. package/dist/gates/chunk-DBMUQAR6.js.map +7 -0
  3. package/dist/gates/{chunk-D466XXVL.js → chunk-DRIHHXQK.js} +5 -1
  4. package/dist/gates/{chunk-D466XXVL.js.map → chunk-DRIHHXQK.js.map} +2 -2
  5. package/dist/gates/cli.js +1 -1
  6. package/dist/gates/create-app.d.ts.map +1 -1
  7. package/dist/gates/create-app.js +1 -1
  8. package/dist/gates/gate-config.d.ts.map +1 -1
  9. package/dist/gates/hook.d.ts.map +1 -1
  10. package/dist/gates/index.js +6 -4
  11. package/dist/gates/index.js.map +2 -2
  12. package/dist/gates/status.d.ts.map +1 -1
  13. package/dist/registry/{chunk-KMZIMB3G.js → chunk-5K2TRR4X.js} +30 -2
  14. package/dist/registry/chunk-5K2TRR4X.js.map +7 -0
  15. package/dist/registry/cli.js +89 -31
  16. package/dist/registry/cli.js.map +3 -3
  17. package/dist/registry/contract.d.ts +14 -0
  18. package/dist/registry/contract.d.ts.map +1 -1
  19. package/dist/registry/index.js +1 -1
  20. package/dist/registry/siblings.d.ts +9 -0
  21. package/dist/registry/siblings.d.ts.map +1 -1
  22. package/dist/registry/storymap.d.ts +19 -5
  23. package/dist/registry/storymap.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/registry.json +1 -1
  26. package/src/gates/create-app.test.ts +8 -0
  27. package/src/gates/create-app.ts +4 -0
  28. package/src/gates/gate-config.ts +4 -2
  29. package/src/gates/gates.test.ts +12 -2
  30. package/src/gates/hook.ts +6 -2
  31. package/src/gates/status.ts +4 -2
  32. package/src/pages.test.ts +11 -0
  33. package/src/registry/cli.ts +34 -17
  34. package/src/registry/contract.test.ts +37 -2
  35. package/src/registry/contract.ts +49 -1
  36. package/src/registry/siblings.test.ts +24 -1
  37. package/src/registry/siblings.ts +23 -0
  38. package/src/registry/storymap.test.ts +52 -31
  39. package/src/registry/storymap.ts +76 -37
  40. package/stories/Choosing.mdx +8 -2
  41. package/dist/gates/chunk-JWOU4UQZ.js.map +0 -7
  42. package/dist/registry/chunk-KMZIMB3G.js.map +0 -7
@@ -60,6 +60,14 @@ describe('create-estiva-app', () => {
60
60
  expect(files['.storybook/preview.tsx']).toContain('document.documentElement.dataset.theme = "dark"')
61
61
  })
62
62
 
63
+ // The re-review after the audit before UIG-26: the catalogue links every story file's part
64
+ // to its Docs page, and without autodocs there is none, so `registry:check` failed the
65
+ // made app's required job on its first commit ("pages-home--docs" leads nowhere). Proved
66
+ // with the real CLI on a made app: with the tag, "all 2 open"; without it, that failure.
67
+ it('gives every story file a Docs page, so the catalogue links it and the gate passes', () => {
68
+ expect(files['.storybook/preview.tsx']).toMatch(/tags: \['autodocs'\]/)
69
+ })
70
+
63
71
  it('starts the count at zero and keeps the CI job named gate', () => {
64
72
  const count = JSON.parse(files['.gates-count.json'])
65
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 }])
@@ -929,6 +929,10 @@ import '../src/index.css'
929
929
  document.documentElement.dataset.theme = ${JSON.stringify(theme)}
930
930
 
931
931
  const preview: Preview = {
932
+ // Every story file gets a Docs page, as in Peek and Ship: the catalogue links each
933
+ // part to it, and \`npm run registry:check\` in CI's job \`gate\` fails a link that
934
+ // leads nowhere.
935
+ tags: ['autodocs'],
932
936
  parameters: { layout: 'centered' },
933
937
  }
934
938
 
@@ -44,8 +44,10 @@ export interface GateConfigOptions {
44
44
  export function gateLint({ audience = 'app' }: Pick<GateConfigOptions, 'audience'> = {}): Linter.Config {
45
45
  return {
46
46
  ...(audience === 'package' ? estiva.configs.package : estiva.configs.recommended),
47
- files: audience === 'package' ? ['src/**/*.tsx'] : ['src/**/*.{ts,tsx}'],
48
- ignores: ['**/*.test.ts', '**/*.test.tsx'],
47
+ // An app's every script type: a raw <button> in a .jsx, or a key listener in a .mts,
48
+ // passed while only .ts and .tsx were read (the re-review after the audit before UIG-26).
49
+ files: audience === 'package' ? ['src/**/*.tsx'] : ['src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'],
50
+ ignores: ['**/*.test.*'],
49
51
  languageOptions: {
50
52
  parser: typescriptParser,
51
53
  parserOptions: { ecmaFeatures: { jsx: true } },
@@ -182,6 +182,13 @@ describe('runHook', () => {
182
182
  expect((await runHook({ root: dir, input: fine })).code).toBe(0)
183
183
  })
184
184
 
185
+ // The re-review after the audit before UIG-26: only .ts and .tsx were read, so these passed.
186
+ it('reads every script type under src: a raw button in a .jsx, a page listener in a .mts', async () => {
187
+ expect((await runHook({ root: dir, input: write('src/Probe.jsx', component('<button type="button">x</button>')) })).code).toBe(2)
188
+ expect((await runHook({ root: dir, input: write('src/lib/probe.mts', "export function listen() {\n document.addEventListener('keydown', () => {})\n}\n") })).code).toBe(2)
189
+ expect((await runHook({ root: dir, input: write('src/Probe.test.jsx', component('<button type="button">x</button>')) })).code).toBe(0)
190
+ })
191
+
185
192
  it('lets through a test, a declaration file, and anything outside src', async () => {
186
193
  for (const path of ['src/Probe.test.tsx', 'src/types.d.ts', 'scripts/probe.tsx']) {
187
194
  expect((await runHook({ root: dir, input: write(path, component('<button type="button">x</button>')) })).code).toBe(0)
@@ -242,14 +249,17 @@ describe('gates:status', () => {
242
249
  // the write goes through.
243
250
  it('runs the committed hook and passes only when it refuses the write', () => {
244
251
  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'
252
+ const refuses = 'node -e "console.error(String.fromCharCode(60) + \'button\' + String.fromCharCode(62)); process.exit(2)" -- gates'
246
253
  expect(helpers(app('hook-refuses', settings('Edit|Write', refuses))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'pass' })
247
254
  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
255
  expect(broken).toMatchObject({ result: 'fail', detail: expect.stringContaining('let a raw <button> through (exit 1)') })
249
256
  expect(helpers(app('hook-waves', settings('Edit|Write', 'node -e "process.exit(0)" -- gates'))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'fail' })
250
257
  expect(helpers(app('hook-never', settings('Read', refuses))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'fail', detail: expect.stringContaining('never for a Write') })
251
258
  // 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'
259
+ const seen = 'node -e "console.error(String.fromCharCode(60) + \'button\' + String.fromCharCode(62)); process.exit(String(process.env.CLAUDE_PROJECT_DIR).endsWith(process.argv[1]) ? 2 : 1)" hook-dir gates'
260
+ // Exit 2 for another reason is not a refusal of the button (in the package a new file with no page is refused too).
261
+ const other = 'node -e "console.error(String.fromCharCode(110) + \'o page\'); process.exit(2)" -- gates'
262
+ expect(helpers(app('hook-other', settings('Edit|Write', other))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'fail' })
253
263
  expect(helpers(app('hook-dir', settings('Edit|Write', seen))).hook('.claude/settings.json', 'gates')).toMatchObject({ result: 'pass' })
254
264
  })
255
265
 
package/src/gates/hook.ts CHANGED
@@ -69,6 +69,8 @@ interface ToolCall {
69
69
  }
70
70
 
71
71
  const PASS: HookResult = { code: 0, message: '' }
72
+ /** An app's source the gate reads: every script type, with or without JSX. */
73
+ const APP_SOURCE = /^src\/.+\.[cm]?[jt]sx?$/
72
74
 
73
75
  export async function runHook({ root, app = '.', audience = 'app', input, seen }: HookOptions = {}): Promise<HookResult> {
74
76
  let call: ToolCall
@@ -84,8 +86,10 @@ export async function runHook({ root, app = '.', audience = 'app', input, seen }
84
86
  const appDir = resolve(top, app)
85
87
  const file = isAbsolute(args.file_path) ? args.file_path : resolve(call.cwd ?? top, args.file_path)
86
88
  const rel = relative(appDir, file).split(sep).join('/')
87
- const source = audience === 'package' ? /^src\/.+\.tsx$/ : /^src\/.+\.tsx?$/
88
- if (rel.startsWith('../') || isAbsolute(rel) || !source.test(rel) || /\.test\.tsx?$/.test(rel) || /\.d\.ts$/.test(rel)) return PASS
89
+ // Every script under an app's src/, as the gate reads it (a raw <button> in a .jsx passed
90
+ // before, the re-review after the audit before UIG-26); the package's own gate is .tsx only.
91
+ const source = audience === 'package' ? /^src\/.+\.tsx$/ : APP_SOURCE
92
+ if (rel.startsWith('../') || isAbsolute(rel) || !source.test(rel) || /\.test\.[cm]?[jt]sx?$/.test(rel) || /\.d\.[cm]?ts$/.test(rel)) return PASS
89
93
 
90
94
  const text = proposed(call.tool_name, args, file)
91
95
  if (text === null) return PASS
@@ -318,11 +318,13 @@ export function helpers(ROOT: string): GateHelpers {
318
318
  hook_event_name: 'PreToolUse',
319
319
  cwd: top,
320
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' },
321
+ tool_input: { file_path: `${resolve(ROOT, app, 'src', '__gates_hook_probe__.tsx').split('\\').join('/')}`, content: 'export function Probe() {\n return <div><button type="button">x</button></div>\n}\n' },
322
322
  })
323
323
  const command = hit.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, top)
324
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)`)
325
+ // Refused for the button, not for something else about a new file (in the package a new
326
+ // file with no page is refused too): nested, so the package's inward rule reads it as well.
327
+ if (run.status === 2 && /<button>/.test(`${run.stderr ?? ''}${run.stdout ?? ''}`)) return PASS(`${settingsRel}: the hook ran and refused a raw <button> (exit 2)`)
326
328
  const why = `${run.stderr ?? ''}${run.stdout ?? ''}`.trim().split(/\r?\n/)[0] || (run.error ? String(run.error.message) : 'no output')
327
329
  return FAIL(`${settingsRel}: the hook ran and let a raw <button> through (exit ${run.status ?? 'none'}): ${why}`)
328
330
  },
package/src/pages.test.ts CHANGED
@@ -140,3 +140,14 @@ describe('the page contract', () => {
140
140
  })
141
141
  }
142
142
  })
143
+
144
+ // The re-review after the audit before UIG-26 (R17): "Choosing a component" is written by
145
+ // hand, and six parts with pages of their own had no row — ContainerHeader and ListColumn
146
+ // among them, the parts behind the skill's first two Gotchas.
147
+ describe('Choosing a component', () => {
148
+ it('names every part that has a page of its own', () => {
149
+ const choosing = readFileSync(new URL('../stories/Choosing.mdx', dir), 'utf8')
150
+ const named = new Set([...choosing.matchAll(/\*\*([A-Z][A-Za-z]+)\*\*/g)].map((m) => m[1]))
151
+ expect(pages.filter((page) => !named.has(page))).toEqual([])
152
+ })
153
+ })
@@ -36,11 +36,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
36
36
  import { tmpdir } from 'node:os'
37
37
  import { basename, dirname, join, relative, resolve } from 'node:path'
38
38
  import { fileURLToPath } from 'node:url'
39
- import { CONTRACT_KINDS, contractProblems, linkProblems } from './contract'
39
+ import { CONTRACT_KINDS, contractProblems, linkProblems, nameProblems } from './contract'
40
40
  import { findInRegistries, formatFindings } from './find'
41
- import { findSiblings, workspaceOf } from './siblings'
41
+ import { behindBy, findSiblings, workspaceOf } from './siblings'
42
42
  import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
43
- import { storyMapProblems } from './storymap'
43
+ import { indexHeadings, storyMapProblems } from './storymap'
44
44
  import { CLASSES, validateRegistry, type Registry } from './schema'
45
45
 
46
46
  /**
@@ -132,11 +132,11 @@ async function buildApp(folder: string, repo: string | undefined): Promise<Regis
132
132
  * Reports, and says whether it passed. A folder with no Storybook has nothing
133
133
  * to check.
134
134
  */
135
- function checkLinks(registry: Registry, folder: string): boolean {
135
+ function checkLinks(registry: Registry, folder: string): { ok: boolean; headings?: Set<string>; titles?: Set<string> } {
136
136
  const bin = resolve(folder, 'node_modules', '.bin', process.platform === 'win32' ? 'storybook.cmd' : 'storybook')
137
137
  if (!existsSync(resolve(folder, '.storybook')) || !existsSync(bin)) {
138
138
  process.stdout.write('story links: no Storybook here, nothing to check\n')
139
- return true
139
+ return { ok: true }
140
140
  }
141
141
  const out = join(mkdtempSync(join(tmpdir(), 'estiva-ui-index-')), 'index.json')
142
142
  // A `.cmd` needs a shell on Windows, and Node wants that as one quoted line
@@ -147,17 +147,20 @@ function checkLinks(registry: Registry, folder: string): boolean {
147
147
  : spawnSync(bin, ['index', '-o', out, '--quiet'], { cwd: folder, encoding: 'utf8' })
148
148
  if (run.status !== 0 || !existsSync(out)) {
149
149
  process.stderr.write(`story links: Storybook could not index its stories, so no link can be trusted:\n${(run.stderr || run.stdout || '').trim()}\n`)
150
- return false
150
+ return { ok: false }
151
151
  }
152
- const ids = new Set(Object.keys((JSON.parse(readFileSync(out, 'utf8')) as { entries: Record<string, unknown> }).entries))
152
+ const entries = (JSON.parse(readFileSync(out, 'utf8')) as { entries: Record<string, { title?: string }> }).entries
153
+ const ids = new Set(Object.keys(entries))
154
+ const headings = indexHeadings(entries)
155
+ const titles = new Set(Object.values(entries).flatMap((e) => (e.title ? [e.title] : [])))
153
156
  const dead = linkProblems(registry, ids)
154
157
  const count = registry.entries.reduce((n, entry) => n + (entry.storyId ? 1 : 0) + (entry.docsId ? 1 : 0), 0)
155
158
  if (dead.length) {
156
159
  process.stderr.write(`story links: ${dead.length} of ${count} lead nowhere:\n ${dead.join('\n ')}\n`)
157
- return false
160
+ return { ok: false, headings, titles }
158
161
  }
159
162
  process.stdout.write(`story links: all ${count} open a story or docs page Storybook has\n`)
160
- return true
163
+ return { ok: true, headings, titles }
161
164
  }
162
165
 
163
166
  /**
@@ -179,12 +182,12 @@ async function checkGated(registry: Registry, folder: string, kinds?: string[]):
179
182
  }
180
183
 
181
184
  /**
182
- * The app's hand-written map of its Storybook — the heading order and the
183
- * Introduction — against the Storybook and the catalogue (`./storymap`).
184
- * Reports, and says whether it passed.
185
+ * The repository's hand-written map of its Storybook — the heading order and the
186
+ * Introduction — against the Storybook (its own index, when there is one) and,
187
+ * in an app, the catalogue (`./storymap`). Reports, and says whether it passed.
185
188
  */
186
- function checkStoryMap(registry: Registry, folder: string): boolean {
187
- const problems = storyMapProblems(registry, folder)
189
+ function checkStoryMap(folder: string, registry?: Registry, headings?: Set<string>): boolean {
190
+ const problems = storyMapProblems(folder, { ...(registry ? { registry } : {}), ...(headings ? { real: headings } : {}) })
188
191
  if (problems.length) {
189
192
  process.stderr.write(`storybook map: ${problems.length === 1 ? 'one line no longer matches' : `${problems.length} lines no longer match`}:\n ${problems.join('\n ')}\n`)
190
193
  return false
@@ -212,6 +215,8 @@ async function main(): Promise<number> {
212
215
  const registries: Registry[] = [readPackageRegistry()]
213
216
  // An app that cannot be read is said out loud, and the rest still answer.
214
217
  const add = async (folder: string, repo: string | undefined) => {
218
+ const behind = behindBy(folder)
219
+ if (behind) process.stderr.write(`note: ${repo ?? basename(folder)} (${folder}) is ${behind} commit${behind === 1 ? '' : 's'} behind main as last fetched — its answers may name parts that are gone; pull it\n`)
215
220
  try {
216
221
  registries.push(await buildApp(folder, repo))
217
222
  } catch (error) {
@@ -283,8 +288,18 @@ async function main(): Promise<number> {
283
288
  const linked = checkLinks(registry, root)
284
289
  const shown = await checkGated(registry, root)
285
290
  const loaded = checkLoader()
286
- const mapped = checkStoryMap(registry, root)
287
- return broken.length || !linked || !shown || !loaded || !mapped ? 1 : 0
291
+ const mapped = checkStoryMap(root, registry, linked.headings)
292
+ // What the pages name must still be there (R14): parts under When and When not,
293
+ // and the stories a Seen in line sends a reader to, here or in the package.
294
+ const pkg = readPackageRegistry()
295
+ const stale = nameProblems(registry, root, {
296
+ known: new Set([...pkg.entries, ...registry.entries].map((e) => e.name)),
297
+ ...(linked.titles ? { titles: linked.titles } : {}),
298
+ packageIds: pkg.entries.flatMap((e) => [e.storyId, e.docsId].filter((id): id is string => Boolean(id))),
299
+ })
300
+ if (stale.length) process.stderr.write(`usage pages: ${stale.length === 1 ? 'one name leads' : `${stale.length} names lead`} nowhere:\n ${stale.join('\n ')}\n`)
301
+ else process.stdout.write('usage pages: every part and story they name is there\n')
302
+ return broken.length || stale.length || !linked.ok || !shown || !loaded || !mapped ? 1 : 0
288
303
  }
289
304
  const { buildRegistry, serializeRegistry } = await packageBuilder()
290
305
  const path = packageRegistryPath()
@@ -308,7 +323,9 @@ async function main(): Promise<number> {
308
323
  const linked = checkLinks(built, root)
309
324
  const shown = await checkGated(built, root, ['component'])
310
325
  const loaded = checkLoader()
311
- return linked && shown && loaded ? 0 : 1
326
+ // The package keeps a heading order too (R16): it left out Layout, and nothing saw.
327
+ const mapped = checkStoryMap(root, undefined, linked.headings)
328
+ return linked.ok && shown && loaded && mapped ? 0 : 1
312
329
  }
313
330
 
314
331
  case 'skill': {
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os'
18
18
  import { dirname, join } from 'node:path'
19
19
  import { afterAll, describe, expect, it } from 'vitest'
20
20
  import { buildAppRegistry } from './app'
21
- import { contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
21
+ import { contractProblems, hasSeenIn, linkProblems, nameProblems, pageProblem } from './contract'
22
22
 
23
23
  const made: string[] = []
24
24
  afterAll(() => {
@@ -86,8 +86,11 @@ describe('a usage page', () => {
86
86
  expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
87
87
  })
88
88
 
89
- it('fails with no code under How', () => {
89
+ // R18: the code must be under How itself; one in a later section passed.
90
+ it('fails with no code under How, even with code further down', () => {
90
91
  expect(pageProblem(PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.'))).toBe('no code under "How"')
92
+ const later = PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.').replace('Nothing. It only draws.', '```tsx\n<Badge count={3} />\n```')
93
+ expect(pageProblem(later)).toBe('no code under "How"')
91
94
  })
92
95
 
93
96
  it('reads a Windows checkout', () => {
@@ -156,3 +159,35 @@ describe('story links', () => {
156
159
  ])
157
160
  })
158
161
  })
162
+
163
+ // R14, the re-review after the audit before UIG-26. Every case is Peek's or Ship's own:
164
+ // StartTopicDialog sent readers to CreateTopicDialog and RepliesRow to HuddleCard, both
165
+ // deleted by FOL-23; EmptyState's Seen in named a story on two lines; Ship's Attachment
166
+ // named the package's story by a title it had lost.
167
+ describe('nameProblems', () => {
168
+ const page = (whenNot: string, seenIn = '') =>
169
+ PAGE.replace('A word.', whenNot).replace('A small count beside a name.', `A small count beside a name.${seenIn ? `\n\n${seenIn}` : ''}`)
170
+ const registry = (dir: string) => ({ entries: [{ name: 'Badge', docPage: 'src/Badge.mdx' }] }) as unknown as Parameters<typeof nameProblems>[0]
171
+ const known = new Set(['Badge', 'Chip'])
172
+
173
+ it('names a part under When not that no catalogue has, and passes one that exists', () => {
174
+ const dir = app({ 'src/Badge.mdx': page('A topic someone is invited to: that is **CreateTopicDialog**; a label is `Chip`.') })
175
+ expect(nameProblems(registry(dir), dir, { known })).toEqual(['src/Badge.mdx: "When not" names CreateTopicDialog, which is no part of the package or this app'])
176
+ })
177
+
178
+ it('reads a Seen in line against the Storybook, a title wrapped across lines too, and the package ids', () => {
179
+ const seen = '**Seen in** — *Parts/Badge*, *To be removed/\nTopicActivity* and\n*Media/AttachmentCard*.'
180
+ const dir = app({ 'src/Badge.mdx': page('A word.', seen) })
181
+ const titles = new Set(['Parts/Badge'])
182
+ expect(nameProblems(registry(dir), dir, { known, titles, packageIds: ['components-attachmentcard--document'] })).toEqual([
183
+ 'src/Badge.mdx: "Seen in" names To be removed/TopicActivity, which no Storybook has',
184
+ 'src/Badge.mdx: "Seen in" names Media/AttachmentCard, which no Storybook has',
185
+ ])
186
+ expect(nameProblems(registry(dir), dir, { known, titles: new Set(['Parts/Badge', 'To be removed/TopicActivity']), packageIds: ['media-attachmentcard--default'] })).toEqual([])
187
+ })
188
+
189
+ it('leaves the Seen in line alone when there is no Storybook index to read', () => {
190
+ const dir = app({ 'src/Badge.mdx': page('A word.', '**Seen in** — *Gone/Story*.') })
191
+ expect(nameProblems(registry(dir), dir, { known })).toEqual([])
192
+ })
193
+ })
@@ -51,7 +51,10 @@ export function pageProblem(source: string): string | null {
51
51
  .map((s) => s.trim())
52
52
  .find((s) => s && !s.startsWith('<') && !s.startsWith('import '))
53
53
  if (!opening) return 'no opening line under the title saying what the part is'
54
- if (!/^##\s+How\s*\n[\s\S]*?```tsx?\n/m.test(text)) return 'no code under "How"'
54
+ // Only the How section: the code block must sit under How, not in a later section
55
+ // (a page with none under How and one under What it owns passed, R18).
56
+ const how = text.split(/^##\s+How\s*$/m)[1]?.split(/^##\s/m)[0] ?? ''
57
+ if (!/```tsx?\n/.test(how)) return 'no code under "How"'
55
58
  return null
56
59
  }
57
60
 
@@ -106,3 +109,48 @@ export function contractProblems(registry: Registry, root: string): string[] {
106
109
  }
107
110
  return problems
108
111
  }
112
+
113
+ /** Storybook's id for a title: lower case, every run of other characters one hyphen. */
114
+ const toStoryId = (title: string) =>
115
+ title
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9]+/g, '-')
118
+ .replace(/^-+|-+$/g, '')
119
+
120
+ /** The names a page's section writes as a part: bold or in backticks, starting with a capital. */
121
+ function namedParts(section: string): string[] {
122
+ return [...section.matchAll(/\*\*([A-Z][A-Za-z0-9]+)\*\*|`([A-Z][A-Za-z0-9]+)`/g)].map((m) => m[1] ?? m[2])
123
+ }
124
+
125
+ /**
126
+ * What an app's usage pages name that is not there (the re-review after the audit
127
+ * before UIG-26, R14): a part named under When or When not that no catalogue has —
128
+ * Peek's StartTopicDialog sent readers to CreateTopicDialog, deleted by FOL-23 —
129
+ * and a story a **Seen in** line names that no Storybook has. `known` is every part
130
+ * of the package and the app; `titles` is the app's Storybook titles, when there
131
+ * is an index to read them from; `packageIds` the package's story and docs ids, so
132
+ * a page may send readers to the package's Storybook too.
133
+ */
134
+ export function nameProblems(registry: Registry, root: string, { known, titles, packageIds = [] }: { known: Set<string>; titles?: Set<string>; packageIds?: string[] }): string[] {
135
+ const problems: string[] = []
136
+ for (const entry of registry.entries) {
137
+ if (!entry.docPage || !existsSync(join(root, entry.docPage))) continue
138
+ const text = readFileSync(join(root, entry.docPage), 'utf8').replace(/\r\n/g, '\n')
139
+ for (const heading of ['When', 'When not']) {
140
+ const section = text.split(new RegExp(`^##\\s+${heading}\\s*$`, 'm'))[1]?.split(/^##\s/m)[0] ?? ''
141
+ for (const name of new Set(namedParts(section).filter((n) => !known.has(n)))) {
142
+ problems.push(`${entry.docPage}: "${heading}" names ${name}, which is no part of the package or this app`)
143
+ }
144
+ }
145
+ if (!titles) continue
146
+ const seenIn = text.match(/^\*\*Seen in\*\*[^\n]*(?:\n(?!\n)[^\n]*)*/m)?.[0] ?? ''
147
+ for (const [, raw] of seenIn.matchAll(/\*([A-Z][^*/]*\/[^*]+)\*/g)) {
148
+ // A title may wrap across lines, and a line may break after its slash.
149
+ const title = raw.replace(/\s+/g, ' ').replace(/\s*\/\s*/g, '/').trim()
150
+ const id = toStoryId(title)
151
+ if (titles.has(title) || packageIds.some((p) => p.startsWith(`${id}--`))) continue
152
+ problems.push(`${entry.docPage}: "Seen in" names ${title}, which no Storybook has`)
153
+ }
154
+ }
155
+ return problems
156
+ }
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
11
11
  import { dirname, join } from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
13
  import { afterAll, describe, expect, it } from 'vitest'
14
- import { appName, findSiblings, workspaceOf } from './siblings'
14
+ import { appName, behindBy, findSiblings, workspaceOf } from './siblings'
15
15
 
16
16
  const workspace = mkdtempSync(join(tmpdir(), 'uig20-'))
17
17
  afterAll(() => rmSync(workspace, { recursive: true, force: true }))
@@ -83,3 +83,26 @@ describe.skipIf(!existsSync(cli))('estiva-ui find, with nothing else typed', ()
83
83
  expect(find(join(workspace, 'alpha'), 'workbench', '--here')).not.toContain('Workbench')
84
84
  })
85
85
  })
86
+
87
+ // The re-review after the audit before UIG-26: a search from one repository read a
88
+ // neighbour's checkout eleven commits behind, and answered with a part it had deleted.
89
+ describe('behindBy', () => {
90
+ it('says how far a checkout is behind its main as last fetched, and null for a folder that is none', () => {
91
+ const base = mkdtempSync(join(tmpdir(), 'behind-'))
92
+ const git = (cwd: string, ...a: string[]) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { cwd, stdio: 'pipe' })
93
+ git(base, 'init', '-q', '--bare', '-b', 'main', 'origin.git')
94
+ git(base, 'clone', '-q', 'origin.git', 'writer')
95
+ git(join(base, 'writer'), 'commit', '-q', '--allow-empty', '-m', 'one')
96
+ git(join(base, 'writer'), 'push', '-q', 'origin', 'HEAD:main')
97
+ git(base, 'clone', '-q', 'origin.git', 'reader')
98
+ const reader = join(base, 'reader')
99
+ expect(behindBy(reader)).toBe(0)
100
+ git(join(base, 'writer'), 'commit', '-q', '--allow-empty', '-m', 'two')
101
+ git(join(base, 'writer'), 'push', '-q', 'origin', 'HEAD:main')
102
+ expect(behindBy(reader)).toBe(0)
103
+ git(reader, 'fetch', '-q')
104
+ expect(behindBy(reader)).toBe(1)
105
+ expect(behindBy(tmpdir())).toBeNull()
106
+ rmSync(base, { recursive: true, force: true })
107
+ })
108
+ })
@@ -16,6 +16,7 @@
16
16
  * somebody's copy) carries the same package name; the app the search runs in
17
17
  * wins, then the one whose top folder has the shortest name.
18
18
  */
19
+ import { execFileSync } from 'node:child_process'
19
20
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
20
21
  import { basename, dirname, join, resolve } from 'node:path'
21
22
 
@@ -94,3 +95,25 @@ export function findSiblings(workspace: string, { skip = [] }: { skip?: string[]
94
95
  }
95
96
  return [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name))
96
97
  }
98
+
99
+ /**
100
+ * How many commits the checkout that holds `folder` is behind its remote's main,
101
+ * as last fetched: nothing is fetched, so a search stays quick. Null when it is
102
+ * not a git checkout or has no remote main. A search that reads a stale copy
103
+ * answers with parts that are gone (the re-review after the audit before UIG-26:
104
+ * "files panel" found Peek's deleted FilesPanel in an old checkout), so `find`
105
+ * says so beside its answer.
106
+ */
107
+ export function behindBy(folder: string): number | null {
108
+ const git = (...a: string[]) => {
109
+ try {
110
+ return execFileSync('git', ['-C', folder, ...a], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).toString().trim()
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+ const main = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD') ?? (git('rev-parse', '--verify', '--quiet', 'origin/main') ? 'origin/main' : null)
116
+ if (!main) return null
117
+ const count = Number(git('rev-list', '--count', `HEAD..${main}`))
118
+ return Number.isFinite(count) ? count : null
119
+ }
@@ -1,16 +1,18 @@
1
1
  /// <reference types="node" />
2
2
  /**
3
- * The app's hand-written map of its Storybook (audit A3). Every case is Peek's:
4
- * the Huddles heading its Introduction and `HEADINGS` kept after FOL-23 deleted
5
- * the stories, the "49 parts worth reusing" it kept when the catalogue built 41,
6
- * and a Storybook with no map at all, which has nothing to rot.
3
+ * The hand-written map of a Storybook (audit A3, widened by R16). Every case is a
4
+ * real one: Peek's Huddles heading, kept in its Introduction and `HEADINGS` after
5
+ * FOL-23 deleted the stories, and its "49 parts worth reusing" when the catalogue
6
+ * built 41; Ship's `storySort.order` naming a Feedback heading it does not have and
7
+ * leaving out Pages, and its Introduction's bullets; the package's order leaving
8
+ * out Layout; and a Storybook with no map at all, which has nothing to rot.
7
9
  */
8
10
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
9
11
  import { tmpdir } from 'node:os'
10
12
  import { dirname, join } from 'node:path'
11
13
  import { afterAll, describe, expect, it } from 'vitest'
12
14
  import type { Registry } from './schema'
13
- import { introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
15
+ import { indexHeadings, introductionCount, introductionHeadings, orderedHeadings, sidebarHeadings, storyMapProblems } from './storymap'
14
16
 
15
17
  const made: string[] = []
16
18
  afterAll(() => {
@@ -31,66 +33,85 @@ function app(files: Record<string, string>): string {
31
33
  const catalogue = (...classes: string[]) => ({ entries: classes.map((cls) => ({ app: { class: cls } })) }) as unknown as Registry
32
34
 
33
35
  const STORY = (title: string) => `import type { Meta } from '@storybook/react-vite'\nconst meta = {\n title: '${title}',\n} satisfies Meta\nexport default meta\n`
34
- const PREVIEW = (headings: string[]) =>
36
+ const HEADINGS_PREVIEW = (headings: string[]) =>
35
37
  `export default {\n parameters: {\n options: {\n storySort: (a, b) => {\n const HEADINGS = [\n${headings.map((h) => ` '${h}', // someone's heading\n`).join('')} ]\n return 0\n },\n },\n },\n}\n`
36
- const INTRO = (count: number, headings: string[]) =>
38
+ const ORDER_PREVIEW = (headings: string[]) =>
39
+ `export default {\n parameters: {\n options: {\n // Docs first; the rest follows\n storySort: {\n order: [${headings.map((h) => (h === 'Docs' ? "'Docs', ['Introduction']" : `'${h}'`)).join(', ')}],\n },\n },\n },\n}\n`
40
+ const TABLE_INTRO = (count: number, headings: string[]) =>
37
41
  `import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title="Docs/Introduction" />\n\nOf everything the app owns, **${count} parts worth reusing** carry a written rule.\n\n| Heading | What |\n|---|---|\n${headings.map((h) => `| **${h}** | About ${h}. |`).join('\n')}\n`
42
+ const BULLET_INTRO = (groups: string[][]) =>
43
+ `<Meta title="Docs/Introduction" />\n\n## How the sidebar is organised\n\n${groups.map((g) => `- **${g.join(' · ')}** — about them.`).join('\n')}\n\n## Conventions\n\n- **Stories first.** A component exists in Storybook first.\n`
38
44
 
39
45
  const PEEK_TODAY = {
40
- 'src/stories/Introduction.mdx': INTRO(2, ['Docs', 'Primitives', 'Huddles']),
46
+ 'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives', 'Huddles']),
41
47
  'src/components/ui/Badge.stories.tsx': STORY('Primitives/Badge'),
42
48
  'src/components/ui/Chip.mdx': `import { Meta } from '@storybook/addon-docs/blocks'\n\n<Meta title='Primitives/Chip' />\n\n# Chip\n`,
43
- '.storybook/preview.tsx': PREVIEW(['Docs', 'Primitives', 'Huddles']),
49
+ '.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives', 'Huddles']),
44
50
  }
45
51
 
46
52
  describe('reading the map', () => {
47
- it('reads the sidebar from story titles and from page titles in either quote', () => {
48
- expect([...sidebarHeadings(app(PEEK_TODAY))].sort()).toEqual(['Docs', 'Primitives'])
53
+ it('reads the sidebar from story titles and page titles in either quote, in src and stories', () => {
54
+ expect([...sidebarHeadings(app({ ...PEEK_TODAY, 'stories/Tokens.mdx': "<Meta title='Tokens/All' />\n" }))].sort()).toEqual(['Docs', 'Primitives', 'Tokens'])
49
55
  })
50
56
 
51
- it('reads the order with its comments removed, and says when there is none', () => {
52
- expect(orderedHeadings(PREVIEW(['Docs', 'Pages']))).toEqual(['Docs', 'Pages'])
57
+ it("reads Storybook's own index, whatever way a title was written", () => {
58
+ expect([...indexHeadings({ a: { title: 'Pages/Desk' }, b: { title: 'Docs/Introduction' }, c: {} })].sort()).toEqual(['Docs', 'Pages'])
59
+ })
60
+
61
+ it('reads a HEADINGS list and a storySort order, skipping comments and nested lists', () => {
62
+ expect(orderedHeadings(HEADINGS_PREVIEW(['Docs', 'Pages']))).toEqual(['Docs', 'Pages'])
63
+ expect(orderedHeadings(ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']))).toEqual(['Docs', 'Primitives', 'Feedback'])
53
64
  expect(orderedHeadings('export default {}')).toBeNull()
54
65
  })
55
66
 
56
- it('reads the Introduction: its bold table rows and its stated count', () => {
57
- const intro = INTRO(49, ['Docs', 'Huddles'])
58
- expect([...introductionHeadings(intro)]).toEqual(['Docs', 'Huddles'])
59
- expect(introductionCount(intro)).toBe(49)
67
+ it("reads the Introduction's table rows and its bullets, and its stated count", () => {
68
+ expect([...introductionHeadings(TABLE_INTRO(49, ['Docs', 'Huddles']))]).toEqual(['Docs', 'Huddles'])
69
+ // A bullet with no dash after its bold words is prose, not a heading.
70
+ expect([...introductionHeadings(BULLET_INTRO([['Docs'], ['Primitives', 'Feedback', 'Inputs']]))]).toEqual(['Docs', 'Primitives', 'Feedback', 'Inputs'])
71
+ expect(introductionCount(TABLE_INTRO(49, ['Docs']))).toBe(49)
60
72
  expect(introductionCount('# No number here')).toBeNull()
61
73
  })
62
74
  })
63
75
 
64
76
  describe('storyMapProblems', () => {
65
77
  it("finds Peek's three stale lines: a heading in both maps with no story, and the wrong count", () => {
66
- const problems = storyMapProblems(catalogue('reusable', 'reusable', 'reusable', 'local'), app(PEEK_TODAY))
67
- expect(problems).toEqual([
68
- '.storybook/preview.tsx: named in HEADINGS but no story uses them: Huddles',
78
+ expect(storyMapProblems(app(PEEK_TODAY), { registry: catalogue('reusable', 'reusable', 'reusable', 'local') })).toEqual([
79
+ '.storybook/preview.tsx: in the heading order but no story uses them: Huddles',
69
80
  'src/stories/Introduction.mdx: the page names, the sidebar does not have: Huddles',
70
81
  'src/stories/Introduction.mdx: says 2 parts worth reusing; the catalogue builds 3',
71
82
  ])
72
83
  })
73
84
 
74
- it('finds a heading the maps forget, which falls to the bottom of the sidebar', () => {
75
- const dir = app({ ...PEEK_TODAY, 'src/pages/Desk.stories.tsx': STORY('Pages/Desk') })
76
- expect(storyMapProblems(catalogue('reusable', 'promote-candidate'), dir)).toEqual([
77
- '.storybook/preview.tsx: not named in HEADINGS, so they fall to the bottom of the sidebar: Pages',
78
- '.storybook/preview.tsx: named in HEADINGS but no story uses them: Huddles',
85
+ it("finds Ship's: an order and bullets naming Feedback, which it has not got, and leaving out Pages", () => {
86
+ const dir = app({
87
+ '.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives', 'Feedback']),
88
+ 'src/stories/Introduction.mdx': BULLET_INTRO([['Docs'], ['Primitives', 'Feedback']]),
89
+ })
90
+ expect(storyMapProblems(dir, { real: new Set(['Docs', 'Primitives', 'Pages']) })).toEqual([
91
+ '.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Pages',
92
+ '.storybook/preview.tsx: in the heading order but no story uses them: Feedback',
79
93
  'src/stories/Introduction.mdx: the sidebar has, the page does not name: Pages',
80
- 'src/stories/Introduction.mdx: the page names, the sidebar does not have: Huddles',
94
+ 'src/stories/Introduction.mdx: the page names, the sidebar does not have: Feedback',
95
+ ])
96
+ })
97
+
98
+ it("finds the package's order leaving out Layout, from a Storybook index", () => {
99
+ const dir = app({ '.storybook/preview.tsx': ORDER_PREVIEW(['Docs', 'Primitives']) })
100
+ expect(storyMapProblems(dir, { real: indexHeadings({ a: { title: 'Layout/ListColumn' }, b: { title: 'Primitives/Button' }, c: { title: 'Docs/Introduction' } }) })).toEqual([
101
+ '.storybook/preview.tsx: not in the heading order, so they fall to the bottom of the sidebar: Layout',
81
102
  ])
82
103
  })
83
104
 
84
105
  it('passes when both maps match the Storybook and the count matches the catalogue', () => {
85
106
  const dir = app({
86
107
  ...PEEK_TODAY,
87
- 'src/stories/Introduction.mdx': INTRO(2, ['Docs', 'Primitives']),
88
- '.storybook/preview.tsx': PREVIEW(['Docs', 'Primitives']),
108
+ 'src/stories/Introduction.mdx': TABLE_INTRO(2, ['Docs', 'Primitives']),
109
+ '.storybook/preview.tsx': HEADINGS_PREVIEW(['Docs', 'Primitives']),
89
110
  })
90
- expect(storyMapProblems(catalogue('reusable', 'promote-candidate', 'local'), dir)).toEqual([])
111
+ expect(storyMapProblems(dir, { registry: catalogue('reusable', 'promote-candidate', 'local') })).toEqual([])
91
112
  })
92
113
 
93
- it('has nothing to check in an app that keeps no map', () => {
94
- expect(storyMapProblems(catalogue('reusable'), app({ 'src/Badge.stories.tsx': STORY('Parts/Badge') }))).toEqual([])
114
+ it('has nothing to check in a repository that keeps no map', () => {
115
+ expect(storyMapProblems(app({ 'src/Badge.stories.tsx': STORY('Parts/Badge') }), { registry: catalogue('reusable') })).toEqual([])
95
116
  })
96
117
  })