@estiva-app/ui 0.27.0 → 0.29.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 (53) hide show
  1. package/dist/gates/app-checks.d.ts.map +1 -1
  2. package/dist/gates/{chunk-EA33NP5B.js → chunk-JZE7TIQY.js} +16 -13
  3. package/dist/gates/chunk-JZE7TIQY.js.map +7 -0
  4. package/dist/gates/{chunk-I7CJLUPM.js → chunk-S75XIY3Z.js} +56 -6
  5. package/dist/gates/chunk-S75XIY3Z.js.map +7 -0
  6. package/dist/gates/chunk-YKZXKVR2.js +39 -0
  7. package/dist/gates/chunk-YKZXKVR2.js.map +7 -0
  8. package/dist/gates/cli.js +2 -1
  9. package/dist/gates/cli.js.map +1 -1
  10. package/dist/gates/create-app.d.ts.map +1 -1
  11. package/dist/gates/create-app.js +2 -1
  12. package/dist/gates/create-app.js.map +1 -1
  13. package/dist/gates/gate-config.d.ts +1 -1
  14. package/dist/gates/gate-config.d.ts.map +1 -1
  15. package/dist/gates/hook.d.ts +7 -1
  16. package/dist/gates/hook.d.ts.map +1 -1
  17. package/dist/gates/index.d.ts +2 -1
  18. package/dist/gates/index.d.ts.map +1 -1
  19. package/dist/gates/index.js +38 -5
  20. package/dist/gates/index.js.map +2 -2
  21. package/dist/gates/token-lint.d.ts +27 -1
  22. package/dist/gates/token-lint.d.ts.map +1 -1
  23. package/dist/registry/{chunk-6OH3TWGF.js → chunk-KMZIMB3G.js} +5 -2
  24. package/dist/registry/{chunk-6OH3TWGF.js.map → chunk-KMZIMB3G.js.map} +2 -2
  25. package/dist/registry/cli.js +187 -25
  26. package/dist/registry/cli.js.map +4 -4
  27. package/dist/registry/find.d.ts +5 -0
  28. package/dist/registry/find.d.ts.map +1 -1
  29. package/dist/registry/index.js +1 -1
  30. package/dist/registry/siblings.d.ts +21 -0
  31. package/dist/registry/siblings.d.ts.map +1 -0
  32. package/dist/registry/skill.d.ts +20 -0
  33. package/dist/registry/skill.d.ts.map +1 -0
  34. package/package.json +4 -2
  35. package/registry.json +1 -1
  36. package/skill/estiva-ui.md +126 -0
  37. package/src/gates/app-checks.ts +20 -1
  38. package/src/gates/create-app.test.ts +5 -1
  39. package/src/gates/create-app.ts +12 -11
  40. package/src/gates/gate-config.ts +3 -2
  41. package/src/gates/gates.test.ts +60 -1
  42. package/src/gates/hook.ts +69 -5
  43. package/src/gates/index.ts +2 -1
  44. package/src/gates/token-lint.ts +47 -2
  45. package/src/registry/app.test.ts +58 -1
  46. package/src/registry/cli.ts +60 -8
  47. package/src/registry/find.ts +10 -1
  48. package/src/registry/siblings.test.ts +85 -0
  49. package/src/registry/siblings.ts +96 -0
  50. package/src/registry/skill.test.ts +96 -0
  51. package/src/registry/skill.ts +117 -0
  52. package/dist/gates/chunk-EA33NP5B.js.map +0 -7
  53. package/dist/gates/chunk-I7CJLUPM.js.map +0 -7
@@ -16,6 +16,9 @@
16
16
  * inline style that sets a colour (errors); heights, spacing and border
17
17
  * widths written by hand (warnings, never blocking).
18
18
  *
19
+ * And `tokenConfig`, the two on their own as an app's `lint:tokens` runs them
20
+ * (UIG-37), with TypeScript's rule names known so its directives pass.
21
+ *
19
22
  * The patterns are the same for everyone. Only the words differ: an app is told
20
23
  * to use "the package", the package is told to add the token to `tokens.css`.
21
24
  * Measured on 17 September: the UIG-28 block was byte for byte the same in Peek
@@ -32,10 +35,10 @@
32
35
  */
33
36
  import { createRequire } from 'node:module'
34
37
  import { fileURLToPath } from 'node:url'
35
- import type { Linter } from 'eslint'
38
+ import type { ESLint, Linter } from 'eslint'
36
39
  import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
37
40
  import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults'
38
- import { parser as typescriptParser } from 'typescript-eslint'
41
+ import { parser as typescriptParser, plugin as typescriptPlugin } from 'typescript-eslint'
39
42
 
