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