@estiva-app/ui 0.28.0 → 0.30.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/README.md +5 -7
- package/dist/cn.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-XE6HC5MJ.js → chunk-JZE7TIQY.js} +9 -2
- package/dist/gates/chunk-JZE7TIQY.js.map +7 -0
- package/dist/gates/{chunk-I7CJLUPM.js → chunk-XAPFC37F.js} +73 -6
- package/dist/gates/chunk-XAPFC37F.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/hook.d.ts +7 -1
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.js +26 -7
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +6 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/index.js.map +2 -2
- 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 +128 -0
- package/src/ScrollArea.mdx +8 -4
- package/src/ScrollArea.stories.tsx +6 -6
- package/src/cn.ts +1 -0
- package/src/gates/app-checks.ts +15 -2
- package/src/gates/create-app.test.ts +8 -1
- package/src/gates/create-app.ts +5 -0
- package/src/gates/gates.test.ts +46 -0
- package/src/gates/hook.ts +69 -5
- package/src/gates/status.ts +25 -0
- package/src/gates/token-lint.ts +5 -0
- 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-I7CJLUPM.js.map +0 -7
- package/dist/gates/chunk-XE6HC5MJ.js.map +0 -7
package/src/gates/token-lint.ts
CHANGED
|
@@ -89,6 +89,7 @@ const WORDS = {
|
|
|
89
89
|
type: '$0 is type written by hand. A token from the package sets the size, line height, letter spacing and weight together (text-body-2, text-caption, text-h5...); if none fits, add one to the package, or say why not in an eslint-disable comment.',
|
|
90
90
|
corner: '$0 is a corner written by hand, and no token has it. Use a corner from the package (rounded-sm 4px, rounded-md 6px, rounded-lg 8px...), or say why not in an eslint-disable comment.',
|
|
91
91
|
shadow: '$0 is a shadow written by hand. Use a shadow from the package (shadow-sm, shadow-md, shadow-focus-ring, drop-shadow-glow-success...); a shadow with no token is a missing token.',
|
|
92
|
+
merge: "Merge classes with the package's cn(), which the app re-exports. A plain tailwind-merge does not know the type ramp, and drops text-body-2 beside a text colour.",
|
|
92
93
|
},
|
|
93
94
|
package: {
|
|
94
95
|
ramp: "Tailwind's type ramp is not a token. Use the ramp in tailwind-preset.js: text-body-2, text-caption, text-h3, text-btn-default...",
|
|
@@ -98,6 +99,7 @@ const WORDS = {
|
|
|
98
99
|
type: '$0 is type written by hand. A token from tailwind-preset.js sets the size, line height, letter spacing and weight together (text-body-2, text-caption, text-h5...); if none fits, add one to the preset and to cn.ts, or say why not in an eslint-disable comment.',
|
|
99
100
|
corner: '$0 is a corner written by hand, and no token has it. Use a corner from tailwind-preset.js (rounded-sm 4px, rounded-md 6px, rounded-lg 8px...), or say why not in an eslint-disable comment.',
|
|
100
101
|
shadow: '$0 is a shadow written by hand. Use a shadow from tailwind-preset.js (shadow-sm, shadow-md, shadow-focus-ring, drop-shadow-glow-success...); a shadow with no token is a missing token.',
|
|
102
|
+
merge: "Merge classes with this package's cn() (src/cn.ts), the one place tailwind-merge is taught the type ramp. A plain tailwind-merge drops text-body-2 beside a text colour.",
|
|
101
103
|
},
|
|
102
104
|
} satisfies Record<TokenAudience, Record<string, string>>
|
|
103
105
|
|
|
@@ -140,6 +142,9 @@ export function tokenLint({ tailwindConfig = 'tailwind.config.js', audience = 'a
|
|
|
140
142
|
],
|
|
141
143
|
},
|
|
142
144
|
],
|
|
145
|
+
// UIG-21: "use the package's cn(), never a plain twMerge" was a sentence
|
|
146
|
+
// in all three repos' CLAUDE.md, and nothing checked it.
|
|
147
|
+
'no-restricted-imports': ['error', { paths: [{ name: 'tailwind-merge', message: words.merge }] }],
|
|
143
148
|
},
|
|
144
149
|
}
|
|
145
150
|
}
|
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
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* The Claude skill (UIG-20): search the catalogue before building anything.
|
|
4
|
+
*
|
|
5
|
+
* Its text lives once, in this package, as `skill/estiva-ui.md` (Katerina,
|
|
6
|
+
* 17 September 2026: the same gate text pasted into two repositories is a
|
|
7
|
+
* defect). Claude Code only loads a skill from a `SKILL.md` under a folder's
|
|
8
|
+
* `.claude/skills/`, and no plugin source can point into `node_modules`, so each
|
|
9
|
+
* repository commits a loader: the trigger below, and one line that reads the
|
|
10
|
+
* installed package's text into the skill when it opens (`` !`…` `` runs before
|
|
11
|
+
* Claude sees the skill). `estiva-ui skill` writes it; `estiva-ui check` fails
|
|
12
|
+
* when a repository's loader is missing or is not what this writes.
|
|
13
|
+
*
|
|
14
|
+
* Measured with fresh sessions of Claude Code 2.1.215 (UIG-20), three things
|
|
15
|
+
* shape that line and the settings beside it:
|
|
16
|
+
*
|
|
17
|
+
* - An injected command runs without a prompt only when Claude Code can read it
|
|
18
|
+
* statically as read-only: a plain `cat` of a plain path. A path with `..`
|
|
19
|
+
* ("may follow a symlink outside the working directory"), a `$(…)`, or a
|
|
20
|
+
* `node -e` are all refused, and a refused command stops the skill opening.
|
|
21
|
+
* So the path starts at `${CLAUDE_PROJECT_DIR}`, the folder the session
|
|
22
|
+
* started in, as the editor gate's hook does: a session started above or
|
|
23
|
+
* below the repository gets neither.
|
|
24
|
+
* - An `allowed-tools` line makes opening the skill itself ask for permission
|
|
25
|
+
* ("Execute skill"). So the search is allowed in the repository's committed
|
|
26
|
+
* `.claude/settings.json` instead, and the skill carries no `allowed-tools`.
|
|
27
|
+
* - Claude reads a skill's description before it opens the skill, so the
|
|
28
|
+
* trigger is written into the loader; it is the one part that cannot be
|
|
29
|
+
* pulled in later.
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
32
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
33
|
+
|
|
34
|
+
export const SKILL_NAME = 'estiva-ui'
|
|
35
|
+
|
|
36
|
+
/** When Claude opens the skill by itself. Kept under Claude Code's 1,536 characters. */
|
|
37
|
+
export const SKILL_DESCRIPTION =
|
|
38
|
+
'Use before building, adding or changing anything on screen in an app that uses @estiva-app/ui, or in @estiva-app/ui itself: ' +
|
|
39
|
+
'a screen, panel, list, row, header, dialog, menu, empty state, a new component, or how something looks. ' +
|
|
40
|
+
'Also when someone asks for a component in plain words ("I need a…", "add a…", "is there a…"). ' +
|
|
41
|
+
"It searches every catalogue at once, the package's and each app's Storybook, for a part that already does the job, " +
|
|
42
|
+
'and lists the mistakes this codebase keeps making.'
|
|
43
|
+
|
|
44
|
+
/** The search, allowed without a prompt in the folder's committed `.claude/settings.json`. */
|
|
45
|
+
export const SEARCH_RULES = ['Bash(npm run ui:find *)', 'Bash(npm --prefix * run ui:find *)', 'PowerShell(npm run ui:find *)', 'PowerShell(npm --prefix * run ui:find *)']
|
|
46
|
+
|
|
47
|
+
/** The skill's text, inside a package folder. */
|
|
48
|
+
export const skillTextPath = (packageRoot: string) => join(packageRoot, 'skill', `${SKILL_NAME}.md`)
|
|
49
|
+
|
|
50
|
+
/** Where a folder keeps its loader. */
|
|
51
|
+
export const loaderPath = (folder: string) => join(folder, '.claude', 'skills', SKILL_NAME, 'SKILL.md')
|
|
52
|
+
|
|
53
|
+
/** Where a folder keeps the settings that allow the search. */
|
|
54
|
+
export const settingsPath = (folder: string) => join(folder, '.claude', 'settings.json')
|
|
55
|
+
|
|
56
|
+
/** The loader for `folder`, reading the text from `packageRoot`, from where a session in `folder` starts. */
|
|
57
|
+
export function loaderText(folder: string, packageRoot: string): string {
|
|
58
|
+
const fromTop = relative(folder, skillTextPath(packageRoot)).split(sep).join('/')
|
|
59
|
+
return [
|
|
60
|
+
'---',
|
|
61
|
+
`name: ${SKILL_NAME}`,
|
|
62
|
+
`description: ${JSON.stringify(SKILL_DESCRIPTION)}`,
|
|
63
|
+
'---',
|
|
64
|
+
'',
|
|
65
|
+
'<!-- Written by `estiva-ui skill` from @estiva-app/ui; `estiva-ui check` fails when it differs. The text is the installed package’s, read in below. -->',
|
|
66
|
+
'',
|
|
67
|
+
`!\`cat "\${CLAUDE_PROJECT_DIR}/${fromTop}"\``,
|
|
68
|
+
'',
|
|
69
|
+
].join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Settings = { permissions?: { allow?: string[] } & Record<string, unknown> } & Record<string, unknown>
|
|
73
|
+
|
|
74
|
+
const readSettings = (folder: string): Settings | null => {
|
|
75
|
+
const path = settingsPath(folder)
|
|
76
|
+
if (!existsSync(path)) return {}
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Settings
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `folder`'s settings with the search allowed, everything else as it was; null when they cannot be read. */
|
|
85
|
+
export function settingsWithSearch(folder: string): string | null {
|
|
86
|
+
const settings = readSettings(folder)
|
|
87
|
+
if (!settings) return null
|
|
88
|
+
const allow = settings.permissions?.allow ?? []
|
|
89
|
+
const missing = SEARCH_RULES.filter((rule) => !allow.includes(rule))
|
|
90
|
+
if (!missing.length && existsSync(settingsPath(folder))) return null
|
|
91
|
+
settings.permissions = { ...settings.permissions, allow: [...allow, ...missing] }
|
|
92
|
+
return `${JSON.stringify(settings, null, 2)}\n`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The repository `from` is in: the nearest folder with `.git`, or `from` when there is none. */
|
|
96
|
+
export function repositoryOf(from: string): string {
|
|
97
|
+
let at = resolve(from)
|
|
98
|
+
for (;;) {
|
|
99
|
+
if (existsSync(join(at, '.git'))) return at
|
|
100
|
+
const up = dirname(at)
|
|
101
|
+
if (up === at) return resolve(from)
|
|
102
|
+
at = up
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What is wrong with `folder`'s loader, or null when it is what `loaderText` writes. */
|
|
107
|
+
export function loaderProblem(folder: string, packageRoot: string): string | null {
|
|
108
|
+
const path = loaderPath(folder)
|
|
109
|
+
const shown = relative(process.cwd(), path).split(sep).join('/') || path
|
|
110
|
+
if (!existsSync(path)) return `${shown} is missing: run "estiva-ui skill" and commit it`
|
|
111
|
+
if (readFileSync(path, 'utf8').replace(/\r\n/g, '\n') !== loaderText(folder, packageRoot)) return `${shown} is not what the package writes: run "estiva-ui skill" and commit it`
|
|
112
|
+
if (!existsSync(skillTextPath(packageRoot))) return `${shown} reads ${skillTextPath(packageRoot)}, which is not there`
|
|
113
|
+
const allow = readSettings(folder)?.permissions?.allow ?? []
|
|
114
|
+
const missing = SEARCH_RULES.filter((rule) => !allow.includes(rule))
|
|
115
|
+
if (missing.length) return `.claude/settings.json does not allow the search (${missing.join(', ')}): run "estiva-ui skill" and commit it`
|
|
116
|
+
return null
|
|
117
|
+
}
|