40
43
  /** Who reads the messages. */
41
44
  export type TokenAudience = 'app' | 'package'
@@ -47,9 +50,30 @@ export interface TokenLintOptions {
47
50
  audience?: TokenAudience
48
51
  }
49
52
 
53
+ export interface TokenConfigOptions extends TokenLintOptions {
54
+ /**
55
+ * Plugins whose `eslint-disable` directives appear in the source, registered
56
+ * with every rule off. ESLint refuses a directive for a rule it does not
57
+ * know. TypeScript's are registered already.
58
+ */
59
+ quiet?: Record<string, ESLint.Plugin>
60
+ /** Folders the token lint never reads, beside build output. */
61
+ ignores?: string[]
62
+ }
63
+
50
64
  /** What the token lint never reads: build output. */
51
65
  export const TOKEN_LINT_IGNORES = ['dist/', 'storybook-static/', 'node_modules/']
52
66
 
67
+ /**
68
+ * Plugins a check that runs on its own registers, every rule off, so the
69
+ * directives another lint writes do not fail it (UIG-37). TypeScript's lint is
70
+ * in every app and writes `eslint-disable-next-line @typescript-eslint/...`;
71
+ * Peek PR #324 failed the token lint on one. Only the checks that run alone
72
+ * take this — a full lint that loads TypeScript's rules from its own copy would
73
+ * refuse a second plugin under the same name.
74
+ */
75
+ export const KNOWN_PLUGINS: Record<string, ESLint.Plugin> = { '@typescript-eslint': typescriptPlugin as ESLint.Plugin }
76
+
53
77
  const CLASS_MAP_SELECTOR = {
54
78
  kind: 'variable',
55
79
  name: '^(?:[A-Z][A-Z0-9_]*_(?:STYLES|CLASSES)|[a-z][a-zA-Z0-9]*(?:Styles|Classes))$',
@@ -229,3 +253,24 @@ export function tokenValues({ audience = 'app' }: Pick<TokenLintOptions, 'audien
229
253
  },
230
254
  }
231
255
  }
256
+
257
+ /**
258
+ * The token lint on its own — an app's `eslint.tokens.config.js`, `npm run
259
+ * lint:tokens`, the CI step. It fails on the token contract and on nothing
260
+ * else, so a backlog in the full lint never blocks it. Peek built this file by
261
+ * hand until UIG-37.
262
+ */
263
+ export function tokenConfig({ quiet = {}, ignores = [], ...options }: TokenConfigOptions = {}): Linter.Config[] {
264
+ return [
265
+ { ignores: [...TOKEN_LINT_IGNORES, ...ignores] },
266
+ {
267
+ plugins: { ...KNOWN_PLUGINS, ...quiet },
268
+ // Every other lint's directives are unused here, because their rules are
269
+ // off here. Reporting them would be this config complaining about the
270
+ // others' business.
271
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
272
+ },
273
+ tokenLint(options),
274
+ tokenValues({ audience: options.audience }),
275
+ ]
276
+ }
@@ -526,6 +526,25 @@ describe('one search over the package and the apps', () => {
526
526
  expect(found.indexOf('estiva-ui:EmptyState')).toBeLessThan(found.indexOf('fixture:EmptyState'))
527
527
  })
528
528
 
