@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.
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-EA33NP5B.js → chunk-JZE7TIQY.js} +16 -13
- package/dist/gates/chunk-JZE7TIQY.js.map +7 -0
- package/dist/gates/{chunk-I7CJLUPM.js → chunk-S75XIY3Z.js} +56 -6
- package/dist/gates/chunk-S75XIY3Z.js.map +7 -0
- package/dist/gates/chunk-YKZXKVR2.js +39 -0
- package/dist/gates/chunk-YKZXKVR2.js.map +7 -0
- package/dist/gates/cli.js +2 -1
- package/dist/gates/cli.js.map +1 -1
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +2 -1
- package/dist/gates/create-app.js.map +1 -1
- package/dist/gates/gate-config.d.ts +1 -1
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts +7 -1
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.d.ts +2 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +38 -5
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/token-lint.d.ts +27 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/registry/{chunk-6OH3TWGF.js → chunk-KMZIMB3G.js} +5 -2
- package/dist/registry/{chunk-6OH3TWGF.js.map → chunk-KMZIMB3G.js.map} +2 -2
- package/dist/registry/cli.js +187 -25
- package/dist/registry/cli.js.map +4 -4
- package/dist/registry/find.d.ts +5 -0
- package/dist/registry/find.d.ts.map +1 -1
- package/dist/registry/index.js +1 -1
- package/dist/registry/siblings.d.ts +21 -0
- package/dist/registry/siblings.d.ts.map +1 -0
- package/dist/registry/skill.d.ts +20 -0
- package/dist/registry/skill.d.ts.map +1 -0
- package/package.json +4 -2
- package/registry.json +1 -1
- package/skill/estiva-ui.md +126 -0
- package/src/gates/app-checks.ts +20 -1
- package/src/gates/create-app.test.ts +5 -1
- package/src/gates/create-app.ts +12 -11
- package/src/gates/gate-config.ts +3 -2
- package/src/gates/gates.test.ts +60 -1
- package/src/gates/hook.ts +69 -5
- package/src/gates/index.ts +2 -1
- package/src/gates/token-lint.ts +47 -2
- package/src/registry/app.test.ts +58 -1
- package/src/registry/cli.ts +60 -8
- package/src/registry/find.ts +10 -1
- package/src/registry/siblings.test.ts +85 -0
- package/src/registry/siblings.ts +96 -0
- package/src/registry/skill.test.ts +96 -0
- package/src/registry/skill.ts +117 -0
- package/dist/gates/chunk-EA33NP5B.js.map +0 -7
- package/dist/gates/chunk-I7CJLUPM.js.map +0 -7
package/src/gates/token-lint.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/registry/app.test.ts
CHANGED
|
@@ -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)
|
package/src/registry/cli.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/registry/find.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|