529
+ it("puts a package part whose name carries a word before an app's part answering as many (UIG-20)", () => {
530
+ // "panel header" put an app's own `Header` above the package's `ContainerHeader`: its whole name is one of the words.
531
+ const header = buildAppRegistry({
532
+ root: app({ 'src/Header.tsx': '/** The top row of a panel: its title and its buttons. */\nexport function Header() {\n return <header />\n}\n' }),
533
+ repo: 'fixture',
534
+ })
535
+ const found = findInRegistries([packageRegistry, header], 'panel header', { limit: 5 }).map((f) => `${f.entry.repo}:${f.entry.name}`)
536
+ expect(found[0]).toBe('estiva-ui:ContainerHeader')
537
+ expect(found).toContain('fixture:Header')
538
+ })
539
+
540
+ it("does not let a package part the words reach only through its notes bury the app's answer", () => {
541
+ const composer = buildAppRegistry({
542
+ root: app({ 'src/Composer.tsx': '/** Where a comment is written and sent. */\nexport function Composer() {\n return <form />\n}\n' }),
543
+ repo: 'fixture',
544
+ })
545
+ expect(findInRegistries([packageRegistry, composer], 'comment composer')[0].entry).toMatchObject({ repo: 'fixture', name: 'Composer' })
546
+ })
547
+
529
548
  it('says which apps hand a package part on, and how a default is imported', () => {
530
549
  const text = formatFindings([packageRegistry, registry], findInRegistries([packageRegistry, registry], 'Button', { limit: 1 }), 'Button')
531
550
  expect(text).toContain('fixture hands it on from @/components/ui/Button')
@@ -554,10 +573,48 @@ describe.skipIf(!existsSync(cli))('estiva-ui in an app', () => {
554
573
  }
555
574
  expect(failed.stdout).toMatch(/^fixture: 17 parts in 15 files \(2 more hold none\)/)
556
575
  expect(failed.stderr).toContain('Timeline (reusable, src/components/Timeline.tsx) has no usage page: write src/components/Timeline.mdx')
557
- const one = app({ 'src/One.tsx': '/** One, drawn once. */\nexport function One() {\n return <i />\n}\n' })
576
+ const one = app({ 'src/One.tsx': '/** One, drawn once. */\nexport function One() {\n return <i />\n}\n', 'node_modules/@estiva-app/ui/skill/estiva-ui.md': '# The skill\n' })
577
+ run(['skill'], one)
558
578
  expect(run(['check'], one)).toContain('usage pages: all 0 reusable parts keep the contract')
559
579
  })
560
580
 
581
+ it('skill writes a loader that reads the installed package, and check fails when it is missing or differs (UIG-20)', () => {
582
+ const two = app({ 'src/Two.tsx': '/** Two, drawn once. */\nexport function Two() {\n return <i />\n}\n', 'node_modules/@estiva-app/ui/skill/estiva-ui.md': '# The skill\n' })
583
+ const failure = (cwd: string) => {
584
+ try {
585
+ run(['check'], cwd)
586
+ } catch (error) {
587
+ return (error as { stderr?: string }).stderr ?? ''
588
+ }
589
+ return ''
590
+ }
591
+ expect(failure(two)).toContain('.claude/skills/estiva-ui/SKILL.md is missing: run "estiva-ui skill" and commit it')
592
+ run(['skill'], two)
593
+ const loader = readFileSync(join(two, '.claude', 'skills', 'estiva-ui', 'SKILL.md'), 'utf8')
594
+ expect(loader).toMatch(/^---\nname: estiva-ui\ndescription: "Use before building/)
595
+ expect(loader).toContain('!`cat "${CLAUDE_PROJECT_DIR}/node_modules/@estiva-app/ui/skill/estiva-ui.md"`')
596
+ expect(JSON.parse(readFileSync(join(two, '.claude', 'settings.json'), 'utf8')).permissions.allow).toContain('Bash(npm run ui:find *)')
597
+ // Its text is the package's, never a copy.
598
+ expect(loader).not.toContain('# The skill')
599
+ expect(run(['check'], two)).toContain('claude skill: the loader reads the package’s text')
600
+ writeFileSync(join(two, '.claude', 'skills', 'estiva-ui', 'SKILL.md'), `${loader}\nA line of our own.\n`)
601
+ expect(failure(two)).toContain('SKILL.md is not what the package writes')
602
+ })
603
+
604
+ it('skill --out writes a loader for a folder that holds the repositories, reading an app inside it', () => {
605
+ const holder = mkdtempSync(join(tmpdir(), 'uig20-holder-'))
606
+ made.push(holder)
607
+ mkdirSync(join(holder, 'one', 'node_modules', '@estiva-app', 'ui', 'skill'), { recursive: true })
608
+ writeFileSync(join(holder, 'one', 'package.json'), JSON.stringify({ name: 'one' }))
609
+ writeFileSync(join(holder, 'one', 'node_modules', '@estiva-app', 'ui', 'skill', 'estiva-ui.md'), '# The skill\n')
610
+ run(['skill', '--out', '..'], join(holder, 'one'))
611
+ const loader = readFileSync(join(holder, '.claude', 'skills', 'estiva-ui', 'SKILL.md'), 'utf8')
612
+ expect(loader).toContain('!`cat "${CLAUDE_PROJECT_DIR}/one/node_modules/@estiva-app/ui/skill/estiva-ui.md"`')
613
+ // Claude Code refuses a path that climbs out with `..`, so a folder that does not hold the app is refused here.
614
+ const elsewhere = app({ 'node_modules/@estiva-app/ui/skill/estiva-ui.md': '# The skill\n' })
615
+ expect(() => run(['skill', '--out', holder], elsewhere)).toThrow(/does not hold/)
616
+ })
617
+
561
618
  it("find searches the app and the package, and never takes an app's own registry.json for the package's", () => {
562
619
  // What `estiva-ui build` leaves behind in an app, and git ignores.
563
620
  run(['build'], root)
@@ -3,9 +3,10 @@
3
3
  * `estiva-ui` — the catalogue as a command (UIG-12, UIG-13; docs/GATES.md §23
4
4
  * for why it ships in the package rather than as a script pasted into each repo).
5
5
  *
6
- * estiva-ui find <words…> [--also [name=]<folder>]… [--json] [--limit n]
6
+ * estiva-ui find <words…> [--here | --also [name=]<folder>…] [--json] [--limit n]
7
7
  * estiva-ui build [--out <path>]
8
8
  * estiva-ui check
9
+ * estiva-ui skill [--out <folder>]
9
10
  *
10
11
  * Every command takes `--root <folder>` (the library it reads; default: here)
11
12
  * and `--repo <name>` (what an app's entries are called; default: its package name).
@@ -21,15 +22,23 @@
21
22
  *
22
23
  * `find` searches the package's catalogue — the one shipped inside the installed
23
24
  * package, or this repo's own in the package — plus, in an app, the app's own,
24
- * built fresh, plus each `--also` app that sits beside it.
25
+ * built fresh, plus every app beside it (UIG-20, `./siblings`): found, not
26
+ * named, so one command answers the same from any repository, or from the
27
+ * folder that holds them. `--here` searches no neighbour; `--also` names them.
28
+ *
29
+ * `skill` writes the repository's Claude skill loader (UIG-20, `./skill`), or
30
+ * the one for `--out <folder>`; `check` fails when the repository's is missing
31
+ * or differs.
25
32
  */
26
33
  import { spawnSync } from 'node:child_process'
27
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
34
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
28
35
  import { tmpdir } from 'node:os'
29
- import { basename, join, resolve } from 'node:path'
36
+ import { basename, dirname, join, relative, resolve } from 'node:path'
30
37
  import { fileURLToPath } from 'node:url'
31
38
  import { CONTRACT_KINDS, contractProblems, linkProblems } from './contract'
32
39
  import { findInRegistries, formatFindings } from './find'
40
+ import { findSiblings, workspaceOf } from './siblings'
41
+ import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
33
42
  import { CLASSES, validateRegistry, type Registry } from './schema'
34
43
 
35
44
  /**
@@ -91,6 +100,20 @@ function readPackageRegistry(): Registry {
91
100
  return registry
92
101
  }
93
102
 
103
+ /** The installed package whose skill text a loader reads: this repo in the package, else the app's install. */
104
+ const packageRoot = () => (isPackage ? root : resolve(root, 'node_modules', '@estiva-app', 'ui'))
105
+
106
+ /** The Claude skill loader (UIG-20): reports, and says whether it passed. */
107
+ function checkLoader(): boolean {
108
+ const problem = loaderProblem(repositoryOf(root), packageRoot())
109
+ if (problem) {
110
+ process.stderr.write(`claude skill: ${problem}\n`)
111
+ return false
112
+ }
113
+ process.stdout.write('claude skill: the loader reads the package’s text\n')
114
+ return true
115
+ }
116
+
94
117
  async function buildApp(folder: string, repo: string | undefined): Promise<Registry> {
95
118
  const { buildAppRegistry } = await appBuilder()
96
119
  const registry = buildAppRegistry({ root: folder, repo })
@@ -179,7 +202,13 @@ async function main(): Promise<number> {
179
202
  process.stderr.write(`not searched: ${first}${more.length ? `\n${more.slice(0, 3).join('\n')}${more.length > 3 ? `\n …and ${more.length - 3} more — run "estiva-ui check" there` : ''}` : ''}\n`)
180
203
  }
181
204
  }
182
- if (!isPackage) await add(root, value('repo'))
205
+ // The folder it runs in is searched when it is an app; the folder that
206
+ // holds the repositories is not one, and only its neighbours answer.
207
+ const here = !isPackage && existsSync(resolve(root, 'package.json')) ? (manifestAt(root).name ?? basename(root)) : null
208
+ if (here) await add(root, value('repo'))
209
+ if (!values('also').length && !flag('here')) {
210
+ for (const sibling of findSiblings(workspaceOf(root), { skip: here ? [here] : [] })) await add(sibling.folder, sibling.name)
211
+ }
183
212
  for (const also of values('also')) {
184
213
  const [name, folder] = also.includes('=') ? [also.slice(0, also.indexOf('=')), also.slice(also.indexOf('=') + 1)] : [undefined, also]
185
214
  const at = resolve(root, folder)
@@ -236,7 +265,8 @@ async function main(): Promise<number> {
236
265
  }
237
266
  const linked = checkLinks(registry, root)
238
267
  const shown = await checkGated(registry, root)
239
- return broken.length || !linked || !shown ? 1 : 0
268
+ const loaded = checkLoader()
269
+ return broken.length || !linked || !shown || !loaded ? 1 : 0
240
270
  }
241
271
  const { buildRegistry, serializeRegistry } = await packageBuilder()
242
272
  const path = packageRegistryPath()
@@ -259,11 +289,33 @@ async function main(): Promise<number> {
259
289
  process.stdout.write(`${path}: current, ${built.entries.length} entries from ${built.builtFrom.exports} exports\n`)
260
290
  const linked = checkLinks(built, root)
261
291
  const shown = await checkGated(built, root, ['component'])
262
- return linked && shown ? 0 : 1
292
+ const loaded = checkLoader()
293
+ return linked && shown && loaded ? 0 : 1
294
+ }
295
+
296
+ case 'skill': {
297
+ const out = value('out')
298
+ const folder = out ? resolve(out) : repositoryOf(root)
299
+ // The loader's path starts where the session does and cannot climb out:
300
+ // Claude Code refuses a `..` in it.
301
+ if (relative(folder, packageRoot()).startsWith('..')) {
302
+ process.stderr.write(`${folder} does not hold ${packageRoot()}: a loader there could not reach the skill's text\n`)
303
+ return 1
304
+ }
305
+ const path = loaderPath(folder)
306
+ mkdirSync(dirname(path), { recursive: true })
307
+ writeFileSync(path, loaderText(folder, packageRoot()), 'utf8')
308
+ process.stdout.write(`${path}: the Claude skill, read from ${packageRoot()}\n`)
309
+ const settings = settingsWithSearch(folder)
310
+ if (settings) {
311
+ writeFileSync(settingsPath(folder), settings, 'utf8')
312
+ process.stdout.write(`${settingsPath(folder)}: the search allowed without a prompt\n`)
313
+ }
314
+ return 0
263
315
  }
264
316
 
265
317
  default:
266
- process.stderr.write('estiva-ui <find | build | check>\n')
318
+ process.stderr.write('estiva-ui <find | build | check | skill>\n')
267
319
  return 1
268
320
  }
269
321
  }
@@ -96,6 +96,11 @@ export function findInRegistry(registry: Registry, query: string, { limit = 5 }:
96
96
  * An app's pass-on is not a result of its own — it is the package's part, and
97
97
  * the package's entry answers for it, saying which apps hand it on. At the
98
98
  * same strength the package's part comes first: it is the one to reach for.
99
+ * And a package part whose name carries one of the words comes before an app's
100
+ * part answering as many words, whatever each scored: "panel header" put an
101
+ * app's own `Header`, whose whole name is one of the words, above the package's
102
+ * `ContainerHeader` (UIG-20). Only by name: a package part the words reach
103
+ * through its notes alone would bury the app part that is the answer.
99
104
  */
100
105
  export function findInRegistries(registries: Registry[], query: string, { limit = 5 }: { limit?: number } = {}): Finding[] {
101
106
  const all = words(query)
@@ -162,7 +167,11 @@ export function findInRegistries(registries: Registry[], query: string, { limit
162
167
  }
163
168
 
164
169
  const fromPackage = (finding: Finding) => (finding.entry.app === null ? 0 : 1)
165
- findings.sort((a, b) => b.matched - a.matched || b.score - a.score || fromPackage(a) - fromPackage(b) || a.entry.name.localeCompare(b.entry.name))
170
+ const namedInPackage = (finding: Finding) => (finding.entry.app === null && finding.where.includes('name') ? 0 : 1)
171
+ findings.sort(
172
+ (a, b) =>
173
+ b.matched - a.matched || namedInPackage(a) - namedInPackage(b) || b.score - a.score || fromPackage(a) - fromPackage(b) || a.entry.name.localeCompare(b.entry.name),
174
+ )
166
175
  return findings.slice(0, limit)
167
176
  }
168
177
 
@@ -0,0 +1,85 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * UIG-20: the apps beside this one, found rather than named. The folder below
4
+ * is the shape of the real one: two apps (one inside a `web/` folder), a
5
+ * service that does not install the package, a worktree and somebody's copy of
6
+ * an app, and the package itself.
7
+ */
8
+ import { execFileSync } from 'node:child_process'
9
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { afterAll, describe, expect, it } from 'vitest'
14
+ import { appName, findSiblings, workspaceOf } from './siblings'
15
+
16
+ const workspace = mkdtempSync(join(tmpdir(), 'uig20-'))
17
+ afterAll(() => rmSync(workspace, { recursive: true, force: true }))
18
+
19
+ const UI = { '@estiva-app/ui': '^0.28.0' }
20
+ function put(path: string, text: string) {
21
+ mkdirSync(dirname(join(workspace, path)), { recursive: true })
22
+ writeFileSync(join(workspace, path), text)
23
+ }
24
+ function appAt(folder: string, name: string, deps: Record<string, string> = UI, part = 'Part') {
25
+ put(`${folder}/package.json`, JSON.stringify({ name, dependencies: deps }))
26
+ put(`${folder}/src/${part}.tsx`, `/** The ${part.toLowerCase()} of ${name}. */\nexport function ${part}() {\n return <i />\n}\n`)
27
+ }
28
+ // A repository is a folder with `.git`: a directory, or a worktree's file.
29
+ appAt('alpha', 'alpha-app', UI, 'Tabletop')
30
+ put('alpha/.git/HEAD', 'ref: refs/heads/main\n')
31
+ put('beta/package.json', JSON.stringify({ name: 'beta' }))
32
+ put('beta/.git/HEAD', 'ref: refs/heads/main\n')
33
+ appAt('beta/web', 'beta-web', { ...UI }, 'Workbench')
34
+ appAt('alpha-copy', 'alpha-app')
35
+ put('alpha-copy/.git', 'gitdir: ../alpha/.git/worktrees/alpha-copy\n')
36
+ appAt('beta-notes/web', 'beta-web')
37
+ appAt('service', 'service', { react: '^19.0.0' })
38
+ put('ui/package.json', JSON.stringify({ name: '@estiva-app/ui' }))
39
+ mkdirSync(join(workspace, 'ui', 'src'), { recursive: true })
40
+
41
+ describe('the apps beside this one', () => {
42
+ it('is an app only when it installs the package and has a src', () => {
43
+ expect(appName(join(workspace, 'alpha'))).toBe('alpha-app')
44
+ expect(appName(join(workspace, 'service'))).toBeNull()
45
+ expect(appName(join(workspace, 'ui'))).toBeNull()
46
+ expect(appName(join(workspace, 'beta'))).toBeNull()
47
+ })
48
+
49
+ it('finds the folder that holds the repositories, from inside one or from that folder itself', () => {
50
+ expect(workspaceOf(join(workspace, 'beta', 'web', 'src'))).toBe(workspace)
51
+ expect(workspaceOf(join(workspace, 'alpha-copy'))).toBe(workspace)
52
+ })
53
+
54
+ it('finds each app once, inside a web folder too, and never a second checkout of the same app', () => {
55
+ expect(findSiblings(workspace).map((s) => [s.name, s.packageName])).toEqual([
56
+ ['alpha', 'alpha-app'],
57
+ ['beta', 'beta-web'],
58
+ ])
59
+ })
60
+
61
+ it('leaves out the app the search runs in, so a worktree of it is not searched twice', () => {
62
+ expect(findSiblings(workspace, { skip: ['alpha-app'] }).map((s) => s.name)).toEqual(['beta'])
63
+ })
64
+ })
65
+
66
+ // The command, as a session runs it: `dist/` is what `npm test` builds first.
67
+ const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist', 'registry', 'cli.js')
68
+ describe.skipIf(!existsSync(cli))('estiva-ui find, with nothing else typed', () => {
69
+ const find = (cwd: string, ...words: string[]) =>
70
+ execFileSync(process.execPath, [cli, 'find', ...words], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
71
+
72
+ it('searches every app beside the one it runs in', () => {
73
+ const out = find(join(workspace, 'alpha-copy'), 'workbench')
74
+ expect(out).toMatch(/^Workbench {2}· .* · {2}beta/m)
75
+ })
76
+
77
+ it('answers the same from the folder that holds them', () => {
78
+ expect(find(workspace, 'tabletop')).toMatch(/^Tabletop {2}· .* · {2}alpha/m)
79
+ expect(find(workspace, 'zzqqx')).toContain('Nothing in estiva-ui, alpha, beta matches')
80
+ })
81
+
82
+ it('searches no neighbour when told --here', () => {
83
+ expect(find(join(workspace, 'alpha'), 'workbench', '--here')).not.toContain('Workbench')
84
+ })
85
+ })
@@ -0,0 +1,96 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * The apps beside this one, found rather than named (UIG-20).
4
+ *
5
+ * `estiva-ui find` with nothing else typed searches the package, the app it is
6
+ * run in, and every app that sits beside it. Katerina's condition for having no
7
+ * shared Storybook is that one search, run unasked from any repository, covers
8
+ * every catalogue; a list of names written into each repository's script would
9
+ * be the drifting copy the gates exist to remove, and would miss the next app.
10
+ *
11
+ * An app is a folder, or one folder inside it, whose `package.json` depends on
12
+ * `@estiva-app/ui` and which has a `src` to read. A service that does not
13
+ * install the package is not an app of this catalogue until it does.
14
+ *
15
+ * Each app is searched once. A second checkout of the same app (a worktree, or
16
+ * somebody's copy) carries the same package name; the app the search runs in
17
+ * wins, then the one whose top folder has the shortest name.
18
+ */
19
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
20
+ import { basename, dirname, join, resolve } from 'node:path'
21
+
22
+ export const PACKAGE_NAME = '@estiva-app/ui'
23
+
24
+ export interface Sibling {
25
+ /** What its entries are called: its top folder's name. */
26
+ name: string
27
+ /** The folder the catalogue is built from. */
28
+ folder: string
29
+ /** Its `package.json` name, which says two checkouts are one app. */
30
+ packageName: string
31
+ }
32
+
33
+ function manifest(folder: string): { name?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> } | null {
34
+ const file = join(folder, 'package.json')
35
+ if (!existsSync(file)) return null
36
+ try {
37
+ return JSON.parse(readFileSync(file, 'utf8'))
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /** The name of the app in `folder`, or null when the folder is not one. */
44
+ export function appName(folder: string): string | null {
45
+ const m = manifest(folder)
46
+ if (!m || m.name === PACKAGE_NAME) return null
47
+ const uses = Boolean(m.dependencies?.[PACKAGE_NAME] ?? m.devDependencies?.[PACKAGE_NAME])
48
+ return uses && existsSync(join(folder, 'src')) ? (m.name ?? basename(folder)) : null
49
+ }
50
+
51
+ const isDir = (path: string) => {
52
+ try {
53
+ return statSync(path).isDirectory()
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The folder that holds the repositories: the parent of the repository `from`
61
+ * is in, or `from` itself when it is in none (a folder that only holds them).
62
+ */
63
+ export function workspaceOf(from: string): string {
64
+ let at = resolve(from)
65
+ for (;;) {
66
+ if (existsSync(join(at, '.git'))) return dirname(at)
67
+ const up = dirname(at)
68
+ if (up === at) return resolve(from)
69
+ at = up
70
+ }
71
+ }
72
+
73
+ /** Every app in `workspace`, one per package name, except those named in `skip`. */
74
+ export function findSiblings(workspace: string, { skip = [] }: { skip?: string[] } = {}): Sibling[] {
75
+ let tops: string[]
76
+ try {
77
+ tops = readdirSync(workspace).filter((name) => !name.startsWith('.') && name !== 'node_modules' && isDir(join(workspace, name)))
78
+ } catch {
79
+ return []
80
+ }
81
+ const found: Sibling[] = []
82
+ for (const top of tops) {
83
+ const folder = join(workspace, top)
84
+ const inside = readdirSync(folder).filter((name) => !name.startsWith('.') && name !== 'node_modules' && isDir(join(folder, name)))
85
+ for (const candidate of [folder, ...inside.map((name) => join(folder, name))]) {
86
+ const packageName = appName(candidate)
87
+ if (packageName) found.push({ name: top, folder: candidate, packageName })
88
+ }
89
+ }
90
+ const byPackage = new Map<string, Sibling>()
91
+ for (const sibling of found.sort((a, b) => a.name.length - b.name.length || a.name.localeCompare(b.name))) {
92
+ if (skip.includes(sibling.packageName) || byPackage.has(sibling.packageName)) continue
93
+ byPackage.set(sibling.packageName, sibling)
94
+ }
95
+ return [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name))
96
+ }
@@ -0,0 +1,96 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * UIG-20: the skill's text, held to what it points at.
4
+ *
5
+ * The skill names the package's parts and props. Renaming one would leave the
6
+ * skill sending sessions to something that is not there, so every part it names
7
+ * is checked against the catalogue. Its Gotchas stand for recorded defects,
8
+ * reconciled in `docs/GATES-SKILL.md`; that table has to keep adding up.
9
+ */
10
+ import { readFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { describe, expect, it } from 'vitest'
14
+ import type { Registry } from './schema'
15
+ import { loaderText, SKILL_DESCRIPTION } from './skill'
16
+
17
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
18
+ const text = readFileSync(join(repoRoot, 'skill', 'estiva-ui.md'), 'utf8').replace(/\r\n/g, '\n')
19
+ const registry = JSON.parse(readFileSync(join(repoRoot, 'registry.json'), 'utf8')) as Registry
20
+ const byName = new Map(registry.entries.map((entry) => [entry.name, entry]))
21
+ const record = readFileSync(join(repoRoot, 'docs', 'GATES-SKILL.md'), 'utf8').replace(/\r\n/g, '\n')
22
+
23
+ describe('the skill', () => {
24
+ it('has the four parts, stays short, and fits Claude Code’s description limit', () => {
25
+ expect(text).toMatch(/^# /)
26
+ for (const heading of ['## When this applies', '## Steps, in order', '## Gotchas']) expect(text).toContain(heading)
27
+ expect(text.split('\n').length).toBeLessThan(200)
28
+ expect(SKILL_DESCRIPTION.length).toBeLessThanOrEqual(1536)
29
+ expect(text).toMatch(/1\. \*\*Search before creating anything\.\*\* Run\n {3}`npm run ui:find/)
30
+ })
31
+
32
+ it('names only parts the package has', () => {
33
+ const named = [...new Set([...text.matchAll(/`([A-Z][A-Za-z]+)`/g)].map((m) => m[1]))]
34
+ expect(named.length).toBeGreaterThan(20)
35
+ expect(named.filter((name) => !byName.has(name))).toEqual([])
36
+ })
37
+
38
+ it('names only props those parts have', () => {
39
+ const props: [string, string][] = [
40
+ ['EmptyState', 'scope'],
41
+ ['ListColumn', 'above'],
42
+ ['IconButton', 'tooltip'],
43
+ ['SectionHeader', 'hover'],
44
+ ['WithTooltip', 'inline'],
45
+ ['Toolbar', 'surface'],
46
+ ['Form', 'busy'],
47
+ ['Popover', 'maxHeight'],
48
+ ['Popover', 'open'],
49
+ ]
50
+ for (const [part, prop] of props) expect(byName.get(part)?.props.map((p) => p.name), `${part}.${prop}`).toContain(prop)
51
+ })
52
+
53
+ it('carries the four regressions the ticket names', () => {
54
+ expect(text).toMatch(/header row drawn by hand[\s\S]*?`ContainerHeader`/)
55
+ expect(text).toMatch(/no scroll container[\s\S]*?`ListColumn`/)
56
+ expect(text).toMatch(/`EditableText` rendering too large/)
57
+ expect(text).toMatch(/`EmptyState` at the wrong level[\s\S]*?scope="page"/)
58
+ })
59
+
60
+ it('assumes no app: no app is named, so it holds in every one', () => {
61
+ expect(`${text}\n${SKILL_DESCRIPTION}`).not.toMatch(/\b(Peek|Ship|Leaf)\b/)
62
+ })
63
+
64
+ it('is read in by the loader, never copied into it', () => {
65
+ const loader = loaderText(repoRoot, repoRoot)
66
+ expect(loader).not.toContain('## Gotchas')
67
+ expect(loader).toContain('!`cat "${CLAUDE_PROJECT_DIR}/skill/estiva-ui.md"`')
68
+ })
69
+ })
70
+
71
+ describe('the Gotchas, against the record', () => {
72
+ const rows = [...record.matchAll(/^\| (\d+) \|.*\| (G\d+|EXCL)\b[^|]*\|$/gm)].map((m) => ({ n: Number(m[1]), verdict: m[2] }))
73
+ const gotchas = [...text.matchAll(/^(\d+)\. \*\*/gm)].map((m) => Number(m[1]))
74
+
75
+ it('gives every recorded defect a row, numbered without a gap', () => {
76
+ expect(rows.length).toBeGreaterThan(500)
77
+ expect(rows.map((row) => row.n)).toEqual(rows.map((_, i) => i + 1))
78
+ })
79
+
80
+ it('backs every Gotcha with at least two recorded defects, and points at none the skill lacks', () => {
81
+ const numbered = gotchas.filter((n, i) => i >= 5 || n > 5) // the five steps are numbered too
82
+ const count = (g: number) => rows.filter((row) => row.verdict === `G${g}`).length
83
+ for (let g = 1; g <= 24; g++) expect(count(g), `G${g}`).toBeGreaterThanOrEqual(2)
84
+ expect(rows.filter((row) => row.verdict.startsWith('G') && Number(row.verdict.slice(1)) > 24)).toEqual([])
85
+ expect(numbered.filter((n) => n >= 1 && n <= 24).length).toBeGreaterThanOrEqual(19)
86
+ expect(text).toMatch(/^24\. \*\*/m)
87
+ })
88
+
89
+ it('states counts that add up', () => {
90
+ const into = rows.filter((row) => row.verdict !== 'EXCL').length
91
+ const out = rows.filter((row) => row.verdict === 'EXCL').length
92
+ expect(record).toContain(`**${rows.length}** | **${into}** | **${out}**`)
93
+ expect(text).toContain(`counted ${rows.length}`)
94
+ expect(text).toContain(`cover the ${into}`)
95
+ })
96
+ })